Transaction Lifecycle & State Machine Standard¶
Version: 1.0 Last Updated: 2026-04-03 Owner: Platform Team Status: Active
Purpose¶
Standardise transaction states, transitions, and naming across all Simpaisa products. Replace legacy numeric status codes with human-readable, consistent state names. Ensure every state transition fires a webhook.
Conventions¶
- All state names:
UPPERCASE_SNAKE_CASE - States are either terminal (no further transitions) or non-terminal (transitions expected)
- Every transition MUST be logged with: timestamp, actor (system/user/operator), reason
- Every transition MUST fire a webhook event to the merchant (no silent transitions)
Pay-In States¶
INITIATED → PENDING_OTP → OTP_VERIFIED → PROCESSING → COMPLETED
→ FAILED
→ EXPIRED
| State | Terminal | Trigger | Webhook Event |
|---|---|---|---|
INITIATED |
No | Merchant submits pay-in request | payin.initiated |
PENDING_OTP |
No | OTP sent to customer MSISDN | payin.otp_sent |
OTP_VERIFIED |
No | Customer submits correct OTP | payin.otp_verified |
PROCESSING |
No | Request forwarded to channel | payin.processing |
COMPLETED |
Yes | Channel confirms debit | payin.completed |
FAILED |
Yes | Channel rejects or business rule violation | payin.failed |
EXPIRED |
Yes | OTP or session timeout (5 min default) | payin.expired |
Allowed transitions:
- INITIATED → PENDING_OTP, FAILED, EXPIRED
- PENDING_OTP → OTP_VERIFIED, FAILED, EXPIRED
- OTP_VERIFIED → PROCESSING, FAILED
- PROCESSING → COMPLETED, FAILED
Pay-Out States¶
PUBLISHED → IN_REVIEW → PROCESSING → DISBURSED
→ REJECTED
→ ON_HOLD → STUCK → REJECTED
→ PROCESSING
→ PROCESSING
→ REVERSED
| State | Terminal | Trigger | Webhook Event |
|---|---|---|---|
PUBLISHED |
No | Merchant submits pay-out request | payout.published |
IN_REVIEW |
No | Compliance/fraud check triggered | payout.in_review |
PROCESSING |
No | Sent to channel for disbursement | payout.processing |
ON_HOLD |
No | Manual review required (amount threshold, AML flag) | payout.on_hold |
STUCK |
No | Channel returned ambiguous response; requires investigation | payout.stuck |
DISBURSED |
Yes | Channel confirms credit to beneficiary | payout.disbursed |
REJECTED |
Yes | Compliance rejection or channel decline | payout.rejected |
REVERSED |
Yes | Funds returned after disbursement failure | payout.reversed |
Allowed transitions:
- PUBLISHED → IN_REVIEW, PROCESSING, REJECTED
- IN_REVIEW → PROCESSING, ON_HOLD, REJECTED
- PROCESSING → DISBURSED, STUCK, REJECTED
- ON_HOLD → PROCESSING, STUCK, REJECTED
- STUCK → PROCESSING, REJECTED
- DISBURSED → REVERSED
Remittance States¶
PUBLISHED → IN_PROCESS → IN_REVIEW → ON_HOLD → AML_REVIEW → REMITTED
→ REJECTED → REVERSED
| State | Terminal | Trigger | Webhook Event |
|---|---|---|---|
PUBLISHED |
No | Remittance partner submits transaction | remittance.published |
IN_PROCESS |
No | Initial validation and routing | remittance.in_process |
IN_REVIEW |
No | Compliance screening triggered | remittance.in_review |
ON_HOLD |
No | Additional documentation required | remittance.on_hold |
AML_REVIEW |
No | Escalated to AML team | remittance.aml_review |
REMITTED |
Yes | Funds credited to beneficiary | remittance.remitted |
REJECTED |
Yes | Compliance rejection or channel failure | remittance.rejected |
REVERSED |
Yes | Funds returned to sending corridor | remittance.reversed |
Webhook gap fix: Legacy system only fires webhooks for PUBLISHED, IN_PROCESS, REMITTED, and REJECTED. The v3 implementation MUST fire webhooks for ALL states including IN_REVIEW, ON_HOLD, AML_REVIEW, and REVERSED.
Allowed transitions:
- PUBLISHED → IN_PROCESS, REJECTED
- IN_PROCESS → IN_REVIEW, REMITTED, REJECTED
- IN_REVIEW → ON_HOLD, AML_REVIEW, REMITTED, REJECTED
- ON_HOLD → IN_REVIEW, AML_REVIEW, REJECTED
- AML_REVIEW → REMITTED, REJECTED
- REJECTED → REVERSED
Card States¶
INITIATED → AUTHORISED → CAPTURED → REFUNDED (partial/full)
→ VOIDED
| State | Terminal | Trigger | Webhook Event |
|---|---|---|---|
INITIATED |
No | Card payment request received | card.initiated |
AUTHORISED |
No | Issuer approves the authorisation | card.authorised |
CAPTURED |
Yes* | Merchant captures the authorised amount | card.captured |
VOIDED |
Yes | Merchant voids before capture | card.voided |
REFUNDED |
Yes | Merchant issues full or partial refund | card.refunded |
*CAPTURED transitions to REFUNDED if a refund is issued.
Allowed transitions:
- INITIATED → AUTHORISED, FAILED
- AUTHORISED → CAPTURED, VOIDED
- CAPTURED → REFUNDED
Partial refunds: Multiple REFUNDED events may fire. Each includes refundAmount and remainingAmount. The transaction reaches terminal state when remainingAmount equals zero.
State Transition Audit Trail¶
Every state change MUST produce an audit record in SurrealDB:
{
"transactionId": "TXN-20260403-00042",
"previousState": "PROCESSING",
"newState": "COMPLETED",
"timestamp": "2026-04-03T14:22:01.123Z",
"actor": "system:payin-svc",
"reason": "Channel confirmed debit",
"traceId": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}
Actor format: system:<service-name>, operator:<email>, or api:<merchant-id>.
Idempotency Interaction¶
| Scenario | Behaviour |
|---|---|
| Duplicate request, same idempotency key, same body | Return current state (no new transition) |
| Duplicate request, same idempotency key, different body | Return 409 Conflict |
| Transaction in terminal state, retry received | Return terminal state, no reprocessing |
Transaction in PROCESSING, retry received |
Return PROCESSING status, do not re-submit to channel |
Transaction in STUCK, retry received |
Allow re-submission to channel (operator-initiated only) |
Legacy Code Mapping¶
For backward compatibility, the v3 API returns both the new state name and the legacy numeric code in the response body. The legacyStatusCode field is deprecated and will be removed in v4.
{
"transactionStatus": "COMPLETED",
"legacyStatusCode": 2
}