2 Commits

Author SHA1 Message Date
624c5809b6 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
2026-03-10 18:12:21 +03:00
1b2a4a076a fix: make invoice payment activation idempotent (#18)
Closes #2

## Summary
- make `markInvoicePaid` idempotent for already-paid invoices and reject invalid terminal transitions
- add admin actor metadata and audit-log writes for `mark-paid`, including replayed no-op calls
- add focused DB tests for first activation, replay safety, and invalid transition handling
- document the current payment system, including invoice creation, manual activation, quota reset, and current limitations

## 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: #18
2026-03-10 17:53:00 +03:00
14 changed files with 1038 additions and 211 deletions

View File

@@ -5,6 +5,7 @@ import {
} from "node:http";
import { loadConfig } from "@nproxy/config";
import {
BillingError,
createPrismaAccountStore,
createPrismaAuthStore,
createPrismaBillingStore,
@@ -211,7 +212,13 @@ const server = createServer(async (request, response) => {
}
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, {
invoice: serializeBillingInvoice(invoice),
});
@@ -672,6 +679,23 @@ function handleRequestError(
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) {
const statusCode =
error.code === "missing_active_subscription"

View File

@@ -1,30 +0,0 @@
# ADR-0001: Use BTCPay Server as the primary payment processor
## Status
Accepted
## Context
- The product requires crypto invoice checkout with manual subscription renewal.
- `merchant noKYC` is a hard requirement.
- `crypto-to-crypto noKYC` is desirable, but it does not replace the merchant-side requirement.
- Hosted processors can offer a usable API surface, but they also introduce AML/KYC escalation risk, payout holds, and custodial exposure.
- The product already targets operator-managed infrastructure, so an additional self-hosted payment component is operationally acceptable.
## Decision
Use `BTCPay Server` as the primary payment processor.
Keep the application payment adapter provider-agnostic, but treat hosted processors as non-default alternatives that require an explicit policy change.
## Rationale
- `BTCPay Server` is self-hosted and non-custodial, which fits the hard `merchant noKYC` requirement better than hosted processors.
- A self-custody path materially reduces the risk that a payment provider freezes merchant balances after receiving suspicious funds.
- The API and webhook model is sufficient for invoice creation, status reconciliation, and callback handling.
- The operational tradeoff is acceptable because the product already assumes server-managed infrastructure.
## Consequences
- Deployment must account for a self-hosted BTCPay stack and its persistent data.
- Payment operations now include wallet, backup, and reconciliation responsibilities that a hosted processor would otherwise absorb.
- Later support for hosted processors remains possible through the shared payment adapter contract, but they are out of policy unless the `merchant noKYC` requirement changes.
## References
- [payment-provider-selection.md](/home/sirily/nroxy/docs/ops/payment-provider-selection.md)

View File

@@ -16,7 +16,6 @@ Deploy on one VPS with Docker Compose.
- `postgres`: primary database
- `caddy`: TLS termination and reverse proxy
- optional `minio`: self-hosted object storage for single-server deployments
- `btcpay`: self-hosted payment stack for crypto invoice checkout
## Deployment notes
- Run one Compose project on a single server.
@@ -24,8 +23,6 @@ Deploy on one VPS with Docker Compose.
- Keep secrets in server-side environment files or a secret manager.
- Back up PostgreSQL and object storage separately.
- Prefer Telegram long polling to avoid an extra public webhook surface for the bot.
- Keep BTCPay persistent data and wallet material isolated from app volumes and back them up separately.
- If BTCPay requires auxiliary services, treat them as part of the payment stack rather than as app-internal services.
## Upgrade strategy
- Build new images.
@@ -45,9 +42,7 @@ Deploy on one VPS with Docker Compose.
- provision DNS and TLS
- provision PostgreSQL storage
- provision S3-compatible storage or enable local MinIO
- provision BTCPay Server storage and wallet backup path
- create `.env`
- deploy Compose stack
- run database migration job
- verify web health, worker job loop, and bot polling
- verify BTCPay invoice creation, callback delivery, and payout/reconciliation procedures

View File

@@ -1,134 +0,0 @@
# Payment Provider Selection
## Status
Working selection criteria for the product payment processor. Reviewed on 2026-03-10 against official provider materials.
## Goal
Pick a crypto payment path that fits `nproxy`:
- monthly crypto subscription
- manual renewal only
- fixed-price invoice checkout
- single-VPS deployment
- safe callback or polling-based reconciliation
## Hard requirements
- Crypto invoice API for fixed-price checkout.
- Manual renewal flow. The provider must not force native recurring billing.
- Stable external invoice identifier for reconciliation.
- Clear invoice lifecycle that can be mapped to `pending`, `paid`, `expired`, and `canceled`.
- Webhook support or a reliable status polling API. Both is preferred.
- Idempotent event handling. Duplicate callbacks or repeated status checks must not cause repeated subscription activation.
- Metadata or reference fields so the app can correlate `userId`, local `invoiceId`, and `subscriptionId`.
- Test mode, sandbox, or another safe way to validate integration without real funds.
- Merchant documentation clear enough to implement and operate without reverse engineering.
- Fees and supported chains viable for small monthly subscription invoices.
## noKYC requirement
`noKYC` is a hard selection criterion for the product.
For this repository, `noKYC` means all of the following in the normal operating path:
- The merchant can start accepting payments without mandatory KYC or KYB review.
- The payer can complete checkout without mandatory identity verification.
- The provider does not require custody of merchant funds unless that is an explicit deployment choice.
Track `crypto-to-crypto noKYC` separately from merchant onboarding:
- `merchant noKYC` asks whether `nproxy` can start operating without provider-side KYC or KYB.
- `crypto-to-crypto noKYC` asks whether a payer can complete an on-chain crypto payment from a normal wallet without entering an identity-verification flow.
The first one is the hard gate. The second one is still valuable and should be recorded in the comparison.
Evaluation rule:
- `pass`: official materials support operating without mandatory KYC in the normal path.
- `borderline`: marketing or docs imply low-friction onboarding, but the provider reserves the right to require KYC, hold funds, or escalate compliance checks.
- `fail`: official docs explicitly require merchant verification, or the product is clearly compliance-gated.
## Comparison matrix
| Provider | Model | Merchant noKYC | Crypto-to-crypto noKYC | API / webhooks | Test mode | Product fit | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- |
| BTCPay Server | Self-hosted, self-custody | pass | pass | pass | pass | strong | Best fit for strict `noKYC`. Higher ops cost because the merchant runs the payment stack. |
| NOWPayments | Hosted processor | borderline | borderline to pass | pass | unclear | medium | Closest hosted option. Normal crypto-to-crypto flow appears low-friction, but official support docs reserve the right to request verification and hold suspicious transactions. |
| Cryptomus | Hosted processor | fail | unclear | pass | unclear | weak | Official help center says merchants need to pass KYC to use the platform. Reviewed materials did not give a clean official `crypto-to-crypto noKYC` promise for payers. |
| Coinbase Commerce / Coinbase Business | Hosted processor | fail | unclear | pass | pass | weak | Strong API surface, but the reviewed official materials do not support a strict `noKYC` posture. |
## Provider notes
### BTCPay Server
- Official BTCPay materials describe it as self-hosted and non-custodial.
- Official integration copy explicitly positions it as avoiding complicated KYC and keeping merchants in control of funds.
- This is the cleanest fit if `noKYC` must be enforced as a hard requirement rather than a preference.
- Tradeoff: the team must operate the service, wallet integration, backups, and chain support.
### NOWPayments
- Official API materials cover invoice creation and payment-status callbacks.
- Official support materials say account verification is not required in general, but NOWPayments may still request KYC details and put transactions on hold in suspicious cases.
- For the payer side, direct crypto-to-crypto checkout looks closer to `noKYC` than card or custodial account flows, but the official materials reviewed still leave compliance-escalation risk.
- That means it does not cleanly satisfy a strict `noKYC` rule. It is only acceptable if the product can tolerate compliance escalation risk.
### Cryptomus
- Official merchant docs cover invoice APIs and callbacks.
- Official help materials state that a merchant must pass KYC verification to use the platform.
- That merchant-side requirement is enough to fail the current hard gate even if a payer-side crypto path is relatively low-friction.
- This makes it a direct mismatch for the current product requirement.
### Coinbase Commerce / Coinbase Business
- Official Coinbase Commerce docs cover charges, webhooks, and test integration paths.
- Official Coinbase legal materials state that identity verification is required for customers to use Coinbase services.
- The reviewed official materials did not give a clean promise that external-wallet crypto-to-crypto checkout remains `noKYC` end to end.
- This is operationally mature, but it is not compatible with a strict `noKYC` requirement.
## Recommendation
- If `noKYC` is truly hard, prefer `BTCPay Server`.
- If a hosted processor is still desired, treat `NOWPayments` as the only current shortlist candidate from this review, but mark it as `borderline`, not `pass`.
- If the team later relaxes the hard gate from `merchant noKYC` to only `crypto-to-crypto noKYC`, rerun the comparison because the shortlist may widen.
- Do not choose `Cryptomus` or `Coinbase` while `noKYC` remains a hard requirement.
## Merchant KYC / AML risk
This section answers the practical merchant-side risk question: can a payment path create a real loss or freeze scenario if a customer pays with suspicious or "dirty" crypto.
### Short answer
- With a hosted or custodial processor, yes, there is a real risk of payout holds, extra KYC/KYB review, delayed settlement, or account restrictions if the provider flags a payment as suspicious.
- With a self-hosted and non-custodial path such as `BTCPay Server`, the processor itself does not hold your funds, so it cannot directly freeze coins that have already landed in your wallet.
- Even with self-custody, the risk is not gone. It moves downstream to whatever exchange, custodian, OTC desk, or banking ramp you use later.
### Can you lose all the money?
- The official materials reviewed support a real hold and compliance-escalation risk for hosted processors, especially around suspicious transactions.
- They do not, by themselves, prove guaranteed confiscation of all funds.
- The practical risk is usually loss of access, payout freeze, account closure, or long investigation windows rather than an automatic irreversible seizure.
- If your operating balance sits inside a custodial processor or exchange account during that event, the business impact can still feel like "losing the money" for an operationally important period.
### Why this matters for provider choice
- `NOWPayments` explicitly says verification may be requested and suspicious transactions may be put on hold.
- `Coinbase` documents identity verification and account restrictions as part of its compliance posture.
- `BTCPay Server` is self-hosted and non-custodial, so the payment processor layer is structurally less exposed to merchant-balance freezes.
### Inference for this repository
This is an inference from the official sources above:
- If minimizing merchant-side AML/KYC freeze risk is a priority, self-custody is materially safer than a hosted custodial processor.
- Self-custody does not make tainted-funds risk disappear. It mainly removes one counterparty that could hold or block your balance before it reaches your own wallet.
- The remaining exposure shows up when funds are consolidated, swapped, or off-ramped through third parties.
### Operational mitigations
- Prefer a non-custodial payment path.
- Keep payment receipt wallets segregated from treasury and personal wallets.
- Avoid commingling funds from unrelated customers before reconciliation.
- Keep invoice-to-transaction mapping so suspicious deposits can be isolated quickly.
- Assume any later exchange or off-ramp may apply AML screening even if the incoming payment path does not.
## Implementation consequence
Before starting issue `#9` or a real provider integration, keep the payment adapter contract provider-agnostic:
- `createInvoice`
- `getInvoiceStatus`
- `verifyWebhook`
- `expireInvoice` or an explicit documented fallback when provider-side expiry is passive only
- correlation metadata round-trip
## Official sources used for this review
- BTCPay Server homepage: https://btcpayserver.org/
- BTCPay Server Greenfield API: https://docs.btcpayserver.org/API/Greenfield/v1/
- NOWPayments API docs: https://nowpayments.io/payment-integration
- NOWPayments support on verification: https://support.nowpayments.io/hc/en-us/articles/21395546303389-Verification
- Cryptomus merchant API docs: https://doc.cryptomus.com/
- Cryptomus KYC verification help: https://help.cryptomus.com/legal-and-security/kyc-verification
- Coinbase Commerce docs: https://docs.cdp.coinbase.com/commerce/docs/welcome
- Coinbase identity verification help: https://help.coinbase.com/en/coinbase/getting-started/verify-my-account/how-do-i-verify-my-identity-when-using-the-mobile-app

199
docs/ops/payment-system.md Normal file
View File

@@ -0,0 +1,199 @@
# 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
- manual admin activation after an operator verifies that the provider reported a final successful payment status
- automatic expiry of elapsed subscription periods during account and generation access checks
- quota-cycle reset on successful activation
The current payment system does not yet cover:
- 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.
## 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.
## 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`

View File

@@ -8,7 +8,7 @@ The service hides provider-key failures behind a routed key pool. A user request
## Confirmed product decisions
- One B2C website.
- One monthly subscription plan.
- Crypto checkout through self-hosted `BTCPay Server`.
- Crypto checkout through a payment processor.
- Manual renewal.
- Text-to-image and image-to-image.
- User-facing synchronous experience implemented with polling over background execution.
@@ -93,7 +93,6 @@ Single VPS with Docker Compose, expected services:
- `bot`
- `postgres`
- `caddy` or `nginx`
- `btcpay`
- optional `minio` when object storage is self-hosted
## Optional extensions

View File

@@ -21,7 +21,9 @@
"db:push": "prisma db push",
"generate": "prisma generate",
"migrate:deploy": "prisma migrate deploy",
"format": "prisma format"
"format": "prisma format",
"pretest": "pnpm build",
"test": "node --test dist/**/*.test.js"
},
"dependencies": {
"@nproxy/domain": "workspace:*",

View File

@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Prisma } from "@prisma/client";
import { createPrismaAccountStore } from "./account-store.js";
test("getUserAccountOverview marks elapsed active subscriptions expired and clears quota", async () => {
const database = createAccountDatabase({
subscription: createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
});
const store = createPrismaAccountStore(database.client);
const overview = await store.getUserAccountOverview("user_1");
assert.ok(overview);
assert.equal(overview.subscription?.status, "expired");
assert.equal(overview.quota, null);
assert.equal(database.calls.subscriptionUpdateMany.length, 1);
assert.equal(database.state.subscription?.status, "expired");
});
function createAccountDatabase(input: {
subscription: ReturnType<typeof createSubscriptionFixture> | null;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
usageLedgerAggregate: [] as Array<Record<string, unknown>>,
};
const state = {
subscription: input.subscription,
};
const client = {
user: {
findUnique: async ({ where }: { where: { id: string } }) =>
where.id === "user_1"
? {
id: "user_1",
email: "user@example.com",
isAdmin: false,
createdAt: new Date("2026-02-10T12:00:00.000Z"),
}
: null,
},
subscription: {
findFirst: async ({ where }: { where: { userId: string } }) =>
state.subscription && state.subscription.userId === where.userId
? state.subscription
: null,
updateMany: async ({
where,
data,
}: {
where: { id: string; status: "active" | "past_due" };
data: { status: "expired" };
}) => {
calls.subscriptionUpdateMany.push({ where, data });
if (
state.subscription &&
state.subscription.id === where.id &&
state.subscription.status === where.status
) {
state.subscription = {
...state.subscription,
status: data.status,
};
return { count: 1 };
}
return { count: 0 };
},
},
usageLedgerEntry: {
aggregate: async (args: Record<string, unknown>) => {
calls.usageLedgerAggregate.push(args);
return {
_sum: {
deltaRequests: 0,
},
};
},
},
} as unknown as Parameters<typeof createPrismaAccountStore>[0];
return {
client,
calls,
state,
};
}
function createSubscriptionFixture(input: {
status: "active" | "expired" | "past_due";
currentPeriodEnd: Date;
}) {
return {
id: "subscription_1",
userId: "user_1",
planId: "plan_1",
status: input.status,
renewsManually: true,
activatedAt: new Date("2026-02-10T12:00:00.000Z"),
currentPeriodStart: new Date("2026-02-10T12:00:00.000Z"),
currentPeriodEnd: input.currentPeriodEnd,
canceledAt: null,
createdAt: new Date("2026-02-10T12:00:00.000Z"),
updatedAt: new Date("2026-02-10T12:00:00.000Z"),
plan: {
id: "plan_1",
code: "monthly",
displayName: "Monthly",
monthlyPriceUsd: new Prisma.Decimal("9.99"),
billingCurrency: "USDT",
isActive: true,
},
};
}

View File

@@ -2,6 +2,7 @@ import { getApproximateQuotaBucket, type QuotaBucket } from "@nproxy/domain";
import type { PrismaClient, SubscriptionStatus } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { prisma as defaultPrisma } from "./prisma-client.js";
import { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
export interface UserAccountOverview {
user: {
@@ -58,13 +59,26 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma)
],
});
const quota = subscription
const currentSubscription = await reconcileElapsedSubscription(database, subscription, {
reload: async () =>
database.subscription.findFirst({
where: {
userId,
},
include: {
plan: true,
},
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
}),
});
const quota = currentSubscription?.status === "active"
? await buildQuotaSnapshot(database, userId, {
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
cycleStart:
subscription.currentPeriodStart ??
subscription.activatedAt ??
subscription.createdAt,
currentSubscription.currentPeriodStart ??
currentSubscription.activatedAt ??
currentSubscription.createdAt,
})
: null;
@@ -75,26 +89,30 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma)
isAdmin: user.isAdmin,
createdAt: user.createdAt,
},
subscription: subscription
subscription: currentSubscription
? {
id: subscription.id,
status: subscription.status,
renewsManually: subscription.renewsManually,
...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}),
...(subscription.currentPeriodStart
? { currentPeriodStart: subscription.currentPeriodStart }
id: currentSubscription.id,
status: currentSubscription.status,
renewsManually: currentSubscription.renewsManually,
...(currentSubscription.activatedAt
? { activatedAt: currentSubscription.activatedAt }
: {}),
...(subscription.currentPeriodEnd
? { currentPeriodEnd: subscription.currentPeriodEnd }
...(currentSubscription.currentPeriodStart
? { currentPeriodStart: currentSubscription.currentPeriodStart }
: {}),
...(currentSubscription.currentPeriodEnd
? { currentPeriodEnd: currentSubscription.currentPeriodEnd }
: {}),
...(currentSubscription.canceledAt
? { canceledAt: currentSubscription.canceledAt }
: {}),
...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}),
plan: {
id: subscription.plan.id,
code: subscription.plan.code,
displayName: subscription.plan.displayName,
monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd),
billingCurrency: subscription.plan.billingCurrency,
isActive: subscription.plan.isActive,
id: currentSubscription.plan.id,
code: currentSubscription.plan.code,
displayName: currentSubscription.plan.displayName,
monthlyPriceUsd: decimalToNumber(currentSubscription.plan.monthlyPriceUsd),
billingCurrency: currentSubscription.plan.billingCurrency,
isActive: currentSubscription.plan.isActive,
},
}
: null,

View File

@@ -0,0 +1,285 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Prisma } from "@prisma/client";
import { BillingError, createPrismaBillingStore } from "./billing-store.js";
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: {
status: "pending" | "paid" | "expired" | "canceled";
paidAt: Date | null;
subscription: ReturnType<typeof createSubscriptionFixture> | null;
}) {
return {
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: new Date("2026-03-10T11:00:00.000Z"),
updatedAt: new Date("2026-03-10T11:00:00.000Z"),
subscription: input.subscription,
};
}
function createSubscriptionFixture() {
return {
id: "subscription_1",
userId: "user_1",
planId: "plan_1",
status: "pending_activation" as const,
renewsManually: true,
activatedAt: null,
currentPeriodStart: null,
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"),
},
};
}

View File

@@ -1,6 +1,13 @@
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 { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
export interface BillingInvoiceRecord {
id: string;
@@ -36,6 +43,20 @@ export interface SubscriptionBillingRecord {
};
}
export interface BillingActorMetadata {
type: AdminActorType;
ref?: string;
}
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) {
return {
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
@@ -54,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: {
@@ -119,6 +149,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
async markInvoicePaid(input: {
invoiceId: string;
actor?: BillingActorMetadata;
}): Promise<BillingInvoiceRecord> {
return database.$transaction(async (transaction) => {
const invoice = await transaction.paymentInvoice.findUnique({
@@ -133,21 +164,75 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
});
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 updatedInvoice =
invoice.status === "paid"
? invoice
: await transaction.paymentInvoice.update({
where: { id: invoice.id },
const transitionResult = await transaction.paymentInvoice.updateMany({
where: {
id: invoice.id,
status: "pending",
},
data: {
status: "paid",
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) {
const periodStart = paidAt;
const periodEnd = addDays(periodStart, 30);
@@ -175,12 +260,51 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
});
}
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
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: {
id: string;
subscriptionId: string | null;

View File

@@ -0,0 +1,158 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Prisma } from "@prisma/client";
import { GenerationRequestError, createGenerationRequest } from "@nproxy/domain";
import { createPrismaGenerationStore } from "./generation-store.js";
test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => {
const database = createGenerationDatabase({
subscription: createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
});
const store = createPrismaGenerationStore(database.client);
await assert.rejects(
createGenerationRequest(store, {
userId: "user_1",
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
}),
(error: unknown) =>
error instanceof GenerationRequestError &&
error.code === "missing_active_subscription",
);
assert.equal(database.calls.subscriptionUpdateMany.length, 1);
assert.equal(database.calls.generationRequestCreate.length, 0);
assert.equal(database.state.subscription?.status, "expired");
});
function createGenerationDatabase(input: {
subscription: ReturnType<typeof createSubscriptionFixture> | null;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
generationRequestCreate: [] as Array<Record<string, unknown>>,
};
const state = {
subscription: input.subscription,
};
const client = {
subscription: {
findFirst: async ({
where,
}: {
where: { userId: string; status?: "active" };
}) => {
if (!state.subscription || state.subscription.userId !== where.userId) {
return null;
}
if (where.status && state.subscription.status !== where.status) {
return null;
}
return state.subscription;
},
updateMany: async ({
where,
data,
}: {
where: { id: string; status: "active" | "past_due" };
data: { status: "expired" };
}) => {
calls.subscriptionUpdateMany.push({ where, data });
if (
state.subscription &&
state.subscription.id === where.id &&
state.subscription.status === where.status
) {
state.subscription = {
...state.subscription,
status: data.status,
};
return { count: 1 };
}
return { count: 0 };
},
},
usageLedgerEntry: {
aggregate: async () => ({
_sum: {
deltaRequests: 0,
},
}),
},
generationRequest: {
create: async ({ data }: { data: Record<string, unknown> }) => {
calls.generationRequestCreate.push({ data });
return {
id: "request_1",
userId: data.userId as string,
mode: data.mode as string,
status: "queued",
providerModel: data.providerModel as string,
prompt: data.prompt as string,
sourceImageKey: null,
resolutionPreset: data.resolutionPreset as string,
batchSize: data.batchSize as number,
imageStrength: null,
idempotencyKey: null,
terminalErrorCode: null,
terminalErrorText: null,
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
startedAt: null,
completedAt: null,
createdAt: new Date("2026-03-10T12:00:00.000Z"),
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
};
},
findFirst: async () => null,
},
} as unknown as Parameters<typeof createPrismaGenerationStore>[0];
return {
client,
calls,
state,
};
}
function createSubscriptionFixture(input: {
status: "active" | "expired" | "past_due";
currentPeriodEnd: Date;
}) {
return {
id: "subscription_1",
userId: "user_1",
planId: "plan_1",
status: input.status,
renewsManually: true,
activatedAt: new Date("2026-02-10T12:00:00.000Z"),
currentPeriodStart: new Date("2026-02-10T12:00:00.000Z"),
currentPeriodEnd: input.currentPeriodEnd,
canceledAt: null,
createdAt: new Date("2026-02-10T12:00:00.000Z"),
updatedAt: new Date("2026-02-10T12:00:00.000Z"),
plan: {
id: "plan_1",
code: "monthly",
displayName: "Monthly",
monthlyRequestLimit: 100,
monthlyPriceUsd: new Prisma.Decimal("9.99"),
billingCurrency: "USDT",
isActive: true,
createdAt: new Date("2026-02-10T12:00:00.000Z"),
updatedAt: new Date("2026-02-10T12:00:00.000Z"),
},
};
}

View File

@@ -8,6 +8,7 @@ import {
} from "@nproxy/domain";
import { Prisma, type PrismaClient } from "@prisma/client";
import { prisma as defaultPrisma } from "./prisma-client.js";
import { reconcileElapsedSubscription } from "./subscription-lifecycle.js";
export interface GenerationStore
extends CreateGenerationRequestDeps,
@@ -45,12 +46,28 @@ export function createPrismaGenerationStore(
],
});
if (!subscription) {
const currentSubscription = await reconcileElapsedSubscription(database, subscription, {
reload: async () =>
database.subscription.findFirst({
where: {
userId,
status: "active",
},
include: {
plan: true,
},
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
}),
});
if (!currentSubscription || currentSubscription.status !== "active") {
return null;
}
const cycleStart =
subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt;
currentSubscription.currentPeriodStart ??
currentSubscription.activatedAt ??
currentSubscription.createdAt;
const usageAggregation = await database.usageLedgerEntry.aggregate({
where: {
@@ -64,9 +81,9 @@ export function createPrismaGenerationStore(
});
return {
subscriptionId: subscription.id,
planId: subscription.planId,
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
subscriptionId: currentSubscription.id,
planId: currentSubscription.planId,
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0,
};
},

View File

@@ -0,0 +1,49 @@
import type { PrismaClient, SubscriptionStatus } from "@prisma/client";
type ExpirableSubscription = {
id: string;
status: SubscriptionStatus;
currentPeriodEnd: Date | null;
};
export async function reconcileElapsedSubscription<T extends ExpirableSubscription>(
database: Pick<PrismaClient, "subscription">,
subscription: T | null,
input?: {
now?: Date;
reload?: () => Promise<T | null>;
},
): Promise<T | null> {
if (!subscription) {
return null;
}
const now = input?.now ?? new Date();
const shouldExpire =
(subscription.status === "active" || subscription.status === "past_due") &&
subscription.currentPeriodEnd !== null &&
subscription.currentPeriodEnd <= now;
if (!shouldExpire) {
return subscription;
}
const result = await database.subscription.updateMany({
where: {
id: subscription.id,
status: subscription.status,
},
data: {
status: "expired",
},
});
if (result.count > 0) {
return {
...subscription,
status: "expired",
};
}
return input?.reload ? input.reload() : subscription;
}