Skip to main content

Sales Order State Machine — Pemesanan Device Pasca-Aktivasi

Spec dokumen untuk state machine pemesanan device QRIS Plus oleh merchant aktif via mobile, beserta semua guard mutasi yang diterapkan backend supaya transisi state konsisten dan tidak ada inkonsistensi data.

Status: order_service adalah sole writer untuk sales order (Phase 9B); tabel hidup di db_kesles_merchant_order schema orders (orders.sales_orders, orders.sales_order_items, orders.sales_order_payments — mig 004). core_api meng-expose endpoint /merchant/sales-order* dan mendelegasikan tulisan ke order_service lewat HTTP; payment lifecycle (Flow B) masuk via endpoint mark-paid dari payment_service ke order_service.

1. Diagram state utama (sales_orders.status)

┌──────────────────┐
│ (no order yet) │
└────────┬─────────┘
│ Mobile: Katalog → "+" → Pesan
│ POST /merchant/sales-order
│ order_service AppendOrCreateOrder

┌──────────────────┐
│ pending │ ← bisa append/edit item
│ (Belum Bayar) │ (selama proof url empty)
└─┬───────┬────────┘
│ │
Upload bukti bayar │ │ Hapus item terakhir
POST /merchant/ │ │ DELETE /sales-order/{id}/items/{lineNo}
sales-order/ │ │ → orderDeleted=true
payment-proof │ │ → soft-delete sales_order
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ pending + │ │ (deleted_at = NOW) │
│ payment_proof_url │ │ Order ditutup │
│ (Verifikasi │ │ Mobile pop back │
│ Pembayaran) │ └──────────────────────┘
└──────────┬───────────┘

┌────────────┴────────────────────────────────────────┐
│ Jalur A: Manual proof │
│ Dashboard admin: Verifikasi Bayar │
│ PATCH /sales-orders/{id}/mark-paid │
│ │
│ Jalur B: Otomatis via payment_service (Flow B) │
│ PSP webhook → payment_service → order_service │
│ POST /internal/orders/sales-orders/{id}/mark-paid │
└────────────────────────────┬────────────────────────┘


┌──────────────────────┐
│ payment_received │ (Sudah Dibayar)
└──────────┬───────────┘
│ Dashboard admin: Buat shipping
│ POST /shipping-orders + allocate inventory

┌──────────────────────┐
│ processing │ (Sudah Dikemas / sedang siapkan)
│ (shipping_orders. │ courier_name & tracking_number
│ status = │ belum diisi
│ not_processed) │
└──────────┬───────────┘
│ Dashboard admin: Dispatch (input courier+tracking)
│ PATCH /shipping-orders/{id}/dispatch

┌──────────────────────┐
│ processing │ (Dalam Perjalanan)
│ (shipping_orders. │ courier_name & tracking_number
│ status = │ filled
│ in_transit) │
└──────────┬───────────┘
│ Dashboard admin: Mark Delivered
│ PATCH /shipping-orders/{id}/mark-delivered

┌──────────────────────┐
│ delivered │ (Sudah Diterima)
│ (terminal status = │
│ delivered) │
└──────────┬───────────┘
│ Mobile: Scan QR Pairing
│ POST /merchant/terminals/pair
│ → terminal status = active

┌──────────────────────┐
│ completed │ (terminal aktif & siap transaksi)
└──────────────────────┘

Cabang lateral (kapan saja sebelum delivered):
• cancelled / expired / rejected / refunded — final, no return

2. Sub-state UI vs status DB

UI mobile mendiskriminasi sub-state lewat kombinasi kolom DB. Backend status enum saja tidak cukup — pending punya 2 sub-state visual.

Sub-state UI mobilesales_orders.statusDiskriminator tambahan
Belum Bayarpendingpayment_proof_url empty
Verifikasi Pembayaranpayment_review (upload bukti transisi pendingpayment_review; pending / accepted masih dianggap verifikasi bila proof ada)payment_proof_url non-empty
Sudah Dibayarpayment_received
Sedang Disiapkanprocessingcourier_name empty
Dalam Perjalananprocessing (shipped / in_transit)courier_name filled / shipping_orders.status = in_transit
Sudah Diterimadelivered / completed
Dibatalkancancelled / expired / rejected
Pengembalian Danarefunded

Mobile mapper di merchant_orders_list_page.dart _statusVisualFor() & _activeTimelineStep() — parameter paymentProofUrl dipakai untuk pisah sub-state Belum Bayar ↔ Verifikasi Pembayaran.


3. Guard table — apa yang BOLEH dilakukan per state

Diterapkan oleh handler backend merchant_sales_order_items_handler.go + merchant_sales_order_create_handler.go.

StateAppend item (POST /sales-order)PATCH qtyDELETE itemBuat order baru?
(no order)✅ create newn/an/a✅ baru
pending + proof empty (Belum Bayar)append ke order existing✅ ya✅ ya– (tidak perlu, append saja)
pending + proof filled (Verifikasi Pembayaran)❌ tidak append❌ ditolak (order not editable: payment proof already uploaded)❌ ditolakbuat order baru
payment_received (Sudah Dibayar)❌ tidak append❌ ditolak (status != pending)❌ ditolakbuat order baru
processing (Sudah Dikemas / Dalam Perjalanan)❌ tidak append❌ ditolak❌ ditolakbuat order baru
delivered / completed (Sudah Diterima)❌ tidak append❌ ditolak❌ ditolakbuat order baru
cancelled / expired / rejected / refunded❌ tidak append❌ ditolak❌ ditolakbuat order baru
deleted_at non-null (soft-deleted)❌ tidak terlihat❌ tidak terlihat❌ tidak terlihatbaru

3.1 Guard SQL — append (AppendOrCreateOrder)

order_service/internal/orders/store_orders.go:

SELECT id FROM orders.sales_orders
WHERE merchant_id = $1
AND status = 'pending'
AND payment_deadline_at IS NULL
AND payment_proof_url = ''
AND deleted_at IS NULL
LIMIT 1
FOR UPDATE SKIP LOCKED

Kalau row tidak match → fall through ke insertOrderTx yang buat order baru dari nol. Response field appended_to_existing: bool membedakan kedua jalur untuk mobile snackbar.

3.2 Guard SQL — PATCH qty / DELETE item

Guard dijalankan di order_service (store_orders.go); core_api handler merchant_sales_order_items_handler.go (serveSalesOrderItemPATCH / serveSalesOrderItemDELETE) mendelegasikan update qty / delete item ke order_service lewat HTTP:

SELECT status, COALESCE(payment_proof_url, '')
FROM orders.sales_orders
WHERE id = $1::uuid AND deleted_at IS NULL
FOR UPDATE

Predicate Go:

  • status != 'pending' → return error order not editable: status=X.
  • payment_proof_url non-empty (after trim) → return error order not editable: payment proof already uploaded.

Mobile menerima error dari backend (HTTP 5xx) dan tampilkan SnackBar.


4. Payment Lifecycle — orders.sales_order_payments (mig 004)

Tabel orders.sales_order_payments di db_kesles_merchant_order merekam lifecycle pembayaran untuk setiap sales order. Diisi oleh order_service saat menerima notifikasi dari payment_service (Flow B).

Flow B lengkap (merchant bayar order via PSP)

1. Merchant upload bukti bayar OR PSP webhook masuk ke payment_service
2. payment_service: verify HMAC → INSERT payment.transactions
3. payment_service: deteksi sales_order_id → POST /internal/orders/sales-orders/{id}/mark-paid
4. order_service:
a. INSERT orders.sales_order_payments {
sales_order_id, payment_transaction_id, amount, currency,
payment_method, payment_channel, paid_at, status='paid'
}
b. UPDATE orders.sales_orders SET status = 'payment_received'

Jalur A (manual, via dashboard admin)

Tetap tersedia untuk kasus rekonsiliasi manual atau transfer bank tanpa webhook:

Dashboard admin: PATCH /api/dashboard/sales/sales-orders/{id}/mark-paid
order_service: UPDATE sales_orders.status = payment_received
(sales_order_payments di-insert manual jika data verifikasi bank tersedia)

5. Behavior tombol "−" + delete item (state Belum Bayar)

merchant_device_payment_instruction_page.dart:

Kondisi lineTap "−"Backend SQL
qty > 1qty = qty - 1, recompute headerUPDATE … SET quantity = qty-1
qty = 1Dialog konfirmasi → DELETE lineDELETE FROM sales_order_items WHERE line_no = N
qty = 1, line satu-satunya di orderDialog konfirmasi → DELETE line + soft-delete orderDELETE FROM items lalu UPDATE sales_orders SET deleted_at = now(), totals = 0. Response: order_deleted: true → mobile pop back
qty = 1, ada ≥ 2 jenis lineDialog konfirmasi → DELETE 1 line saja, order tetap existDELETE FROM items saja. Response: order_deleted: false

Tombol "−" di _QtyStepper:

final bool decEnabled = !updating && onDecrement != null;

Predicate quantity > 1 dihapus — tombol tetap aktif di qty=1 supaya trigger _changeLineQuantity(lineNo, -1)newQty = 0 < 1_confirmAndDeleteLine(current) → DELETE flow.

5.1 Soft-delete chain saat hapus item terakhir

Backend order_service DeleteItem (store_orders.go):

// Setelah DELETE FROM sales_order_items …
SELECT COUNT(*) FROM sales_order_items WHERE sales_order_id = $1

if remainingCount == 0 {
UPDATE orders.sales_orders
SET deleted_at = now(),
updated_at = now(),
subtotal_amount = 0,
promo_amount = 0,
shipping_fee_amount = 0,
total_amount = 0
WHERE id = $1
return ([], header_zero, orderDeleted=true, nil)
}
// Else: recompute header normal, return (items, header, false, nil)

Mobile:

if (result.orderDeleted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(
'"${line.deviceName}" dihapus. Surat pesanan ${order.orderNumber} ditutup.',
)),
);
Navigator.of(context).pop(true); // back to caller
}

Soft-delete (deleted_at = NOW()) — bukan hard delete — untuk audit trail. Query mobile yang filter deleted_at IS NULL otomatis tidak akan tampilkan order ini lagi di list, dashboard, atau lookup.


6. Race-safety

Setiap mutasi (append / patch qty / delete item) jalan dalam DB transaction dengan SELECT … FOR UPDATE di row sales_orders. Skenario race yang ter-handle:

RaceBehavior
Double-tap "+" di mobile (2 request paralel)Request kedua block di FOR UPDATE → ketika lock release, sudah ada line baru / order baru → behavior idempotent
User upload bukti bayar saat admin sedang mark-paidSalah satu menang lock; kalau upload bukti masuk dulu, mark-paid lihat status pending+proof dan lanjut. Sebaliknya, mark-paid masuk dulu, upload bukti gagal karena status != 'pending'
User PATCH qty saat order sudah dipindah ke payment_received oleh adminLock query baca status terbaru → status != 'pending' → reject dengan error
User upload bukti saat user lain (multi-device) tap "+"Tap "+" lihat payment_proof_url non-empty → guard tolak append → buat order baru
PSP webhook Flow B datang dobel (idempotency)payment_service pakai transaction_code sebagai idempotency key (Redis-backed middleware); order_service guard terhadap sales_order_payments yang sudah ada status=paid

7. Nomor SO

Setiap order baru dapat order_number baru (SO-YYYYMMDD-NNNN via orders.sales_order_seq). Append ke pending order existing tidak bikin SO baru — line ditambah ke SO yang sama, header subtotal

  • total ter-increment. SO baru hanya dibuat saat user pesan padahal:
  • Belum punya order sama sekali, atau
  • Punya order tapi sudah lewat sub-state "Belum Bayar" (proof uploaded / payment_received / processing / delivered / completed / cancelled / expired).

Mobile snackbar membedakan dua jalur lewat field appended_to_existing:

created.appendedToExisting
? '${created.deviceName} ditambahkan ke pesanan ${created.orderNumber}. Lanjutkan ke pembayaran.'
: 'Pesanan ${created.deviceName} (${created.orderNumber}) berhasil dibuat. Lanjutkan ke pembayaran.';

8. Endpoint matrix

MethodPathSource-of-truth fileGuard
POST/merchant/sales-ordermerchant_sales_order_create_handler.gomerchant active + offer active. Append vs new diskriminasi by §3.1 SQL
GET/merchant/sales-order/currentsales_order_api_service.dart:565 (mobile) → backend handlerFilter deleted_at IS NULL
GET/merchant/sales-orders?status_group=Xmobile orders listFilter status_group + soft-delete
GET/merchant/sales-order/{id}detail handlerownership check
GET/merchant/sales-order/{id}/itemsitems listownership
PATCH/merchant/sales-order/{id}/items/{lineNo}merchant_sales_order_items_handler.go serveSalesOrderItemPATCH§3.2 lock guard
DELETE/merchant/sales-order/{id}/items/{lineNo}same handler serveSalesOrderItemDELETE§3.2 lock guard + auto soft-delete order kalau line terakhir
POST/merchant/sales-order/{id}/start-paymentstart paymentstatus pending only
POST/merchant/sales-order/payment-proofupload proof MinIO + set payment_proof_urlstatus pending/accepted/payment_review; transisi pendingpayment_review
POST/internal/orders/sales-orders/{id}/mark-paidorder_service handlers_internal.goDipanggil oleh payment_service Flow B (X-Internal-API-Key). INSERT orders.sales_order_payments + UPDATE status
PATCH/api/dashboard/sales/sales-orders/{id}/mark-paiddashboardstatus pending + proof non-empty (Jalur A manual)
POST/api/dashboard/sales/shipping-ordersdashboardsales_order status payment_received
PATCH/api/dashboard/sales/shipping-orders/{id}/dispatchdashboardshipping status not_processed
PATCH/api/dashboard/sales/shipping-orders/{id}/mark-delivereddashboardshipping status in_transit
POST/merchant/terminals/pairmobile pairingterminal status delivered

9. Open follow-ups

  1. Cancel order — saat ini tidak ada endpoint mobile/admin untuk cancel order pending sebelum upload bukti (alternatif: hapus item terakhir → soft-delete via §5.1). Ke depan bisa ditambah explicit POST /merchant/sales-order/{id}/cancel kalau butuh audit-trail status=cancelled (vs implicit soft-delete).
  2. Expired auto-cancel — sudah diimplementasi. order_service worker startAutoCancel (worker_autocancel.go, sweep tiap 5 menit, dijalankan dari StartWorkers) men-set status='cancelled' + auto_cancelled_at untuk order pending yang payment_deadline_at < NOW(). Reminder WA H-24 sebelum deadline dikirim oleh startPaymentReminder.
  3. FCM event ke mobile saat admin mark-paid / dispatch / delivered — belum ada push pada transisi admin tersebut (merchant tahu via pull-to-refresh halaman pesanan). Catatan: notifikasi otomatis sudah ada untuk auto-cancel (FCM) dan reminder pembayaran (WA H-24) lewat worker order_service.
  4. Backend response error code untuk guard violations — saat ini pakai fmt.Errorf("order not editable: …") plain text. Bisa ditingkatkan ke kode error structured (mis. "code": "order_locked") supaya mobile bisa render pesan kontekstual berbasis error code, bukan parsing pesan.
  5. Dual-write cutover — selesai. Dual-write ke tabel legacy sudah dihentikan (order_service 2026-06-02, payment_service 2026-06-16: POSTGRES_DSN_LEGACY + store_legacy_mirror.go dihapus). Jalur B tidak lagi melalui dual-write; order_service adalah sole writer.

10. Referensi

  • Backend handler items (core_api, HTTP entry → delegasi order_service): merchant_sales_order_items_handler.go
  • Backend handler create (core_api, HTTP entry → delegasi order_service): merchant_sales_order_create_handler.go
  • Backend store SQL (order_service, sole writer): order_service/internal/orders/store_orders.go
  • order_service internal mark-paid handler: order_service/internal/orders/transport/http/handlers_internal.go
  • Mobile orders list: merchant_orders_list_page.dart
  • Mobile payment instruction (qty stepper + delete): merchant_device_payment_instruction_page.dart
  • Mobile API client: sales_order_api_service.dart
  • DB migration: db_kesles_merchant_order mig 004 (orders.sales_order_payments)
  • Transaction Notification Flow — Flow B detail PSP webhook
  • Service Topology — payment_service + order_service wiring