Compare commits
3 Commits
docs/payme
...
feat/renew
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb5272d2cb | ||
| 624c5809b6 | |||
| 1b2a4a076a |
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "node:http";
|
} from "node:http";
|
||||||
import { loadConfig } from "@nproxy/config";
|
import { loadConfig } from "@nproxy/config";
|
||||||
import {
|
import {
|
||||||
|
BillingError,
|
||||||
createPrismaAccountStore,
|
createPrismaAccountStore,
|
||||||
createPrismaAuthStore,
|
createPrismaAuthStore,
|
||||||
createPrismaBillingStore,
|
createPrismaBillingStore,
|
||||||
@@ -211,7 +212,13 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
||||||
const invoice = await billingStore.markInvoicePaid({ invoiceId });
|
const invoice = await billingStore.markInvoicePaid({
|
||||||
|
invoiceId,
|
||||||
|
actor: {
|
||||||
|
type: "web_admin",
|
||||||
|
ref: authenticatedSession.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
invoice: serializeBillingInvoice(invoice),
|
invoice: serializeBillingInvoice(invoice),
|
||||||
});
|
});
|
||||||
@@ -672,6 +679,23 @@ function handleRequestError(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof BillingError) {
|
||||||
|
const statusCode =
|
||||||
|
error.code === "invoice_not_found"
|
||||||
|
? 404
|
||||||
|
: error.code === "invoice_transition_not_allowed"
|
||||||
|
? 409
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
sendJson(response, statusCode, {
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof GenerationRequestError) {
|
if (error instanceof GenerationRequestError) {
|
||||||
const statusCode =
|
const statusCode =
|
||||||
error.code === "missing_active_subscription"
|
error.code === "missing_active_subscription"
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { loadConfig } from "@nproxy/config";
|
import { loadConfig } from "@nproxy/config";
|
||||||
import { createPrismaWorkerStore, prisma } from "@nproxy/db";
|
import { createPrismaBillingStore, createPrismaWorkerStore, prisma } from "@nproxy/db";
|
||||||
import { createNanoBananaSimulatedAdapter } from "@nproxy/providers";
|
import {
|
||||||
|
createEmailTransport,
|
||||||
|
createNanoBananaSimulatedAdapter,
|
||||||
|
createPaymentProviderAdapter,
|
||||||
|
} from "@nproxy/providers";
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const intervalMs = config.keyPool.balancePollSeconds * 1000;
|
const intervalMs = config.keyPool.balancePollSeconds * 1000;
|
||||||
|
const renewalLeadTimeHours = 72;
|
||||||
const workerStore = createPrismaWorkerStore(prisma, {
|
const workerStore = createPrismaWorkerStore(prisma, {
|
||||||
cooldownMinutes: config.keyPool.cooldownMinutes,
|
cooldownMinutes: config.keyPool.cooldownMinutes,
|
||||||
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
|
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
|
||||||
});
|
});
|
||||||
|
const billingStore = createPrismaBillingStore(prisma);
|
||||||
const nanoBananaAdapter = createNanoBananaSimulatedAdapter();
|
const nanoBananaAdapter = createNanoBananaSimulatedAdapter();
|
||||||
|
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
||||||
|
const emailTransport = createEmailTransport(config.email);
|
||||||
let isTickRunning = false;
|
let isTickRunning = false;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
service: "worker",
|
service: "worker",
|
||||||
balancePollSeconds: config.keyPool.balancePollSeconds,
|
balancePollSeconds: config.keyPool.balancePollSeconds,
|
||||||
|
renewalLeadTimeHours,
|
||||||
providerModel: config.provider.nanoBananaDefaultModel,
|
providerModel: config.provider.nanoBananaDefaultModel,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -44,6 +53,54 @@ async function runTick(): Promise<void> {
|
|||||||
isTickRunning = true;
|
isTickRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const expiredInvoices = await billingStore.expireElapsedPendingInvoices();
|
||||||
|
|
||||||
|
if (expiredInvoices.expiredCount > 0) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
service: "worker",
|
||||||
|
event: "pending_invoices_expired",
|
||||||
|
expiredCount: expiredInvoices.expiredCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewalNotifications = await billingStore.createUpcomingRenewalInvoices({
|
||||||
|
paymentProvider: config.payment.provider,
|
||||||
|
paymentProviderAdapter,
|
||||||
|
renewalLeadTimeHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const notification of renewalNotifications) {
|
||||||
|
const billingUrl = new URL("/billing", config.urls.appBaseUrl);
|
||||||
|
await emailTransport.send({
|
||||||
|
to: notification.email,
|
||||||
|
subject: "Your nproxy subscription renewal invoice",
|
||||||
|
text: [
|
||||||
|
"Your current subscription period is ending soon.",
|
||||||
|
`Current access ends at ${notification.subscriptionCurrentPeriodEnd.toISOString()}.`,
|
||||||
|
`Invoice amount: ${notification.invoice.amountCrypto} ${notification.invoice.currency}.`,
|
||||||
|
...(notification.invoice.paymentAddress
|
||||||
|
? [`Payment address: ${notification.invoice.paymentAddress}.`]
|
||||||
|
: []),
|
||||||
|
...(notification.invoice.expiresAt
|
||||||
|
? [`Invoice expires at ${notification.invoice.expiresAt.toISOString()}.`]
|
||||||
|
: []),
|
||||||
|
`Open billing: ${billingUrl.toString()}`,
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renewalNotifications.length > 0) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
service: "worker",
|
||||||
|
event: "renewal_invoices_created",
|
||||||
|
createdCount: renewalNotifications.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const recovery = await workerStore.recoverCooldownProviderKeys();
|
const recovery = await workerStore.recoverCooldownProviderKeys();
|
||||||
|
|
||||||
if (recovery.recoveredCount > 0) {
|
if (recovery.recoveredCount > 0) {
|
||||||
|
|||||||
214
docs/ops/payment-system.md
Normal file
214
docs/ops/payment-system.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Payment System
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Describe how billing and payment processing currently work in `nproxy`.
|
||||||
|
|
||||||
|
This document is about the implemented system, not the desired future state.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
The current payment system covers:
|
||||||
|
- default subscription plan bootstrap
|
||||||
|
- user subscription creation at registration time
|
||||||
|
- invoice creation through a provider adapter
|
||||||
|
- automatic renewal-invoice creation `72 hours` before `currentPeriodEnd`
|
||||||
|
- one renewal-invoice creation attempt per paid cycle unless the user explicitly creates a new one manually
|
||||||
|
- 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
|
||||||
|
- automatic expiry of elapsed pending invoices
|
||||||
|
- quota-cycle reset on successful activation
|
||||||
|
|
||||||
|
The current payment system does not yet cover:
|
||||||
|
- provider webhooks
|
||||||
|
- polling-based reconciliation
|
||||||
|
- automatic `expired` or `canceled` transitions
|
||||||
|
- recurring billing
|
||||||
|
|
||||||
|
## Main records
|
||||||
|
|
||||||
|
### `SubscriptionPlan`
|
||||||
|
- one default active plan is bootstrapped by `packages/db/src/bootstrap.ts`
|
||||||
|
- current default seed:
|
||||||
|
- `code`: `monthly`
|
||||||
|
- `displayName`: `Monthly`
|
||||||
|
- `monthlyRequestLimit`: `100`
|
||||||
|
- `monthlyPriceUsd`: `9.99`
|
||||||
|
- `billingCurrency`: `USDT`
|
||||||
|
|
||||||
|
### `Subscription`
|
||||||
|
- created for a new user during registration if the default plan exists
|
||||||
|
- starts in `pending_activation`
|
||||||
|
- becomes `active` only after successful invoice activation
|
||||||
|
- stores:
|
||||||
|
- `activatedAt`
|
||||||
|
- `currentPeriodStart`
|
||||||
|
- `currentPeriodEnd`
|
||||||
|
- `renewsManually`
|
||||||
|
|
||||||
|
### `PaymentInvoice`
|
||||||
|
- stores one provider-facing payment attempt for a subscription
|
||||||
|
- `pending` means the invoice exists, but the payment is not yet considered final or accepted
|
||||||
|
- `paid` means the payment is considered final and safe to activate against
|
||||||
|
- important fields:
|
||||||
|
- local `id`
|
||||||
|
- `subscriptionId`
|
||||||
|
- `provider`
|
||||||
|
- `providerInvoiceId`
|
||||||
|
- `status`
|
||||||
|
- `currency`
|
||||||
|
- `amountCrypto`
|
||||||
|
- `amountUsd`
|
||||||
|
- `paymentAddress`
|
||||||
|
- `expiresAt`
|
||||||
|
- `paidAt`
|
||||||
|
|
||||||
|
### `UsageLedgerEntry`
|
||||||
|
- records quota-affecting events
|
||||||
|
- on successful payment activation the system writes `cycle_reset`
|
||||||
|
- successful generations later write `generation_success`
|
||||||
|
|
||||||
|
### `AdminAuditLog`
|
||||||
|
- records state-changing admin actions
|
||||||
|
- the current admin `mark-paid` flow writes:
|
||||||
|
- `invoice_mark_paid`
|
||||||
|
- `invoice_mark_paid_replayed`
|
||||||
|
|
||||||
|
## Provider boundary
|
||||||
|
Payment-provider code stays behind `packages/providers/src/payments.ts`.
|
||||||
|
|
||||||
|
Current provider contract:
|
||||||
|
- `createInvoice(input) -> providerInvoiceId, paymentAddress, amountCrypto, amountUsd, currency, expiresAt`
|
||||||
|
|
||||||
|
Current runtime note:
|
||||||
|
- the provider adapter is still a placeholder adapter
|
||||||
|
- provider callbacks and status lookups are not implemented yet
|
||||||
|
- the rest of the payment flow is intentionally provider-agnostic
|
||||||
|
|
||||||
|
## Registration flow
|
||||||
|
1. User registers through `POST /api/auth/register`.
|
||||||
|
2. `packages/db/src/auth-store.ts` creates the user and session.
|
||||||
|
3. If the default plan exists, the store also creates a `Subscription` in `pending_activation`.
|
||||||
|
4. At this point the user has an account and a plan assignment, but not an active paid cycle.
|
||||||
|
|
||||||
|
## Invoice creation flow
|
||||||
|
1. Authenticated user calls `POST /api/billing/invoices`.
|
||||||
|
2. `packages/db/src/billing-store.ts` loads the latest subscription for that user.
|
||||||
|
3. If there is already a non-expired `pending` invoice for the same subscription, it is reused.
|
||||||
|
4. Otherwise the app calls the payment-provider adapter `createInvoice`.
|
||||||
|
5. The returned provider invoice data is persisted as a new local `PaymentInvoice` in `pending`.
|
||||||
|
6. The API returns invoice details, including provider invoice id, amount, address, and expiry time.
|
||||||
|
|
||||||
|
## Automatic renewal invoice flow
|
||||||
|
1. The worker scans `active` manual-renewal subscriptions.
|
||||||
|
2. If `currentPeriodEnd` is within the next `72 hours`, the worker checks whether the current paid cycle already has an invoice.
|
||||||
|
3. If the current cycle has no invoice yet, the worker creates one renewal invoice through the payment-provider adapter.
|
||||||
|
4. The worker sends the invoice details to the user by email.
|
||||||
|
5. If any invoice already exists for the current cycle, the worker does not auto-create another one.
|
||||||
|
|
||||||
|
Current rule:
|
||||||
|
- after the first invoice exists for the current paid cycle, automatic re-creation stops for that cycle
|
||||||
|
- if that invoice later expires or is canceled, the next invoice is created only when the user explicitly goes to billing and creates one
|
||||||
|
|
||||||
|
## Payment status semantics
|
||||||
|
- `pending` does not count as paid.
|
||||||
|
- `pending` does not activate the subscription.
|
||||||
|
- `pending` does not reset quota.
|
||||||
|
- The system must treat an invoice as `paid` only after the payment provider reports a final successful status, meaning the funds are accepted strongly enough for access activation.
|
||||||
|
- The current implementation does not fetch or verify that provider-final status automatically yet.
|
||||||
|
|
||||||
|
## Invoice listing flow
|
||||||
|
- `GET /api/billing/invoices` returns the user's invoices ordered by newest first.
|
||||||
|
- This is a read-only view over persisted `PaymentInvoice` rows.
|
||||||
|
- The worker also marks `pending` invoices `expired` when `expiresAt` has passed.
|
||||||
|
|
||||||
|
## Current activation flow
|
||||||
|
The implemented activation path is manual and admin-driven.
|
||||||
|
|
||||||
|
1. An authenticated admin calls `POST /api/admin/invoices/:id/mark-paid`.
|
||||||
|
2. The web app resolves the admin session and passes actor metadata into the billing store.
|
||||||
|
3. This endpoint is intended to be used only after the operator has already verified that the provider reached a final successful payment state.
|
||||||
|
4. `markInvoicePaid` runs inside one database transaction.
|
||||||
|
5. If the invoice is `pending`, the store:
|
||||||
|
- updates the invoice to `paid`
|
||||||
|
- sets `paidAt`
|
||||||
|
- updates the related subscription to `active`
|
||||||
|
- sets `activatedAt` if it was not already set
|
||||||
|
- sets `currentPeriodStart = paidAt`
|
||||||
|
- sets `currentPeriodEnd = paidAt + 30 days`
|
||||||
|
- clears `canceledAt`
|
||||||
|
- writes a `UsageLedgerEntry` with `entryType = cycle_reset`
|
||||||
|
- writes an `AdminAuditLog` entry `invoice_mark_paid`
|
||||||
|
6. The API returns the updated invoice.
|
||||||
|
|
||||||
|
Important constraint:
|
||||||
|
- `mark-paid` is not evidence by itself that a `pending` invoice became payable.
|
||||||
|
- It is only the current manual mechanism for recording that the provider has already given final confirmation outside the app.
|
||||||
|
|
||||||
|
## Idempotency and transition rules
|
||||||
|
`markInvoicePaid` is replay-safe.
|
||||||
|
|
||||||
|
### Allowed cases
|
||||||
|
- `pending -> paid`
|
||||||
|
- `paid -> paid` as a no-op replay
|
||||||
|
|
||||||
|
### Replay behavior
|
||||||
|
If the invoice is already `paid`:
|
||||||
|
- the subscription is not mutated again
|
||||||
|
- no extra `cycle_reset` entry is created
|
||||||
|
- an audit event `invoice_mark_paid_replayed` is written
|
||||||
|
|
||||||
|
### Rejected cases
|
||||||
|
If the invoice is already terminal:
|
||||||
|
- `expired`
|
||||||
|
- `canceled`
|
||||||
|
|
||||||
|
the store rejects the request with `invoice_transition_not_allowed`.
|
||||||
|
|
||||||
|
If the invoice does not exist, the store returns `invoice_not_found`.
|
||||||
|
|
||||||
|
## How quota ties to payment
|
||||||
|
- A user can create generation requests only with an active subscription.
|
||||||
|
- 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`
|
||||||
|
- create or reuse the current pending invoice for the authenticated user
|
||||||
|
- `GET /api/billing/invoices`
|
||||||
|
- list the authenticated user's invoices
|
||||||
|
- `POST /api/admin/invoices/:id/mark-paid`
|
||||||
|
- admin-only manual payment activation path
|
||||||
|
|
||||||
|
## Error behavior
|
||||||
|
Current payment-specific errors surfaced by the web app:
|
||||||
|
- `invoice_not_found` -> `404`
|
||||||
|
- `invoice_transition_not_allowed` -> `409`
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
- The system still depends on manual admin confirmation to activate access.
|
||||||
|
- Because provider-final status is not ingested automatically yet, the app currently relies on operator judgment when calling `mark-paid`.
|
||||||
|
- 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 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.
|
||||||
|
- Persist provider-final status before activating access automatically.
|
||||||
|
- Reduce or remove the need for operator judgment in the normal payment-success path.
|
||||||
|
|
||||||
|
## Code references
|
||||||
|
- `packages/db/src/bootstrap.ts`
|
||||||
|
- `packages/db/src/auth-store.ts`
|
||||||
|
- `packages/db/src/billing-store.ts`
|
||||||
|
- `packages/db/src/account-store.ts`
|
||||||
|
- `packages/providers/src/payments.ts`
|
||||||
|
- `apps/web/src/main.ts`
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"generate": "prisma generate",
|
"generate": "prisma generate",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"format": "prisma format"
|
"format": "prisma format",
|
||||||
|
"pretest": "pnpm build",
|
||||||
|
"test": "node --test dist/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nproxy/domain": "workspace:*",
|
"@nproxy/domain": "workspace:*",
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
487
packages/db/src/billing-store.test.ts
Normal file
487
packages/db/src/billing-store.test.ts
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { BillingError, createPrismaBillingStore } from "./billing-store.js";
|
||||||
|
|
||||||
|
test("createUpcomingRenewalInvoices creates one invoice for subscriptions entering the 72h renewal window", async () => {
|
||||||
|
const database = createRenewalBillingDatabase({
|
||||||
|
subscriptions: [
|
||||||
|
createRenewalSubscriptionFixture({
|
||||||
|
id: "subscription_1",
|
||||||
|
currentPeriodStart: new Date("2026-03-01T12:00:00.000Z"),
|
||||||
|
currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
|
||||||
|
const notifications = await store.createUpcomingRenewalInvoices({
|
||||||
|
paymentProvider: "nowpayments",
|
||||||
|
paymentProviderAdapter: {
|
||||||
|
createInvoice: async () => ({
|
||||||
|
providerInvoiceId: "provider_invoice_renewal_1",
|
||||||
|
paymentAddress: "wallet_renewal_1",
|
||||||
|
amountCrypto: 29,
|
||||||
|
amountUsd: 29,
|
||||||
|
currency: "USDT",
|
||||||
|
expiresAt: new Date("2026-03-10T13:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
renewalLeadTimeHours: 72,
|
||||||
|
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(notifications.length, 1);
|
||||||
|
assert.equal(notifications[0]?.email, "user_subscription_1@example.com");
|
||||||
|
assert.equal(notifications[0]?.subscriptionId, "subscription_1");
|
||||||
|
assert.equal(database.calls.paymentInvoiceCreate.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createUpcomingRenewalInvoices does not auto-create another invoice after one already exists in the current cycle", async () => {
|
||||||
|
const currentPeriodStart = new Date("2026-03-01T12:00:00.000Z");
|
||||||
|
const database = createRenewalBillingDatabase({
|
||||||
|
subscriptions: [
|
||||||
|
createRenewalSubscriptionFixture({
|
||||||
|
id: "subscription_1",
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
existingInvoicesBySubscriptionId: {
|
||||||
|
subscription_1: [
|
||||||
|
createInvoiceFixture({
|
||||||
|
id: "invoice_existing",
|
||||||
|
status: "expired",
|
||||||
|
createdAt: new Date("2026-03-09T09:00:00.000Z"),
|
||||||
|
paidAt: null,
|
||||||
|
subscription: createSubscriptionFixture({
|
||||||
|
id: "subscription_1",
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"),
|
||||||
|
status: "active",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
|
||||||
|
const notifications = await store.createUpcomingRenewalInvoices({
|
||||||
|
paymentProvider: "nowpayments",
|
||||||
|
paymentProviderAdapter: {
|
||||||
|
createInvoice: async () => {
|
||||||
|
throw new Error("createInvoice should not be called");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renewalLeadTimeHours: 72,
|
||||||
|
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(notifications.length, 0);
|
||||||
|
assert.equal(database.calls.paymentInvoiceCreate.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expireElapsedPendingInvoices marks pending invoices expired", async () => {
|
||||||
|
const database = createRenewalBillingDatabase({
|
||||||
|
subscriptions: [],
|
||||||
|
expirePendingCount: 2,
|
||||||
|
});
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
|
||||||
|
const result = await store.expireElapsedPendingInvoices(new Date("2026-03-10T12:00:00.000Z"));
|
||||||
|
|
||||||
|
assert.equal(result.expiredCount, 2);
|
||||||
|
assert.equal(database.calls.paymentInvoiceExpireUpdateMany.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markInvoicePaid activates a pending invoice once and writes an admin audit log", async () => {
|
||||||
|
const invoice = createInvoiceFixture({
|
||||||
|
status: "pending",
|
||||||
|
paidAt: null,
|
||||||
|
subscription: createSubscriptionFixture(),
|
||||||
|
});
|
||||||
|
const database = createBillingDatabase({
|
||||||
|
invoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
const result = await store.markInvoicePaid({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
actor: {
|
||||||
|
type: "web_admin",
|
||||||
|
ref: "admin_user_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, "paid");
|
||||||
|
assert.ok(result.paidAt instanceof Date);
|
||||||
|
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
|
||||||
|
assert.equal(database.calls.subscriptionUpdate.length, 1);
|
||||||
|
assert.equal(database.calls.usageLedgerCreate.length, 1);
|
||||||
|
assert.equal(database.calls.adminAuditCreate.length, 1);
|
||||||
|
|
||||||
|
const paymentUpdate = database.calls.paymentInvoiceUpdateMany[0] as ({
|
||||||
|
data: { status: "paid"; paidAt: Date };
|
||||||
|
} | undefined);
|
||||||
|
const auditEntry = database.calls.adminAuditCreate[0];
|
||||||
|
assert.ok(paymentUpdate);
|
||||||
|
assert.ok(auditEntry);
|
||||||
|
assert.equal(paymentUpdate.data.status, "paid");
|
||||||
|
assert.equal(result.paidAt?.toISOString(), paymentUpdate.data.paidAt.toISOString());
|
||||||
|
assert.equal(auditEntry.actorType, "web_admin");
|
||||||
|
assert.equal(auditEntry.actorRef, "admin_user_1");
|
||||||
|
assert.equal(auditEntry.action, "invoice_mark_paid");
|
||||||
|
assert.equal(auditEntry.targetType, "payment_invoice");
|
||||||
|
assert.equal(auditEntry.targetId, invoice.id);
|
||||||
|
assert.deepEqual(auditEntry.metadata, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
subscriptionId: invoice.subscriptionId,
|
||||||
|
provider: invoice.provider,
|
||||||
|
providerInvoiceId: invoice.providerInvoiceId,
|
||||||
|
status: "paid",
|
||||||
|
paidAt: paymentUpdate.data.paidAt.toISOString(),
|
||||||
|
replayed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markInvoicePaid is idempotent for already paid invoices", async () => {
|
||||||
|
const paidAt = new Date("2026-03-10T12:00:00.000Z");
|
||||||
|
const invoice = createInvoiceFixture({
|
||||||
|
status: "paid",
|
||||||
|
paidAt,
|
||||||
|
subscription: createSubscriptionFixture(),
|
||||||
|
});
|
||||||
|
const database = createBillingDatabase({
|
||||||
|
invoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
const result = await store.markInvoicePaid({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
actor: {
|
||||||
|
type: "web_admin",
|
||||||
|
ref: "admin_user_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, "paid");
|
||||||
|
assert.equal(result.paidAt?.toISOString(), paidAt.toISOString());
|
||||||
|
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 0);
|
||||||
|
assert.equal(database.calls.subscriptionUpdate.length, 0);
|
||||||
|
assert.equal(database.calls.usageLedgerCreate.length, 0);
|
||||||
|
assert.equal(database.calls.adminAuditCreate.length, 1);
|
||||||
|
assert.equal(database.calls.adminAuditCreate[0]?.action, "invoice_mark_paid_replayed");
|
||||||
|
assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markInvoicePaid rejects invalid terminal invoice transitions", async () => {
|
||||||
|
const invoice = createInvoiceFixture({
|
||||||
|
status: "expired",
|
||||||
|
paidAt: null,
|
||||||
|
subscription: createSubscriptionFixture(),
|
||||||
|
});
|
||||||
|
const database = createBillingDatabase({
|
||||||
|
invoice,
|
||||||
|
});
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
store.markInvoicePaid({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
actor: {
|
||||||
|
type: "web_admin",
|
||||||
|
ref: "admin_user_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof BillingError &&
|
||||||
|
error.code === "invoice_transition_not_allowed" &&
|
||||||
|
error.message === 'Invoice in status "expired" cannot be marked paid.',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 0);
|
||||||
|
assert.equal(database.calls.subscriptionUpdate.length, 0);
|
||||||
|
assert.equal(database.calls.usageLedgerCreate.length, 0);
|
||||||
|
assert.equal(database.calls.adminAuditCreate.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markInvoicePaid treats a concurrent pending->paid race as a replay without duplicate side effects", async () => {
|
||||||
|
const paidAt = new Date("2026-03-10T12:00:00.000Z");
|
||||||
|
const database = createBillingDatabase({
|
||||||
|
invoice: createInvoiceFixture({
|
||||||
|
status: "pending",
|
||||||
|
paidAt: null,
|
||||||
|
subscription: createSubscriptionFixture(),
|
||||||
|
}),
|
||||||
|
updateManyCount: 0,
|
||||||
|
invoiceAfterFailedTransition: createInvoiceFixture({
|
||||||
|
status: "paid",
|
||||||
|
paidAt,
|
||||||
|
subscription: createSubscriptionFixture(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createPrismaBillingStore(database.client);
|
||||||
|
const result = await store.markInvoicePaid({
|
||||||
|
invoiceId: "invoice_1",
|
||||||
|
actor: {
|
||||||
|
type: "web_admin",
|
||||||
|
ref: "admin_user_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, "paid");
|
||||||
|
assert.equal(result.paidAt?.toISOString(), paidAt.toISOString());
|
||||||
|
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
|
||||||
|
assert.equal(database.calls.subscriptionUpdate.length, 0);
|
||||||
|
assert.equal(database.calls.usageLedgerCreate.length, 0);
|
||||||
|
assert.equal(database.calls.adminAuditCreate.length, 1);
|
||||||
|
assert.equal(database.calls.adminAuditCreate[0]?.action, "invoice_mark_paid_replayed");
|
||||||
|
assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createBillingDatabase(input: {
|
||||||
|
invoice: ReturnType<typeof createInvoiceFixture>;
|
||||||
|
updateManyCount?: number;
|
||||||
|
invoiceAfterFailedTransition?: ReturnType<typeof createInvoiceFixture>;
|
||||||
|
}) {
|
||||||
|
const calls = {
|
||||||
|
paymentInvoiceUpdateMany: [] as Array<Record<string, unknown>>,
|
||||||
|
subscriptionUpdate: [] as Array<Record<string, unknown>>,
|
||||||
|
usageLedgerCreate: [] as Array<Record<string, unknown>>,
|
||||||
|
adminAuditCreate: [] as Array<Record<string, any>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentInvoice = input.invoice;
|
||||||
|
let findUniqueCallCount = 0;
|
||||||
|
|
||||||
|
const transaction = {
|
||||||
|
paymentInvoice: {
|
||||||
|
findUnique: async () => {
|
||||||
|
findUniqueCallCount += 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.invoiceAfterFailedTransition &&
|
||||||
|
input.updateManyCount === 0 &&
|
||||||
|
findUniqueCallCount > 1
|
||||||
|
) {
|
||||||
|
currentInvoice = input.invoiceAfterFailedTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentInvoice;
|
||||||
|
},
|
||||||
|
updateMany: async ({
|
||||||
|
where,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
where: { id: string; status: "pending" };
|
||||||
|
data: { status: "paid"; paidAt: Date };
|
||||||
|
}) => {
|
||||||
|
calls.paymentInvoiceUpdateMany.push({ where, data });
|
||||||
|
|
||||||
|
const count =
|
||||||
|
input.updateManyCount ??
|
||||||
|
(currentInvoice.id === where.id && currentInvoice.status === where.status ? 1 : 0);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
currentInvoice = {
|
||||||
|
...currentInvoice,
|
||||||
|
status: data.status,
|
||||||
|
paidAt: data.paidAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
update: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.subscriptionUpdate.push({ data });
|
||||||
|
return currentInvoice.subscription;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageLedgerEntry: {
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.usageLedgerCreate.push({ data });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminAuditLog: {
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.adminAuditCreate.push(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
$transaction: async <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction),
|
||||||
|
} as unknown as Parameters<typeof createPrismaBillingStore>[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInvoiceFixture(input: {
|
||||||
|
id?: string;
|
||||||
|
status: "pending" | "paid" | "expired" | "canceled";
|
||||||
|
paidAt: Date | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
subscription: ReturnType<typeof createSubscriptionFixture> | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: input.id ?? "invoice_1",
|
||||||
|
userId: "user_1",
|
||||||
|
subscriptionId: input.subscription?.id ?? null,
|
||||||
|
provider: "nowpayments",
|
||||||
|
providerInvoiceId: "provider_invoice_1",
|
||||||
|
status: input.status,
|
||||||
|
currency: "USDT",
|
||||||
|
amountCrypto: new Prisma.Decimal("29"),
|
||||||
|
amountUsd: new Prisma.Decimal("29"),
|
||||||
|
paymentAddress: "wallet_1",
|
||||||
|
expiresAt: new Date("2026-03-11T12:00:00.000Z"),
|
||||||
|
paidAt: input.paidAt,
|
||||||
|
createdAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
updatedAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
subscription: input.subscription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSubscriptionFixture(
|
||||||
|
overrides?: Partial<{
|
||||||
|
id: string;
|
||||||
|
status: "pending_activation" | "active" | "expired";
|
||||||
|
currentPeriodStart: Date | null;
|
||||||
|
currentPeriodEnd: Date | null;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: overrides?.id ?? "subscription_1",
|
||||||
|
userId: "user_1",
|
||||||
|
planId: "plan_1",
|
||||||
|
status: overrides?.status ?? ("pending_activation" as const),
|
||||||
|
renewsManually: true,
|
||||||
|
activatedAt: overrides?.activatedAt ?? null,
|
||||||
|
currentPeriodStart: overrides?.currentPeriodStart ?? null,
|
||||||
|
currentPeriodEnd: overrides?.currentPeriodEnd ?? null,
|
||||||
|
canceledAt: null,
|
||||||
|
createdAt: new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
plan: {
|
||||||
|
id: "plan_1",
|
||||||
|
code: "basic",
|
||||||
|
displayName: "Basic",
|
||||||
|
monthlyRequestLimit: 100,
|
||||||
|
monthlyPriceUsd: new Prisma.Decimal("29"),
|
||||||
|
billingCurrency: "USDT",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-10T11:00:00.000Z"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenewalBillingDatabase(input: {
|
||||||
|
subscriptions: Array<ReturnType<typeof createRenewalSubscriptionFixture>>;
|
||||||
|
existingInvoicesBySubscriptionId?: Record<string, ReturnType<typeof createInvoiceFixture>[]>;
|
||||||
|
expirePendingCount?: number;
|
||||||
|
}) {
|
||||||
|
const calls = {
|
||||||
|
paymentInvoiceCreate: [] as Array<Record<string, unknown>>,
|
||||||
|
paymentInvoiceFindFirst: [] as Array<Record<string, unknown>>,
|
||||||
|
paymentInvoiceExpireUpdateMany: [] as Array<Record<string, unknown>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
subscription: {
|
||||||
|
findMany: async () => input.subscriptions,
|
||||||
|
},
|
||||||
|
paymentInvoice: {
|
||||||
|
findFirst: async ({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: {
|
||||||
|
subscriptionId: string;
|
||||||
|
createdAt: { gte: Date };
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
calls.paymentInvoiceFindFirst.push({ where });
|
||||||
|
const invoices = input.existingInvoicesBySubscriptionId?.[where.subscriptionId] ?? [];
|
||||||
|
return (
|
||||||
|
invoices
|
||||||
|
.filter((invoice) => invoice.createdAt >= where.createdAt.gte)
|
||||||
|
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())[0] ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.paymentInvoiceCreate.push({ data });
|
||||||
|
return {
|
||||||
|
id: "invoice_created_1",
|
||||||
|
subscriptionId: data.subscriptionId as string,
|
||||||
|
provider: data.provider as string,
|
||||||
|
providerInvoiceId: data.providerInvoiceId as string,
|
||||||
|
status: "pending" as const,
|
||||||
|
currency: data.currency as string,
|
||||||
|
amountCrypto: new Prisma.Decimal(String(data.amountCrypto)),
|
||||||
|
amountUsd: new Prisma.Decimal(String(data.amountUsd)),
|
||||||
|
paymentAddress: data.paymentAddress as string,
|
||||||
|
expiresAt: data.expiresAt as Date,
|
||||||
|
paidAt: null,
|
||||||
|
createdAt: new Date("2026-03-09T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-09T12:00:00.000Z"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
updateMany: async ({
|
||||||
|
where,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
where: { status: "pending"; expiresAt: { lte: Date } };
|
||||||
|
data: { status: "expired" };
|
||||||
|
}) => {
|
||||||
|
calls.paymentInvoiceExpireUpdateMany.push({ where, data });
|
||||||
|
return { count: input.expirePendingCount ?? 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createPrismaBillingStore>[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenewalSubscriptionFixture(input: {
|
||||||
|
id: string;
|
||||||
|
currentPeriodStart: Date;
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
userId: `user_${input.id}`,
|
||||||
|
planId: "plan_1",
|
||||||
|
status: "active" as const,
|
||||||
|
renewsManually: true,
|
||||||
|
activatedAt: input.currentPeriodStart,
|
||||||
|
currentPeriodStart: input.currentPeriodStart,
|
||||||
|
currentPeriodEnd: input.currentPeriodEnd,
|
||||||
|
canceledAt: null,
|
||||||
|
createdAt: input.currentPeriodStart,
|
||||||
|
updatedAt: input.currentPeriodStart,
|
||||||
|
user: {
|
||||||
|
id: `user_${input.id}`,
|
||||||
|
email: `user_${input.id}@example.com`,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
id: "plan_1",
|
||||||
|
code: "monthly",
|
||||||
|
displayName: "Monthly",
|
||||||
|
monthlyRequestLimit: 100,
|
||||||
|
monthlyPriceUsd: new Prisma.Decimal("29"),
|
||||||
|
billingCurrency: "USDT",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { PaymentProviderAdapter } from "@nproxy/providers";
|
import type { PaymentProviderAdapter } from "@nproxy/providers";
|
||||||
import { Prisma, type PaymentInvoiceStatus, type PrismaClient, type SubscriptionStatus } from "@prisma/client";
|
import {
|
||||||
|
Prisma,
|
||||||
|
type AdminActorType,
|
||||||
|
type PaymentInvoiceStatus,
|
||||||
|
type PrismaClient,
|
||||||
|
type SubscriptionStatus,
|
||||||
|
} 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;
|
||||||
@@ -36,6 +43,32 @@ export interface SubscriptionBillingRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BillingActorMetadata {
|
||||||
|
type: AdminActorType;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpirePendingInvoicesResult {
|
||||||
|
expiredCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalInvoiceNotification {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
subscriptionCurrentPeriodEnd: Date;
|
||||||
|
invoice: BillingInvoiceRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BillingError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: "invoice_not_found" | "invoice_transition_not_allowed",
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) {
|
export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) {
|
||||||
return {
|
return {
|
||||||
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
|
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
|
||||||
@@ -54,7 +87,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: {
|
||||||
@@ -117,8 +159,118 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
return mapInvoice(invoice);
|
return mapInvoice(invoice);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async expireElapsedPendingInvoices(
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<ExpirePendingInvoicesResult> {
|
||||||
|
const result = await database.paymentInvoice.updateMany({
|
||||||
|
where: {
|
||||||
|
status: "pending",
|
||||||
|
expiresAt: {
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "expired",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
expiredCount: result.count,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async createUpcomingRenewalInvoices(input: {
|
||||||
|
paymentProvider: string;
|
||||||
|
paymentProviderAdapter: PaymentProviderAdapter;
|
||||||
|
renewalLeadTimeHours: number;
|
||||||
|
now?: Date;
|
||||||
|
}): Promise<RenewalInvoiceNotification[]> {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
const renewalWindowEnd = addHours(now, input.renewalLeadTimeHours);
|
||||||
|
const subscriptions = await database.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
status: "active",
|
||||||
|
renewsManually: true,
|
||||||
|
currentPeriodEnd: {
|
||||||
|
gt: now,
|
||||||
|
lte: renewalWindowEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
currentPeriodEnd: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifications: RenewalInvoiceNotification[] = [];
|
||||||
|
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
if (!subscription.currentPeriodEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleStart =
|
||||||
|
subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt;
|
||||||
|
const existingCycleInvoice = await database.paymentInvoice.findFirst({
|
||||||
|
where: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: cycleStart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCycleInvoice) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountUsd = subscription.plan.monthlyPriceUsd.toNumber();
|
||||||
|
const currency = subscription.plan.billingCurrency;
|
||||||
|
const amountCrypto = amountUsd;
|
||||||
|
const providerInvoice = await input.paymentProviderAdapter.createInvoice({
|
||||||
|
userId: subscription.userId,
|
||||||
|
planCode: subscription.plan.code,
|
||||||
|
amountUsd,
|
||||||
|
amountCrypto,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = await database.paymentInvoice.create({
|
||||||
|
data: {
|
||||||
|
userId: subscription.userId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
provider: input.paymentProvider,
|
||||||
|
providerInvoiceId: providerInvoice.providerInvoiceId,
|
||||||
|
status: "pending",
|
||||||
|
currency: providerInvoice.currency,
|
||||||
|
amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto),
|
||||||
|
amountUsd: new Prisma.Decimal(providerInvoice.amountUsd),
|
||||||
|
paymentAddress: providerInvoice.paymentAddress,
|
||||||
|
expiresAt: providerInvoice.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.push({
|
||||||
|
userId: subscription.userId,
|
||||||
|
email: subscription.user.email,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
subscriptionCurrentPeriodEnd: subscription.currentPeriodEnd,
|
||||||
|
invoice: mapInvoice(invoice),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
},
|
||||||
|
|
||||||
async markInvoicePaid(input: {
|
async markInvoicePaid(input: {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
|
actor?: BillingActorMetadata;
|
||||||
}): Promise<BillingInvoiceRecord> {
|
}): Promise<BillingInvoiceRecord> {
|
||||||
return database.$transaction(async (transaction) => {
|
return database.$transaction(async (transaction) => {
|
||||||
const invoice = await transaction.paymentInvoice.findUnique({
|
const invoice = await transaction.paymentInvoice.findUnique({
|
||||||
@@ -133,21 +285,75 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new Error("Invoice not found.");
|
throw new BillingError("invoice_not_found", "Invoice not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === "canceled" || invoice.status === "expired") {
|
||||||
|
throw new BillingError(
|
||||||
|
"invoice_transition_not_allowed",
|
||||||
|
`Invoice in status "${invoice.status}" cannot be marked paid.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === "paid") {
|
||||||
|
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
|
||||||
|
return mapInvoice(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paidAt = invoice.paidAt ?? new Date();
|
const paidAt = invoice.paidAt ?? new Date();
|
||||||
const updatedInvoice =
|
const transitionResult = await transaction.paymentInvoice.updateMany({
|
||||||
invoice.status === "paid"
|
where: {
|
||||||
? invoice
|
id: invoice.id,
|
||||||
: await transaction.paymentInvoice.update({
|
status: "pending",
|
||||||
where: { id: invoice.id },
|
},
|
||||||
data: {
|
data: {
|
||||||
status: "paid",
|
status: "paid",
|
||||||
paidAt,
|
paidAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (transitionResult.count === 0) {
|
||||||
|
const currentInvoice = await transaction.paymentInvoice.findUnique({
|
||||||
|
where: { id: input.invoiceId },
|
||||||
|
include: {
|
||||||
|
subscription: {
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentInvoice) {
|
||||||
|
throw new BillingError("invoice_not_found", "Invoice not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentInvoice.status === "paid") {
|
||||||
|
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
|
||||||
|
return mapInvoice(currentInvoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BillingError(
|
||||||
|
"invoice_transition_not_allowed",
|
||||||
|
`Invoice in status "${currentInvoice.status}" cannot be marked paid.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedInvoice = await transaction.paymentInvoice.findUnique({
|
||||||
|
where: { id: invoice.id },
|
||||||
|
include: {
|
||||||
|
subscription: {
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedInvoice) {
|
||||||
|
throw new BillingError("invoice_not_found", "Invoice not found.");
|
||||||
|
}
|
||||||
|
|
||||||
if (invoice.subscription) {
|
if (invoice.subscription) {
|
||||||
const periodStart = paidAt;
|
const periodStart = paidAt;
|
||||||
const periodEnd = addDays(periodStart, 30);
|
const periodEnd = addDays(periodStart, 30);
|
||||||
@@ -175,12 +381,51 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
|
||||||
|
|
||||||
return mapInvoice(updatedInvoice);
|
return mapInvoice(updatedInvoice);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeInvoicePaidAuditLog(
|
||||||
|
database: Pick<PrismaClient, "adminAuditLog">,
|
||||||
|
invoice: {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
provider: string;
|
||||||
|
providerInvoiceId: string | null;
|
||||||
|
status: PaymentInvoiceStatus;
|
||||||
|
paidAt: Date | null;
|
||||||
|
},
|
||||||
|
actor: BillingActorMetadata | undefined,
|
||||||
|
replayed: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!actor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.adminAuditLog.create({
|
||||||
|
data: {
|
||||||
|
actorType: actor.type,
|
||||||
|
...(actor.ref ? { actorRef: actor.ref } : {}),
|
||||||
|
action: replayed ? "invoice_mark_paid_replayed" : "invoice_mark_paid",
|
||||||
|
targetType: "payment_invoice",
|
||||||
|
targetId: invoice.id,
|
||||||
|
metadata: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
subscriptionId: invoice.subscriptionId,
|
||||||
|
provider: invoice.provider,
|
||||||
|
providerInvoiceId: invoice.providerInvoiceId,
|
||||||
|
status: invoice.status,
|
||||||
|
paidAt: invoice.paidAt?.toISOString() ?? null,
|
||||||
|
replayed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mapInvoice(invoice: {
|
function mapInvoice(invoice: {
|
||||||
id: string;
|
id: string;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
@@ -252,3 +497,7 @@ function mapSubscription(subscription: {
|
|||||||
function addDays(value: Date, days: number): Date {
|
function addDays(value: Date, days: number): Date {
|
||||||
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addHours(value: Date, hours: number): Date {
|
||||||
|
return new Date(value.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|||||||
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