bug double subscription

This commit is contained in:
Alessio
2026-06-02 21:30:09 +02:00
parent 93be8add4e
commit 938e19c84f
16 changed files with 317 additions and 21 deletions

View File

@@ -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

View File

@@ -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,

View 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);

View File

@@ -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(),

View File

@@ -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);
} }

View File

@@ -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 });
} }

View File

@@ -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),

View File

@@ -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>
); );

View File

@@ -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");

View File

@@ -50,6 +50,8 @@ const tableFields = {
"userId", "userId",
"subscriptionId", "subscriptionId",
"renewalDate", "renewalDate",
"title",
"amount",
"status", "status",
"paymentDate", "paymentDate",
"linkedPaymentId", "linkedPaymentId",

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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(),

View 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;
}

View File

@@ -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) {

View File

@@ -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;