Payment Webhooks
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-SALE-PAY |
| Status | Stable |
| Owner | sale-team |
| Depends on | @nx/mq-pay (webhook source), SaleOrder, SaleCheck, AllocationUsage, CustomerPointService, Kafka producer |
Sale receives payment events from @nx/mq-pay via HTTP webhook (no auth — trusted internal). The flow is router → subordinate-service → state transition → side effects → Kafka emit.
2. Three-Service Architecture
Service responsibilities
| Service | File | Role |
|---|---|---|
PaymentWebhookService | payment-webhook.service.ts (99 lines) | Router only — extracts checkId + orderId from payload, dispatches to appropriate sub-service. Does not transition state itself. |
SaleOrderPaymentWebhookService | sale-order-payment-webhook.service.ts (376 lines) | Order-level transitions, customer points, allocation usage updates, Kafka emit |
SaleCheckPaymentWebhookService | sale-check-payment-webhook.service.ts (282 lines) | Check-level transitions; aggregates check states to potentially complete the order |
3. Webhook Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /v1/api/sale/webhooks/payment |
| Auth | none (trusted; secured via Cilium network policy) |
| Event type source | header X-Webhook-Event-Type (preferred) → falls back to body.eventType |
| Controller | PaymentWebhookController |
| Service | PaymentWebhookService.handleEvent |
Request schema (zod)
Source:
src/common/webhook-types.ts—PaymentWebhookRequestSchema.
{
eventType: string,
timestamp: number,
payload: {
timestamp: string,
source?: string,
transaction?: {
id: string, uid: string, status: string,
total: number | string, paid: number | string,
sourceType?: string, // 'SaleOrder' | 'SaleCheck'
sourceId?: string,
metadata?: Record<string, unknown>,
},
attempt?: {
id: string, uid: string, status: string,
amount: number | string, paymentProvider: string,
reason?: string,
metadata?: { source: { id: string, uid: string, type: string } },
},
},
}4. Routing — PaymentWebhookService.handleEvent
Source: payment-webhook.service.ts:36-68.
async handleEvent(opts: { eventType: string; payload }): Promise<boolean> {
const checkId = this._extractCheckId(payload);
const orderId = this._extractOrderId(payload);
if (!checkId && !orderId) return false; // log warn
if (checkId) return SaleCheckPaymentWebhookService.handleCheckEvent({...});
if (orderId) return SaleOrderPaymentWebhookService.handleSaleOrderEvent({...});
return false;
}Extraction logic
| Helper | Source | Returns |
|---|---|---|
_extractCheckId(payload) | payment-webhook.service.ts:71-81 | transaction.sourceId if sourceType === 'SaleCheck', else attempt.metadata.source.id if type === 'SaleCheck', else null |
_extractOrderId(payload) | payment-webhook.service.ts:84-98 | Same shape but matched against 'SaleOrder' |
Check takes precedence: if both checkId and orderId resolve, the check service handles it.
5. SaleOrder Event Handlers
All in
SaleOrderPaymentWebhookService. Methods are private (_handle*), invoked byhandleSaleOrderEventswitch.
5.1 ATTEMPT_SUCCESS → PROCESSING → PARTIAL or COMPLETED
Source: _handleOrderPaymentSuccess (lines 119-194).
| Step | Action |
|---|---|
| 1 | Guard: order.status === PROCESSING (else log + skip) |
| 2 | Compute isFullyPaid = paid >= total |
| 3 | UPDATE order → COMPLETED (+ completedAt) or PARTIAL (+ partialAt) |
| 4 | WS broadcast ORDER_PAYMENT_UPDATED |
| 5 | Emit Kafka PAYMENT_SUCCESS (post-commit, fire-and-forget) — see §7 |
| 6 | If isFullyPaid AND order.customerId → CustomerPointService.awardPointsForOrder |
| 7 | Find ACTIVE AllocationUsage rows for (usageId=order.id, usageType=SALE_ORDER) → bulk UPDATE → SUCCESS |
5.2 ATTEMPT_FAILED / ATTEMPT_EXPIRED / ATTEMPT_CANCELLED → PROCESSING → CANCELLED
Sources: _handleOrderPaymentFailed (197-228), _handleOrderPaymentExpired (231-257), _handleOrderPaymentCancelled (260-286).
| Event | Cancellation reason |
|---|---|
ATTEMPT_FAILED | payload.attempt.reason or 'Payment failed' |
ATTEMPT_EXPIRED | 'Payment expired' |
ATTEMPT_CANCELLED | 'Payment cancelled' |
All three: guard PROCESSING, UPDATE order → CANCELLED + cancelledAt + cancellationReason, WS broadcast.
5.3 TRANSACTION_SETTLED / TRANSACTION_CANCELLED → log-only
Sources: _handleOrderTransactionSettled (289-294), _handleOrderTransactionCancelled (297-306).
Log informational message; no state change. Returns true so the webhook is acknowledged.
6. SaleCheck Event Handlers
All in
SaleCheckPaymentWebhookService. SaleCheck statuses are PROCESSING / PARTIAL / COMPLETED / CANCELLED (see Domain Model §4.2).
6.1 ATTEMPT_SUCCESS → PARTIAL or COMPLETED + maybe complete order
Source: _handleCheckPaymentSuccess (lines 112-156).
| Step | Action |
|---|---|
| 1 | Guard: check.status === PROCESSING |
| 2 | Compute isFullyPaid = paid >= total |
| 3 | UPDATE check → COMPLETED or PARTIAL |
| 4 | If isFullyPaid: call _checkOrderCompletionViaChecks (lines 237-281) |
6.2 ATTEMPT_FAILED / ATTEMPT_EXPIRED / ATTEMPT_CANCELLED → PROCESSING → CANCELLED
Sources: _handleCheckPaymentFailed (159-176), _handleCheckPaymentExpired (179-196), _handleCheckPaymentCancelled (199-216).
All three: guard PROCESSING, UPDATE check → CANCELLED. No order-level cascade — sibling checks remain.
6.3 Order completion via checks — _checkOrderCompletionViaChecks
Asymmetry vs order path: order-path also marks
AllocationUsage → SUCCESS. Check-path does not explicitly set allocation usage. (TODO in code; sale team aware.)
7. Kafka Emit
_enqueuePaymentSuccess (lines 311-375) — only on the order-payment-success path, after DB commit.
| Property | Value |
|---|---|
| Topic | KafkaTopics.PAYMENT_SUCCESS ('payment.success') |
| Key | order.id |
| Producer | KafkaProducerHelper from BindingKeys.APPLICATION_KAFKA_PRODUCER |
| Delivery | fire-and-forget; failure is logged but does not roll back DB |
Payload (TSalePaymentSuccess)
{
saleOrderId, saleOrderNumber, saleOrderStatus,
merchantId, saleChannelId,
createdBy, modifiedBy,
payment: {
total, paid, currency, isFullyPaid,
paidAt: ISO,
sessionId?: string, // order.closedInSessionId
finance?: any, // from order.metadata.finance
},
items: Array<{
id, itemType, itemId,
quantity: number,
mode: 'PRODUCT' | 'CUSTOM',
}>,
}Consumers
| Consumer | What it does |
|---|---|
@nx/inventory | InventoryWorkerService.handlePaymentSuccess — deduct stock, reserve materials |
@nx/finance | record INCOME wallet transaction (TODO: confirm method name) |
8. Side-effect Summary Table
| Event Type | Order path effect | Check path effect |
|---|---|---|
ATTEMPT_SUCCESS (full) | order → COMPLETED, allocation → SUCCESS, points awarded, Kafka emit | check → COMPLETED; if all checks COMPLETED → order → COMPLETED + points + WS (no Kafka emit on this path) |
ATTEMPT_SUCCESS (partial) | order → PARTIAL, Kafka emit | check → PARTIAL |
ATTEMPT_FAILED | order → CANCELLED + reason | check → CANCELLED |
ATTEMPT_EXPIRED | order → CANCELLED + 'Payment expired' | check → CANCELLED |
ATTEMPT_CANCELLED | order → CANCELLED + 'Payment cancelled' | check → CANCELLED |
TRANSACTION_SETTLED | log-only | log-only |
TRANSACTION_CANCELLED | log-only | log-only |
9. Idempotency
| Surface | Mechanism |
|---|---|
| Webhook handler | Guard on status === PROCESSING — repeated deliveries to a non-PROCESSING order are silently dropped |
| Customer points | PointTransactionRepository.existsBySaleOrderId check before write |
| Kafka emit | not idempotent at sale layer; consumer-side dedup (e.g., inventory InventoryTracking lookup) |
10. End-to-End Flow
11. Related Pages
- Sale Order
- Check Splitting — SaleCheck details
- Customer Points — award flow
- Allocation Usage — SUCCESS transition
- API Events — Kafka topic spec
- ADR-0003 Payment via webhook