From 938e19c84fee834bc6f708e5e7c686b6ec33156a Mon Sep 17 00:00:00 2001 From: Alessio Date: Tue, 2 Jun 2026 21:30:09 +0200 Subject: [PATCH] bug double subscription --- README.md | 8 +- database/init.sql | 2 + .../006_subscription_payment_snapshots.sql | 22 +++++ src/app/api/subscription-payments/route.ts | 2 + src/app/api/subscriptions/[id]/route.ts | 88 ++++++++++++++++++- src/app/api/subscriptions/route.ts | 7 +- src/components/reports/ReportsPageClient.tsx | 9 +- .../subscriptions/SubscriptionForm.tsx | 41 ++++++++- .../subscriptions/SubscriptionsPageClient.tsx | 3 + src/lib/database/server.ts | 2 + src/lib/finance/ledger.ts | 4 +- src/lib/finance/reportAnalysis.ts | 24 +++-- src/lib/finance/subscriptionOccurrences.ts | 44 +++++++++- src/lib/finance/subscriptionPaymentHistory.ts | 69 +++++++++++++++ src/lib/validation/subscriptions.ts | 11 ++- src/types/finance.ts | 2 + 16 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 database/migrations/006_subscription_payment_snapshots.sql create mode 100644 src/lib/finance/subscriptionPaymentHistory.ts diff --git a/README.md b/README.md index 3e72e8a..90ea166 100644 --- a/README.md +++ b/README.md @@ -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: ```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/002_subscription_custom_intervals.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 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 diff --git a/database/init.sql b/database/init.sql index 0239d94..600eacf 100644 --- a/database/init.sql +++ b/database/init.sql @@ -57,6 +57,8 @@ CREATE TABLE subscription_payments ( user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, subscription_id uuid NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, renewal_date text NOT NULL, + title text NOT NULL, + amount double precision NOT NULL CHECK (amount > 0), status text NOT NULL, payment_date text, linked_payment_id uuid, diff --git a/database/migrations/006_subscription_payment_snapshots.sql b/database/migrations/006_subscription_payment_snapshots.sql new file mode 100644 index 0000000..d8e375d --- /dev/null +++ b/database/migrations/006_subscription_payment_snapshots.sql @@ -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); diff --git a/src/app/api/subscription-payments/route.ts b/src/app/api/subscription-payments/route.ts index d3626d6..96b40ed 100644 --- a/src/app/api/subscription-payments/route.ts +++ b/src/app/api/subscription-payments/route.ts @@ -146,6 +146,8 @@ export async function POST(request: Request) { collections.subscriptionPayments, { ...data, + title: subscription.title, + amount: subscription.amount, userId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/app/api/subscriptions/[id]/route.ts b/src/app/api/subscriptions/[id]/route.ts index 4845b4f..48d4832 100644 --- a/src/app/api/subscriptions/[id]/route.ts +++ b/src/app/api/subscriptions/[id]/route.ts @@ -7,7 +7,16 @@ import { query, updateDocument, } 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 { parseSubscriptionPatch } from "@/lib/validation/subscriptions"; import type { Subscription, SubscriptionPayment } from "@/types/finance"; @@ -36,6 +45,7 @@ export async function PATCH(request: Request, ctx: Ctx) { { status: 400 }, ); } + await materializeSubscriptionHistory(current, "unpaid"); const document = await updateDocument( collections.subscriptions, id, @@ -44,6 +54,82 @@ export async function PATCH(request: Request, ctx: Ctx) { ...updatedField(), }, ); + const payments = await listDocuments( + 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( + collections.subscriptionPayments, + payment.$id, + { + renewalDate, + title: document.title, + amount: document.amount, + ...updatedField(), + }, + ); + const ledger = await syncSubscriptionLedger(document, updated); + await updateDocument( + 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( + collections.subscriptionPayments, + payment.$id, + { + title: document.title, + amount: document.amount, + ...updatedField(), + }, + ); + const ledger = await syncSubscriptionLedger(document, updated); + await updateDocument( + collections.subscriptionPayments, + payment.$id, + { + linkedPaymentId: ledger?.$id ?? "", + ...updatedField(), + }, + ); + } return Response.json(document); } diff --git a/src/app/api/subscriptions/route.ts b/src/app/api/subscriptions/route.ts index 6258955..e5a4629 100644 --- a/src/app/api/subscriptions/route.ts +++ b/src/app/api/subscriptions/route.ts @@ -5,6 +5,7 @@ import { listDocuments, query, } from "@/lib/database/server"; +import { materializeSubscriptionHistory } from "@/lib/finance/subscriptionPaymentHistory"; import { nowFields, parseBody } from "@/lib/validation/common"; import { parseSubscriptionInput } from "@/lib/validation/subscriptions"; import type { Subscription } from "@/types/finance"; @@ -28,13 +29,17 @@ export async function POST(request: Request) { if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 }); const { collections } = databaseConfig(); + const { markPreviousAsPaid, ...subscription } = parsed.data; const document = await createDocument( collections.subscriptions, { - ...parsed.data, + ...subscription, userId: auth.user.id, ...nowFields(), }, ); + if (markPreviousAsPaid) { + await materializeSubscriptionHistory(document, "paid"); + } return Response.json(document, { status: 201 }); } diff --git a/src/components/reports/ReportsPageClient.tsx b/src/components/reports/ReportsPageClient.tsx index fe9e781..5b6e7fb 100644 --- a/src/components/reports/ReportsPageClient.tsx +++ b/src/components/reports/ReportsPageClient.tsx @@ -101,8 +101,13 @@ export function ReportsPageClient({ initialYear }: { initialYear: number }) { [annual, ledger.data, period, selectedYear], ); const subscriptionInsights = useMemo( - () => getSubscriptionInsights(subscriptions.data ?? [], period), - [period, subscriptions.data], + () => + getSubscriptionInsights( + subscriptions.data ?? [], + subscriptionPayments.data ?? [], + period, + ), + [period, subscriptionPayments.data, subscriptions.data], ); const billsInsights = useMemo( () => getBillsInsights(bills.data ?? [], period), diff --git a/src/components/subscriptions/SubscriptionForm.tsx b/src/components/subscriptions/SubscriptionForm.tsx index 353771b..5c95457 100644 --- a/src/components/subscriptions/SubscriptionForm.tsx +++ b/src/components/subscriptions/SubscriptionForm.tsx @@ -61,6 +61,7 @@ export function SubscriptionForm({ const [subscriptionStatus, setSubscriptionStatus] = useState( editing?.subscriptionStatus ?? "active", ); + const [markPreviousAsPaid, setMarkPreviousAsPaid] = useState(false); const [error, setError] = useState(""); useEffect(() => { @@ -76,6 +77,7 @@ export function SubscriptionForm({ setCustomIntervalValue(String(editing?.customIntervalValue ?? "")); setCustomIntervalUnit(editing?.customIntervalUnit ?? "months"); setSubscriptionStatus(editing?.subscriptionStatus ?? "active"); + setMarkPreviousAsPaid(false); setError(""); }, [editing]); @@ -96,6 +98,7 @@ export function SubscriptionForm({ customIntervalValue: billingCycle === "custom" ? customIntervalValue : "", customIntervalUnit: billingCycle === "custom" ? customIntervalUnit : "", subscriptionStatus, + markPreviousAsPaid: !editing && markPreviousAsPaid, }); } @@ -193,6 +196,9 @@ export function SubscriptionForm({ billingCycle={billingCycle} durationMode={durationMode} editing={editing} + firstPaymentDate={firstPaymentDate} + markPreviousAsPaid={markPreviousAsPaid} + onMarkPreviousAsPaidChange={setMarkPreviousAsPaid} onStatusChange={setSubscriptionStatus} status={subscriptionStatus} title={title} @@ -439,6 +445,9 @@ function ReviewStep({ billingCycle, durationMode, editing, + firstPaymentDate, + markPreviousAsPaid, + onMarkPreviousAsPaidChange, status, onStatusChange, }: { @@ -447,6 +456,9 @@ function ReviewStep({ billingCycle: BillingCycle; durationMode: DurationMode; editing?: Subscription; + firstPaymentDate: string; + markPreviousAsPaid: boolean; + onMarkPreviousAsPaidChange: (value: boolean) => void; status: SubscriptionStatus; onStatusChange: (value: SubscriptionStatus) => void; }) { @@ -478,9 +490,32 @@ function ReviewStep({ } /> ) : ( -

- Il nuovo abbonamento sarĂ  creato come attivo. -

+ <> +

+ Il nuovo abbonamento sarĂ  creato come attivo. +

+ {firstPaymentDate < todayIso() ? ( + + ) : null} + )} ); diff --git a/src/components/subscriptions/SubscriptionsPageClient.tsx b/src/components/subscriptions/SubscriptionsPageClient.tsx index 314f89c..4c9c94c 100644 --- a/src/components/subscriptions/SubscriptionsPageClient.tsx +++ b/src/components/subscriptions/SubscriptionsPageClient.tsx @@ -89,6 +89,9 @@ export function SubscriptionsPageClient() { ? list.map((item) => (item.$id === saved.$id ? saved : item)) : [saved, ...list]; }); + if (editing) { + await subscriptionPayments.mutate(); + } setModalOpen(false); setEditing(undefined); notify("Abbonamento salvato"); diff --git a/src/lib/database/server.ts b/src/lib/database/server.ts index ceeda9c..a140112 100644 --- a/src/lib/database/server.ts +++ b/src/lib/database/server.ts @@ -50,6 +50,8 @@ const tableFields = { "userId", "subscriptionId", "renewalDate", + "title", + "amount", "status", "paymentDate", "linkedPaymentId", diff --git a/src/lib/finance/ledger.ts b/src/lib/finance/ledger.ts index d810846..b440627 100644 --- a/src/lib/finance/ledger.ts +++ b/src/lib/finance/ledger.ts @@ -193,8 +193,8 @@ export async function syncSubscriptionLedger( } return upsertLedgerForSource({ userId: payment.userId, - title: subscription.title, - amount: subscription.amount, + title: payment.title ?? subscription.title, + amount: payment.amount ?? subscription.amount, date: payment.renewalDate, sourceType: "subscription", sourceId: payment.$id, diff --git a/src/lib/finance/reportAnalysis.ts b/src/lib/finance/reportAnalysis.ts index 1d93d9e..fdcf8bd 100644 --- a/src/lib/finance/reportAnalysis.ts +++ b/src/lib/finance/reportAnalysis.ts @@ -5,6 +5,7 @@ import type { Income, PaymentLedger, Subscription, + SubscriptionPayment, } from "@/types/finance"; export function getMonthlyReportSummary(input: { @@ -21,17 +22,26 @@ export function getMonthlyReportSummary(input: { export function getSubscriptionInsights( subscriptions: Subscription[], + payments: SubscriptionPayment[], period: number | string = new Date().getFullYear(), ) { const { start, end } = periodBounds(period); return subscriptions - .filter((item) => item.subscriptionStatus === "active") - .map((subscription) => ({ - subscription, - periodCost: - generateSubscriptionOccurrencesForRange([subscription], [], start, end) - .length * subscription.amount, - })) + .map((subscription) => { + const occurrences = generateSubscriptionOccurrencesForRange( + [subscription], + payments, + start, + end, + ); + return { + subscription, + periodCost: occurrences.reduce( + (total, occurrence) => total + occurrence.subscription.amount, + 0, + ), + }; + }) .filter((item) => item.periodCost > 0) .sort((a, b) => b.periodCost - a.periodCost); } diff --git a/src/lib/finance/subscriptionOccurrences.ts b/src/lib/finance/subscriptionOccurrences.ts index 1c68811..10f973d 100644 --- a/src/lib/finance/subscriptionOccurrences.ts +++ b/src/lib/finance/subscriptionOccurrences.ts @@ -2,6 +2,7 @@ import { addDays, addMonths, addYears, + fromIso, monthBounds, todayIso, } from "@/lib/date/formatDate"; @@ -48,7 +49,7 @@ export function generateSubscriptionOccurrencesForRange( for (const payment of paymentsBySubscription.get(subscription.$id) ?? []) { if (payment.renewalDate < start || payment.renewalDate > end) continue; occurrences.set(occurrenceKey(subscription.$id, payment.renewalDate), { - subscription, + subscription: subscriptionSnapshot(subscription, payment), renewalDate: payment.renewalDate, payment, status: resolveSubscriptionOccurrenceStatus( @@ -64,6 +65,12 @@ export function generateSubscriptionOccurrencesForRange( occurrence.payment ??= paymentsBySubscription .get(occurrence.subscription.$id) ?.find((payment) => payment.renewalDate === occurrence.renewalDate); + if (occurrence.payment) { + occurrence.subscription = subscriptionSnapshot( + occurrence.subscription, + occurrence.payment, + ); + } occurrence.status = resolveSubscriptionOccurrenceStatus( occurrence.renewalDate, 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( subscription: Subscription, renewalDate: string, @@ -89,6 +107,30 @@ export function isScheduledSubscriptionOccurrence( ).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( subscription: Subscription, referenceDate = todayIso(), diff --git a/src/lib/finance/subscriptionPaymentHistory.ts b/src/lib/finance/subscriptionPaymentHistory.ts new file mode 100644 index 0000000..e7e7331 --- /dev/null +++ b/src/lib/finance/subscriptionPaymentHistory.ts @@ -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( + 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( + 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( + collections.subscriptionPayments, + payment.$id, + { + linkedPaymentId: ledger?.$id ?? "", + ...updatedField(), + }, + ); + created.push(payment); + } + return created; +} diff --git a/src/lib/validation/subscriptions.ts b/src/lib/validation/subscriptions.ts index ebbe052..4632d0f 100644 --- a/src/lib/validation/subscriptions.ts +++ b/src/lib/validation/subscriptions.ts @@ -19,7 +19,7 @@ const intervalUnits: RenewalIntervalUnit[] = [ "years", ]; -export function parseSubscriptionInput(body: Record) { +function parseSubscriptionFields(body: Record) { const amount = validateAmount(body.amount); if (!amount) throw new Error("L'importo deve essere maggiore di zero"); const activationDate = requireDate(body.activationDate, "activationDate"); @@ -65,8 +65,15 @@ export function parseSubscriptionInput(body: Record) { }; } +export function parseSubscriptionInput(body: Record) { + return { + ...parseSubscriptionFields(body), + markPreviousAsPaid: body.markPreviousAsPaid === true, + }; +} + export function parseSubscriptionPatch(body: Record) { - return parseSubscriptionInput(body); + return parseSubscriptionFields(body); } function optionalDate(value: unknown, name: string) { diff --git a/src/types/finance.ts b/src/types/finance.ts index 2e5e57b..84877de 100644 --- a/src/types/finance.ts +++ b/src/types/finance.ts @@ -47,6 +47,8 @@ export type Subscription = BaseDocument & { export type SubscriptionPayment = BaseDocument & { subscriptionId: string; renewalDate: string; + title: string; + amount: number; status: "paid" | "unpaid"; paymentDate?: string; linkedPaymentId?: string;