feat(billing): payment provider integration for invoice Pay Now (PMS-711) #489

Merged
longjacksonle merged 5 commits from feat/PMS-711-stripe-pay-now into main 2026-08-03 15:50:40 +02:00

What

Implements PMS-711: an invoice sent from Mokosh carries a working Pay Now action that takes the recipient to the sender's own Stripe account, and the resulting payment (and any refund) syncs back onto the invoice via a signed webhook. Stripe is the first adapter behind a provider interface; PayPal follows as an adapter, not a rewrite.

Design decisions

  • Per-tenant restricted API key (not Stripe Connect). Each MSP pastes their own Stripe restricted secret + webhook secret into the existing write-only payment_gateway_configs blob (PMS-342: encrypted at rest, never returned to a client, never logged). Checkout sessions are created ON the tenant's account, so funds settle to them and the platform is never a money transmitter.
  • Partial payments and refunds are implemented (not deferred). recompute_invoice_payment_state now derives amount_paid = SUM(payments) - SUM(payment_refunds), so a full refund walks the invoice back to sent and a partial to partially_paid with no bespoke transition code.
  • Per-tenant webhook URL POST /api/v1/stripe/webhooks/{tenant_id}. The signing secret is per-tenant, so the receiver must know which secret to verify against before trusting the body; the path segment selects the tenant and is trusted only after the signature verifies. Modeled on the existing bunyip webhook: mounted outside the JWT chain, verify-before-parse over raw bytes, migrator_pool for the pre-auth credential read with a SAFETY note.
  • Idempotency at the DB layer, not an events table: a partial unique index on payments(tenant_id, gateway_transaction_id) and UNIQUE(tenant_id, provider_reference) on payment_refunds, both with ON CONFLICT DO NOTHING, so Stripe's retries and cumulative refund lists converge.

Surfaces

  • Portal POST /invoices/{id}/pay mints a hosted checkout session (company-scoped exactly like get_invoice) and returns its URL for the SPA to redirect to.
  • On the first invoice send transition, the billing contact is emailed a Pay Now link (best-effort, post-commit, only when a gateway is active).
  • Webhook records the payment with amount, currency, provider reference, and timestamp, then reconciles the invoice.

Out of scope (deliberate, documented)

Stripe Connect onboarding (the tenant's own key is used instead), PayPal (a follow-up adapter), and zero-decimal currencies (amounts convert at 100 minor units per major unit). Manual payments/refunds keep working through the existing /payments path unchanged.

Acceptance criteria

  • A tenant can connect their own Stripe account (write-only config).
  • Provider credentials are write-only and never returned to the client (unchanged PMS-342 masking; payment_gateway_secret_is_write_only still passes).
  • Sent invoices include a working Pay Now action (portal /pay + outbound email button).
  • Paying settles to the tenant's account, not the platform's (tenant's own key).
  • Payment status syncs back via webhook with amount, currency, provider reference, timestamp.
  • Partial payment and refund behavior implemented (not deferred).
  • Provider interface makes PayPal an adapter, not a rewrite.
  • Tests cover the paid webhook path and a failed/abandoned payment (plus refund + bad signature).

Testing

  • tests/pms711_stripe_pay_now.rs: paid + idempotent redelivery, partial refund, abandoned (unpaid) no-op, bad-signature 401 that changes nothing. All through the real route on the NOBYPASSRLS app role.
  • 11 provider unit tests (signature accept/reject/stale/tamper, event mapping).
  • Regression: billing, recurring_invoicing, portal, rls_coverage (new table carries fail-closed RLS) all green.
  • just check clean (fmt, clippy -D warnings, migration/mail/runner/pool-safety gates); just pre-commit clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss

## What Implements PMS-711: an invoice sent from Mokosh carries a working Pay Now action that takes the recipient to the sender's own Stripe account, and the resulting payment (and any refund) syncs back onto the invoice via a signed webhook. Stripe is the first adapter behind a provider interface; PayPal follows as an adapter, not a rewrite. ## Design decisions - **Per-tenant restricted API key** (not Stripe Connect). Each MSP pastes their own Stripe restricted secret + webhook secret into the existing write-only `payment_gateway_configs` blob (PMS-342: encrypted at rest, never returned to a client, never logged). Checkout sessions are created ON the tenant's account, so funds settle to them and the platform is never a money transmitter. - **Partial payments and refunds are implemented** (not deferred). `recompute_invoice_payment_state` now derives `amount_paid = SUM(payments) - SUM(payment_refunds)`, so a full refund walks the invoice back to `sent` and a partial to `partially_paid` with no bespoke transition code. - **Per-tenant webhook URL** `POST /api/v1/stripe/webhooks/{tenant_id}`. The signing secret is per-tenant, so the receiver must know which secret to verify against before trusting the body; the path segment selects the tenant and is trusted only after the signature verifies. Modeled on the existing bunyip webhook: mounted outside the JWT chain, verify-before-parse over raw bytes, `migrator_pool` for the pre-auth credential read with a SAFETY note. - **Idempotency at the DB layer**, not an events table: a partial unique index on `payments(tenant_id, gateway_transaction_id)` and `UNIQUE(tenant_id, provider_reference)` on `payment_refunds`, both with `ON CONFLICT DO NOTHING`, so Stripe's retries and cumulative refund lists converge. ## Surfaces - Portal `POST /invoices/{id}/pay` mints a hosted checkout session (company-scoped exactly like `get_invoice`) and returns its URL for the SPA to redirect to. - On the first invoice send transition, the billing contact is emailed a Pay Now link (best-effort, post-commit, only when a gateway is active). - Webhook records the payment with amount, currency, provider reference, and timestamp, then reconciles the invoice. ## Out of scope (deliberate, documented) Stripe Connect onboarding (the tenant's own key is used instead), PayPal (a follow-up adapter), and zero-decimal currencies (amounts convert at 100 minor units per major unit). Manual payments/refunds keep working through the existing `/payments` path unchanged. ## Acceptance criteria - [x] A tenant can connect their own Stripe account (write-only config). - [x] Provider credentials are write-only and never returned to the client (unchanged PMS-342 masking; `payment_gateway_secret_is_write_only` still passes). - [x] Sent invoices include a working Pay Now action (portal `/pay` + outbound email button). - [x] Paying settles to the tenant's account, not the platform's (tenant's own key). - [x] Payment status syncs back via webhook with amount, currency, provider reference, timestamp. - [x] Partial payment and refund behavior implemented (not deferred). - [x] Provider interface makes PayPal an adapter, not a rewrite. - [x] Tests cover the paid webhook path and a failed/abandoned payment (plus refund + bad signature). ## Testing - `tests/pms711_stripe_pay_now.rs`: paid + idempotent redelivery, partial refund, abandoned (unpaid) no-op, bad-signature 401 that changes nothing. All through the real route on the NOBYPASSRLS app role. - 11 provider unit tests (signature accept/reject/stale/tamper, event mapping). - Regression: `billing`, `recurring_invoicing`, `portal`, `rls_coverage` (new table carries fail-closed RLS) all green. - `just check` clean (fmt, clippy -D warnings, migration/mail/runner/pool-safety gates); `just pre-commit` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
Additive schema for the tenant-owned payment-provider integration. Adds payments.currency (the provider-reported currency the acceptance criteria require recorded per payment; nullable so existing manual rows keep inheriting the invoice currency), a partial UNIQUE index on payments(tenant_id, gateway_transaction_id) that makes the webhook paid-sync idempotent via ON CONFLICT DO NOTHING under Stripe's redelivery, and a payment_refunds table (refunds cannot be negative payment rows since payments.amount is CHECK > 0). payment_refunds carries fail-closed tenant-isolation RLS matching 038/094/095 so it is covered from day one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
One PaymentProvider trait abstracts the gateway so PayPal is a future adapter, not a rewrite; StripeProvider is the first impl (hosted Checkout Session create + Stripe-Signature verify/parse over raw bytes, with timestamp tolerance and rotation-friendly multi-v1 matching). BillingService gains the reconciliation surface: decrypt the tenant's write-only Stripe key, mint a checkout session for an invoice balance, and record gateway payments/refunds idempotently. recompute_invoice_payment_state now derives amount_paid as SUM(payments) - SUM(payment_refunds), so a full refund walks the invoice back to sent and a partial to partially_paid with no bespoke transition logic. Adds the Mailer::send_invoice_pay_now default and a StripeWebhookState the HTTP layer wires next. The stored credential is never returned to a client and never logged (PMS-342).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
Mounts POST /api/v1/stripe/webhooks/{tenant_id} OUTSIDE the JWT chain (Stripe authenticates itself via the per-tenant signing secret; the path tenant selects which secret to verify against and is trusted only after verification), adds the portal POST /invoices/{id}/pay that mints a checkout session scoped to the contact's own company, and switches the agent-facing BillingService to with_delivery so the first invoice send transition emails the billing contact a Pay Now link when a gateway is active. Verify-before-parse, migrator_pool for the pre-auth credential read with a SAFETY note, and the email is best-effort post-commit so a mail failure never undoes the send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
End-to-end coverage through the real webhook route on the NOBYPASSRLS app role (boot_rls), each event signed with the tenant's stored secret so the platform signature verification runs for real. Asserts a paid checkout marks the invoice paid and redelivery is a no-op, a partial refund reopens the balance to partially_paid, an abandoned (unpaid) session is a 200 no-op, and a bad signature is a 401 that changes nothing. Adds hmac/sha2 dev-deps to sign the synthetic events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
docs(billing): record the Pay Now surface + scope boundaries (PMS-711)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 2m1s
Check / fmt + clippy + build + tests (pull_request) Successful in 2m25s
Integration / integration tests (pull_request) Successful in 4m18s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
b35e23ff6b
Adds payment_refunds and the Pay Now / Stripe-webhook routes to the billing row in the codebase-state module catalog, and documents in the module doc what is in scope (full/partial payment, refunds) versus deliberately out (Stripe Connect, PayPal, zero-decimal currencies), so the scope decision is explicit rather than implicit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLd9c7niucrqx68v4AxVss
longjacksonle deleted branch feat/PMS-711-stripe-pay-now 2026-08-03 15:50:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!489
No description provided.