Files
nroxy/docs/ops/payment-system.md
sirily 9641678fa3 feat: add renewal invoice sweep (#20)
Refs #9

## Summary
- add a worker-side renewal invoice sweep that creates one invoice 72 hours before subscription expiry
- expire elapsed pending invoices automatically and email users when an automatic renewal invoice is created
- stop auto-recreating invoices for the same paid cycle once any invoice already exists for that cycle
- document the current renewal-invoice and pending-invoice expiry behavior

## 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
- built `infra/docker/worker.Dockerfile`

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #20
2026-03-11 12:33:03 +03:00

11 KiB

Payment System

Purpose

Describe how billing and payment processing currently work in nproxy.

This document is about the implemented system, not the desired future state.

Scope

The current payment system covers:

  • default subscription plan bootstrap
  • user subscription creation at registration time
  • invoice creation through a provider adapter
  • automatic renewal-invoice creation 72 hours before currentPeriodEnd
  • one renewal-invoice creation attempt per paid cycle unless the user explicitly creates a new one manually
  • worker-side polling reconciliation for pending invoices
  • manual admin activation after an operator verifies that the provider reported a final successful payment status
  • automatic expiry of elapsed subscription periods during account and generation access checks
  • automatic expiry of elapsed pending invoices
  • automatic provider-driven expired and canceled transitions for pending invoices
  • quota-cycle reset on successful activation

The current payment system does not yet cover:

  • provider webhooks
  • 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
  • getInvoiceStatus(providerInvoiceId) -> pending|paid|expired|canceled + paidAt? + expiresAt?

Current runtime note:

  • the provider adapter is still a placeholder adapter
  • worker polling is implemented, but provider-specific HTTP/status mapping is still placeholder logic
  • provider callbacks and webhook signature verification are not implemented yet
  • the rest of the payment flow is intentionally provider-agnostic

Registration flow

  1. User registers through POST /api/auth/register.
  2. packages/db/src/auth-store.ts creates the user and session.
  3. If the default plan exists, the store also creates a Subscription in pending_activation.
  4. At this point the user has an account and a plan assignment, but not an active paid cycle.

Invoice creation flow

  1. Authenticated user calls POST /api/billing/invoices.
  2. packages/db/src/billing-store.ts loads the latest subscription for that user.
  3. If there is already a non-expired pending invoice for the same subscription, it is reused.
  4. Otherwise the app calls the payment-provider adapter createInvoice.
  5. The returned provider invoice data is persisted as a new local PaymentInvoice in pending.
  6. The API returns invoice details, including provider invoice id, amount, address, and expiry time.

Automatic renewal invoice flow

  1. The worker scans active manual-renewal subscriptions.
  2. If currentPeriodEnd is within the next 72 hours, the worker checks whether the current paid cycle already has an invoice.
  3. If the current cycle has no invoice yet, the worker creates one renewal invoice through the payment-provider adapter.
  4. The worker sends the invoice details to the user by email.
  5. If any invoice already exists for the current cycle, the worker does not auto-create another one.

Current rule:

  • after the first invoice exists for the current paid cycle, automatic re-creation stops for that cycle
  • if that invoice later expires or is canceled, the next invoice is created only when the user explicitly goes to billing and creates one

Payment status semantics

  • pending does not count as paid.
  • pending does not activate the subscription.
  • pending does not reset quota.
  • The system must treat an invoice as paid only after the payment provider reports a final successful status, meaning the funds are accepted strongly enough for access activation.
  • Worker reconciliation now polls provider status for stored pending invoices.
  • If the provider reports final paid, the worker activates access from provider paidAt when that timestamp is available.
  • If the provider reports final paid after a local fallback expiry, provider paid still wins and access is activated.
  • If the provider reports final expired or canceled, the worker finalizes the local invoice to the same terminal status.

Worker reconciliation flow

  1. The worker loads a batch of pending invoices that have providerInvoiceId.
  2. For each invoice, it calls paymentProviderAdapter.getInvoiceStatus(providerInvoiceId).
  3. If the provider still reports pending, the worker leaves the invoice unchanged.
  4. If the provider reports paid, the worker calls the same idempotent activation path as admin mark-paid.
  5. If the provider reports expired or canceled, the worker atomically moves the local invoice from pending to that terminal state.
  6. After provider polling, the worker also expires any still-pending invoices whose local expiresAt has elapsed.
  7. If a manual admin action or another worker already finalized the invoice, reconciliation degrades to replay/no-op behavior instead of duplicating side effects.

Implementation note:

  • invoice creation paths take a per-subscription database lock so manual invoice creation and worker renewal creation do not create duplicate invoices for the same subscription at the same time.

Invoice listing flow

  • GET /api/billing/invoices returns the user's invoices ordered by newest first.
  • This is a read-only view over persisted PaymentInvoice rows.
  • The worker also marks pending invoices expired when expiresAt has passed.

Current activation flow

The implemented activation paths are:

  • automatic worker reconciliation after provider final status
  • manual admin override after an operator verifies provider final status outside the app

Shared activation behavior:

  • markInvoicePaid runs inside one database transaction.
  • 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 when actor metadata is present

Manual admin path:

  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. 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 provider adapter still uses placeholder status lookups; real provider HTTP integration is not implemented yet.
  • No provider callback or webhook signature verification path exists yet.
  • Manual admin mark-paid still exists as an override, so operator judgment is still part of the system for exceptional cases.
  • The worker polls invoice status in batches; there is no provider push path yet.

Required future direction

  • Replace placeholder provider status lookups with real provider integration.
  • Add provider callbacks or webhook ingestion on top of polling where the chosen provider supports it.
  • 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