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_serviceadalah sole writer untuk sales order (Phase 9B); tabel hidup didb_kesles_merchant_orderschemaorders(orders.sales_orders,orders.sales_order_items,orders.sales_order_payments— mig 004).core_apimeng-expose endpoint/merchant/sales-order*dan mendelegasikan tulisan keorder_servicelewat HTTP; payment lifecycle (Flow B) masuk via endpointmark-paiddaripayment_servicekeorder_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 mobile | sales_orders.status | Diskriminator tambahan |
|---|---|---|
| Belum Bayar | pending | payment_proof_url empty |
| Verifikasi Pembayaran | payment_review (upload bukti transisi pending → payment_review; pending / accepted masih dianggap verifikasi bila proof ada) | payment_proof_url non-empty |
| Sudah Dibayar | payment_received | — |
| Sedang Disiapkan | processing | courier_name empty |
| Dalam Perjalanan | processing (shipped / in_transit) | courier_name filled / shipping_orders.status = in_transit |
| Sudah Diterima | delivered / completed | — |
| Dibatalkan | cancelled / expired / rejected | — |
| Pengembalian Dana | refunded | — |
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.
| State | Append item (POST /sales-order) | PATCH qty | DELETE item | Buat order baru? |
|---|---|---|---|---|
| (no order) | ✅ create new | n/a | n/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) | ❌ ditolak | ✅ buat order baru |
payment_received (Sudah Dibayar) | ❌ tidak append | ❌ ditolak (status != pending) | ❌ ditolak | ✅ buat order baru |
processing (Sudah Dikemas / Dalam Perjalanan) | ❌ tidak append | ❌ ditolak | ❌ ditolak | ✅ buat order baru |
delivered / completed (Sudah Diterima) | ❌ tidak append | ❌ ditolak | ❌ ditolak | ✅ buat order baru |
cancelled / expired / rejected / refunded | ❌ tidak append | ❌ ditolak | ❌ ditolak | ✅ buat order baru |
deleted_at non-null (soft-deleted) | ❌ tidak terlihat | ❌ tidak terlihat | ❌ tidak terlihat | ✅ baru |
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 errororder not editable: status=X.payment_proof_urlnon-empty (after trim) → return errororder 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 line | Tap "−" | Backend SQL |
|---|---|---|
| qty > 1 | qty = qty - 1, recompute header | UPDATE … SET quantity = qty-1 |
| qty = 1 | Dialog konfirmasi → DELETE line | DELETE FROM sales_order_items WHERE line_no = N |
| qty = 1, line satu-satunya di order | Dialog konfirmasi → DELETE line + soft-delete order | DELETE FROM items lalu UPDATE sales_orders SET deleted_at = now(), totals = 0. Response: order_deleted: true → mobile pop back |
| qty = 1, ada ≥ 2 jenis line | Dialog konfirmasi → DELETE 1 line saja, order tetap exist | DELETE 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:
| Race | Behavior |
|---|---|
| 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-paid | Salah 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 admin | Lock 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
| Method | Path | Source-of-truth file | Guard |
|---|---|---|---|
POST | /merchant/sales-order | merchant_sales_order_create_handler.go | merchant active + offer active. Append vs new diskriminasi by §3.1 SQL |
GET | /merchant/sales-order/current | sales_order_api_service.dart:565 (mobile) → backend handler | Filter deleted_at IS NULL |
GET | /merchant/sales-orders?status_group=X | mobile orders list | Filter status_group + soft-delete |
GET | /merchant/sales-order/{id} | detail handler | ownership check |
GET | /merchant/sales-order/{id}/items | items list | ownership |
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-payment | start payment | status pending only |
POST | /merchant/sales-order/payment-proof | upload proof MinIO + set payment_proof_url | status pending/accepted/payment_review; transisi pending → payment_review |
POST | /internal/orders/sales-orders/{id}/mark-paid | order_service handlers_internal.go | Dipanggil oleh payment_service Flow B (X-Internal-API-Key). INSERT orders.sales_order_payments + UPDATE status |
PATCH | /api/dashboard/sales/sales-orders/{id}/mark-paid | dashboard | status pending + proof non-empty (Jalur A manual) |
POST | /api/dashboard/sales/shipping-orders | dashboard | sales_order status payment_received |
PATCH | /api/dashboard/sales/shipping-orders/{id}/dispatch | dashboard | shipping status not_processed |
PATCH | /api/dashboard/sales/shipping-orders/{id}/mark-delivered | dashboard | shipping status in_transit |
POST | /merchant/terminals/pair | mobile pairing | terminal status delivered |
9. Open follow-ups
- 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}/cancelkalau butuh audit-trailstatus=cancelled(vs implicit soft-delete). - Expired auto-cancel — sudah diimplementasi.
order_serviceworkerstartAutoCancel(worker_autocancel.go, sweep tiap 5 menit, dijalankan dariStartWorkers) men-setstatus='cancelled'+auto_cancelled_atuntuk order pending yangpayment_deadline_at < NOW(). Reminder WA H-24 sebelum deadline dikirim olehstartPaymentReminder. - 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. - 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. - Dual-write cutover — selesai. Dual-write ke tabel legacy sudah
dihentikan (
order_service2026-06-02,payment_service2026-06-16:POSTGRES_DSN_LEGACY+store_legacy_mirror.godihapus). Jalur B tidak lagi melalui dual-write;order_serviceadalah 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_ordermig 004 (orders.sales_order_payments) - Transaction Notification Flow — Flow B detail PSP webhook
- Service Topology — payment_service + order_service wiring