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 <sirily@git.shararam.party>
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-03-10 18:12:21 +03:00
parent 1b2a4a076a
commit 624c5809b6
7 changed files with 410 additions and 28 deletions

View File

@@ -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<typeof createSubscriptionFixture> | null;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
usageLedgerAggregate: [] as Array<Record<string, unknown>>,
};
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<string, unknown>) => {
calls.usageLedgerAggregate.push(args);
return {
_sum: {
deltaRequests: 0,
},
};
},
},
} as unknown as Parameters<typeof createPrismaAccountStore>[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,
},
};
}

View File

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

View File

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

View File

@@ -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<typeof createSubscriptionFixture> | null;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
generationRequestCreate: [] as Array<Record<string, unknown>>,
};
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<string, unknown> }) => {
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<typeof createPrismaGenerationStore>[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"),
},
};
}

View File

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

View File

@@ -0,0 +1,49 @@
import type { PrismaClient, SubscriptionStatus } from "@prisma/client";
type ExpirableSubscription = {
id: string;
status: SubscriptionStatus;
currentPeriodEnd: Date | null;
};
export async function reconcileElapsedSubscription<T extends ExpirableSubscription>(
database: Pick<PrismaClient, "subscription">,
subscription: T | null,
input?: {
now?: Date;
reload?: () => Promise<T | null>;
},
): Promise<T | null> {
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;
}