Focus mode active/Press Alt+Shift+R to toggle/Esc to exit
Start HereOrdered learning track

Payment State Machines

Build From Scratch: Large Production Grade Java Payment Systems - Part 008

Membahas payment state machines untuk sistem pembayaran enterprise: legal transition, sub-state, unknown outcome, idempotency, concurrency, provider event, ledger boundary, dan implementasi Java yang deterministik.

14 min read2694 words
PrevNext
Lesson 0864 lesson track01–12 Start Here
#java#payments#state-machine#payment-lifecycle+3 more

title: Build From Scratch: Large Production Grade Java Payment Systems - Part 008 description: Membahas payment state machines untuk sistem pembayaran enterprise: legal transition, sub-state, unknown outcome, idempotency, concurrency, provider event, ledger boundary, dan implementasi Java yang deterministik. series: learn-java-payment-systems seriesTitle: Build From Scratch: Large Production Grade Java Payment Systems order: 8 partTitle: Payment State Machines tags:

  • java
  • payments
  • state-machine
  • payment-lifecycle
  • idempotency
  • ledger
  • enterprise-architecture date: 2026-07-02

Part 008 — Payment State Machines

Payment system yang buruk biasanya punya enum seperti ini:

public enum PaymentStatus {
    PENDING,
    SUCCESS,
    FAILED
}

Untuk demo, cukup.

Untuk production payment platform, ini terlalu miskin.

Kenapa?

Karena pembayaran bukan hanya sukses/gagal. Payment bisa:

  • dibuat tapi belum dikonfirmasi,
  • menunggu authentication,
  • authorized tapi belum captured,
  • capture sedang dikirim ke provider,
  • provider timeout dan outcome belum diketahui,
  • captured tapi belum settled,
  • partially refunded,
  • disputed,
  • reversed,
  • settled net of fee,
  • payout pending,
  • atau sukses di provider tapi gagal diposting ke ledger.

Payment system bukan CRUD. Payment system adalah state transition system.

Tujuan part ini: membangun cara berpikir dan implementasi state machine yang cukup kuat untuk sistem pembayaran enterprise.


1. State Machine Bukan Diagram Cantik

State machine dalam payment system adalah financial control.

Ia menentukan:

  1. operasi apa yang legal,
  2. operasi apa yang harus ditolak,
  3. kapan uang boleh dicatat di ledger,
  4. kapan refund boleh dibuat,
  5. kapan merchant balance boleh bertambah,
  6. kapan unknown outcome harus direpair,
  7. kapan ops boleh melakukan manual adjustment,
  8. evidence apa yang wajib ada untuk setiap transisi.

Kalau state machine salah, sistem bisa melakukan:

  • double capture,
  • double refund,
  • payout sebelum settlement,
  • refund setelah chargeback tanpa policy,
  • settlement untuk payment yang belum captured,
  • atau ledger posting dari event provider palsu/duplikat.

2. Kesalahan Utama: Satu Status untuk Semua Hal

Banyak sistem menyimpan:

payment.status = 'SUCCESS'

Lalu semua tim menafsirkan berbeda:

  • product: order boleh fulfilled,
  • finance: uang sudah masuk,
  • merchant: balance bertambah,
  • ops: tidak perlu follow-up,
  • customer: dana sudah terpotong,
  • engineer: provider API return 200.

Ini berbahaya.

Payment platform harus memisahkan beberapa lifecycle:

LifecyclePertanyaan yang dijawab
Payment intentApakah customer/merchant punya instruction pembayaran?
AttemptApakah satu percobaan pembayaran ke provider berhasil/gagal/unknown?
AuthenticationApakah customer perlu/berhasil authentication?
AuthorizationApakah dana/limit disetujui issuer/provider?
CaptureApakah authorization sudah dikonversi menjadi charge final?
Ledger postingApakah efek finansial sudah dicatat secara internal?
SettlementApakah provider/bank sudah settle ke platform/merchant?
RefundApakah pengembalian dana diminta/diproses/berhasil?
DisputeApakah ada sengketa/chargeback?
PayoutApakah merchant payout sudah dikirim/diselesaikan?

Satu enum tidak cukup.


3. Layered State Model

Kita butuh beberapa state machine kecil, bukan satu monster state machine.

flowchart TD PI[Payment Intent State] PA[Payment Attempt State] AUTH[Authentication State] AZ[Authorization State] CAP[Capture State] LED[Ledger Posting State] SET[Settlement State] REF[Refund State] DSP[Dispute State] PI --> PA PA --> AUTH PA --> AZ AZ --> CAP CAP --> LED LED --> SET CAP --> REF SET --> REF CAP --> DSP SET --> DSP

Prinsip:

State machine harus mengikuti boundary domain, bukan mengikuti nama status provider.

Provider boleh punya SUCCESS, PAID, SETTLED, COMPLETED, CAPTURED. Internal platform harus punya status sendiri yang stabil.


4. Payment Intent State Machine

Payment intent mewakili niat/instruction pembayaran dari merchant/customer.

State dasar:

stateDiagram-v2 [*] --> CREATED CREATED --> CONFIRMING: confirm requested CONFIRMING --> REQUIRES_ACTION: authentication required REQUIRES_ACTION --> CONFIRMING: customer completed action CONFIRMING --> AUTHORIZED: auth approved CONFIRMING --> CAPTURED: sale/capture success CONFIRMING --> FAILED: terminal failure CONFIRMING --> UNKNOWN: provider timeout/ambiguous UNKNOWN --> AUTHORIZED: inquiry/webhook confirms auth UNKNOWN --> CAPTURED: inquiry/webhook confirms capture UNKNOWN --> FAILED: inquiry confirms failure AUTHORIZED --> CAPTURED: capture success AUTHORIZED --> CANCELLED: void/cancel success CAPTURED --> PARTIALLY_REFUNDED: partial refund success CAPTURED --> REFUNDED: full refund success PARTIALLY_REFUNDED --> REFUNDED: remaining refund success CAPTURED --> DISPUTED: dispute opened PARTIALLY_REFUNDED --> DISPUTED: dispute opened REFUNDED --> [*] FAILED --> [*] CANCELLED --> [*]

Ini masih simplified.

Perhatikan UNKNOWN adalah state kelas satu. Ia bukan FAILED.

Timeout bukan failure finansial. Timeout adalah ketidaktahuan sistem lokal.


5. Unknown State: State Paling Penting di Payment

Dalam sistem biasa:

request timeout = failure

Dalam payment:

request timeout = maybe succeeded, maybe failed, maybe still processing

Contoh:

  1. Platform mengirim capture ke provider.
  2. Provider memproses dan berhasil.
  3. Network putus sebelum response diterima.
  4. Platform melihat timeout.

Kalau platform menandai FAILED, lalu retry capture tanpa idempotency/provider reference, customer bisa double charged.

State yang benar:

CAPTURE_UNKNOWN

Lalu resolution flow:

flowchart TD A[Operation sent to provider] --> B{Response received?} B -->|Success| C[Apply success transition] B -->|Definitive failure| D[Apply failure transition] B -->|Timeout / ambiguous| E[Move to UNKNOWN] E --> F[Block conflicting operation] E --> G[Schedule provider inquiry] E --> H[Wait webhook] E --> I[Reconcile external report] G --> J{Outcome known?} H --> J I --> J J -->|Success| C J -->|Failure| D J -->|Still unknown| K[Ops case / retry inquiry]

Unknown state harus punya:

  • operation id,
  • provider idempotency key,
  • provider reference kalau ada,
  • request timestamp,
  • last inquiry timestamp,
  • retry count,
  • resolution deadline,
  • blocked operations list,
  • audit trail.

Buruk:

update payment set status = :new_status where id = :id;

Ini membiarkan siapa pun mengubah FAILED menjadi CAPTURED, REFUNDED menjadi AUTHORIZED, atau CANCELLED menjadi SETTLED.

Lebih aman:

update payment
set status = :to_status,
    version = version + 1,
    updated_at = now()
where id = :payment_id
  and status = :from_status
  and version = :expected_version;

Tapi legalitas tetap harus dicek di domain layer.

public final class PaymentStateMachine {
    private static final Map<PaymentStatus, Set<PaymentStatus>> LEGAL = Map.of(
            PaymentStatus.CREATED, Set.of(PaymentStatus.CONFIRMING, PaymentStatus.CANCELLED),
            PaymentStatus.CONFIRMING, Set.of(
                    PaymentStatus.REQUIRES_ACTION,
                    PaymentStatus.AUTHORIZED,
                    PaymentStatus.CAPTURED,
                    PaymentStatus.FAILED,
                    PaymentStatus.UNKNOWN),
            PaymentStatus.REQUIRES_ACTION, Set.of(PaymentStatus.CONFIRMING, PaymentStatus.FAILED),
            PaymentStatus.UNKNOWN, Set.of(PaymentStatus.AUTHORIZED, PaymentStatus.CAPTURED, PaymentStatus.FAILED),
            PaymentStatus.AUTHORIZED, Set.of(PaymentStatus.CAPTURED, PaymentStatus.CANCELLED, PaymentStatus.EXPIRED),
            PaymentStatus.CAPTURED, Set.of(PaymentStatus.PARTIALLY_REFUNDED, PaymentStatus.REFUNDED, PaymentStatus.DISPUTED),
            PaymentStatus.PARTIALLY_REFUNDED, Set.of(PaymentStatus.REFUNDED, PaymentStatus.DISPUTED),
            PaymentStatus.FAILED, Set.of(),
            PaymentStatus.CANCELLED, Set.of(),
            PaymentStatus.REFUNDED, Set.of(),
            PaymentStatus.EXPIRED, Set.of()
    );

    public void assertTransition(PaymentStatus from, PaymentStatus to) {
        if (!LEGAL.getOrDefault(from, Set.of()).contains(to)) {
            throw new IllegalPaymentTransitionException(from, to);
        }
    }
}

LEGAL sebaiknya tidak tersebar di banyak service.


7. Event yang Memicu Transition Harus Dibedakan Dari State

State:

AUTHORIZED

Event:

AuthorizationApproved

Command:

AuthorizePayment

Jangan campur.

flowchart LR C[Command<br/>CapturePayment] --> H[Handler] H --> E[Event<br/>CaptureSucceeded] E --> S[State<br/>CAPTURED] E --> L[Ledger Journal]

Perbedaannya:

KonsepMakna
CommandPermintaan melakukan sesuatu
EventFakta bahwa sesuatu terjadi
StateRingkasan kondisi saat ini

Dalam audit, event lebih penting daripada state.

State bisa direbuild dari event/history. Event tidak boleh hilang.


8. Transition Record: Evidence untuk Setiap Perubahan State

Setiap state transition harus meninggalkan jejak.

create table payment_state_transition (
    id uuid primary key,
    payment_id uuid not null,
    from_status varchar(50) not null,
    to_status varchar(50) not null,
    trigger_type varchar(50) not null,
    trigger_id varchar(150),
    actor_type varchar(50) not null,
    actor_id varchar(150),
    reason_code varchar(100),
    provider_reference varchar(150),
    metadata jsonb not null default '{}'::jsonb,
    occurred_at timestamptz not null,
    recorded_at timestamptz not null default now()
);

Contoh trigger_type:

  • API_COMMAND,
  • PROVIDER_RESPONSE,
  • WEBHOOK,
  • PROVIDER_INQUIRY,
  • RECONCILIATION_FILE,
  • OPS_MANUAL_ACTION,
  • SYSTEM_TIMEOUT,
  • SCHEDULED_EXPIRY.

Ini membuat sistem bisa menjawab:

“Kenapa payment ini berubah dari UNKNOWN menjadi CAPTURED?”

Jawaban bukan “karena status diupdate”.

Jawaban yang benar:

“Karena provider inquiry pada 2026-07-02 10:18:22 mengembalikan capture success dengan provider reference X, lalu transition rule UNKNOWN -> CAPTURED diterapkan.”


9. Authorization State Machine

Authorization bukan capture.

Authorization adalah persetujuan/hold/approval dari issuer/provider. Capture adalah finalisasi charge.

State authorization:

stateDiagram-v2 [*] --> REQUESTED REQUESTED --> APPROVED: provider approved REQUESTED --> DECLINED: provider declined REQUESTED --> UNKNOWN: timeout/ambiguous UNKNOWN --> APPROVED: inquiry/webhook approved UNKNOWN --> DECLINED: inquiry/webhook declined APPROVED --> PARTIALLY_CAPTURED: partial capture APPROVED --> FULLY_CAPTURED: full capture APPROVED --> VOIDED: void success APPROVED --> EXPIRED: auth expired PARTIALLY_CAPTURED --> FULLY_CAPTURED: remaining capture PARTIALLY_CAPTURED --> VOIDED: remaining voided DECLINED --> [*] FULLY_CAPTURED --> [*] VOIDED --> [*] EXPIRED --> [*]

Important invariants:

total_captured <= authorized_amount
total_voided + total_captured <= authorized_amount
capture allowed only when auth is APPROVED or PARTIALLY_CAPTURED
void allowed only for uncaptured amount
expired auth cannot be captured unless provider explicitly allows late capture

10. Capture State Machine

Capture adalah operation sendiri.

Satu authorization bisa punya beberapa capture kalau rail/provider mendukung partial capture.

stateDiagram-v2 [*] --> CREATED CREATED --> SUBMITTED: sent to provider SUBMITTED --> SUCCEEDED: success response/webhook SUBMITTED --> FAILED: definitive failure SUBMITTED --> UNKNOWN: timeout/ambiguous UNKNOWN --> SUCCEEDED: inquiry/recon confirms UNKNOWN --> FAILED: inquiry/recon confirms failure SUCCEEDED --> POSTED_TO_LEDGER: ledger journal posted POSTED_TO_LEDGER --> SETTLEMENT_PENDING: settlement expected FAILED --> [*]

Kenapa SUCCEEDED dan POSTED_TO_LEDGER dipisah?

Karena provider success dan internal accounting success adalah dua fakta berbeda.

Jika provider capture sukses tapi ledger posting gagal, payment tidak boleh dianggap sehat.

State perlu memperlihatkan gap ini.


11. Ledger Posting Boundary

Payment state tidak boleh melompat ke financial truth tanpa ledger.

Contoh buruk:

provider capture success -> payment status CAPTURED -> merchant balance bertambah by update balance

Lebih aman:

provider capture success -> capture SUCCEEDED -> post ledger journal -> payment CAPTURED / ledger POSTED
sequenceDiagram participant Provider participant PaymentCore participant Ledger participant Outbox Provider->>PaymentCore: capture success webhook PaymentCore->>PaymentCore: validate transition PaymentCore->>Ledger: post capture journal Ledger-->>PaymentCore: journal posted PaymentCore->>PaymentCore: move CAPTURED PaymentCore->>Outbox: publish PaymentCaptured

Rule:

State yang memberi hak finansial harus bergantung pada ledger posting, bukan hanya provider status.


12. Refund State Machine

Refund punya lifecycle sendiri.

stateDiagram-v2 [*] --> CREATED CREATED --> RESERVED: refundable amount reserved RESERVED --> SUBMITTED: sent to provider SUBMITTED --> SUCCEEDED: provider success SUBMITTED --> FAILED: definitive failure SUBMITTED --> UNKNOWN: timeout/ambiguous UNKNOWN --> SUCCEEDED: inquiry/webhook/recon success UNKNOWN --> FAILED: inquiry/webhook/recon failure SUCCEEDED --> POSTED_TO_LEDGER: refund journal posted FAILED --> RESERVATION_RELEASED: release reserved amount POSTED_TO_LEDGER --> [*] RESERVATION_RELEASED --> [*]

Refund state machine harus menjaga amount reservation.

flowchart TD A[Refund command] --> B{Refundable capacity enough?} B -->|No| C[Reject] B -->|Yes| D[Reserve amount] D --> E[Submit to provider] E --> F{Outcome?} F -->|Success| G[Post refund ledger] F -->|Failure| H[Release reservation] F -->|Unknown| I[Keep reservation + resolve]

Unknown refund tidak boleh release reservation.


13. Settlement State Machine

Settlement bukan payment success.

Payment captured berarti customer/provider menyatakan charge berhasil. Settlement berarti dana sudah masuk/matang sesuai report/statement.

State settlement item:

stateDiagram-v2 [*] --> EXPECTED EXPECTED --> REPORTED: provider settlement report contains item REPORTED --> MATCHED: matched internal payment REPORTED --> BREAK: mismatch amount/currency/reference MATCHED --> POSTED_TO_LEDGER: settlement journal posted POSTED_TO_LEDGER --> SETTLED: settlement batch closed BREAK --> INVESTIGATING INVESTIGATING --> MATCHED: resolved INVESTIGATING --> ADJUSTED: manual/system adjustment SETTLED --> [*]

Jangan set payment.status = SETTLED hanya karena payment captured.

Settlement harus punya evidence:

  • report id,
  • batch id,
  • settlement date,
  • provider reference,
  • gross/fee/net amount,
  • bank statement reference jika ada,
  • reconciliation match id.

14. Dispute/Chargeback State Machine

Dispute bukan refund.

Refund adalah pengembalian yang biasanya diinisiasi merchant/platform. Chargeback/dispute biasanya dipicu customer/issuer/scheme/bank.

State dispute:

stateDiagram-v2 [*] --> OPENED OPENED --> EVIDENCE_REQUIRED EVIDENCE_REQUIRED --> EVIDENCE_SUBMITTED EVIDENCE_SUBMITTED --> UNDER_REVIEW UNDER_REVIEW --> WON UNDER_REVIEW --> LOST OPENED --> ACCEPTED LOST --> LOSS_POSTED WON --> REVERSAL_POSTED ACCEPTED --> LOSS_POSTED LOSS_POSTED --> [*] REVERSAL_POSTED --> [*]

Dispute affects payment state, merchant balance, reserve, payout eligibility, and risk scoring.

Tapi dispute lifecycle jangan dipaksa masuk satu enum payment utama.

Payment boleh CAPTURED dan dispute OPENED secara bersamaan.


15. Payout State Machine

Merchant payout juga state machine.

stateDiagram-v2 [*] --> CREATED CREATED --> APPROVAL_REQUIRED APPROVAL_REQUIRED --> APPROVED APPROVAL_REQUIRED --> REJECTED CREATED --> APPROVED: auto approval APPROVED --> FUNDS_RESERVED FUNDS_RESERVED --> SUBMITTED SUBMITTED --> SUCCEEDED SUBMITTED --> FAILED SUBMITTED --> UNKNOWN UNKNOWN --> SUCCEEDED UNKNOWN --> FAILED SUCCEEDED --> POSTED_TO_LEDGER FAILED --> FUNDS_RELEASED POSTED_TO_LEDGER --> [*]

Payout sama berbahayanya dengan charge. Double payout bisa menghancurkan platform.

Maka payout juga butuh:

  • idempotency,
  • amount reservation,
  • maker-checker untuk high amount,
  • beneficiary verification,
  • provider inquiry,
  • reconciliation,
  • ledger posting.

16. State Explosion: Cara Menghindari Monster Enum

Kalau semua kombinasi dimasukkan ke PaymentStatus, enum bisa menjadi seperti:

AUTHORIZED_PARTIALLY_CAPTURED_PARTIALLY_REFUNDED_DISPUTED_SETTLEMENT_PENDING

Ini buruk.

Gunakan orthogonal states.

create table payment (
    id uuid primary key,
    intent_status varchar(50) not null,
    fulfillment_status varchar(50) not null,
    ledger_status varchar(50) not null,
    settlement_status varchar(50) not null,
    dispute_status varchar(50) not null,
    version bigint not null,
    created_at timestamptz not null,
    updated_at timestamptz not null
);

Atau pecah ke aggregate/table berbeda:

  • payment_intent,
  • payment_attempt,
  • authorization,
  • capture,
  • refund,
  • settlement_item,
  • dispute_case,
  • payout.

Pecahan aggregate lebih aman untuk sistem besar.


17. Mapping Provider Status ke Internal State

Provider A:

PAID

Provider B:

SUCCESS

Provider C:

CAPTURED

Provider D:

SETTLED

Jangan langsung simpan provider status sebagai internal state.

Buat normalization layer:

public record ProviderStatusMapping(
        String provider,
        String providerStatus,
        ProviderOperation operation,
        InternalOutcome outcome,
        boolean terminal,
        boolean requiresInquiry
) {}

Contoh:

Provider statusOperationInternal outcome
AUTHORIZEDauthorizeAUTH_APPROVED
PAIDsaleCAPTURE_SUCCEEDED
SUCCESSrefundREFUND_SUCCEEDED
PENDINGbank transferAWAITING_CUSTOMER_PAYMENT
PROCESSINGpayoutPAYOUT_PENDING
ERRORanydepends on error code
timeoutanyUNKNOWN

Mapping harus mempertimbangkan operation, bukan status text saja.

SUCCESS untuk authorization berbeda dari SUCCESS untuk refund.


18. Event Ordering: Webhook Bisa Datang Tidak Urut

Provider bisa mengirim:

CAPTURED webhook
AUTHORIZED webhook

Atau settlement report datang sebelum webhook.

Atau duplicate webhook datang setelah manual repair.

State machine harus robust terhadap event ordering.

Prinsip:

  1. Setiap provider event disimpan dulu sebagai raw event.
  2. Dedup berdasarkan provider event id atau fingerprint.
  3. Normalize event.
  4. Apply jika transition legal.
  5. Jika transition belum legal tetapi event plausible, parkir sebagai pending event.
  6. Jika event kontradiktif, buat anomaly.
flowchart TD A[Webhook received] --> B[Store raw event] B --> C{Duplicate?} C -->|Yes| D[Ignore application, keep evidence] C -->|No| E[Normalize] E --> F{Transition legal now?} F -->|Yes| G[Apply transition] F -->|No but plausible| H[Park pending event] F -->|Contradiction| I[Create anomaly case]

19. Idempotency dan State Machine

Idempotency bukan hanya “same request returns same response”.

Dalam state machine, idempotency menentukan apakah command boleh diterapkan ulang.

Contoh:

CapturePayment(paymentId=123, amount=100000, idempotencyKey=k1)

Jika command pertama timeout setelah provider success, retry dengan k1 harus mengembalikan operation yang sama, bukan membuat capture baru.

Idempotency record:

create table idempotency_record (
    id uuid primary key,
    scope varchar(100) not null,
    key varchar(200) not null,
    request_hash varchar(128) not null,
    operation_type varchar(50) not null,
    operation_id uuid not null,
    status varchar(50) not null,
    response_snapshot jsonb,
    created_at timestamptz not null,
    expires_at timestamptz not null,
    unique(scope, key)
);

Rule:

  • same key + same payload = return same operation/result,
  • same key + different payload = conflict,
  • different key + conflicting state = reject by state/amount invariant,
  • provider idempotency key should be derived from internal operation id where possible.

Scenario:

  • Captured amount = Rp100.000.
  • Refund A = Rp70.000.
  • Refund B = Rp50.000.
  • Keduanya datang bersamaan.

Masing-masing terlihat valid jika membaca state lama.

Bersama-sama total refund Rp120.000, illegal.

Solusi:

  • optimistic locking,
  • amount reservation,
  • unique operation keys,
  • serializable transaction untuk area tertentu,
  • domain-level invariant check dalam transaction.
update payment_refund_amount_state
set pending_refund_amount_minor = pending_refund_amount_minor + :refund_amount,
    version = version + 1
where payment_id = :payment_id
  and version = :expected_version
  and successful_refund_amount_minor + pending_refund_amount_minor + :refund_amount <= captured_amount_minor;

State machine tanpa concurrency control hanya diagram.


21. Terminal State Tidak Selalu Benar-Benar Terminal

FAILED biasanya terminal untuk satu attempt.

Tapi payment intent mungkin masih bisa punya attempt baru.

Contoh:

Attempt 1: card declined
Payment intent: still requires new payment method
Attempt 2: wallet success
Payment intent: captured

Jadi bedakan terminal untuk:

ObjectTerminal meaning
Attemptpercobaan itu selesai
Authorizationapproval itu selesai/expired/voided/captured
Capturecapture operation selesai
Payment intentobligation pembayaran selesai atau tidak mungkin lanjut
Refundrefund operation selesai

Jangan membuat payment FAILED hanya karena satu attempt gagal, kecuali policy memang single-attempt.


22. State Machine dan Order Fulfillment

Payment state sering dipakai oleh order system.

Tapi order fulfillment tidak boleh membaca status provider mentah.

Contoh policy:

Payment conditionOrder action
AUTHORIZEDreserve inventory, do not ship physical goods unless auth-only flow allows
CAPTURED + ledger postedfulfill digital goods / mark paid
CAPTURE_UNKNOWNhold fulfillment
FAILEDallow retry / release reservation
REFUNDEDtrigger return/cancel policy
DISPUTEDfreeze high-risk merchant operations if needed

Order should subscribe to domain event:

PaymentFinanciallyConfirmed

Not:

ProviderWebhookSuccessReceived

23. Manual State Transition: Ops Tidak Boleh Bebas Mengubah Status

Backoffice biasanya butuh manual repair.

Tapi manual action harus tetap melewati state machine.

Contoh action:

  • mark provider event as duplicate,
  • force inquiry,
  • resolve unknown as failed with evidence,
  • link settlement item to payment,
  • post adjustment journal,
  • release stale reservation,
  • create dispute loss entry.

Manual action tidak boleh:

set payment.status = CAPTURED

Manual action harus berupa command:

ResolveCaptureUnknown(paymentId, operationId, outcome, evidenceId, reasonCode)

Lalu state machine memvalidasi transition.

Maker-checker bisa diwajibkan untuk transition berisiko.


24. Expiry Transitions

Beberapa state punya expiry:

  • payment intent expires,
  • virtual account expires,
  • QR expires,
  • authorization expires,
  • 3DS challenge expires,
  • refund reservation expires only if safe,
  • payout approval expires,
  • dispute evidence deadline expires.

Expiry adalah transition juga.

create table scheduled_state_transition (
    id uuid primary key,
    aggregate_type varchar(50) not null,
    aggregate_id uuid not null,
    expected_from_status varchar(50) not null,
    to_status varchar(50) not null,
    reason_code varchar(100) not null,
    execute_after timestamptz not null,
    executed_at timestamptz,
    status varchar(30) not null,
    created_at timestamptz not null
);

Worker expiry harus idempotent:

update payment_authorization
set status = 'EXPIRED',
    version = version + 1
where id = :authorization_id
  and status = 'APPROVED'
  and expires_at <= now();

Jika status sudah berubah menjadi captured, expiry job tidak melakukan apa-apa.


25. Persistence Pattern: Current State + History

Production system biasanya menyimpan:

  1. current state untuk query cepat,
  2. transition history untuk audit,
  3. domain events/outbox untuk integration,
  4. raw provider events untuk evidence.
flowchart TD A[Command/Webhook/Reconciliation] --> B[Domain Handler] B --> C[Current State Table] B --> D[State Transition Table] B --> E[Domain Event Outbox] B --> F[Raw Evidence Table]

Jangan hanya simpan current state.

Tanpa history, debugging payment incident akan sangat mahal.


26. Java Implementation Pattern

Contoh command handler capture:

public final class CapturePaymentHandler {
    private final PaymentRepository paymentRepository;
    private final AuthorizationRepository authorizationRepository;
    private final CaptureRepository captureRepository;
    private final PaymentStateMachine paymentStateMachine;
    private final LedgerService ledgerService;
    private final Outbox outbox;

    public CaptureResult handle(CapturePayment command) {
        return paymentRepository.inTransaction(() -> {
            Payment payment = paymentRepository.getForUpdate(command.paymentId());
            Authorization authorization = authorizationRepository.getForUpdate(command.authorizationId());

            paymentStateMachine.assertCaptureAllowed(payment, authorization, command.amount());

            Capture capture = captureRepository.createPending(command);
            authorization.reserveCapture(command.amount());

            paymentRepository.save(payment);
            authorizationRepository.save(authorization);
            captureRepository.save(capture);

            outbox.enqueue(new CaptureSubmissionRequested(capture.id()));

            return CaptureResult.accepted(capture.id());
        });
    }
}

External provider call sebaiknya tidak dilakukan di dalam DB transaction panjang.

Flow:

  1. Reserve state/amount internal.
  2. Commit.
  3. Outbox worker submit ke provider.
  4. Provider response/webhook/inquiry menggerakkan state berikutnya.

Ini menghindari transaction DB menggantung karena network call.


27. Provider Submission Worker

public final class CaptureSubmissionWorker {
    private final CaptureRepository captureRepository;
    private final ProviderClient providerClient;
    private final Outbox outbox;

    public void submit(UUID captureId) {
        Capture capture = captureRepository.get(captureId);

        if (!capture.isSubmittable()) {
            return;
        }

        ProviderCaptureRequest request = ProviderCaptureRequest.from(capture);

        try {
            ProviderCaptureResponse response = providerClient.capture(request);
            outbox.enqueue(ProviderCaptureResponseReceived.from(captureId, response));
        } catch (ProviderTimeoutException e) {
            outbox.enqueue(new ProviderCaptureOutcomeUnknown(captureId, e.requestId()));
        } catch (ProviderDefinitiveDeclineException e) {
            outbox.enqueue(new ProviderCaptureFailed(captureId, e.reasonCode()));
        }
    }
}

Worker tidak langsung sembarang update. Ia menghasilkan event internal untuk handler state transition.


28. Transition Handler dengan Optimistic Locking

public final class CaptureOutcomeHandler {
    private final CaptureRepository captureRepository;
    private final PaymentRepository paymentRepository;
    private final LedgerService ledgerService;
    private final StateTransitionRecorder recorder;

    public void on(CaptureSucceeded event) {
        captureRepository.inTransaction(() -> {
            Capture capture = captureRepository.getForUpdate(event.captureId());
            Payment payment = paymentRepository.getForUpdate(capture.paymentId());

            if (capture.isSucceeded()) {
                return; // idempotent duplicate event
            }

            capture.assertCanMoveTo(CaptureStatus.SUCCEEDED);
            payment.assertCanApplyCapture(capture.amount());

            LedgerJournal journal = ledgerService.postCapture(capture);

            capture.markSucceeded(event.providerReference(), journal.id());
            payment.markCapturedIfComplete(capture.amount());

            recorder.record(
                    payment.id(),
                    payment.previousStatus(),
                    payment.status(),
                    "PROVIDER_RESPONSE",
                    event.eventId(),
                    event.providerReference()
            );

            captureRepository.save(capture);
            paymentRepository.save(payment);
        });
    }
}

Duplicate event harus aman.

Contradictory event harus anomaly.


29. State Machine Test Matrix

State machine wajib dites sebagai matrix.

Contoh:

FromToExpected
CREATEDCONFIRMINGallowed
CREATEDCAPTUREDrejected
CONFIRMINGUNKNOWNallowed
UNKNOWNCAPTUREDallowed with evidence
FAILEDCAPTUREDrejected except explicit repair policy
CAPTUREDREFUNDEDallowed if amount invariant satisfied
REFUNDEDCAPTUREDrejected

Test bukan hanya happy path.

Minimal test:

  • all legal transitions pass,
  • all illegal transitions fail,
  • duplicate event idempotent,
  • out-of-order plausible event parked,
  • contradictory event creates anomaly,
  • unknown blocks conflicting command,
  • amount invariant enforced under concurrent commands,
  • ledger posting failure prevents financial confirmation,
  • manual action requires reason/evidence.

30. State Machine as Documentation

State machine yang baik harus dibaca oleh:

  • engineer,
  • product,
  • finance,
  • ops,
  • compliance,
  • support.

Karena itu, setiap status harus punya definisi operasional.

Contoh:

StatusDefinisiBoleh fulfill order?Boleh refund?Boleh payout?
CREATEDintent dibuat, belum ada attempt finalNoNoNo
AUTHORIZEDdana/limit disetujui, belum capturedDependsNoNo
CAPTUREDcharge sukses dan ledger postedYesYesNot until settlement policy
CAPTURE_UNKNOWNprovider outcome ambiguousNoNoNo
REFUNDEDfull refund succeeded and ledger postedNoNoNo
DISPUTEDdispute aktifDependsDependsMaybe frozen

Kalau tabel ini tidak bisa dibuat, state model belum jelas.


31. Anti-Pattern State Machine

Anti-Pattern 1: Enum terlalu generik

PENDING, SUCCESS, FAILED

Tidak cukup untuk payment.

Anti-Pattern 2: Provider status menjadi internal state

payment.status = provider.status

Membuat internal model bergantung pada provider.

Anti-Pattern 3: Timeout dianggap failure

Timeout harus menjadi unknown sampai resolved.

Anti-Pattern 4: State update tanpa transition history

Audit hilang.

Anti-Pattern 5: Manual update langsung ke DB

Backoffice harus command-based dan evidence-based.

Anti-Pattern 6: Ledger posting setelah publish event

Jika event keluar sebelum ledger posting, downstream bisa bertindak pada uang yang belum tercatat.


32. Minimal Production Checklist

  • Payment intent, attempt, authorization, capture, refund, settlement, dispute, payout punya state terpisah.
  • UNKNOWN adalah state eksplisit.
  • Timeout tidak otomatis menjadi failed.
  • Transition legal divalidasi di domain layer.
  • DB update memakai expected state/version.
  • Semua transition punya history/evidence.
  • Provider status dinormalisasi, tidak langsung dipakai.
  • Duplicate webhook/event idempotent.
  • Out-of-order event bisa diparkir atau dianomali.
  • State yang memberi hak finansial menunggu ledger posting.
  • Manual action tetap melewati command/state machine.
  • Expiry dimodelkan sebagai transition.
  • State machine punya test matrix illegal/legal transition.

33. Mini Design Exercise

Desain flow berikut:

Payment intent IDR 100000 dibuat.
Customer memilih card.
Provider authorization success.
Capture request dikirim.
Provider timeout.
Webhook capture success datang 5 menit kemudian.
Ledger posting success.
Settlement report datang besok dengan amount IDR 97000 net of fee.

Tentukan:

  1. State payment intent setelah authorization?
  2. State capture setelah timeout?
  3. Operation apa yang diblokir selama capture unknown?
  4. Apa yang dilakukan webhook capture success?
  5. Kapan order boleh fulfilled?
  6. Kapan merchant balance pending bertambah?
  7. Kapan settlement status berubah?
  8. Evidence apa saja yang harus tersimpan?

Jawaban ringkas:

  • setelah authorization: AUTHORIZED,
  • setelah capture timeout: capture UNKNOWN, payment financial confirmation belum final,
  • refund/second capture/payout harus diblokir atau dibatasi,
  • webhook success resolve capture unknown menjadi succeeded,
  • order boleh fulfilled setelah capture success + ledger posted sesuai policy,
  • merchant pending balance bertambah saat capture ledger journal posted,
  • settlement berubah setelah settlement item matched dan settlement journal posted,
  • evidence meliputi command id, provider request id, timeout event, webhook raw payload, provider reference, ledger journal id, settlement report id, reconciliation match id.

34. Kesimpulan

Payment state machine bukan aksesori desain.

Ia adalah pagar yang mencegah sistem melakukan operasi finansial yang salah.

State machine yang benar harus:

  • memisahkan lifecycle berbeda,
  • memperlakukan unknown sebagai state nyata,
  • memvalidasi legal transition,
  • menyimpan transition evidence,
  • menormalisasi provider event,
  • tahan duplicate dan out-of-order event,
  • terkait erat dengan ledger boundary,
  • dan bisa diuji sebagai matrix, bukan hanya happy path.

Part berikutnya akan masuk ke Idempotency as a Financial Control: bagaimana mendesain idempotency key, request hash, provider idempotency, replay window, conflict detection, dan idempotency under concurrency supaya retry tidak berubah menjadi double charge.


Referensi

Lesson Recap

You just completed lesson 08 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.