Compare commits
1 Commits
master
...
docs/payme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92f912be5 |
@@ -5,7 +5,6 @@ import {
|
||||
} from "node:http";
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import {
|
||||
BillingError,
|
||||
createPrismaAccountStore,
|
||||
createPrismaAuthStore,
|
||||
createPrismaBillingStore,
|
||||
@@ -212,13 +211,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
|
||||
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
||||
const invoice = await billingStore.markInvoicePaid({
|
||||
invoiceId,
|
||||
actor: {
|
||||
type: "web_admin",
|
||||
ref: authenticatedSession.user.id,
|
||||
},
|
||||
});
|
||||
const invoice = await billingStore.markInvoicePaid({ invoiceId });
|
||||
sendJson(response, 200, {
|
||||
invoice: serializeBillingInvoice(invoice),
|
||||
});
|
||||
@@ -679,23 +672,6 @@ 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"
|
||||
|
||||
30
docs/architecture/adr-0001-payment-processor-selection.md
Normal file
30
docs/architecture/adr-0001-payment-processor-selection.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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)
|
||||
@@ -16,6 +16,7 @@ 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.
|
||||
@@ -23,6 +24,8 @@ 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.
|
||||
@@ -42,7 +45,9 @@ 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
|
||||
|
||||
134
docs/ops/payment-provider-selection.md
Normal file
134
docs/ops/payment-provider-selection.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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
|
||||
@@ -1,199 +0,0 @@
|
||||
# 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`
|
||||
@@ -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 a payment processor.
|
||||
- Crypto checkout through self-hosted `BTCPay Server`.
|
||||
- Manual renewal.
|
||||
- Text-to-image and image-to-image.
|
||||
- User-facing synchronous experience implemented with polling over background execution.
|
||||
@@ -93,6 +93,7 @@ Single VPS with Docker Compose, expected services:
|
||||
- `bot`
|
||||
- `postgres`
|
||||
- `caddy` or `nginx`
|
||||
- `btcpay`
|
||||
- optional `minio` when object storage is self-hosted
|
||||
|
||||
## Optional extensions
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"db:push": "prisma db push",
|
||||
"generate": "prisma generate",
|
||||
"migrate:deploy": "prisma migrate deploy",
|
||||
"format": "prisma format",
|
||||
"pretest": "pnpm build",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
"format": "prisma format"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/domain": "workspace:*",
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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,7 +2,6 @@ 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: {
|
||||
@@ -59,26 +58,13 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma)
|
||||
],
|
||||
});
|
||||
|
||||
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"
|
||||
const quota = subscription
|
||||
? await buildQuotaSnapshot(database, userId, {
|
||||
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
cycleStart:
|
||||
currentSubscription.currentPeriodStart ??
|
||||
currentSubscription.activatedAt ??
|
||||
currentSubscription.createdAt,
|
||||
subscription.currentPeriodStart ??
|
||||
subscription.activatedAt ??
|
||||
subscription.createdAt,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -89,30 +75,26 @@ export function createPrismaAccountStore(database: PrismaClient = defaultPrisma)
|
||||
isAdmin: user.isAdmin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
subscription: currentSubscription
|
||||
subscription: subscription
|
||||
? {
|
||||
id: currentSubscription.id,
|
||||
status: currentSubscription.status,
|
||||
renewsManually: currentSubscription.renewsManually,
|
||||
...(currentSubscription.activatedAt
|
||||
? { activatedAt: currentSubscription.activatedAt }
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
renewsManually: subscription.renewsManually,
|
||||
...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}),
|
||||
...(subscription.currentPeriodStart
|
||||
? { currentPeriodStart: subscription.currentPeriodStart }
|
||||
: {}),
|
||||
...(currentSubscription.currentPeriodStart
|
||||
? { currentPeriodStart: currentSubscription.currentPeriodStart }
|
||||
: {}),
|
||||
...(currentSubscription.currentPeriodEnd
|
||||
? { currentPeriodEnd: currentSubscription.currentPeriodEnd }
|
||||
: {}),
|
||||
...(currentSubscription.canceledAt
|
||||
? { canceledAt: currentSubscription.canceledAt }
|
||||
...(subscription.currentPeriodEnd
|
||||
? { currentPeriodEnd: subscription.currentPeriodEnd }
|
||||
: {}),
|
||||
...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}),
|
||||
plan: {
|
||||
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,
|
||||
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,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { PaymentProviderAdapter } from "@nproxy/providers";
|
||||
import {
|
||||
Prisma,
|
||||
type AdminActorType,
|
||||
type PaymentInvoiceStatus,
|
||||
type PrismaClient,
|
||||
type SubscriptionStatus,
|
||||
} from "@prisma/client";
|
||||
import { Prisma, 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;
|
||||
@@ -43,20 +36,6 @@ 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[]> {
|
||||
@@ -75,16 +54,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
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;
|
||||
return subscription ? mapSubscription(subscription) : null;
|
||||
},
|
||||
|
||||
async createSubscriptionInvoice(input: {
|
||||
@@ -149,7 +119,6 @@ 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({
|
||||
@@ -164,74 +133,20 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
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);
|
||||
throw new Error("Invoice not found.");
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? new Date();
|
||||
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,
|
||||
const updatedInvoice =
|
||||
invoice.status === "paid"
|
||||
? invoice
|
||||
: await transaction.paymentInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: {
|
||||
status: "paid",
|
||||
paidAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -260,51 +175,12 @@ 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;
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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,7 +8,6 @@ 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,
|
||||
@@ -46,28 +45,12 @@ export function createPrismaGenerationStore(
|
||||
],
|
||||
});
|
||||
|
||||
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") {
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cycleStart =
|
||||
currentSubscription.currentPeriodStart ??
|
||||
currentSubscription.activatedAt ??
|
||||
currentSubscription.createdAt;
|
||||
subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt;
|
||||
|
||||
const usageAggregation = await database.usageLedgerEntry.aggregate({
|
||||
where: {
|
||||
@@ -81,9 +64,9 @@ export function createPrismaGenerationStore(
|
||||
});
|
||||
|
||||
return {
|
||||
subscriptionId: currentSubscription.id,
|
||||
planId: currentSubscription.planId,
|
||||
monthlyRequestLimit: currentSubscription.plan.monthlyRequestLimit,
|
||||
subscriptionId: subscription.id,
|
||||
planId: subscription.planId,
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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