Skip to main content

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:

TypeDirectionPatternDurability
1, Event / notificationmain โ†’ cuanOutbox โ†’ webhookAt-least-once (retry up to 5ร—)
1, Event / notificationcuan โ†’ mainOutbox โ†’ webhookAt-least-once (retry up to 5ร—)
2, Commandcuan โ†’ mainSynchronous HTTPLog + manual retry
3, Query / readapp โ†’ cuan (via main proxy)HTTP proxyNone (synchronous, stale fallback)

Tablesโ€‹

In pasarseken (main)โ€‹

TablePurpose
cuan_outbox_eventsSender queue for main โ†’ cuan events. Written atomically with the aggregate save. Consumed by the in-process CuanDeliveryScheduler (@nestjs/schedule).
inbound_cuan_logReceiver audit log for events and commands received FROM cuan. Used for idempotency (check before process) and audit trail.

In pasarseken-cuan (cuan)โ€‹

TablePurpose
fin_outbox_eventsSender queue for cuan โ†’ main events. Same retry structure as cuan_outbox_events.
fin_command_logLog of every synchronous command sent from cuan to main. Stores the payload, actor, HTTP response, and retry count.
inbound_main_logReceiver 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โ€‹

SecretLives inUsed byVerifies
CUAN_INTERNAL_SECRETmain + cuanMain signs calls to cuan; cuan verifiesInbound webhooks on cuan (/webhooks/order-event, /webhooks/boost-event) and balance queries
CUAN_TO_MAIN_SECRETcuan + mainCuan signs calls to main; main verifiesInbound events/commands on main (/internal/events, /internal/commands/*)
CUAN_WEBHOOK_URLmaincuan-delivery cron calls thisCuan's Edge Function base URL
MAIN_API_URLcuancuan-to-main-delivery cron + FinCommandClientMain'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 typeRaised byCuan action
order.createdCheckoutFacadeINSERT fin_orders (status=pending)
order.paidOrder.confirmPayment()UPDATE fin_orders (status=paid), UPSERT fin_seller_balances
order.shippedOrder.markShipped()UPDATE fin_orders (status=shipped)
order.deliveredOrder.markDelivered()UPDATE fin_orders (status=delivered)
order.completedOrder.complete()UPDATE fin_orders (status=completed, funds_released_at)
order.disputedOrder.openDispute()UPDATE fin_orders (status=disputed), INSERT fin_fraud_flags
order.cancelledOrder.cancel()UPDATE fin_orders (status=cancelled), reverse fin_seller_balances if was_paid
boost.purchasedActivateBoostUseCaseINSERT 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.ts
  • backend/src/infrastructure/scheduling/CuanDeliveryScheduler.ts (in-process @nestjs/schedule cron)
  • 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 typeRaised byMain action
disbursement.completedPATCH /finances/disbursements/:id/approveInsert seller notification
disbursement.blockedPATCH /finances/disbursements/:id/holdInsert seller notification
fraud_flag.raisedorder.disputed webhook handlerSet 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.ts
  • backend/src/infrastructure/scheduling/FinOutboxScheduler.ts (in-process @nestjs/schedule cron)

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 typePath on mainActorDescription
resolve_disputePOST /internal/commands/resolve-disputeFinance adminRuns 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 endpointProxied toFallback
GET /finances/sellers/:sellerId/balanceCUAN_WEBHOOK_URL/finances/sellers/:sellerId/balancestale cached response
GET /finances/sellers/balancesCUAN_WEBHOOK_URL/finances/sellers/balancesstale 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).

MethodPathDescription
GET/admin/comms/outboundList cuan_outbox_events. Query params: status, event_type, aggregate_id, from, to, limit, offset.
POST/admin/comms/outbound/retry-failedBulk reset all failed rows to pending. Requires ?confirm=true. Superadmin only.
POST/admin/comms/outbound/:id/retryReset single row. Superadmin only.
GET/admin/comms/inboundList 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.

MethodPathDescription
GET/finances/admin/comms/outboundList fin_outbox_events.
POST/finances/admin/comms/outbound/retry-failedBulk reset failed rows. Superadmin only.
POST/finances/admin/comms/outbound/:id/retryReset single row.
GET/finances/admin/comms/inboundList inbound_main_log.
GET/finances/admin/comms/commandsList fin_command_log.
POST/finances/admin/comms/commands/:id/retryRe-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โ€‹

  1. Sender side: idempotency_key format is "{event_type}:{aggregate_id}". The key is UNIQUE; duplicate writeEvent() calls fail silently (ON CONFLICT DO NOTHING).
  2. Receiver side (cuan, Type 1): Before processing, query inbound_main_log for idempotency_key. If found with status='processed', return {skipped: true} 200. The cron marks the outbox row processed.
  3. Receiver side (main, Type 1): Same with inbound_cuan_log.
  4. Receiver side (main, Type 2): Same check before running the RPC. Returns {already_processed: true} if already processed.
  5. Manual retry reuses the original key. If delivery succeeded but the acknowledgment was lost, the next delivery gets skipped and correctly marks the outbox row processed.

Failure mode referenceโ€‹

FailureWhere it showsManual retry path
Order event not delivered to cuanMain ops โ†’ cuan-comms tab โ†’ OutboundClick row retry or Retry All Failed
Cuan webhook returned 500Same, row stays pending until max_attempts=5Same
Cuan โ†’ main event not deliveredCuan CommsPage โ†’ Outbound tabClick row retry
Command to main failed (timeout, 5xx)Cuan CommsPage โ†’ Commands tabClick retry; finance admin sees inline error
Command returned 4xx (bad payload)Cuan CommsPage โ†’ Commands tab, failedFix payload in UI before retrying
Duplicate delivery (idempotency hit)Receiver log shows status=skippedNo action needed
Secret mismatchMain logs 401; inbound_cuan_log not writtenFix 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.