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
This commit was merged in pull request #20.
This commit is contained in:
@@ -10,14 +10,17 @@ 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
|
||||
- polling-based reconciliation
|
||||
- automatic `expired` or `canceled` transitions
|
||||
- recurring billing
|
||||
|
||||
## Main records
|
||||
@@ -74,10 +77,12 @@ 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
|
||||
- provider callbacks and status lookups are not implemented yet
|
||||
- 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
|
||||
@@ -94,25 +99,52 @@ Current runtime note:
|
||||
5. The returned provider invoice data is persisted as a new local `PaymentInvoice` in `pending`.
|
||||
6. The API returns invoice details, including provider invoice id, amount, address, and expiry time.
|
||||
|
||||
## Automatic renewal invoice flow
|
||||
1. The worker scans `active` manual-renewal subscriptions.
|
||||
2. If `currentPeriodEnd` is within the next `72 hours`, the worker checks whether the current paid cycle already has an invoice.
|
||||
3. If the current cycle has no invoice yet, the worker creates one renewal invoice through the payment-provider adapter.
|
||||
4. The worker sends the invoice details to the user by email.
|
||||
5. If any invoice already exists for the current cycle, the worker does not auto-create another one.
|
||||
|
||||
Current rule:
|
||||
- after the first invoice exists for the current paid cycle, automatic re-creation stops for that cycle
|
||||
- if that invoice later expires or is canceled, the next invoice is created only when the user explicitly goes to billing and creates one
|
||||
|
||||
## Payment status semantics
|
||||
- `pending` does not count as paid.
|
||||
- `pending` does not activate the subscription.
|
||||
- `pending` does not reset quota.
|
||||
- The system must treat an invoice as `paid` only after the payment provider reports a final successful status, meaning the funds are accepted strongly enough for access activation.
|
||||
- The current implementation does not fetch or verify that provider-final status automatically yet.
|
||||
- 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 path is manual and admin-driven.
|
||||
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
|
||||
|
||||
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:
|
||||
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`
|
||||
@@ -121,8 +153,13 @@ The implemented activation path is manual and admin-driven.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -178,16 +215,14 @@ Current payment-specific errors surfaced by the web app:
|
||||
- `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.
|
||||
- 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
|
||||
- Add provider callbacks or polling-based reconciliation.
|
||||
- Persist provider-final status before activating access automatically.
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user