Cuan Bidirectional Communication
Implemented 2026-05-16. Covers PS-454-PS-481 (28 tickets). This page documents the production-deployed cross-service communication layer between the SEKEN marketplace (
pasarseken) and the Cuan finance portal (pasarseken-cuan).
Why bidirectional?โ
pasarseken and pasarseken-cuan are two independent services on Dokploy, each with its own NestJS backend and its own external Postgres database. They share no database. Any cross-service sync must travel over HTTP with durability guarantees, no fire-and-forget. Three communication patterns cover all current use cases:
| Type | Direction | Pattern | Durability |
|---|---|---|---|
| 1, Event / notification | main โ cuan | Outbox โ webhook | At-least-once (retry up to 5ร) |
| 1, Event / notification | cuan โ main | Outbox โ webhook | At-least-once (retry up to 5ร) |
| 2, Command | cuan โ main | Synchronous HTTP | Log + manual retry |
| 3, Query / read | app โ cuan (via main proxy) | HTTP proxy | None (synchronous, stale fallback) |
Tablesโ
In pasarseken (main)โ
| Table | Purpose |
|---|---|
cuan_outbox_events | Sender queue for main โ cuan events. Written atomically with the aggregate save. Consumed by the in-process CuanDeliveryScheduler (@nestjs/schedule). |
inbound_cuan_log | Receiver audit log for events and commands received FROM cuan. Used for idempotency (check before process) and audit trail. |
In pasarseken-cuan (cuan)โ
| Table | Purpose |
|---|---|
fin_outbox_events | Sender queue for cuan โ main events. Same retry structure as cuan_outbox_events. |
fin_command_log | Log of every synchronous command sent from cuan to main. Stores the payload, actor, HTTP response, and retry count. |
inbound_main_log | Receiver idempotency guard + audit for webhooks received FROM main. |
All 5 tables deny direct client access via RLS. All mutations go through the NestJS backend controllers.
Secretsโ
| Secret | Lives in | Used by | Verifies |
|---|---|---|---|
CUAN_INTERNAL_SECRET | main + cuan | Main signs calls to cuan; cuan verifies | Inbound webhooks on cuan (/webhooks/order-event, /webhooks/boost-event) and balance queries |
CUAN_TO_MAIN_SECRET | cuan + main | Cuan signs calls to main; main verifies | Inbound events/commands on main (/internal/events, /internal/commands/*) |
CUAN_WEBHOOK_URL | main | cuan-delivery cron calls this | Cuan's Edge Function base URL |
MAIN_API_URL | cuan | cuan-to-main-delivery cron + FinCommandClient | Main's Edge Function base URL |
Type 1, Event delivery (outbox pattern)โ
Main โ Cuanโ
Trigger: Domain events raised after every order lifecycle transition and boost purchase.
Events dispatched:
| Event type | Raised by | Cuan action |
|---|---|---|
order.created | CheckoutFacade | INSERT fin_orders (status=pending) |
order.paid | Order.confirmPayment() | UPDATE fin_orders (status=paid), UPSERT fin_seller_balances |
order.shipped | Order.markShipped() | UPDATE fin_orders (status=shipped) |
order.delivered | Order.markDelivered() | UPDATE fin_orders (status=delivered) |
order.completed | Order.complete() | UPDATE fin_orders (status=completed, funds_released_at) |
order.disputed | Order.openDispute() | UPDATE fin_orders (status=disputed), INSERT fin_fraud_flags |
order.cancelled | Order.cancel() | UPDATE fin_orders (status=cancelled), reverse fin_seller_balances if was_paid |
boost.purchased | ActivateBoostUseCase | INSERT fin_boost_purchases |
Delivery path:
Order domain event raised
โ CuanOutboxWriter.write() inserts to cuan_outbox_events (fail-open)
โ CuanDeliveryScheduler (@nestjs/schedule, every 15s) polls pending rows
โ POST CUAN_WEBHOOK_URL/webhooks/{order-event|boost-event}
with X-Internal-Secret: CUAN_INTERNAL_SECRET
โ cuan checks inbound_main_log for idempotency_key
- if found processed โ {skipped: true, 200} โ scheduler marks processed
- if new โ processes, writes inbound_main_log, 200 โ scheduler marks processed
- if error โ 500 โ scheduler leaves pending, increments attempts
- if attempts >= 5 โ scheduler marks failed
Key files (main):
backend/src/infrastructure/outbox/CuanOutboxWriter.tsbackend/src/infrastructure/scheduling/CuanDeliveryScheduler.ts(in-process@nestjs/schedulecron)backend/src/modules/internal/internal-events.controller.ts
Key files (cuan):
backend/src/modules/webhooks/webhook.controller.ts
Cuan โ Mainโ
Trigger: Finance actions in cuan that main needs to know about (disbursement approved/blocked, fraud flag raised).
Events dispatched:
| Event type | Raised by | Main action |
|---|---|---|
disbursement.completed | PATCH /finances/disbursements/:id/approve | Insert seller notification |
disbursement.blocked | PATCH /finances/disbursements/:id/hold | Insert seller notification |
fraud_flag.raised | order.disputed webhook handler | Set user_profiles.flagged_for_ops_review = true |
Delivery path:
Finance route handler calls FinOutboxProcessor.writeEvent() (fail-open)
โ FinOutboxScheduler (@nestjs/schedule, in-process cron) polls fin_outbox_events
โ POST MAIN_API_URL/internal/events
with X-Internal-Secret: CUAN_TO_MAIN_SECRET
โ main checks inbound_cuan_log for idempotency_key
โ dispatches to handler by event_type
โ writes inbound_cuan_log
Key files (cuan):
backend/src/infrastructure/outbox/FinOutboxProcessor.tsbackend/src/infrastructure/scheduling/FinOutboxScheduler.ts(in-process@nestjs/schedulecron)
Key files (main):
backend/src/modules/internal/internal-events.controller.ts(POST /internal/events)
Type 2, Commands (synchronous, cuan โ main)โ
Commands are finance-admin actions that must execute in main's context because they touch main's aggregates (e.g., dispute resolution uses main's RPC, chat, and email side-effects).
Current command:
| Command type | Path on main | Actor | Description |
|---|---|---|---|
resolve_dispute | POST /internal/commands/resolve-dispute | Finance admin | Runs resolve_dispute_atomic RPC, sends buyer/seller emails, inserts disbursement |
Command flow:
Finance admin submits DisputesPage form
โ POST /finances/admin/disputes/resolve (cuan backend)
โ FinCommandClient.send()
1. INSERT fin_command_log (status=pending)
2. POST MAIN_API_URL/internal/commands/resolve-dispute
with X-Internal-Secret: CUAN_TO_MAIN_SECRET
3. Main checks inbound_cuan_log for idempotency_key
4. Main runs resolve_dispute_atomic RPC
5. Main fires emails + insertPendingDisbursement
6. Main returns { success, order_id, new_status }
7. FinCommandClient updates fin_command_log (success/failed)
โ Response includes commandLogId (links to Commands tab in CommsPage)
Idempotency on main: If inbound_cuan_log already has the idempotency_key with status='processed', returns { success: true, already_processed: true } without re-running the RPC.
Key files:
- Cuan:
backend/src/infrastructure/commands/FinCommandClient.ts - Cuan:
backend/src/modules/disputes/disputes-admin.controller.ts - Cuan:
frontend/src/pages/DisputesPage.tsx - Main:
backend/src/modules/internal/internal-commands.controller.ts(POST /internal/commands/resolve-dispute)
Type 3, Query proxy (main proxies to cuan)โ
Seller-facing balance queries hit main's standard API contract, but main proxies to cuan for live data with KV as stale fallback.
Endpoints:
| Main endpoint | Proxied to | Fallback |
|---|---|---|
GET /finances/sellers/:sellerId/balance | CUAN_WEBHOOK_URL/finances/sellers/:sellerId/balance | stale cached response |
GET /finances/sellers/balances | CUAN_WEBHOOK_URL/finances/sellers/balances | stale cached response |
Cuan verifies X-Internal-Secret: CUAN_INTERNAL_SECRET on these endpoints. 5-second timeout; failure returns stale data with a stale: true flag in response.
Key files:
- Main:
backend/src/modules/finances/finances-proxy.controller.ts(proxy logic) - Cuan:
backend/src/modules/finances/seller-balance.controller.ts(GET /finances/sellers/*)
Admin routes referenceโ
Main (pasarseken), /admin/comms/*โ
Auth: admin + all sub-roles can read; only super_admin can write (retry).
| Method | Path | Description |
|---|---|---|
| GET | /admin/comms/outbound | List cuan_outbox_events. Query params: status, event_type, aggregate_id, from, to, limit, offset. |
| POST | /admin/comms/outbound/retry-failed | Bulk reset all failed rows to pending. Requires ?confirm=true. Superadmin only. |
| POST | /admin/comms/outbound/:id/retry | Reset single row. Superadmin only. |
| GET | /admin/comms/inbound | List inbound_cuan_log. Query params: status, message_type, event_type, from, to, limit, offset. |
Cuan (pasarseken-cuan), /finances/admin/comms/*โ
Auth: verifyAdmin for reads; superadmin role for bulk mutations.
| Method | Path | Description |
|---|---|---|
| GET | /finances/admin/comms/outbound | List fin_outbox_events. |
| POST | /finances/admin/comms/outbound/retry-failed | Bulk reset failed rows. Superadmin only. |
| POST | /finances/admin/comms/outbound/:id/retry | Reset single row. |
| GET | /finances/admin/comms/inbound | List inbound_main_log. |
| GET | /finances/admin/comms/commands | List fin_command_log. |
| POST | /finances/admin/comms/commands/:id/retry | Re-POST stored command payload to main. |
Monitoring surfacesโ
Cuan portal, CommsPage (/comms)โ
Three tabs accessible to all cuan admin roles:
- Outbound (โ main):
fin_outbox_events, status badges, retry per row, bulk retry-all-failed, 30s auto-refresh - Inbound (โ main):
inbound_main_log, read-only audit, 30s auto-refresh - Commands (โ main):
fin_command_log, command type, actor email, HTTP status, last error, per-row retry, 30s auto-refresh
SEKEN ops portal, cuan-comms tab (superadmin only)โ
CuanOutboxMonitor component under Admin โ System โ Cuan Comms:
- Outbound tab:
cuan_outbox_events, same retry surface as cuan's outbound tab - Inbound tab:
inbound_cuan_log, read-only
Idempotency rulesโ
- Sender side:
idempotency_keyformat is"{event_type}:{aggregate_id}". The key isUNIQUE; duplicatewriteEvent()calls fail silently (ON CONFLICT DO NOTHING). - Receiver side (cuan, Type 1): Before processing, query
inbound_main_logforidempotency_key. If found withstatus='processed', return{skipped: true}200. The cron marks the outbox rowprocessed. - Receiver side (main, Type 1): Same with
inbound_cuan_log. - Receiver side (main, Type 2): Same check before running the RPC. Returns
{already_processed: true}if already processed. - Manual retry reuses the original key. If delivery succeeded but the acknowledgment was lost, the next delivery gets
skippedand correctly marks the outbox rowprocessed.
Failure mode referenceโ
| Failure | Where it shows | Manual retry path |
|---|---|---|
| Order event not delivered to cuan | Main ops โ cuan-comms tab โ Outbound | Click row retry or Retry All Failed |
| Cuan webhook returned 500 | Same, row stays pending until max_attempts=5 | Same |
| Cuan โ main event not delivered | Cuan CommsPage โ Outbound tab | Click row retry |
| Command to main failed (timeout, 5xx) | Cuan CommsPage โ Commands tab | Click retry; finance admin sees inline error |
| Command returned 4xx (bad payload) | Cuan CommsPage โ Commands tab, failed | Fix payload in UI before retrying |
| Duplicate delivery (idempotency hit) | Receiver log shows status=skipped | No action needed |
| Secret mismatch | Main logs 401; inbound_cuan_log not written | Fix CUAN_TO_MAIN_SECRET / CUAN_INTERNAL_SECRET in Dokploy env, retry failed rows |
Sequence diagramsโ
Order.paid โ fin_seller_balances updateโ
pasarseken (Order domain)
โ
โโ Order.confirmPayment() raises OrderPaid
โโ CuanOutboxWriter.write() โ cuan_outbox_events (pending)
โ
cuan-delivery cron (15s interval)
โ
โโ SELECT status='pending' FROM cuan_outbox_events LIMIT 20
โโ UPDATE status='processing'
โโ POST cuan/webhooks/order-event โโโโโโโโโโโโโโโโโโโโโโโโโโโบ pasarseken-cuan
โ โ
โ โโ SELECT inbound_main_log WHERE idempotency_key=โฆ
โ โโ (not found) โ UPSERT fin_orders
โ โโ UPDATE fin_seller_balances
โ โโ INSERT inbound_main_log (processed)
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 200 OK โโโโโโโโโโโโโโค
โ
โโ UPDATE cuan_outbox_events SET status='processed'
Finance admin resolves dispute from cuanโ
Finance admin (cuan DisputesPage)
โ
โโ POST /finances/admin/disputes/resolve
โ
cuan routes-admin-disputes.ts
โ
โโ FinCommandClient.send('resolve_dispute', payload)
โ โโ INSERT fin_command_log (pending)
โ โโ POST main/internal/commands/resolve-dispute โโโโโโโโโโโบ pasarseken
โ โ โ
โ โ โโ SELECT inbound_cuan_log WHERE idempotency_key=โฆ
โ โ โโ (not found) โ INSERT inbound_cuan_log (processing)
โ โ โโ resolve_dispute_atomic RPC
โ โ โโ insertPendingDisbursement (fire-and-forget)
โ โ โโ buyer + seller emails (fire-and-forget)
โ โ โโ UPDATE inbound_cuan_log (processed)
โ โ โโโโโโโโโโโโโ { success, order_id, new_status } โโโโโโโโโโโโโค
โ โ
โ โโ UPDATE fin_command_log (success, http_status=200)
โ
โโ Response: { commandLogId, success }
Last updated: 2026-05-16 (PS-454-PS-481, full implementation). See Cuan Portal โ for the portal architecture overview.