fix: enforce subscription period end #19
@@ -11,6 +11,7 @@ The current payment system covers:
|
|||||||
- user subscription creation at registration time
|
- user subscription creation at registration time
|
||||||
- invoice creation through a provider adapter
|
- invoice creation through a provider adapter
|
||||||
- manual admin activation after an operator verifies that the provider reported a final successful payment status
|
- 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
|
- quota-cycle reset on successful activation
|
||||||
|
|
||||||
The current payment system does not yet cover:
|
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.
|
- 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.
|
- A successful payment activation starts a new cycle by writing `cycle_reset` and moving the subscription window forward.
|
||||||
- Failed generations do not consume quota.
|
- 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
|
## HTTP surface
|
||||||
- `POST /api/billing/invoices`
|
- `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 provider callback or reconciliation job updates invoice state automatically.
|
||||||
- No runtime path currently moves invoices to `expired` or `canceled`.
|
- No runtime path currently moves invoices to `expired` or `canceled`.
|
||||||
- The provider adapter does not yet verify external status or signatures.
|
- 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
|
## Required future direction
|
||||||
- Add provider callbacks or polling-based reconciliation.
|
- Add provider callbacks or polling-based reconciliation.
|
||||||
|
|||||||
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 type { PrismaClient, SubscriptionStatus } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||||
|
import { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
|
||||||
|
|
||||||
export interface UserAccountOverview {
|
export interface UserAccountOverview {
|
||||||
user: {
|
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, {
|
? await buildQuotaSnapshot(database, userId, {
|
||||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
|
||||||
cycleStart:
|
cycleStart:
|
||||||
subscription.currentPeriodStart ??
|
currentSubscription.currentPeriodStart ??
|
||||||
subscription.activatedAt ??
|
currentSubscription.activatedAt ??
|
||||||
subscription.createdAt,
|
currentSubscription.createdAt,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -75,26 +89,30 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma)
|
|||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
},
|
},
|
||||||
subscription: subscription
|
subscription: currentSubscription
|
||||||
? {
|
? {
|
||||||
id: subscription.id,
|
id: currentSubscription.id,
|
||||||
status: subscription.status,
|
status: currentSubscription.status,
|
||||||
renewsManually: subscription.renewsManually,
|
renewsManually: currentSubscription.renewsManually,
|
||||||
...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}),
|
...(currentSubscription.activatedAt
|
||||||
...(subscription.currentPeriodStart
|
? { activatedAt: currentSubscription.activatedAt }
|
||||||
? { currentPeriodStart: subscription.currentPeriodStart }
|
|
||||||
: {}),
|
: {}),
|
||||||
...(subscription.currentPeriodEnd
|
...(currentSubscription.currentPeriodStart
|
||||||
? { currentPeriodEnd: subscription.currentPeriodEnd }
|
? { currentPeriodStart: currentSubscription.currentPeriodStart }
|
||||||
|
: {}),
|
||||||
|
...(currentSubscription.currentPeriodEnd
|
||||||
|
? { currentPeriodEnd: currentSubscription.currentPeriodEnd }
|
||||||
|
: {}),
|
||||||
|
...(currentSubscription.canceledAt
|
||||||
|
? { canceledAt: currentSubscription.canceledAt }
|
||||||
: {}),
|
: {}),
|
||||||
...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}),
|
|
||||||
plan: {
|
plan: {
|
||||||
id: subscription.plan.id,
|
id: currentSubscription.plan.id,
|
||||||
code: subscription.plan.code,
|
code: currentSubscription.plan.code,
|
||||||
displayName: subscription.plan.displayName,
|
displayName: currentSubscription.plan.displayName,
|
||||||
monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd),
|
monthlyPriceUsd: decimalToNumber(currentSubscription.plan.monthlyPriceUsd),
|
||||||
billingCurrency: subscription.plan.billingCurrency,
|
billingCurrency: currentSubscription.plan.billingCurrency,
|
||||||
isActive: subscription.plan.isActive,
|
isActive: currentSubscription.plan.isActive,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type SubscriptionStatus,
|
type SubscriptionStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||||
|
import { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
|
||||||
|
|
||||||
export interface BillingInvoiceRecord {
|
export interface BillingInvoiceRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,7 +75,16 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
|
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: {
|
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";
|
} from "@nproxy/domain";
|
||||||
import { Prisma, type PrismaClient } from "@prisma/client";
|
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||||
|
import { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
|
||||||
|
|
||||||
export interface GenerationStore
|
export interface GenerationStore
|
||||||
extends CreateGenerationRequestDeps,
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleStart =
|
const cycleStart =
|
||||||
subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt;
|
currentSubscription.currentPeriodStart ??
|
||||||
|
currentSubscription.activatedAt ??
|
||||||
|
currentSubscription.createdAt;
|
||||||
|
|
||||||
const usageAggregation = await database.usageLedgerEntry.aggregate({
|
const usageAggregation = await database.usageLedgerEntry.aggregate({
|
||||||
where: {
|
where: {
|
||||||
@@ -64,9 +81,9 @@ export function createPrismaGenerationStore(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: currentSubscription.id,
|
||||||
planId: subscription.planId,
|
planId: currentSubscription.planId,
|
||||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
|
||||||
usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0,
|
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