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:
```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

View File

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

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,
{
...data,
title: subscription.title,
amount: subscription.amount,
userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),

View File

@@ -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<Subscription>(
collections.subscriptions,
id,
@@ -44,6 +54,82 @@ export async function PATCH(request: Request, ctx: Ctx) {
...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);
}

View File

@@ -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<Subscription>(
collections.subscriptions,
{
...parsed.data,
...subscription,
userId: auth.user.id,
...nowFields(),
},
);
if (markPreviousAsPaid) {
await materializeSubscriptionHistory(document, "paid");
}
return Response.json(document, { status: 201 });
}

View File

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

View File

@@ -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({
}
/>
) : (
<>
<p className="text-sm text-slate-400">
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>
);

View File

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

View File

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

View File

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

View File

@@ -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) => ({
.map((subscription) => {
const occurrences = generateSubscriptionOccurrencesForRange(
[subscription],
payments,
start,
end,
);
return {
subscription,
periodCost:
generateSubscriptionOccurrencesForRange([subscription], [], start, end)
.length * subscription.amount,
}))
periodCost: occurrences.reduce(
(total, occurrence) => total + occurrence.subscription.amount,
0,
),
};
})
.filter((item) => item.periodCost > 0)
.sort((a, b) => b.periodCost - a.periodCost);
}

View File

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

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",
];
export function parseSubscriptionInput(body: Record<string, unknown>) {
function parseSubscriptionFields(body: Record<string, unknown>) {
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<string, unknown>) {
};
}
export function parseSubscriptionInput(body: Record<string, unknown>) {
return {
...parseSubscriptionFields(body),
markPreviousAsPaid: body.markPreviousAsPaid === true,
};
}
export function parseSubscriptionPatch(body: Record<string, unknown>) {
return parseSubscriptionInput(body);
return parseSubscriptionFields(body);
}
function optionalDate(value: unknown, name: string) {

View File

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