PRD: Payment lifecycle & providers
| Module | Payment & Transaction (CORE-08) | PRD ID | PRD-PAY-001 |
| Status | Shipped | Owner | Payment squad |
| Date | 2026-05-27 | Version | v1.0 |
| Packages | @nx/payment · @nx/sale · @nx/finance · @nx/core | URD | PAY · PRV |
TL;DR
Lets a merchant collect payments through a payment provider and watch them resolve live - a dedicated
@nx/paymentpackage ingests provider results, pushes status to the cashier over WebSocket, and notifies the sale the instant a payment succeeds. Owners connect VNPAY QR MMS / PhonePOS credentials per merchant, stored encrypted and masked. The result: a fast, decoupled payment loop that any subscriber can react to, with no more cashier polling and no shared-credential leakage between merchants.
1. Context & Problem
Payment results originally flowed through an in-process event emitter that tightly coupled sale to the money-queue path, while the cashier polled for QR payment status. The status loop was slow and the sale ↔ payment boundary brittle: a payment outcome could not be consumed by an arbitrary subscriber, and a provider's redelivered notification risked double-applying. There was also no per-merchant home for provider credentials, so connecting VNPAY QR MMS or PhonePOS could not be done safely or scoped to one merchant.
This increment carves out a dedicated @nx/payment package, replaces the emitter/polling pattern with webhooks plus a WebSocket push, and wires per-merchant provider credentials through a configuration controller so an owner can connect a provider per merchant.
2. Goals & Non-Goals
Goals
- A standalone
@nx/paymentpackage that ingests provider results and updates payment status. - Webhook subscriptions per merchant replacing the sale↔money-queue event emitter;
payment-successfan-out runs on Kafka. - Live payment status to the cashier over WebSocket, replacing polling.
- Idempotent application of a provider result so a redelivered notification has no duplicate effect.
- Per-merchant provider credentials (VNPAY QR MMS, PhonePOS) stored encrypted, masked in responses, and surfaced in BO/client settings.
Non-Goals
- Structured refund / reversal back through the original provider - Planned (
URD-PAY-006, URD §7). - SoftPOS / NFC tap-to-pay and additional e-wallets (Momo, ZaloPay) - URD Non-Goals.
- Accounts/wallets, vouchers/ledger, categories (
WAL/VCH/CAT) - owned by Wallets, Vouchers & Ledger. - Checkout / split-payment UX - owned by the Orders / sale module.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Status latency | Cashier sees pending → paid/failed/expired in near-real-time (no polling delay) |
| Idempotency | Redelivered provider results produce zero duplicate effects |
| Decoupling | payment-success consumers (sale, finance, inventory) react via Kafka without a direct sale↔mq-pay coupling |
| Provider isolation | Zero cross-merchant credential reuse; credentials always masked in responses |
| CASH convergence | CASH and provider paths both fire the webhook, so downstream status is uniform |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Cashier | Take a payment and see it resolve live, without manual refresh |
| Owner | Connect a payment provider per merchant; trust that money events post downstream |
| Sale / subscriber | Be notified the instant a payment succeeds so the order can settle |
Core scenarios: an owner connects VNPAY QR MMS / PhonePOS for a merchant → the cashier takes a payment → the provider reports a result → payment status updates idempotently → the cashier sees it live over WebSocket and the sale is notified via webhook / payment-success on Kafka.
5. User Stories
- As a cashier, I want the payment status to update live, so that I don't have to poll or refresh to know whether a QR payment succeeded.
- As a subscriber (the sale), I want to be notified the moment a payment succeeds, so that I can settle the order without owning the provider path.
- As an owner, I want a redelivered provider result to be applied only once, so that a flaky network never double-charges or double-posts.
- As an owner, I want to connect VNPAY QR MMS / PhonePOS per merchant, so that each merchant collects with its own credentials.
- As an owner, I want provider credentials stored encrypted and masked, so that no one - including another merchant - can read or reuse them.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | The payment package ingests provider results and updates the payment's status (pending → paid / failed / expired) | URD-PAY-001 |
| FR-2 | On success, the system notifies subscribers (e.g. the sale settling the order) via webhook; fan-out runs on Kafka payment-success | URD-PAY-002 · URD-PAY-005 |
| FR-3 | Payment status is broadcast live to the cashier over WebSocket, replacing polling | URD-PAY-003 |
| FR-4 | A provider result is applied only once even if redelivered (idempotent) | URD-PAY-004 |
| FR-5 | An owner subscribes webhook endpoints by event type, with retries on delivery failure | URD-PAY-005 |
| FR-6 | The CASH path also fires the webhook so downstream status converges with provider payments | URD-PAY-002 |
| FR-7 | An owner connects a provider (VNPAY QR MMS, PhonePOS) per merchant via a configuration controller | URD-PRV-001 |
| FR-8 | Provider credentials are stored encrypted and never shown in full (masked in responses) | URD-PRV-002 |
| FR-9 | Credentials are scoped per merchant so one merchant cannot use another's | URD-PRV-003 |
Full requirement text and acceptance criteria live in the Payment & Transaction URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Idempotency | A provider result carries a key so redelivery is a no-op; no duplicate status change or downstream post |
| Decoupling | No direct sale↔mq-pay coupling; subscribers react to webhook / Kafka payment-success only |
| Real-time | Status reaches the cashier over a WebSocket push, not a polling loop |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id); provider config is owner-gated |
| Security | Provider credentials encrypted at rest, masked in responses (URD constraint C-03) |
| i18n | User-facing labels/statuses are bilingual ({ en, vi }) |
8. UX & Flows
Key screens: provider-credential configuration in apps/bo settings (moved from apps/client), and the live QR-payment status surface in sale-renderer (WebSocket-driven, no polling).
9. Data & Domain
| Entity | Role |
|---|---|
| Payment | The payment record whose status the provider result drives (pending → paid / failed / expired) |
| Webhook subscription | Per-merchant outbound endpoint config, by event type, with retry on failure |
| Payment configuration | Per-merchant provider credentials (VNPAY QR MMS, PhonePOS), encrypted + masked |
payment-success event | The Kafka fan-out consumed by sale / finance / inventory |
Conceptual only - full schema and invariants in the payment domain model.
10. Dependencies & Assumptions
Depends on
- Orders / sale (
@nx/sale) - a sale order triggers the payment; the sale is the primarypayment-successsubscriber. - Finance (
@nx/finance) - consumespayment-successto auto-post money downstream. - Core (
@nx/core) - shared event/Kafka seam and configuration plumbing. - A payment provider (VNPAY QR MMS, PhonePOS) - the external source of payment results.
Assumptions
- The merchant has connected at least one provider before collecting via that provider.
- Kafka is available for the
payment-successfan-out. - The cashier client maintains a WebSocket connection for live status.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Redelivered provider notification double-applies | Result applied idempotently via a key; redelivery is a no-op |
| Sale and payment re-coupling over time | Boundary is webhook / Kafka only; no in-process emitter back |
| CASH vs provider status divergence | CASH path also fires the webhook so downstream status converges |
| Credential leakage across merchants | Encrypted at rest, masked in responses, scoped per merchant (C-03) |
| Structured provider refund | Deferred - Planned (URD-PAY-006, URD §7) |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 - both PAY and PRV (per URD §5 feature catalog) |
| Rollout | All merchants; provider config gated on the owner connecting a provider |
| Migration | New @nx/payment package; payment-success seam moved BullMQ → Kafka |
| Launch criteria | Provider result → status update → live WebSocket push → sale notified verified end-to-end; redelivery proven idempotent; credentials encrypted + masked |
| Monitoring | Webhook delivery / retry rate, payment-success consumer lag, idempotency-rejection count, WebSocket connection health |
13. FAQ
Why a separate @nx/payment package? To decouple the sale from the money-queue path. Payment results now flow over webhooks and Kafka, so any subscriber can react without a tight in-process coupling.
How does the cashier see status without polling? A WebSocket push broadcasts each status change live to the cashier, replacing the old polling loop.
What happens if the provider sends the same result twice? Nothing the second time - the result is applied idempotently, so a redelivered notification has no duplicate effect.
Does a CASH payment skip the webhook? No - the CASH path also fires the webhook so downstream status converges with provider payments.
Can another merchant use my provider credentials? No - credentials are encrypted, masked in responses, and scoped per merchant.
Can I refund through the provider here? Not in this increment - structured provider refund is Planned (URD-PAY-006, URD §7).
References
- URD: Payment & Transaction - Payment Lifecycle · Provider Credentials
- Related PRD: Wallets, Vouchers & Ledger
- Module: Payment & Transaction - overview + traceability
- Developer: @nx/payment · @nx/finance