From 624c5809b6b8a05d61c0eda360c63f8366573331 Mon Sep 17 00:00:00 2001 From: sirily Date: Tue, 10 Mar 2026 18:12:21 +0300 Subject: [PATCH] fix: enforce subscription period end (#19) Closes #3 ## Summary - enforce `currentPeriodEnd` as a hard access boundary for generation requests - transition elapsed `active` and `past_due` subscriptions to `expired` during runtime reads - stop showing active-cycle quota for non-active subscriptions and document the current lifecycle behavior - add DB tests for post-expiry generation rejection and expired account-view normalization ## Testing - built `infra/docker/web.Dockerfile` - ran `pnpm --filter @nproxy/db test` inside the built container - verified `@nproxy/db build` and `@nproxy/web build` during the image build Co-authored-by: sirily Reviewed-on: http://git.shararam.party/sirily/nroxy/pulls/19 --- docs/ops/payment-system.md | 11 +- packages/db/src/account-store.test.ts | 121 +++++++++++++++++ packages/db/src/account-store.ts | 60 +++++--- packages/db/src/billing-store.ts | 12 +- packages/db/src/generation-store.test.ts | 158 ++++++++++++++++++++++ packages/db/src/generation-store.ts | 27 +++- packages/db/src/subscription-lifecycle.ts | 49 +++++++ 7 files changed, 410 insertions(+), 28 deletions(-) create mode 100644 packages/db/src/account-store.test.ts create mode 100644 packages/db/src/generation-store.test.ts create mode 100644 packages/db/src/subscription-lifecycle.ts diff --git a/docs/ops/payment-system.md b/docs/ops/payment-system.md index 76a18e4..d4e147b 100644 --- a/docs/ops/payment-system.md +++ b/docs/ops/payment-system.md @@ -11,6 +11,7 @@ The current payment system covers: - user subscription creation at registration time - invoice creation through a provider adapter - manual admin activation after an operator verifies that the provider reported a final successful payment status +- automatic expiry of elapsed subscription periods during account and generation access checks - quota-cycle reset on successful activation The current payment system does not yet cover: @@ -154,6 +155,14 @@ If the invoice does not exist, the store returns `invoice_not_found`. - Approximate quota shown to the user is derived from `generation_success` entries since the current billing cycle start. - A successful payment activation starts a new cycle by writing `cycle_reset` and moving the subscription window forward. - Failed generations do not consume quota. +- When a subscription period has elapsed, user-facing quota is no longer shown as an active-cycle quota. + +## Subscription period enforcement +- `currentPeriodEnd` is the hard end of paid access. +- At or after `currentPeriodEnd`, the runtime no longer treats the subscription as active. +- During generation access checks, an elapsed `active` subscription is transitioned to `expired` before access is denied. +- During account and billing reads, an elapsed `active` or `past_due` subscription is normalized to `expired` so the stored lifecycle is reflected consistently. +- There is no grace period after `currentPeriodEnd`. ## HTTP surface - `POST /api/billing/invoices` @@ -174,7 +183,7 @@ Current payment-specific errors surfaced by the web app: - No provider callback or reconciliation job updates invoice state automatically. - No runtime path currently moves invoices to `expired` or `canceled`. - The provider adapter does not yet verify external status or signatures. -- Subscription lifecycle beyond the current `mark-paid` path is still incomplete. +- Subscription lifecycle is still incomplete on the invoice side because provider-driven expiry, cancelation, and reconciliation are not implemented yet. ## Required future direction - Add provider callbacks or polling-based reconciliation. diff --git a/packages/db/src/account-store.test.ts b/packages/db/src/account-store.test.ts new file mode 100644 index 0000000..e92a5c5 --- /dev/null +++ b/packages/db/src/account-store.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Prisma } from "@prisma/client"; +import { createPrismaAccountStore } from "./account-store.js"; + +test("getUserAccountOverview marks elapsed active subscriptions expired and clears quota", async () => { + const database = createAccountDatabase({ + subscription: createSubscriptionFixture({ + status: "active", + currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), + }), + }); + const store = createPrismaAccountStore(database.client); + + const overview = await store.getUserAccountOverview("user_1"); + + assert.ok(overview); + assert.equal(overview.subscription?.status, "expired"); + assert.equal(overview.quota, null); + assert.equal(database.calls.subscriptionUpdateMany.length, 1); + assert.equal(database.state.subscription?.status, "expired"); +}); + +function createAccountDatabase(input: { + subscription: ReturnType | null; +}) { + const calls = { + subscriptionUpdateMany: [] as Array>, + usageLedgerAggregate: [] as Array>, + }; + + const state = { + subscription: input.subscription, + }; + + const client = { + user: { + findUnique: async ({ where }: { where: { id: string } }) => + where.id === "user_1" + ? { + id: "user_1", + email: "user@example.com", + isAdmin: false, + createdAt: new Date("2026-02-10T12:00:00.000Z"), + } + : null, + }, + subscription: { + findFirst: async ({ where }: { where: { userId: string } }) => + state.subscription && state.subscription.userId === where.userId + ? state.subscription + : null, + updateMany: async ({ + where, + data, + }: { + where: { id: string; status: "active" | "past_due" }; + data: { status: "expired" }; + }) => { + calls.subscriptionUpdateMany.push({ where, data }); + + if ( + state.subscription && + state.subscription.id === where.id && + state.subscription.status === where.status + ) { + state.subscription = { + ...state.subscription, + status: data.status, + }; + return { count: 1 }; + } + + return { count: 0 }; + }, + }, + usageLedgerEntry: { + aggregate: async (args: Record) => { + calls.usageLedgerAggregate.push(args); + return { + _sum: { + deltaRequests: 0, + }, + }; + }, + }, + } as unknown as Parameters[0]; + + return { + client, + calls, + state, + }; +} + +function createSubscriptionFixture(input: { + status: "active" | "expired" | "past_due"; + currentPeriodEnd: Date; +}) { + return { + id: "subscription_1", + userId: "user_1", + planId: "plan_1", + status: input.status, + renewsManually: true, + activatedAt: new Date("2026-02-10T12:00:00.000Z"), + currentPeriodStart: new Date("2026-02-10T12:00:00.000Z"), + currentPeriodEnd: input.currentPeriodEnd, + canceledAt: null, + createdAt: new Date("2026-02-10T12:00:00.000Z"), + updatedAt: new Date("2026-02-10T12:00:00.000Z"), + plan: { + id: "plan_1", + code: "monthly", + displayName: "Monthly", + monthlyPriceUsd: new Prisma.Decimal("9.99"), + billingCurrency: "USDT", + isActive: true, + }, + }; +} diff --git a/packages/db/src/account-store.ts b/packages/db/src/account-store.ts index f0880b4..636d6e6 100644 --- a/packages/db/src/account-store.ts +++ b/packages/db/src/account-store.ts @@ -2,6 +2,7 @@ import { getApproximateQuotaBucket, type QuotaBucket } from "@nproxy/domain"; import type { PrismaClient, SubscriptionStatus } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { prisma as defaultPrisma } from "./prisma-client.js"; +import { reconcileElapsedSubscription } from "./subscription-lifecycle.js"; export interface UserAccountOverview { user: { @@ -58,13 +59,26 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma) ], }); - const quota = subscription + const currentSubscription = await reconcileElapsedSubscription(database, subscription, { + reload: async () => + database.subscription.findFirst({ + where: { + userId, + }, + include: { + plan: true, + }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }), + }); + + const quota = currentSubscription?.status === "active" ? await buildQuotaSnapshot(database, userId, { - monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit, cycleStart: - subscription.currentPeriodStart ?? - subscription.activatedAt ?? - subscription.createdAt, + currentSubscription.currentPeriodStart ?? + currentSubscription.activatedAt ?? + currentSubscription.createdAt, }) : null; @@ -75,26 +89,30 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma) isAdmin: user.isAdmin, createdAt: user.createdAt, }, - subscription: subscription + subscription: currentSubscription ? { - id: subscription.id, - status: subscription.status, - renewsManually: subscription.renewsManually, - ...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}), - ...(subscription.currentPeriodStart - ? { currentPeriodStart: subscription.currentPeriodStart } + id: currentSubscription.id, + status: currentSubscription.status, + renewsManually: currentSubscription.renewsManually, + ...(currentSubscription.activatedAt + ? { activatedAt: currentSubscription.activatedAt } : {}), - ...(subscription.currentPeriodEnd - ? { currentPeriodEnd: subscription.currentPeriodEnd } + ...(currentSubscription.currentPeriodStart + ? { currentPeriodStart: currentSubscription.currentPeriodStart } + : {}), + ...(currentSubscription.currentPeriodEnd + ? { currentPeriodEnd: currentSubscription.currentPeriodEnd } + : {}), + ...(currentSubscription.canceledAt + ? { canceledAt: currentSubscription.canceledAt } : {}), - ...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}), plan: { - id: subscription.plan.id, - code: subscription.plan.code, - displayName: subscription.plan.displayName, - monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd), - billingCurrency: subscription.plan.billingCurrency, - isActive: subscription.plan.isActive, + id: currentSubscription.plan.id, + code: currentSubscription.plan.code, + displayName: currentSubscription.plan.displayName, + monthlyPriceUsd: decimalToNumber(currentSubscription.plan.monthlyPriceUsd), + billingCurrency: currentSubscription.plan.billingCurrency, + isActive: currentSubscription.plan.isActive, }, } : null, diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts index 723a31c..c8e552c 100644 --- a/packages/db/src/billing-store.ts +++ b/packages/db/src/billing-store.ts @@ -7,6 +7,7 @@ import { type SubscriptionStatus, } from "@prisma/client"; import { prisma as defaultPrisma } from "./prisma-client.js"; +import { reconcileElapsedSubscription } from "./subscription-lifecycle.js"; export interface BillingInvoiceRecord { id: string; @@ -74,7 +75,16 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], }); - return subscription ? mapSubscription(subscription) : null; + const currentSubscription = await reconcileElapsedSubscription(database, subscription, { + reload: async () => + database.subscription.findFirst({ + where: { userId }, + include: { plan: true }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }), + }); + + return currentSubscription ? mapSubscription(currentSubscription) : null; }, async createSubscriptionInvoice(input: { diff --git a/packages/db/src/generation-store.test.ts b/packages/db/src/generation-store.test.ts new file mode 100644 index 0000000..45230b8 --- /dev/null +++ b/packages/db/src/generation-store.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Prisma } from "@prisma/client"; +import { GenerationRequestError, createGenerationRequest } from "@nproxy/domain"; +import { createPrismaGenerationStore } from "./generation-store.js"; + +test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => { + const database = createGenerationDatabase({ + subscription: createSubscriptionFixture({ + status: "active", + currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), + }), + }); + const store = createPrismaGenerationStore(database.client); + + await assert.rejects( + createGenerationRequest(store, { + userId: "user_1", + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + }), + (error: unknown) => + error instanceof GenerationRequestError && + error.code === "missing_active_subscription", + ); + + assert.equal(database.calls.subscriptionUpdateMany.length, 1); + assert.equal(database.calls.generationRequestCreate.length, 0); + assert.equal(database.state.subscription?.status, "expired"); +}); + +function createGenerationDatabase(input: { + subscription: ReturnType | null; +}) { + const calls = { + subscriptionUpdateMany: [] as Array>, + generationRequestCreate: [] as Array>, + }; + + const state = { + subscription: input.subscription, + }; + + const client = { + subscription: { + findFirst: async ({ + where, + }: { + where: { userId: string; status?: "active" }; + }) => { + if (!state.subscription || state.subscription.userId !== where.userId) { + return null; + } + + if (where.status && state.subscription.status !== where.status) { + return null; + } + + return state.subscription; + }, + updateMany: async ({ + where, + data, + }: { + where: { id: string; status: "active" | "past_due" }; + data: { status: "expired" }; + }) => { + calls.subscriptionUpdateMany.push({ where, data }); + + if ( + state.subscription && + state.subscription.id === where.id && + state.subscription.status === where.status + ) { + state.subscription = { + ...state.subscription, + status: data.status, + }; + return { count: 1 }; + } + + return { count: 0 }; + }, + }, + usageLedgerEntry: { + aggregate: async () => ({ + _sum: { + deltaRequests: 0, + }, + }), + }, + generationRequest: { + create: async ({ data }: { data: Record }) => { + calls.generationRequestCreate.push({ data }); + return { + id: "request_1", + userId: data.userId as string, + mode: data.mode as string, + status: "queued", + providerModel: data.providerModel as string, + prompt: data.prompt as string, + sourceImageKey: null, + resolutionPreset: data.resolutionPreset as string, + batchSize: data.batchSize as number, + imageStrength: null, + idempotencyKey: null, + terminalErrorCode: null, + terminalErrorText: null, + requestedAt: new Date("2026-03-10T12:00:00.000Z"), + startedAt: null, + completedAt: null, + createdAt: new Date("2026-03-10T12:00:00.000Z"), + updatedAt: new Date("2026-03-10T12:00:00.000Z"), + }; + }, + findFirst: async () => null, + }, + } as unknown as Parameters[0]; + + return { + client, + calls, + state, + }; +} + +function createSubscriptionFixture(input: { + status: "active" | "expired" | "past_due"; + currentPeriodEnd: Date; +}) { + return { + id: "subscription_1", + userId: "user_1", + planId: "plan_1", + status: input.status, + renewsManually: true, + activatedAt: new Date("2026-02-10T12:00:00.000Z"), + currentPeriodStart: new Date("2026-02-10T12:00:00.000Z"), + currentPeriodEnd: input.currentPeriodEnd, + canceledAt: null, + createdAt: new Date("2026-02-10T12:00:00.000Z"), + updatedAt: new Date("2026-02-10T12:00:00.000Z"), + plan: { + id: "plan_1", + code: "monthly", + displayName: "Monthly", + monthlyRequestLimit: 100, + monthlyPriceUsd: new Prisma.Decimal("9.99"), + billingCurrency: "USDT", + isActive: true, + createdAt: new Date("2026-02-10T12:00:00.000Z"), + updatedAt: new Date("2026-02-10T12:00:00.000Z"), + }, + }; +} diff --git a/packages/db/src/generation-store.ts b/packages/db/src/generation-store.ts index 0fdfd11..244d3cf 100644 --- a/packages/db/src/generation-store.ts +++ b/packages/db/src/generation-store.ts @@ -8,6 +8,7 @@ import { } from "@nproxy/domain"; import { Prisma, type PrismaClient } from "@prisma/client"; import { prisma as defaultPrisma } from "./prisma-client.js"; +import { reconcileElapsedSubscription } from "./subscription-lifecycle.js"; export interface GenerationStore extends CreateGenerationRequestDeps, @@ -45,12 +46,28 @@ export function createPrismaGenerationStore( ], }); - if (!subscription) { + const currentSubscription = await reconcileElapsedSubscription(database, subscription, { + reload: async () => + database.subscription.findFirst({ + where: { + userId, + status: "active", + }, + include: { + plan: true, + }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }), + }); + + if (!currentSubscription || currentSubscription.status !== "active") { return null; } const cycleStart = - subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt; + currentSubscription.currentPeriodStart ?? + currentSubscription.activatedAt ?? + currentSubscription.createdAt; const usageAggregation = await database.usageLedgerEntry.aggregate({ where: { @@ -64,9 +81,9 @@ export function createPrismaGenerationStore( }); return { - subscriptionId: subscription.id, - planId: subscription.planId, - monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + subscriptionId: currentSubscription.id, + planId: currentSubscription.planId, + monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit, usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0, }; }, diff --git a/packages/db/src/subscription-lifecycle.ts b/packages/db/src/subscription-lifecycle.ts new file mode 100644 index 0000000..fc115f2 --- /dev/null +++ b/packages/db/src/subscription-lifecycle.ts @@ -0,0 +1,49 @@ +import type { PrismaClient, SubscriptionStatus } from "@prisma/client"; + +type ExpirableSubscription = { + id: string; + status: SubscriptionStatus; + currentPeriodEnd: Date | null; +}; + +export async function reconcileElapsedSubscription( + database: Pick, + subscription: T | null, + input?: { + now?: Date; + reload?: () => Promise; + }, +): Promise { + if (!subscription) { + return null; + } + + const now = input?.now ?? new Date(); + const shouldExpire = + (subscription.status === "active" || subscription.status === "past_due") && + subscription.currentPeriodEnd !== null && + subscription.currentPeriodEnd <= now; + + if (!shouldExpire) { + return subscription; + } + + const result = await database.subscription.updateMany({ + where: { + id: subscription.id, + status: subscription.status, + }, + data: { + status: "expired", + }, + }); + + if (result.count > 0) { + return { + ...subscription, + status: "expired", + }; + } + + return input?.reload ? input.reload() : subscription; +}