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:
121
packages/db/src/account-store.test.ts
Normal file
121
packages/db/src/account-store.test.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
158
packages/db/src/generation-store.test.ts
Normal file
158
packages/db/src/generation-store.test.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
49
packages/db/src/subscription-lifecycle.ts
Normal file
49
packages/db/src/subscription-lifecycle.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user