bug double subscription
This commit is contained in:
@@ -77,8 +77,12 @@ Versioned files in `database/migrations` update existing volumes. After pulling
|
|||||||
an update that includes a new migration, apply it once with:
|
an update that includes a new migration, apply it once with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat database/migrations/001_subscription_lifecycle.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
|
cat database/migrations/001_subscription_lifecycle.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
cat database/migrations/002_subscription_custom_intervals.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
|
cat database/migrations/002_subscription_custom_intervals.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
cat database/migrations/003_subscription_ledger_renewal_dates.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
cat database/migrations/004_income_titles.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
cat database/migrations/005_user_avatar.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
cat database/migrations/006_subscription_payment_snapshots.sql | docker compose exec -T postgres sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ CREATE TABLE subscription_payments (
|
|||||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
subscription_id uuid NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
subscription_id uuid NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
renewal_date text NOT NULL,
|
renewal_date text NOT NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL CHECK (amount > 0),
|
||||||
status text NOT NULL,
|
status text NOT NULL,
|
||||||
payment_date text,
|
payment_date text,
|
||||||
linked_payment_id uuid,
|
linked_payment_id uuid,
|
||||||
|
|||||||
22
database/migrations/006_subscription_payment_snapshots.sql
Normal file
22
database/migrations/006_subscription_payment_snapshots.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE subscription_payments
|
||||||
|
ADD COLUMN IF NOT EXISTS title text,
|
||||||
|
ADD COLUMN IF NOT EXISTS amount double precision;
|
||||||
|
|
||||||
|
UPDATE subscription_payments AS payment
|
||||||
|
SET
|
||||||
|
title = subscription.title,
|
||||||
|
amount = subscription.amount
|
||||||
|
FROM subscriptions AS subscription
|
||||||
|
WHERE
|
||||||
|
subscription.id = payment.subscription_id
|
||||||
|
AND (payment.title IS NULL OR payment.amount IS NULL);
|
||||||
|
|
||||||
|
ALTER TABLE subscription_payments
|
||||||
|
ALTER COLUMN title SET NOT NULL,
|
||||||
|
ALTER COLUMN amount SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_payments
|
||||||
|
DROP CONSTRAINT IF EXISTS subscription_payments_amount_positive;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_payments
|
||||||
|
ADD CONSTRAINT subscription_payments_amount_positive CHECK (amount > 0);
|
||||||
@@ -146,6 +146,8 @@ export async function POST(request: Request) {
|
|||||||
collections.subscriptionPayments,
|
collections.subscriptionPayments,
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
|
title: subscription.title,
|
||||||
|
amount: subscription.amount,
|
||||||
userId,
|
userId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import {
|
|||||||
query,
|
query,
|
||||||
updateDocument,
|
updateDocument,
|
||||||
} from "@/lib/database/server";
|
} from "@/lib/database/server";
|
||||||
import { removeLinkedLedger } from "@/lib/finance/ledger";
|
import { todayIso } from "@/lib/date/formatDate";
|
||||||
|
import {
|
||||||
|
removeLinkedLedger,
|
||||||
|
syncSubscriptionLedger,
|
||||||
|
} from "@/lib/finance/ledger";
|
||||||
|
import {
|
||||||
|
findClosestSubscriptionOccurrenceDate,
|
||||||
|
isScheduledSubscriptionOccurrence,
|
||||||
|
} from "@/lib/finance/subscriptionOccurrences";
|
||||||
|
import { materializeSubscriptionHistory } from "@/lib/finance/subscriptionPaymentHistory";
|
||||||
import { parseBody, updatedField } from "@/lib/validation/common";
|
import { parseBody, updatedField } from "@/lib/validation/common";
|
||||||
import { parseSubscriptionPatch } from "@/lib/validation/subscriptions";
|
import { parseSubscriptionPatch } from "@/lib/validation/subscriptions";
|
||||||
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
@@ -36,6 +45,7 @@ export async function PATCH(request: Request, ctx: Ctx) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await materializeSubscriptionHistory(current, "unpaid");
|
||||||
const document = await updateDocument<Subscription>(
|
const document = await updateDocument<Subscription>(
|
||||||
collections.subscriptions,
|
collections.subscriptions,
|
||||||
id,
|
id,
|
||||||
@@ -44,6 +54,82 @@ export async function PATCH(request: Request, ctx: Ctx) {
|
|||||||
...updatedField(),
|
...updatedField(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const payments = await listDocuments<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.equal("subscriptionId", id),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const occupiedDates = new Set(
|
||||||
|
payments.documents.map((payment) => payment.renewalDate),
|
||||||
|
);
|
||||||
|
const today = todayIso();
|
||||||
|
for (const payment of payments.documents) {
|
||||||
|
if (payment.renewalDate <= today) continue;
|
||||||
|
const renewalDate = findClosestSubscriptionOccurrenceDate(
|
||||||
|
document,
|
||||||
|
payment.renewalDate,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
renewalDate &&
|
||||||
|
renewalDate !== payment.renewalDate &&
|
||||||
|
!occupiedDates.has(renewalDate)
|
||||||
|
) {
|
||||||
|
occupiedDates.delete(payment.renewalDate);
|
||||||
|
occupiedDates.add(renewalDate);
|
||||||
|
const updated = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
renewalDate,
|
||||||
|
title: document.title,
|
||||||
|
amount: document.amount,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncSubscriptionLedger(document, updated);
|
||||||
|
await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger?.$id ?? "",
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isScheduledSubscriptionOccurrence(document, payment.renewalDate)) {
|
||||||
|
if (payment.status === "paid") continue;
|
||||||
|
await removeLinkedLedger(
|
||||||
|
auth.user.id,
|
||||||
|
payment.linkedPaymentId,
|
||||||
|
"subscription",
|
||||||
|
payment.$id,
|
||||||
|
);
|
||||||
|
await deleteDocument(collections.subscriptionPayments, payment.$id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
title: document.title,
|
||||||
|
amount: document.amount,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncSubscriptionLedger(document, updated);
|
||||||
|
await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger?.$id ?? "",
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return Response.json(document);
|
return Response.json(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
listDocuments,
|
listDocuments,
|
||||||
query,
|
query,
|
||||||
} from "@/lib/database/server";
|
} from "@/lib/database/server";
|
||||||
|
import { materializeSubscriptionHistory } from "@/lib/finance/subscriptionPaymentHistory";
|
||||||
import { nowFields, parseBody } from "@/lib/validation/common";
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
import { parseSubscriptionInput } from "@/lib/validation/subscriptions";
|
import { parseSubscriptionInput } from "@/lib/validation/subscriptions";
|
||||||
import type { Subscription } from "@/types/finance";
|
import type { Subscription } from "@/types/finance";
|
||||||
@@ -28,13 +29,17 @@ export async function POST(request: Request) {
|
|||||||
if (!parsed.ok)
|
if (!parsed.ok)
|
||||||
return Response.json({ error: parsed.error }, { status: 400 });
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
const { collections } = databaseConfig();
|
const { collections } = databaseConfig();
|
||||||
|
const { markPreviousAsPaid, ...subscription } = parsed.data;
|
||||||
const document = await createDocument<Subscription>(
|
const document = await createDocument<Subscription>(
|
||||||
collections.subscriptions,
|
collections.subscriptions,
|
||||||
{
|
{
|
||||||
...parsed.data,
|
...subscription,
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
...nowFields(),
|
...nowFields(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (markPreviousAsPaid) {
|
||||||
|
await materializeSubscriptionHistory(document, "paid");
|
||||||
|
}
|
||||||
return Response.json(document, { status: 201 });
|
return Response.json(document, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ export function ReportsPageClient({ initialYear }: { initialYear: number }) {
|
|||||||
[annual, ledger.data, period, selectedYear],
|
[annual, ledger.data, period, selectedYear],
|
||||||
);
|
);
|
||||||
const subscriptionInsights = useMemo(
|
const subscriptionInsights = useMemo(
|
||||||
() => getSubscriptionInsights(subscriptions.data ?? [], period),
|
() =>
|
||||||
[period, subscriptions.data],
|
getSubscriptionInsights(
|
||||||
|
subscriptions.data ?? [],
|
||||||
|
subscriptionPayments.data ?? [],
|
||||||
|
period,
|
||||||
|
),
|
||||||
|
[period, subscriptionPayments.data, subscriptions.data],
|
||||||
);
|
);
|
||||||
const billsInsights = useMemo(
|
const billsInsights = useMemo(
|
||||||
() => getBillsInsights(bills.data ?? [], period),
|
() => getBillsInsights(bills.data ?? [], period),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function SubscriptionForm({
|
|||||||
const [subscriptionStatus, setSubscriptionStatus] = useState(
|
const [subscriptionStatus, setSubscriptionStatus] = useState(
|
||||||
editing?.subscriptionStatus ?? "active",
|
editing?.subscriptionStatus ?? "active",
|
||||||
);
|
);
|
||||||
|
const [markPreviousAsPaid, setMarkPreviousAsPaid] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +77,7 @@ export function SubscriptionForm({
|
|||||||
setCustomIntervalValue(String(editing?.customIntervalValue ?? ""));
|
setCustomIntervalValue(String(editing?.customIntervalValue ?? ""));
|
||||||
setCustomIntervalUnit(editing?.customIntervalUnit ?? "months");
|
setCustomIntervalUnit(editing?.customIntervalUnit ?? "months");
|
||||||
setSubscriptionStatus(editing?.subscriptionStatus ?? "active");
|
setSubscriptionStatus(editing?.subscriptionStatus ?? "active");
|
||||||
|
setMarkPreviousAsPaid(false);
|
||||||
setError("");
|
setError("");
|
||||||
}, [editing]);
|
}, [editing]);
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ export function SubscriptionForm({
|
|||||||
customIntervalValue: billingCycle === "custom" ? customIntervalValue : "",
|
customIntervalValue: billingCycle === "custom" ? customIntervalValue : "",
|
||||||
customIntervalUnit: billingCycle === "custom" ? customIntervalUnit : "",
|
customIntervalUnit: billingCycle === "custom" ? customIntervalUnit : "",
|
||||||
subscriptionStatus,
|
subscriptionStatus,
|
||||||
|
markPreviousAsPaid: !editing && markPreviousAsPaid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +196,9 @@ export function SubscriptionForm({
|
|||||||
billingCycle={billingCycle}
|
billingCycle={billingCycle}
|
||||||
durationMode={durationMode}
|
durationMode={durationMode}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
|
firstPaymentDate={firstPaymentDate}
|
||||||
|
markPreviousAsPaid={markPreviousAsPaid}
|
||||||
|
onMarkPreviousAsPaidChange={setMarkPreviousAsPaid}
|
||||||
onStatusChange={setSubscriptionStatus}
|
onStatusChange={setSubscriptionStatus}
|
||||||
status={subscriptionStatus}
|
status={subscriptionStatus}
|
||||||
title={title}
|
title={title}
|
||||||
@@ -439,6 +445,9 @@ function ReviewStep({
|
|||||||
billingCycle,
|
billingCycle,
|
||||||
durationMode,
|
durationMode,
|
||||||
editing,
|
editing,
|
||||||
|
firstPaymentDate,
|
||||||
|
markPreviousAsPaid,
|
||||||
|
onMarkPreviousAsPaidChange,
|
||||||
status,
|
status,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -447,6 +456,9 @@ function ReviewStep({
|
|||||||
billingCycle: BillingCycle;
|
billingCycle: BillingCycle;
|
||||||
durationMode: DurationMode;
|
durationMode: DurationMode;
|
||||||
editing?: Subscription;
|
editing?: Subscription;
|
||||||
|
firstPaymentDate: string;
|
||||||
|
markPreviousAsPaid: boolean;
|
||||||
|
onMarkPreviousAsPaidChange: (value: boolean) => void;
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
onStatusChange: (value: SubscriptionStatus) => void;
|
onStatusChange: (value: SubscriptionStatus) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -478,9 +490,32 @@ function ReviewStep({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-400">
|
<>
|
||||||
Il nuovo abbonamento sarà creato come attivo.
|
<p className="text-sm text-slate-400">
|
||||||
</p>
|
Il nuovo abbonamento sarà creato come attivo.
|
||||||
|
</p>
|
||||||
|
{firstPaymentDate < todayIso() ? (
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-white/10 bg-slate-950/20 p-4 text-sm text-slate-200">
|
||||||
|
<input
|
||||||
|
checked={markPreviousAsPaid}
|
||||||
|
className="mt-0.5 size-4 accent-sky-500"
|
||||||
|
onChange={(event) =>
|
||||||
|
onMarkPreviousAsPaidChange(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium text-white">
|
||||||
|
Segna le rate precedenti come pagate
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-relaxed text-slate-400">
|
||||||
|
Crea la cronologia dei rinnovi precedenti a oggi e li registra
|
||||||
|
come pagati.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function SubscriptionsPageClient() {
|
|||||||
? list.map((item) => (item.$id === saved.$id ? saved : item))
|
? list.map((item) => (item.$id === saved.$id ? saved : item))
|
||||||
: [saved, ...list];
|
: [saved, ...list];
|
||||||
});
|
});
|
||||||
|
if (editing) {
|
||||||
|
await subscriptionPayments.mutate();
|
||||||
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
notify("Abbonamento salvato");
|
notify("Abbonamento salvato");
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const tableFields = {
|
|||||||
"userId",
|
"userId",
|
||||||
"subscriptionId",
|
"subscriptionId",
|
||||||
"renewalDate",
|
"renewalDate",
|
||||||
|
"title",
|
||||||
|
"amount",
|
||||||
"status",
|
"status",
|
||||||
"paymentDate",
|
"paymentDate",
|
||||||
"linkedPaymentId",
|
"linkedPaymentId",
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ export async function syncSubscriptionLedger(
|
|||||||
}
|
}
|
||||||
return upsertLedgerForSource({
|
return upsertLedgerForSource({
|
||||||
userId: payment.userId,
|
userId: payment.userId,
|
||||||
title: subscription.title,
|
title: payment.title ?? subscription.title,
|
||||||
amount: subscription.amount,
|
amount: payment.amount ?? subscription.amount,
|
||||||
date: payment.renewalDate,
|
date: payment.renewalDate,
|
||||||
sourceType: "subscription",
|
sourceType: "subscription",
|
||||||
sourceId: payment.$id,
|
sourceId: payment.$id,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Income,
|
Income,
|
||||||
PaymentLedger,
|
PaymentLedger,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
SubscriptionPayment,
|
||||||
} from "@/types/finance";
|
} from "@/types/finance";
|
||||||
|
|
||||||
export function getMonthlyReportSummary(input: {
|
export function getMonthlyReportSummary(input: {
|
||||||
@@ -21,17 +22,26 @@ export function getMonthlyReportSummary(input: {
|
|||||||
|
|
||||||
export function getSubscriptionInsights(
|
export function getSubscriptionInsights(
|
||||||
subscriptions: Subscription[],
|
subscriptions: Subscription[],
|
||||||
|
payments: SubscriptionPayment[],
|
||||||
period: number | string = new Date().getFullYear(),
|
period: number | string = new Date().getFullYear(),
|
||||||
) {
|
) {
|
||||||
const { start, end } = periodBounds(period);
|
const { start, end } = periodBounds(period);
|
||||||
return subscriptions
|
return subscriptions
|
||||||
.filter((item) => item.subscriptionStatus === "active")
|
.map((subscription) => {
|
||||||
.map((subscription) => ({
|
const occurrences = generateSubscriptionOccurrencesForRange(
|
||||||
subscription,
|
[subscription],
|
||||||
periodCost:
|
payments,
|
||||||
generateSubscriptionOccurrencesForRange([subscription], [], start, end)
|
start,
|
||||||
.length * subscription.amount,
|
end,
|
||||||
}))
|
);
|
||||||
|
return {
|
||||||
|
subscription,
|
||||||
|
periodCost: occurrences.reduce(
|
||||||
|
(total, occurrence) => total + occurrence.subscription.amount,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter((item) => item.periodCost > 0)
|
.filter((item) => item.periodCost > 0)
|
||||||
.sort((a, b) => b.periodCost - a.periodCost);
|
.sort((a, b) => b.periodCost - a.periodCost);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
addMonths,
|
addMonths,
|
||||||
addYears,
|
addYears,
|
||||||
|
fromIso,
|
||||||
monthBounds,
|
monthBounds,
|
||||||
todayIso,
|
todayIso,
|
||||||
} from "@/lib/date/formatDate";
|
} from "@/lib/date/formatDate";
|
||||||
@@ -48,7 +49,7 @@ export function generateSubscriptionOccurrencesForRange(
|
|||||||
for (const payment of paymentsBySubscription.get(subscription.$id) ?? []) {
|
for (const payment of paymentsBySubscription.get(subscription.$id) ?? []) {
|
||||||
if (payment.renewalDate < start || payment.renewalDate > end) continue;
|
if (payment.renewalDate < start || payment.renewalDate > end) continue;
|
||||||
occurrences.set(occurrenceKey(subscription.$id, payment.renewalDate), {
|
occurrences.set(occurrenceKey(subscription.$id, payment.renewalDate), {
|
||||||
subscription,
|
subscription: subscriptionSnapshot(subscription, payment),
|
||||||
renewalDate: payment.renewalDate,
|
renewalDate: payment.renewalDate,
|
||||||
payment,
|
payment,
|
||||||
status: resolveSubscriptionOccurrenceStatus(
|
status: resolveSubscriptionOccurrenceStatus(
|
||||||
@@ -64,6 +65,12 @@ export function generateSubscriptionOccurrencesForRange(
|
|||||||
occurrence.payment ??= paymentsBySubscription
|
occurrence.payment ??= paymentsBySubscription
|
||||||
.get(occurrence.subscription.$id)
|
.get(occurrence.subscription.$id)
|
||||||
?.find((payment) => payment.renewalDate === occurrence.renewalDate);
|
?.find((payment) => payment.renewalDate === occurrence.renewalDate);
|
||||||
|
if (occurrence.payment) {
|
||||||
|
occurrence.subscription = subscriptionSnapshot(
|
||||||
|
occurrence.subscription,
|
||||||
|
occurrence.payment,
|
||||||
|
);
|
||||||
|
}
|
||||||
occurrence.status = resolveSubscriptionOccurrenceStatus(
|
occurrence.status = resolveSubscriptionOccurrenceStatus(
|
||||||
occurrence.renewalDate,
|
occurrence.renewalDate,
|
||||||
occurrence.payment,
|
occurrence.payment,
|
||||||
@@ -76,6 +83,17 @@ export function generateSubscriptionOccurrencesForRange(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subscriptionSnapshot(
|
||||||
|
subscription: Subscription,
|
||||||
|
payment: SubscriptionPayment,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...subscription,
|
||||||
|
title: payment.title ?? subscription.title,
|
||||||
|
amount: payment.amount ?? subscription.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isScheduledSubscriptionOccurrence(
|
export function isScheduledSubscriptionOccurrence(
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
renewalDate: string,
|
renewalDate: string,
|
||||||
@@ -89,6 +107,30 @@ export function isScheduledSubscriptionOccurrence(
|
|||||||
).some((occurrence) => occurrence.renewalDate === renewalDate);
|
).some((occurrence) => occurrence.renewalDate === renewalDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findClosestSubscriptionOccurrenceDate(
|
||||||
|
subscription: Subscription,
|
||||||
|
renewalDate: string,
|
||||||
|
) {
|
||||||
|
if (subscription.subscriptionStatus !== "active") return undefined;
|
||||||
|
let step = 0;
|
||||||
|
let closest: string | undefined;
|
||||||
|
let closestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
while (step < MAX_GENERATED_OCCURRENCES) {
|
||||||
|
const candidate = occurrenceDate(subscription, step);
|
||||||
|
if (!hasOccurrence(subscription, candidate, step)) break;
|
||||||
|
const distance = Math.abs(
|
||||||
|
fromIso(candidate).getTime() - fromIso(renewalDate).getTime(),
|
||||||
|
);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closest = candidate;
|
||||||
|
closestDistance = distance;
|
||||||
|
}
|
||||||
|
if (candidate >= renewalDate) break;
|
||||||
|
step += 1;
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
export function getEffectiveSubscriptionStatus(
|
export function getEffectiveSubscriptionStatus(
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
referenceDate = todayIso(),
|
referenceDate = todayIso(),
|
||||||
|
|||||||
69
src/lib/finance/subscriptionPaymentHistory.ts
Normal file
69
src/lib/finance/subscriptionPaymentHistory.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { addDays, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import { syncSubscriptionLedger } from "@/lib/finance/ledger";
|
||||||
|
import { generateSubscriptionOccurrencesForRange } from "@/lib/finance/subscriptionOccurrences";
|
||||||
|
import { nowFields, updatedField } from "@/lib/validation/common";
|
||||||
|
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function materializeSubscriptionHistory(
|
||||||
|
subscription: Subscription,
|
||||||
|
status: SubscriptionPayment["status"],
|
||||||
|
beforeDate = todayIso(),
|
||||||
|
) {
|
||||||
|
const end = addDays(beforeDate, -1);
|
||||||
|
if (end < subscription.firstPaymentDate) return [];
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const stored = await listDocuments<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
[
|
||||||
|
query.equal("userId", subscription.userId),
|
||||||
|
query.equal("subscriptionId", subscription.$id),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const storedDates = new Set(
|
||||||
|
stored.documents.map((payment) => payment.renewalDate),
|
||||||
|
);
|
||||||
|
const scheduled = generateSubscriptionOccurrencesForRange(
|
||||||
|
[{ ...subscription, subscriptionStatus: "active" }],
|
||||||
|
[],
|
||||||
|
subscription.firstPaymentDate,
|
||||||
|
end,
|
||||||
|
);
|
||||||
|
const created: SubscriptionPayment[] = [];
|
||||||
|
for (const occurrence of scheduled) {
|
||||||
|
if (storedDates.has(occurrence.renewalDate)) continue;
|
||||||
|
let payment = await createDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
{
|
||||||
|
userId: subscription.userId,
|
||||||
|
subscriptionId: subscription.$id,
|
||||||
|
renewalDate: occurrence.renewalDate,
|
||||||
|
title: subscription.title,
|
||||||
|
amount: subscription.amount,
|
||||||
|
status,
|
||||||
|
paymentDate: status === "paid" ? occurrence.renewalDate : undefined,
|
||||||
|
...nowFields(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncSubscriptionLedger(subscription, payment);
|
||||||
|
payment = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger?.$id ?? "",
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
created.push(payment);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const intervalUnits: RenewalIntervalUnit[] = [
|
|||||||
"years",
|
"years",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function parseSubscriptionInput(body: Record<string, unknown>) {
|
function parseSubscriptionFields(body: Record<string, unknown>) {
|
||||||
const amount = validateAmount(body.amount);
|
const amount = validateAmount(body.amount);
|
||||||
if (!amount) throw new Error("L'importo deve essere maggiore di zero");
|
if (!amount) throw new Error("L'importo deve essere maggiore di zero");
|
||||||
const activationDate = requireDate(body.activationDate, "activationDate");
|
const activationDate = requireDate(body.activationDate, "activationDate");
|
||||||
@@ -65,8 +65,15 @@ export function parseSubscriptionInput(body: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionInput(body: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
...parseSubscriptionFields(body),
|
||||||
|
markPreviousAsPaid: body.markPreviousAsPaid === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSubscriptionPatch(body: Record<string, unknown>) {
|
export function parseSubscriptionPatch(body: Record<string, unknown>) {
|
||||||
return parseSubscriptionInput(body);
|
return parseSubscriptionFields(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalDate(value: unknown, name: string) {
|
function optionalDate(value: unknown, name: string) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export type Subscription = BaseDocument & {
|
|||||||
export type SubscriptionPayment = BaseDocument & {
|
export type SubscriptionPayment = BaseDocument & {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
renewalDate: string;
|
renewalDate: string;
|
||||||
|
title: string;
|
||||||
|
amount: number;
|
||||||
status: "paid" | "unpaid";
|
status: "paid" | "unpaid";
|
||||||
paymentDate?: string;
|
paymentDate?: string;
|
||||||
linkedPaymentId?: string;
|
linkedPaymentId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user