Skip to main content

RFC: Midtrans Native Integration — Phase 1 (P0 Core Actions)

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Mark sections N/A — reason when truly inapplicable rather than deleting them.

It is also agent-execution-ready: the §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), §2 Repo Reading Guide (Detail 2.0) for both layers, mermaid diagrams, the §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

Delivery & project management live elsewhere. This RFC is the technical artifact only. Staffing, effort, timeline and rollout schedule live in the initiative's delivery/ folder. Until this RFC is handed to delivery, the Delivery row reads not yet handed to delivery.

The YAML frontmatter at the very top is the machine-readable index. The metadata table below is the human-readable governance record. Both must agree.

Metadata

FieldValueNotes
StatusRFC (IDEA)Human label; YAML status: carries the remapped linter enum draft
DRIDimas Fauzi HidayatSingle accountable owner (frontmatter dri). Staffing lives in delivery/.
TeamchatbotAdvisory squad slug carried from the source PRD / initiative README
Author(s)Dimas Fauzi HidayatPrimary author
ReviewersBOT Squad Backend Lead, BOT Squad Frontend LeadTech reviewers across affected layers
Approver(s)BOT Squad Tech Lead, InfoSec ApproverTech leaders + infosec
Submitted Date2026-06-20Date RFC opened for discussion
Last Updated2026-06-20Bump on every material edit
Target Release2026-Q2Quarter
Target Quarter2026-Q2Advisory, carried from source PRD
Deliverynot yet handed to deliveryPointer to delivery/ artifacts once handed off
Related../prds/midtrans-phase-1-p0-core-actions.md, ../native-integration-anchor.mdSource PRD + initiative ANCHOR
Discussion#bot-squadSlack channel

Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature

Sections at a Glance

  1. Overview (Design References — FE half, and PRD-to-Schema Derivation — BE half)
  2. Technical Design (Repo Reading Guide → topology → ADRs → diagrams → DDL → APIs → cross-layer contract)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

⚠️ Critical grounding note — PRD ⇆ shipped-code reconciliation

The PRD §5/§13 describe BOT-4243 as delivering a midtrans_oauth auth type with fields server_key, client_key, environment, partner_id, merchant_id, a ~15 min Redis TTL, and a token endpoint POST https://api.midtrans.com/v1/access-token. The code that actually shipped under BOT-4243 differs materially. The merged descriptor chatbot/config/auth_providers/midtrans_oauth2.yml (in the chatbot backend repo) declares:

PRD claimShipped reality (verified in code)
auth_type key midtrans_oauthmidtrans_oauth2 (registered in OrganizationConnection#auth_type_enum)
fields server_key, client_key, partner_id, merchant_idenvironment, client_id, client_secret (no server/client-key, no partner/merchant id)
token endpoint /v1/access-token…/v1/oauth/token (sandbox + production map)
~15 min TTLttl_seconds: 3300 (~55 min)
(unspecified flow)oauth2_client_credentials (Basic-auth client id/secret → Bearer)

This RFC grounds in the shipped code (the source of truth), not the PRD prose. The credential the AI Agent uses is a midtrans_oauth2 OrganizationConnection with {environment, client_id, client_secret}. The divergence is tracked as OQ-1 / OQ-2 in §5 and must be reconciled with the PM before the FE credential form copy is finalised. It does not block backend execution-layer work.


1. Overview

This RFC specifies the engineering design for Phase 1 (P0) of the Midtrans native integration: four AI-Agent-invokable payment actions — check payment status, create payment link, cancel transaction, and request refund — plus the Admin/SPV configuration surface that connects a Midtrans credential and exposes the actions in the Bot & Automation action drawer.

It sits under the Native Integration ANCHOR. Midtrans is the inaugural payment provider. The OAuth/provider-token connectivity foundation is already shipped (BOT-4243 midtrans_oauth2, BOT-4278 Google OAuth2, BOT-4285 server-managed OAuth2); what remains is the action-executor layer and the config UI, which this RFC delivers by reusing the existing node-execution runtime (the same machinery that powers the generic API Integration and Google Sheets actions).

Success Criteria

  • All four P0 actions execute end-to-end in a live conversation within the 60 s budget (PRD §5), returning a natural-language result (or a WhatsApp CTA-URL button for create_payment_link).
  • cancel_transaction and request_refund never call the mutating Midtrans endpoint without (a) a status pre-check confirming eligibility and (b) an explicit customer confirmation.
  • A single customer confirmation maps to exactly one mutating POST (refund idempotency via refund_key + in-conversation dedupe).
  • AI Agent Midtrans action success rate ≥ 95 % (PRD §11) — measured from the midtrans_action_executedmidtrans_action_success event ratio.
  • Net-new feature, flag-gated default OFF; zero change to existing action behavior.

Out of Scope

  • Midtrans dashboard UI embedded in Qontak (PRD §4.1).
  • Multiple Midtrans accounts per workspace — single connection only (PRD §4.2).
  • Manual trigger by human agents — all P0 actions are AI-Agent-initiated (PRD §4.3).
  • P1 billing/subscription actions and P2 advanced actions (PRD §4.4–§4.5).
  • Inbound Midtrans webhooks / payment notification handling — out of scope; all four actions are outbound request/response. (PRD names no webhook receiver.)
  • Re-implementing the OAuth/token foundation — already shipped under BOT-4243.

Assumptions

  1. The shipped midtrans_oauth2 descriptor + token manager (BOT-4243) are correct and the OrganizationConnection credential machinery is production-stable.
  2. The standard Midtrans v2 transaction endpoints (/v2/{order_id}/status|cancel|refund) and Payment Links v1 (/v1/payment-links) accept the Bearer access token minted by the oauth2_client_credentials flow. (Confirm — OQ-3: Midtrans Core/v2 APIs historically use HTTP Basic with server_key; if the v2 endpoints do not accept the OAuth2 client-credentials Bearer token, a second midtrans_basic auth descriptor is required. This is the single highest-risk assumption in the RFC.)
  3. The AI Agent runtime resolves the customer-supplied order_id from the conversation and passes it to the executor as an argument (the same arguments channel the API and Google Sheets executors already consume).
  4. Plan entitlement (Sales Suite Plus / Ultimate / Qontak 360) is resolvable via the existing QontakBilling::GetSubscription feature list.

Dependencies

DependencyLayer / OwnerAvailabilityBlocking?
midtrans_oauth2 auth descriptor + Auth::TokenManager (BOT-4243)BE / BOT Squadexistsconfig/auth_providers/midtrans_oauth2.yml, lib/auth/token_manager.rbNo
Auth::AuthorizedHttpClient (Bearer + redis cache + 401/403 retry)BE / BOT Squadexistslib/auth/authorized_http_client.rbNo
Node-execution runtime (NodeTypeRegistry, ActionExecutorFactory, NodeRegistry)BE / BOT SquadexistsNo
SendMessageInteractive (WhatsApp CTA-URL button)BE / BOT Squadexistsapp/core/use_cases/system/hub/send_message_interactive.rbNo
Mixpanel event pipeline (SendMixpanelEventWorker)BE / BOT SquadexistsNo
FE action drawer + dynamic-field form + credential selectorFE / BOT Squadexistsmodules/bot-automation/**No
Midtrans sandbox credentials (client_id/client_secret)PM / Midtransobtain in parallelNo
Confirmation that v2 endpoints accept the OAuth2 Bearer token (OQ-3)BE / Midtrans docsneeds confirmingYes — gates Chunk 3–6

Design References (frontend half)

The PRD §HEADER declares Figma Master: N/A — no UI changes in this phase (follows Google Calendar native integration pattern). The config surface is built by reusing the production dynamic-field action form + credential machinery, so there are no new design frames. Per the skill, this is recorded explicitly rather than fabricating frames.

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
MidtransActionConfigPanel (action config)n/a — reuses existing action-config layout; no new frame (PRD §HEADER "Figma Master: N/A")n/a@mekari/pixel3 (chatbot-fe package.json)BOT Squad designBuilt from the generic CreateActionForm/ActionIntegrationForm rendering NodeRegistry properties[]
MidtransAuthBlock (connect/reauthorize/disconnect)n/a — design pendingn/a@mekari/pixel3BOT Squad designNo connect/disconnect UI exists in production chatbot-fe today (verified). The credential is a midtrans_oauth2 OrganizationConnection created via /v1/api_connections. A minimal credential form is net-new FE work — see OQ-4.

If a frame is required for the credential form, add it to §5 OQ-4 before starting the FE credential-form chunk; do not implement bespoke design against imagined frames.

PRD-to-Schema Derivation (backend half)

The backend half derives entities, attributes and business rules from the PRD as a domain spec. Phase 1 persists no new transaction data — Midtrans is the system of record for payments. The only persisted entities are the action configuration (AiAgentAction), the action catalog (NodeRegistry rows), and the credential (OrganizationConnection, auth_type midtrans_oauth2) — all of which already exist.

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
4 Midtrans action types appear in the action drawernode_registries rows (node_type, node_type_group='midtrans', properties, categories, enabled)GET /v1/node_registriesDB seed/migration + NodeRegistry model validationPRD §6
Action config (name, trigger desc, dynamic fields, credential)ai_agent_actions (action_type, name, description, parameters, credentials)POST/PUT /v1/ai_agent_toolsAiAgentAction model + ActionExecute use casePRD §6, S01
Midtrans connection credential (environment, client_id, client_secret)organization_connections (auth_type='midtrans_oauth2', auth_data encrypted)POST/PUT /v1/api_connectionsAuth::Registry GenericValidator + PayloadEncryptor (Lockbox)PRD §5, S01
Bearer token cached ~TTL, refreshed automaticallyredis key auth:{env}:midtrans_oauth2:token:{credential.id} (TTL 3300 s)(internal) Auth::TokenManager#fetchlib/auth/token_stores/redis_store.rbPRD §5 (TTL differs — see grounding note)
Check payment status (read-only)none — read-through to Midtransexecutor Midtrans::Execute (midtrans_check_payment_status) → GET /v2/{order_id}/statusAuthorizedHttpClientPRD §6 A1, S02
Create payment link → URL as WhatsApp CTA-URL buttonnone — URL returned to conversationexecutor → POST /v1/payment-links; result via SendMessageInteractive#send_button_message(link:)executor + hub send use casePRD §6 A2, S03
Cancel only when status ∈ {pending, authorize}; confirm firstnone (state read live from Midtrans)executor (midtrans_cancel_transaction): status pre-check → confirm → POST /v2/{order_id}/cancelexecutor branch logicPRD §7 #3, S04
Refund only when status = settlement; confirm first; idempotentrefund_key generated per attempt (passed in request body; not persisted in P1)executor (midtrans_request_refund): eligibility check → confirm → POST /v2/{order_id}/refundexecutor + in-conversation dedupePRD §7 idempotency, S05
All actions flag-gated + plan-scopedsystem_preferences (feature flag) + QontakBilling::GetSubscription featuresflag check in ActionExecute/registry visibilityFeatureFlag.enabled? + subscription gatePRD §5, §9
Observability eventsnone (emitted to Mixpanel)SendMixpanelEventWorker.perform_async(org_id, '<event>', props)executor + hubPRD §10

Every §2.3 DDL row and §2.4 endpoint traces back to a row here. Where a PRD entity has no persistence (transaction state lives in Midtrans), it is marked "none — read-through".

Detail 1.A — PRD Traceability Matrix (cross-layer)

Cite PRD composite AC ids (<STORY-ID>/AC-n, ERR-n, NEG-n).

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
MIDTRANS-S01/AC-1§2.A Action config panel (action-type selector → form)GET /v1/node_registries (4 midtrans rows)
MIDTRANS-S01/AC-2§2.A MidtransAuthBlock connect → credential createdPOST /v1/api_connections (auth_type midtrans_oauth2); validate = token fetch
MIDTRANS-S01/AC-3§2.A SavePOST /v1/ai_agent_tools
MIDTRANS-S01/AC-4§2.A ReauthorizePUT /v1/api_connections/:id (re-encrypt auth_data, invalidate token cache)
MIDTRANS-S01/ERR-1§3.C FE error copytoken-fetch failure → midtrans_action_failed reason auth_error
MIDTRANS-S01/ERR-2§3.C "connection required"Save validation
MIDTRANS-S01/ERR-3§3.C load error + RetryGET /v1/api_connections/:id failure
MIDTRANS-S02/AC-1..3, ERR-1..3§2.C conversation statesMidtrans::Execute midtrans_check_payment_statusGET /v2/{order_id}/status
MIDTRANS-S03/AC-1..3, ERR-1..3§2.C; CTA-URL buttonmidtrans_create_payment_linkPOST /v1/payment-links + send_button_message
MIDTRANS-S04/AC-1..3, ERR-1..4§2.C confirmation promptmidtrans_cancel_transaction (status pre-check → confirm → POST /v2/{order_id}/cancel)
MIDTRANS-S05/AC-1..3, ERR-1..4§2.C confirmation promptmidtrans_request_refund (eligibility → confirm → POST /v2/{order_id}/refund)
NEG-1§2.A single-connection (no "add another")organization_connections uniqueness per org
NEG-2§2.C read-only result, no re-trigger controlno manual-trigger endpoint
NEG-3§2.A only 4 P0 rows in selectoronly 4 enabled node_registries rows
NEG-4§3 web-only gaten/a — config endpoints unchanged

Reverse (RFC → PRD AC):

New FE component / BE endpoint / dependencyPRD composite AC id it serves
Midtrans::Execute executor (4 node types)MIDTRANS-S02..S05/AC-*
4 node_registries rows (node_type_group='midtrans')MIDTRANS-S01/AC-1, NEG-3
midtrans_oauth2 credential create/edit FE formMIDTRANS-S01/AC-2, AC-4, NEG-1
Confirmation + status-precheck branch logicMIDTRANS-S04/AC-1..2, MIDTRANS-S05/AC-1..2
refund_key + in-conversation dedupePRD §7 idempotency note (no dedicated AC — RFC-introduced)
Mixpanel event emissionPRD §10 (observability)

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Action config panel (/bot-automation/actions)web (Admin/SPV)GET /v1/node_registries, GET /v1/api_connectionsPOST/PUT /v1/ai_agent_tools, POST/PUT /v1/api_connectionsNewActionDrawerCreateActionForm/ActionIntegrationFormcredential connected/not-connected (presence of midtrans_oauth2 connection)
In-conversation result (WhatsApp + other)chatbot (customer)n/a — fully covered by executor resultn/a (Midtrans is external)hub message render (CTA-URL button / text)Midtrans transaction status (read live)
Mobile Bot & Automationmobilen/a — config menu not rendered (web-only)n/an/a (NEG-4)n/a

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
Admin / SPVExisting Bot & Automation auth (JWT/session) + plan entitlement + flagGET/POST/PUT /v1/node_registries (read), /v1/api_connections, /v1/ai_agent_toolsConfig drawer visible (eligible plan + flag ON, web)No — organization_id scopedpaper_trail on OrganizationConnection, AiAgentAction
AI Agent (runtime)Internal service auth; executes configured actioninternal ActionExecute use case (no public route)n/a (not a UI actor)No — organization_id scopedMixpanel events
CS Agent / humanread-onlynone (no manual-trigger route exists)result is read-only in conversation (NEG-2)Non/a

PRD Section Coverage

PRD §TitleWhere covered
HEADERHeader blockMetadata
2One-liner + Problem§1 Overview
3Target Users + Persona§1 (Role Coverage)
4Non-Goals§1 Out of Scope
5Constraints§1 Dependencies, §3 (perf/security), §4 (flag)
6New Features (4 actions + config)§1 PRD-to-Schema, §2.A, §2.4
7API & Webhook Behavior§2.2 sequences, §3.A failure catalog, §2.D integrity (idempotency)
8System Flow + Stories + ACs§1 Detail 1.A/1.C, §2.2
9Rollout§4 Rollout Strategy
10Observability§3 Monitoring & Alerting
11Success Metrics§1 Success Criteria, §3 monitoring
12Launch Plan & Stage Gatesn/a — delivery/ artifact (rollout schedule out of RFC scope); gate signals in §4
13Dependencies§1 Dependencies
14Key Decisions + Alternatives§1 Detail 1.B, §2 Technical Decisions
15Open Questions§5

Detail 1.B — Decisions Closed (cross-layer)

DecisionChosen optionAlternatives rejectedWhy rejectedLayer
Action executor structureOne group executor Midtrans::Execute (node_type_group='midtrans') dispatching on node_type4 separate executor classesMirrors GoogleSheets::Execute (3 node types, one class); shared auth/HTTP/error codeBE
Outbound HTTP seamAuth::AuthorizedHttpClientRaw ::Http + manual tokenAuthorizedHttpClient already does Bearer + redis cache + 401/403 retry-onceBE
Auth credential modelReuse shipped midtrans_oauth2 OrganizationConnectionNew Midtrans-specific credential tableFoundation shipped (BOT-4243); descriptor-driven, encrypted, cachedBE
Auth header for v2 calls (Decision 8)Default Bearer (midtrans_oauth2); spec'd midtrans_basic fallbackBearer-only (assume it works); Basic-onlyBoth Midtrans conventions are real; executor is auth-agnostic via AuthorizedHttpClient, so it's a config swap (REV-1/OQ-3)BE
Sync vs async executionSynchronous (AI Agent awaits result ≤60 s)Queue the action callPRD requires in-conversation result within 60 s; queueing adds latency + a result-delivery callbackBE
Cancel/refund safetyStatus pre-check + explicit customer confirmation before mutating callDirect mutateIrreversible actions; PRD §14 decisionBE
Refund idempotencyrefund_key per attempt + in-conversation dedupe windowRely on Midtrans dedupRefund is not naturally idempotent; flaky WhatsApp can double-confirm (PRD §7)BE
FE config formReuse data-driven CreateActionForm/ActionIntegrationForm (NodeRegistry properties[] + credential selector)Build bespoke MidtransActionConfigPanel tree per PRD §6Production already renders dynamic fields from registry; bespoke duplicates it. Reconciled with PRD as OQ-4.FE
Feature flagFeatureFlag.enabled?('rollout','midtrans_native_action') (proposed name)Per-account env varMatches existing rollout-flag pattern (app/core/repositories/system_preferences/rollout/)both
Transaction persistenceNone — read-through to MidtransMirror transactions locallyMidtrans is system of record; PRD persists no transactionsBE
Inbound webhookNone this phaseBuild notification receiverPRD names no webhook; all 4 actions are request/responseBE

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
MIDTRANS-S01Admin configures a Midtrans actionFE + BEdrawer rows for 4 actions (auto from registry); credential connect/reauthorize/disconnect form (midtrans_oauth2); Save wires ai_agent_tools4 node_registries rows (properties[]); credential create/update via /v1/api_connections; token-fetch validation on connectS01/AC-1..4, ERR-1..3, NEG-1rspec: connection create encrypts auth_data; vitest: form renders 4 actions, Save disabled until connected; connect with bad creds → error copy§2.A · §2.4 · §4.D chunk 1,2,7 · §1 PRD-to-Schema rows 1–4
MIDTRANS-S02AI Agent checks payment statusBE-onlyn/a — result rendered by existing hub text pathMidtrans::Execute#midtrans_check_payment_statusGET /v2/{order_id}/status; prompt for order_id if missing; eventsS02/AC-1..3, ERR-1..3rspec (webmock): 200 → status mapped to NL; 404 → "not found"; >60 s → timeout copy + midtrans_action_failed reason timeout§2.2 seq-1 · §2.4 · §4.D chunk 3
MIDTRANS-S03AI Agent creates a payment linkBE-only (+ existing hub)n/a — uses existing CTA-URL button rendermidtrans_create_payment_linkPOST /v1/payment-links; WhatsApp → send_button_message(link:), else text; eventsS03/AC-1..3, ERR-1..3rspec (webmock): 201 → URL extracted; WhatsApp path calls send_button_message; 400 → invalid copy + reason invalid_request§2.2 seq-2 · §2.4 · §4.D chunk 4
MIDTRANS-S04AI Agent cancels a transaction (confirm)BE-onlyn/a — confirmation prompt is conversation textmidtrans_cancel_transaction: status pre-check → if pending/authorize confirm → POST /v2/{order_id}/cancel; else not-cancellable copy, no POSTS04/AC-1..3, ERR-1..4rspec: non-cancellable status → cancel endpoint NOT hit (webmock asserts 0 calls); decline → "not cancelled"; confirm → success§2.1 state · §2.2 seq-3 · §4.D chunk 5
MIDTRANS-S05AI Agent requests a refund (confirm)BE-onlyn/a — confirmation prompt is conversation textmidtrans_request_refund: eligibility (settlement) → confirm → POST /v2/{order_id}/refund with refund_key; dedupe one confirm→one POSTS05/AC-1..3, ERR-1..4rspec: non-eligible → refund NOT hit; duplicate confirm within window → single POST (idempotency); confirm → success§2.1 state · §2.2 seq-4 · §2.D · §4.D chunk 6

Coverage rule satisfied: every PRD story has exactly one row. FE + BE rows (S01) have both columns filled; S02–S05 are BE-only (results render through existing hub paths).


2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
customer([Customer / WhatsApp etc.]) -->|inbound msg| channel[Channel gateway]
admin([Admin / SPV browser]) -->|HTTPS| lb[Ingress / Load Balancer]
lb -->|HTTP| fe["chatbot-fe (Nuxt SSR pods)"]
fe -->|REST| be["chatbot API pods (Rails / Grape) ×N"]
channel --> be
be -->|read/write encrypted creds| db[(Postgres primary)]
be -->|read| db_r[(Postgres replica)]
be -->|token cache get/set TTL 3300s| redis[(Redis — auth token store)]
be -->|enqueue| sidekiq[[Sidekiq queue]]
sidekiq --> worker["chatbot worker pods (Sidekiq) ×M"]
worker -->|track event| mixpanel(["Mixpanel (events)"])
be -->|HTTPS Bearer| midtrans(["Midtrans API\n/v2/* + /v1/payment-links + /v1/oauth/token"])
worker -->|HTTPS send| channel

Per-service responsibility

flowchart LR
subgraph chatbot["chatbot (Rails) — BOT squad"]
ae["ActionExecute use case\n(internal_service/v1/ai_agent)"]
me["Midtrans::Execute\n(4 node types)"]
tm["Auth::TokenManager + AuthorizedHttpClient"]
hub["SendMessageInteractive\n(CTA-URL button)"]
cred["api_connections (OrganizationConnection CRUD)"]
reg["node_registries (action catalog)"]
end

ae --> me
me -->|"AuthorizedHttpClient.call"| tm
tm -->|"HTTPS Bearer"| midtrans(["Midtrans API"])
tm -->|"GET/SET token"| redis[(Redis)]
me -->|"result"| hub
hub -->|"async send"| channel(["Channel gateway"])
me -->|"perform_async"| mix(["SendMixpanelEventWorker → Mixpanel"])
cred -->|"encrypt auth_data (Lockbox)"| pg[(Postgres)]
reg --> pg

Technical Decisions (ADR-format)

Decision 1: Single group executor Midtrans::Execute over four classes

Context — Four P0 actions share the same credential, HTTP seam, error mapping and event emission. The runtime resolves an executor per node_type via ActionExecutorFactory.for(action_type), which maps NodeRegistry#node_type_groupNodeTypeRegistry::GROUP[group] class.

Options considered

  • Option A — one Midtrans::Execute (group midtrans) dispatching on @node_type.
    • Pros: mirrors GoogleSheets::Execute (handles google_sheet_row_get/create/update); one place for shared auth/HTTP/error/event code; one registry-group entry.
    • Cons: a single class branches on 4 node types (slightly larger file).
  • Option B — four executor classes, four NodeTypeRegistry::GROUP entries.
    • Pros: smaller classes.
    • Cons: 4× duplicated auth/HTTP/error/event boilerplate; diverges from the established group pattern.

Decision: Option A.

Rationale — Directly follows the shipped GoogleSheets::Execute precedent (app/core/repositories/node_executions/nodes/google_sheets/execute.rb dispatches on @node_type). Shared confirmation/idempotency logic lives once.

ConsequencesMidtrans::Execute owns a case @node_type dispatch; each P0 action is a private method. Adding P1/P2 actions extends the same class/group.

Reversibility — Splitting into per-action classes later is mechanical: add NodeTypeRegistry::GROUP entries and move methods. Low cost.


Decision 2: Outbound calls via Auth::AuthorizedHttpClient

Context — Every Midtrans call needs a valid Bearer token; tokens are minted via the oauth2_client_credentials flow and cached in Redis (TTL 3300 s). On 401/403 the cache must be invalidated and the call retried.

Options considered

  • Option A — Auth::AuthorizedHttpClient.call(credential:, request:).
    • Pros: already builds auth headers via Auth::Registry.lookup(auth_type), applies the descriptor's bearer strategy, retries once on 401/403, returns a Dry::Monads::Result. Zero token handling in the executor.
    • Cons: returns Failure([:auth_failed|:http_error, …]) monads the executor must unwrap.
  • Option B — raw ::Http + manual TokenManager#fetch.
    • Pros: full control of timeout/headers.
    • Cons: re-implements token fetch, caching, and 401/403 retry already centralised.

Decision: Option A.

Rationalelib/auth/authorized_http_client.rb is documented as "the single seam for outbound HTTP requests that need an OrganizationConnection credential's auth applied." It is exactly the integration point for credentialed third-party calls.

Consequences — Executor maps Failure([:auth_failed, _])auth_error event and Failure([:http_error, _]) → timeout/api_error. The 60 s budget is enforced via the underlying ::Http timeout (see Decision 3).

Reversibility — Swapping to raw ::Http is contained to the executor's request helper.


Decision 3: 60 s timeout strategy

Context — PRD §5: an action must complete within 60 s; the default ::Http open/read timeout is 20 s (lib/http.rb). ::Http raises 60 s only when the tag includes qontak, or honours ENV['VENDOR_API_TIMEOUT'] as an upper bound.

Options considered

  • Option A — pass explicit open_timeout: 60, read_timeout: 60 through AuthorizedHttpClient call_options to the underlying ::Http#call.
    • Pros: per-call, explicit, matches PRD 60 s.
    • Cons: must confirm call_options is forwarded to ::Http#call (verify in send_request).
  • Option B — set ENV['VENDOR_API_TIMEOUT']=60 globally.
    • Cons: affects all vendor calls; too broad.

Decision: Option A (per-call 60 s via call_options), with the cancel/refund status pre-check + mutate both counted inside the 60 s budget for the user-facing turn.

Rationale — Localises the timeout to Midtrans calls without changing global behavior.

Consequences — On Net::OpenTimeout/ReadTimeout, ::Http returns synthetic 504/408; the executor maps these to the PRD timeout copy + midtrans_action_failed reason timeout.

Reversibility — Trivial — change the numeric option.


Decision 4: Refund idempotency — refund_key + in-conversation dedupe

Context — Cancel is naturally idempotent (the status pre-check rejects a second cancel on an already-cancelled order). Refund is not; a flaky WhatsApp connection can deliver a duplicated "yes", risking a double refund POST (PRD §7).

Options considered

  • Option A — generate a stable refund_key per logical refund + a short dedupe window keyed on (conversation_id, order_id) in Redis (e.g. 120 s) so a re-delivered confirm maps to the same refund_key and is suppressed before the POST.
    • Pros: defends both at the Midtrans layer (refund_key dedup) and locally (no second POST).
    • Cons: needs a Redis dedupe entry + TTL.
  • Option B — rely on Midtrans to reject duplicate refund_key.
    • Cons: still emits a second POST; depends on Midtrans behavior; no local guard.

Decision: Option A.

Rationale — Belt-and-braces: the in-conversation dedupe prevents the second POST; the refund_key makes the Midtrans call itself idempotent if a POST does slip through.

Consequences — A Redis key midtrans:refund:dedupe:{conversation_id}:{order_id} (TTL ~120 s) gates the POST; refund_key (UUID per logical refund, stored in the dedupe value) is sent in the request body. P0 default: full refund only (amount omitted) unless OQ-5 decides otherwise.

Reversibility — Removing dedupe is a code deletion; refund_key remains harmless.


Decision 5: Synchronous execution; events async

Context — The AI Agent must deliver a result in the same conversational turn within 60 s.

Decision — The action call is synchronous within ActionExecute. Only observability events are async (SendMixpanelEventWorker.perform_async), and the WhatsApp send goes through the existing SendMessageInteractiveWorker (Sidekiq) — i.e. the result delivery uses the platform's normal async send path, but the Midtrans call/result computation is synchronous.

Rationale — Queueing the Midtrans call would require a callback to resume the agent turn; the 60 s budget makes synchronous request/response the correct model.

Consequences — A pod is blocked up to 60 s per in-flight Midtrans action. Sized by concurrency expectations (low volume P0; target ≥5 CIDs). Reversibility: high.


Decision 6: Feature flag + plan gating

Context — PRD §5/§9: flag default OFF; plans Sales Suite Plus / Ultimate / Qontak 360. OQ-1 leaves the flag name TBD.

Decision — Use FeatureFlag.enabled?('rollout', 'midtrans_native_action', default: false) (app/core/repositories/system_preferences/feature_flag.rb), proposed group/code rollout / midtrans_native_action, plus plan entitlement via QontakBilling::GetSubscription feature list. Both checked at (a) registry visibility for the FE drawer and (b) ActionExecute before dispatch.

Rationale — Matches the existing rollout-flag directory pattern. Reversibility — Flag delete + entitlement check removal.


Decision 7: Multi-tenancy isolation

Context — Credentials and actions are per-organization.

DecisionOrganizationConnection and AiAgentAction are organization_id-scoped (existing model scoping); Midtrans::Execute receives organization_id and resolves the credential within that org. Token cache key includes credential.id.

Rationale / Consequences / Reversibility — Inherits the platform's existing tenant scoping; no new isolation mechanism. n/a to reverse.


Decision 8: Auth header strategy for Midtrans calls — Bearer, with a fully-specified Basic fallback

Context — The shipped midtrans_oauth2 descriptor mints an OAuth2 Bearer token. But Midtrans's Core/v2 transaction APIs (/v2/{order_id}/status|cancel|refund) and Payment Links v1 are historically authenticated with HTTP Basic (server_key as username, empty password). It is unconfirmed whether the OAuth2 Bearer token is accepted on /v2/* (OQ-3/REV-1). Rather than leave this dangling, both paths are specified so the agent has a deterministic plan and only needs a one-line confirmation before wiring the live header.

Options considered

  • Option A — Bearer via existing midtrans_oauth2 (AuthorizedHttpClient applies it automatically).
    • Pros: zero new auth code; reuses the shipped descriptor + redis cache + 401/403 retry.
    • Cons: only correct if /v2/* accepts the OAuth2 token.
  • Option B — Basic via a new midtrans_basic descriptor (server_key as Basic user).
    • Pros: matches documented Midtrans Core/v2 auth; no token mint round-trip.
    • Cons: a second credential field set (server_key) the Admin must supply; new descriptor file.

DecisionDefault to Option A; specify Option B as a drop-in fallback selected by a single confirmation (REV-1). The executor is auth-agnostic because it calls AuthorizedHttpClient.call(credential:, request:) — switching auth is a descriptor + credential swap, not an executor change. If OQ-3 resolves to Basic, add this descriptor and point the action's credential at it:

# config/auth_providers/midtrans_basic.yml (ONLY if OQ-3 confirms /v2 rejects Bearer)
key: midtrans_basic
display_name: Midtrans (Basic — server key)
fields:
- { name: environment, required: true, enum: [sandbox, production] }
- { name: server_key, required: true, secret: true }
header_strategy: basic_auth # server_key as username, blank password (Base64)
header_config:
username_field: server_key
password_value: ""
storage: none # no token to cache; Basic header built per request

Rationale — Both Midtrans auth conventions are real; which applies per-endpoint is the only unknown. Pinning both removes the design risk; the AuthorizedHttpClient seam (Decision 2) makes the choice a config swap. The FE credential form (§2.A) collects whichever field set the chosen descriptor declares.

Consequences — If Basic is chosen, the PRD's server_key field (OQ-2) becomes correct after all, and the credential form shows server_key instead of client_id/client_secret. The executor, registry rows, events, and confirmation logic are unchanged either way.

Reversibility — Trivial: swap the credential's auth_type (and re-collect fields); no executor code change.

Minimum-coverage check: storage (D7/none-new), sync/async (D5), caching (D2 redis TTL), third-party integration (D2), consistency (read-through, no local transaction state), multi-tenancy (D7), reuse-vs-new (D1/D2/D3 — all reuse; only Midtrans::Execute + 4 node_registries rows are new).


Detail 2.0 — Repo Reading Guide

Repo Map (mermaid, both layers)

flowchart LR
subgraph fe["chatbot-fe (Nuxt/Vue)"]
drawer["modules/bot-automation/components/drawers/NewActionDrawer.vue"]
forms["forms/CreateActionForm.vue + ActionIntegrationForm.vue"]
store["store/ai-agent-tool/ + store/ai-agent/"]
svc["common/services/main/v1/ai-agent-tools.ts + ai-agents.ts"]
end
subgraph be["chatbot (Rails)"]
exec["node_executions/action_executor_factory.rb + node_type_registry.rb"]
nodes["node_executions/nodes/midtrans/execute.rb (NEW)"]
auth["lib/auth/authorized_http_client.rb + token_manager.rb"]
cfg["config/auth_providers/midtrans_oauth2.yml"]
api["api/frontend_service/v1/{node_registries,api_connection,ai_agent_tool}"]
hub["use_cases/system/hub/send_message_interactive.rb"]
end
subgraph infra
pg[(Postgres)]
redis[(Redis)]
end
svc --> api
api --> exec --> nodes --> auth --> cfg
auth --> redis
nodes --> hub
api --> pg

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEapp/core/repositories/node_executions/nodes/google_sheets/execute.rbClosest precedent: a group executor over multiple node types using a credentialinclude NodeExecutorInterface + ArgumentInterpolation; dispatch on @node_type; credential[:id] = OrganizationConnection id
BEapp/core/repositories/node_executions/nodes/api/execute.rbGeneric REST action — interpolation, credential lookup, {status, code, body, header} resultResult shape + credential resolution
BEapp/core/repositories/node_executions/node_type_registry.rbWhere to register the new midtrans groupGROUP = { 'google_sheets' => 'GoogleSheets::Execute', … }
BEapp/core/repositories/node_executions/action_executor_factory.rbHow a node_type resolves to an executorNodeRegistry.find_by_type_and_version(action_type)&.node_type_groupGROUP
BEapp/core/repositories/node_executions/nodes/node_executor_interface.rbExecutor contractinitialize(organization_id:, credential:, parameters:, arguments:, room_id:, node_type:), call, before_execute, after_execute
BElib/auth/authorized_http_client.rbThe HTTP seam for credentialed callscall(credential:, request:)Success(resp) / `Failure([:auth_failed
BEconfig/auth_providers/midtrans_oauth2.ymlThe shipped Midtrans descriptor (ground truth)client_credentials, Bearer, redis, ttl_seconds: 3300, env→token_url map
BElib/auth/token_manager.rb + lib/auth/token_stores/redis_store.rbToken mint + cachefetch with lock; redis key auth:{env}:midtrans_oauth2:token:{credential.id}
BEapp/models/organization_connection.rbCredential model + auth_type_enum (contains midtrans_oauth2)encrypted auth_data, paper_trail, org scoping
BEapp/api/frontend_service/v1/node_registries/node_registries_controller.rbAction catalog endpoint that feeds the FE drawerGET /v1/node_registries
BEapp/api/frontend_service/v1/api_connection/ (oauth_start.rb, create.rb, update.rb, encrypt_auth_data.rb)Credential CRUD + encryptionPayloadEncryptor.encrypt; token-cache invalidation on secret rotation
BEapp/core/use_cases/system/hub/send_message_interactive.rbCTA-URL button rendersend_button_message(header_format:, body_text:, button_actions:, link:)
BEapp/workers/send_mixpanel_event_worker.rbEvent emissionMIXPANEL_TRACKER.track(distinct_id, event_name, params)
BEapp/core/repositories/system_preferences/feature_flag.rbFlag checkFeatureFlag.enabled?(group_code, code, default:)
BEapp/core/repositories/qontak_billing/get_subscription.rbPlan entitlementGetSubscription.new(params:).call{package_name, features:[{code, enabled}]}
FEmodules/bot-automation/components/drawers/NewActionDrawer.vueAction drawer switch + selectionformAction state; ActionSelection → form
FEmodules/bot-automation/components/forms/CreateActionForm.vue + ActionIntegrationForm.vueDynamic-field config form + credential selectorrenders requiredItem.properties[] by prop.html.element; credential autocomplete
FEmodules/bot-automation/constants/bot-automation-actions-constants.tsActionListItem shape, ACTION_LISTaction list item structure (node_type, settings.action_config)
FEcommon/services/main/v1/ai-agents.ts (fetchActionList/v1/node_registries) + ai-agent-tools.tsAPI clientmainService.aiAgentTools.{get,create,detail,update,delete}/v1/ai_agent_tools
FEstore/ai-agent-tool/ (Pinia)State for create/update toolCREATE_AI_AGENT_TOOL action pattern
FEmiddleware/nlp-feature.tsPlan/feature gating precedentsubscriptionData.features.find(code===…)?.enabled

Existing Contracts to Reuse, Extend, or Replace (BE)

Contract (endpoint / table / event)StatusJustificationOwner
GET /v1/node_registriesreusedAction catalog feeds the FE drawer; add 4 enabled rowsBOT
POST/PUT/GET /v1/api_connectionsreusedCredential CRUD for midtrans_oauth2 connectionBOT
POST/PUT/GET /v1/ai_agent_toolsreusedAction-instance CRUD (AiAgentAction)BOT
internal ActionExecute use caseextendedAdd flag/plan gate + dispatch to Midtrans::ExecuteBOT
NodeTypeRegistry::GROUPextendedAdd 'midtrans' => 'Midtrans::Execute'BOT
node_registries rows (4 midtrans)new-with-justificationNew action catalog entries; no existing rows describe Midtrans P0 actionsBOT
Midtrans::Execute executornew-with-justificationNo executor exists for Midtrans; the API/Sheets executors are different node typesBOT
Redis dedupe key midtrans:refund:dedupe:*new-with-justificationRefund idempotency guard (Decision 4); no existing equivalentBOT
Mixpanel events midtrans_*new-with-justificationPRD §10 names net-new events; no existing midtrans eventsBOT

Patterns to Follow

LayerConcernPattern in repoReference fileDeviation?
BEExecutor shapeinclude NodeExecutorInterface + keyword initialize + callnodes/google_sheets/execute.rbnone
BEArgument interpolationinclude Concerns::ArgumentInterpolationnode_executions/concerns/argument_interpolation.rbnone
BECredentialed HTTPAuthorizedHttpClient.call(credential:, request:) returning monadslib/auth/authorized_http_client.rbnone
BEResult shape{ status: 'success'|'error', code:, body:, … }nodes/api/execute.rbnone
BEError/loggingRails.logger.error("[Auth]…") + Rollbar.warning/errorauthorized_http_client.rb, token_manager.rbnone
BEEventsSendMixpanelEventWorker.perform_async(org_id, name, props)app/workers/send_mixpanel_event_worker.rbnone
BEFeature flagFeatureFlag.enabled?(group, code, default:)system_preferences/feature_flag.rbnone
BESecrets encryptionPayloadEncryptor.encrypt (Lockbox)lib/auth/payload_encryptor.rbnone
FEDynamic field formrender properties[] by html.elementforms/ActionIntegrationForm.vuenone
FEAPI servicemainService.<resource>.<method> (ofetch)common/services/main/v1/ai-agent-tools.tsnone
FEStatePinia store slice (state/getters/actions)store/ai-agent-tool/none
Crosssnake_case API ↔ camelCase FEservices map payload casingcommon/services/main/v1/*.tsnone

Reading Order for the Agent

  1. config/auth_providers/midtrans_oauth2.yml — the real Midtrans auth contract (ground truth).
  2. lib/auth/authorized_http_client.rb — the HTTP seam Midtrans calls use.
  3. app/core/repositories/node_executions/nodes/google_sheets/execute.rb — the executor pattern to copy.
  4. app/core/repositories/node_executions/node_type_registry.rb + action_executor_factory.rb — registry wiring.
  5. app/core/repositories/node_executions/nodes/node_executor_interface.rb — the executor contract.
  6. app/api/internal_service/v1/ai_agent/use_cases/action_execute.rb — how executors are invoked.
  7. app/core/use_cases/system/hub/send_message_interactive.rb — CTA-URL button render.
  8. app/api/frontend_service/v1/node_registries/node_registries_controller.rb — action catalog.
  9. modules/bot-automation/components/forms/ActionIntegrationForm.vue — FE dynamic-field form.
  10. common/services/main/v1/ai-agents.ts + ai-agent-tools.ts — FE API contract.

Source Verification (anti-hallucination — required)

LayerAnchor / pattern / contractVerified byEvidence
BEconfig/auth_providers/midtrans_oauth2.ymlreadkey: midtrans_oauth2; strategy: oauth2_client_credentials; ttl_seconds: 3300; token_url_map sandbox …/v1/oauth/token; fields environment, client_id, client_secret
BEOrganizationConnection#auth_type_enumreadreturns %w[… midtrans_oauth2 google_oauth2] (lines 22–25 of app/models/organization_connection.rb)
BENodeTypeRegistry::GROUPreadGROUP = { 'api'=>'API::Execute', 'mekari_qontak_crm'=>…, 'google_sheets'=>'GoogleSheets::Execute' }
BEActionExecutorFactory.forreadresolves NodeRegistry.find_by_type_and_version(action_type)&.node_type_groupGROUP[group], constantize under PREFIX='Repositories::NodeExecutions::Nodes'
BENodeExecutorInterfacereaddocumented initialize(organization_id:, credential:, parameters:, arguments:, room_id:, node_type:) + call
BEGoogleSheets::Executereadinclude NodeExecutorInterface/ArgumentInterpolation; dispatches on @node_type (google_sheet_row_get/create/update); credential[:id] = OrganizationConnection id
BEAuthorizedHttpClient.callreaddef self.call(credential:, request:, http_client:, max_retries: 1, call_options:); AUTH_FAILURE_CODES=%w[401 403]; returns `Success/Failure([:auth_failed
BEtoken cachereadredis_store.rb key auth:#{Rails.env}:#{descriptor.key}:token:#{credential&.id}; TTL capped [60s, 24h]
BEnode_registries schemareaddb/schema.rb:1219 table with node_type, node_type_group ("Maps to an executor class via NodeTypeRegistry::GROUP"), properties jsonb, categories jsonb, enabled
BEGET /v1/node_registriesgrepapi.rb:51 mount V1::NodeRegistries::NodeRegistriesController => '/v1/node_registries'
BE/v1/api_connectionsgrepapi.rb:28 mount V1::APIConnection => '/v1/api_connections'
BE/v1/ai_agent_toolsgrepapi.rb:46 mount V1::AiAgentTool::AiAgentToolsController => '/v1/ai_agent_tools'
BECTA-URL buttonreadsend_message_interactive.rb send_button_message(header_format:, header_text:, body_text:, button_actions:, link:, filename:)
BEeventsreadsend_mixpanel_event_worker.rb MIXPANEL_TRACKER.track(distinct_id, event_name, params)
BEfeature flagreadfeature_flag.rb FeatureFlag.enabled?(group_code, code, default: false)
BEplan entitlementreadget_subscription.rb returns {package_name, features:[{code, enabled, …}]}
BEsecrets encryptionreadpayload_encryptor.rb encrypt"v#{VERSION}:#{base64}" via $lockbox.encrypt
BEtest commandsreadbitbucket-pipelines.yml:74 RAILS_ENV=test bundle exec rspec …; :144 … spec/api/internal_service …; .rspec --require spec_helper
FEdrawerreadNewActionDrawer.vue formAction switch (selection → ApiIntegration/CreateAction)
FEdynamic formreadActionIntegrationForm.vue loops requiredItem.properties[] by prop.html.element (select/input-text/textarea/date/…) + credential autocomplete
FEAPI clientreadai-agents.ts fetchActionList()endpoint.v1.ai_agents.actions.get (/v1/node_registries); ai-agent-tools.ts CRUD → /v1/ai_agent_tools
FEno Google Calendar / connect UI in productionreadchatbot-fe has only SOURCE_ICON["Google Calendar"] icon (utils/actionIconConfig.ts); the ACTION_GROUPS/Google-Calendar drawer row + connect block exist only in qontak-designer prototype
FEplan gatingreadmiddleware/nlp-feature.ts subscriptionData.features.find(code==='nlp')?.enabled
FEtest commandsreadpackage.json "test":"vitest run" (L17), "lint:ts" (L13), "build":"nuxt build" (L11); bitbucket-pipelines.yml jsLint/nodeUnitTest

No unverifiable rows remain. The one open technical unknown (do Midtrans v2 endpoints accept the OAuth2 Bearer token?) is tracked as OQ-3 and gates execution chunks 3–6.

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
agent([AI Agent runtime]) --> ae[ActionExecute use case]
ae -->|flag + plan gate| gate{enabled?}
gate -- no --> noop[not executed]
gate -- yes --> fac[ActionExecutorFactory.for node_type]
fac --> me[Midtrans::Execute]
me --> disp{node_type}
disp --> s[check_payment_status]
disp --> l[create_payment_link]
disp --> c[cancel_transaction]
disp --> r[request_refund]
s & l & c & r --> ahc[AuthorizedHttpClient.call]
ahc --> tm[(Auth::TokenManager + Redis)]
ahc --> mid([Midtrans API])
l --> hub[SendMessageInteractive send_button_message]
me --> ev[[SendMixpanelEventWorker]]

Data model (erDiagram)

erDiagram
ORGANIZATION ||--o{ ORGANIZATION_CONNECTION : has
ORGANIZATION ||--o{ AI_AGENT_ACTION : has
NODE_REGISTRY ||--o{ AI_AGENT_ACTION : "typed by (node_type+version)"
ORGANIZATION_CONNECTION {
bigint id PK
string auth_type "midtrans_oauth2"
text auth_data "ENCRYPTED {environment,client_id,client_secret}"
text token_cache "ENCRYPTED (db-store providers only)"
boolean auth_is_managed
bigint organization_id FK
}
AI_AGENT_ACTION {
bigint id PK
string action_type "midtrans_* (= node_registries.node_type)"
string name
text description
jsonb parameters
jsonb credentials "OrganizationConnection ref"
bigint organization_id FK
}
NODE_REGISTRY {
uuid id PK
string node_type "midtrans_check_payment_status | …"
string node_type_group "midtrans"
jsonb properties "dynamic config fields"
jsonb categories "[Other integration]"
float version
boolean enabled
}

No new tables. Midtrans P0 adds rows to node_registries and reuses organization_connections / ai_agent_actions.

State machine — Midtrans transaction status (external, read-only)

We do not own this enum — it is Midtrans's. The diagram captures which states gate cancel (S04) and refund (S05). Transitions are driven by Midtrans, not by us.

stateDiagram-v2
[*] --> pending
pending --> settlement: paid/captured
pending --> expire: timeout
pending --> deny: rejected
pending --> cancel: cancel action (S04)
authorize --> settlement: capture
authorize --> cancel: cancel action (S04)
settlement --> refund: refund action (S05)
note right of pending: cancellable (S04)
note right of authorize: cancellable (S04)
note right of settlement: refund-eligible (S05)

Branch & skip flow — cancel/refund pre-check + confirmation

flowchart TD
trigger([AI Agent identifies cancel/refund + order_id]) --> chk[GET /v2/order_id/status]
chk --> elig{eligible? cancel:pending/authorize · refund:settlement}
elig -- no --> deny["reply: cannot cancel/refund — status: X · NO mutating call"]
elig -- yes --> confirm{customer confirms?}
confirm -- no --> declined["reply: no changes made"]
confirm -- yes --> dedupe{refund only: dedupe window open?}
dedupe -- duplicate --> suppress[suppress second POST]
dedupe -- first --> mutate["POST /v2/order_id/cancel|refund"]
mutate --> ok["reply success + midtrans_action_success"]

Detail 2.2 — Sequence diagrams

Happy path — check payment status (S02)

sequenceDiagram
actor Agent as AI Agent
participant AE as ActionExecute
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant R as Redis (token)
participant MID as Midtrans API
participant MX as SendMixpanelEventWorker

Agent->>AE: execute(midtrans_check_payment_status, {order_id})
AE->>AE: flag + plan gate (enabled?)
AE->>ME: call(credential, parameters, arguments)
ME->>MX: perform_async(midtrans_action_executed)
ME->>AHC: call(credential, GET /v2/{order_id}/status, timeout 60s)
AHC->>R: GET token (hit → reuse; miss → mint via /v1/oauth/token, SET TTL 3300s)
AHC->>MID: GET /v2/{order_id}/status (Bearer)
Note right of MID: must return ≤60s
MID-->>AHC: 200 {transaction_status}
AHC-->>ME: Success(resp)
ME->>MX: perform_async(midtrans_action_success {status,duration_ms})
ME-->>AE: {status:'success', body:{...}}
AE-->>Agent: NL status message

Failure path — timeout / 404 / auth (S02 ERR-1..3)

sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant MX as SendMixpanelEventWorker

ME->>AHC: call(GET /v2/{order_id}/status, timeout 60s)
alt no response >60s
MID--xAHC: Net::ReadTimeout → synthetic 504/408
AHC-->>ME: Success(resp code 504)
ME->>MX: midtrans_action_failed reason=timeout
ME-->>ME: "unable to check payment status…"
else 404 not found
MID-->>AHC: 404
AHC-->>ME: Success(resp 404)
ME->>MX: midtrans_action_failed reason=api_error
ME-->>ME: "couldn't find a payment with that order ID"
else token mint / 401 after retry
AHC->>MID: GET (Bearer) → 401
AHC->>AHC: invalidate token, retry once → still 401
AHC-->>ME: Failure([:auth_failed, resp])
ME->>MX: midtrans_action_failed reason=auth_error
ME-->>ME: auth error message
end

Happy path — create payment link → WhatsApp CTA-URL (S03)

sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant HUB as SendMessageInteractive
participant CH as Channel gateway

ME->>AHC: call(POST /v1/payment-links {transaction_details,…})
AHC->>MID: POST (Bearer)
MID-->>AHC: 201 {payment_url}
AHC-->>ME: Success(resp)
alt channel == whatsapp
ME->>HUB: send_button_message(body_text, button_actions:[CTA-URL], link: payment_url)
HUB->>CH: interactive CTA-URL button
else other channel
ME->>HUB: send text with clickable URL
HUB->>CH: text message
end
ME->>ME: midtrans_payment_link_created {payment_link_url, channel_type}

Happy/branch path — cancel with confirmation (S04); refund (S05) is symmetric

sequenceDiagram
participant ME as Midtrans::Execute
participant AHC as AuthorizedHttpClient
participant MID as Midtrans API
participant R as Redis (refund dedupe)

ME->>AHC: GET /v2/{order_id}/status
AHC->>MID: GET (Bearer)
MID-->>ME: status
alt not eligible
ME-->>ME: "cannot cancel/refund — status: X" (NO mutating POST)
else eligible
ME-->>ME: confirmation prompt
Note over ME: customer reply
alt declines
ME-->>ME: "no changes were made"
else confirms
opt refund only
ME->>R: SETNX midtrans:refund:dedupe:{conv}:{order} (TTL 120s)
R-->>ME: duplicate? → suppress second POST
end
ME->>AHC: POST /v2/{order_id}/cancel (or /refund {refund_key, amount?})
AHC->>MID: POST (Bearer)
MID-->>ME: 200 success
ME-->>ME: success message + midtrans_action_success
end
end

Detail 2.3 — Database Model (DDL)

No DDL/migrations that create tables. Phase 1 adds data rows to the existing node_registries table (4 enabled rows) via a Rails data migration / seed. The organization_connections and ai_agent_actions tables are unchanged.

Reference node_registries schema (existing, db/schema.rb:1219):

-- EXISTING table — no ALTER. Phase 1 inserts 4 rows.
-- create_table "node_registries", id: :uuid, default: gen_random_uuid()
-- node_type varchar NOT NULL -- e.g. 'midtrans_check_payment_status'
-- name varchar -- UI label
-- categories jsonb DEFAULT '[]'-- e.g. ["Other integration"]
-- version float
-- inputs jsonb DEFAULT '[]'
-- outputs jsonb DEFAULT '[]'
-- properties jsonb DEFAULT '[]'-- dynamic config field schema (FE renders this)
-- description text
-- settings jsonb DEFAULT '{}'
-- enabled boolean DEFAULT true NOT NULL
-- node_type_group varchar -- 'midtrans' → NodeTypeRegistry::GROUP
-- UNIQUE (node_type, version) WHERE deleted_at IS NULL

Seed rows (data migration, idempotent on node_type+version):

node_typenode_type_groupnamecategoriesversionenabled
midtrans_check_payment_statusmidtransCheck Payment Status["Other integration"]1.0true
midtrans_create_payment_linkmidtransCreate Payment Link["Other integration"]1.0true
midtrans_cancel_transactionmidtransCancel Transaction["Other integration"]1.0true
midtrans_request_refundmidtransRequest Refund["Other integration"]1.0true
  • Cardinality / growth: +4 catalog rows total (org-independent). ai_agent_actions and organization_connections grow per configuring account (target ≥5 CIDs → tens of rows).
  • PII classification: organization_connections.auth_data holds client_secret (sensitive credential) — encrypted at rest (Lockbox via PayloadEncryptor). No cardholder/PAN data is ever stored (payment data lives in Midtrans).
  • Retention: credential rows retained until disconnected (soft-delete via acts_as_paranoid).
  • Per-status lifecycle: n/a — no new status enum is owned by this RFC. Transaction status is Midtrans's (read-through only); node_registries.enabled is the only on/off flag (true = visible; soft-deletable). See §2.1 state diagram for the external enum.
  • Partition/sharding: none.
  • NoSQL alternative: n/a — reuses existing relational tables.

properties[] per action (the dynamic-field schema the FE renders; field names remain provisional per PRD §6/OQ-6 — confirm exact Midtrans field names during build).

Every seeded property MUST carry a non-blank type (REV-9). As of chatbot HEAD fa6dd8b79 ("fix(service): skip actions with unresolved argument types"), FrontendService::V2::AiAgent::Repositories::SyncToAiService#build_non_api_args (app/api/frontend_service/v2/ai_agent/repositories/sync_to_ai_service.rb:451-467) resolves each AI-resolvable argument's type from the matching registry properties[].type (registry_prop&.dig('type')map_param_type), and build_skill_actions (:404-420) then drops the entire actionnext if args_with_unresolved_type?(args) (:408, :424-428) — when any AI-resolvable arg resolves to a blank type. So a property with no type ⇒ the Midtrans action is silently absent from the synced skill pack (configured but never invokable). type is the data type (map_param_type maps stringstr, integerint, numberfloat, booleanbool; array/object pass through for nested fields) and is distinct from html.element (the FE render hint). This matches the canonical registry shape {name, type, required, html{…}}spec/factories/node_registry.rb with_properties ({name, type, required}) and the FE PropertiesItem.type, which is non-optional (chatbot-fe modules/bot-automation/constants/bot-automation-actions-constants.ts:4).

Action (node_type)property nametypehtml.elementrequiredAI-resolvable¹Source
midtrans_check_payment_statusorder_idstringinput (html.type:"text")yesyesPRD §6 A1
midtrans_create_payment_linktransaction_details.order_idstringinput (html.type:"text")yesyesPRD §6 A2
midtrans_create_payment_linktransaction_details.gross_amountintegerinput (html.type:"number")yesyesPRD §6 A2
midtrans_create_payment_linkitem_detailsarraytextareanonoPRD §6 A2
midtrans_create_payment_linkcustomer_detailsobjecttextareanonoPRD §6 A2
midtrans_create_payment_linkusage_limitintegerinput (html.type:"number")nonoPRD §6 A2
midtrans_create_payment_linkexpiryobjecttextareanonoPRD §6 A2
midtrans_create_payment_linkenabled_paymentsarraytextareanonoPRD §6 A2
midtrans_cancel_transactionorder_idstringinput (html.type:"text")yesyesPRD §6 A3
midtrans_request_refundorder_idstringinput (html.type:"text")yesyesPRD §6 A4
midtrans_request_refundrefund_keystringinput (html.type:"text")nono²PRD §6 A4
midtrans_request_refundamountintegerinput (html.type:"number")nonoPRD §6 A4
midtrans_request_refundreasonstringinput (html.type:"text")nonoPRD §6 A4

¹ "AI-resolvable" = typically configured use_ai: true in the action's parameters.arguments, so the agent fills it from the conversation. Only these gate the sync (build_non_api_args builds args only for use_ai: true params, :456), but every property still needs a type to satisfy the FE PropertiesItem contract. ² refund_key is system-generated (Decision 4) and never use_ai.

Canonical seeded property (the order_id row, full shape the migration writes):

{
"name": "order_id",
"display_name": "Order ID",
"type": "string",
"required": true,
"html": { "element": "input", "type": "text" },
"description": "Midtrans order_id (AI-resolvable from the conversation)"
}

Detail 2.4 — APIs

Outbound endpoints (consumers call us) — all reused

EndpointMethodAuthN/AuthZRequestResponseStatusIdempotencyVersioningReuse?
/v1/node_registriesGETsession + plan/flag (FE)filter by categoryaction catalog incl. 4 midtrans rows200n/a (read)v1reused
/v1/api_connectionsPOST/PUT/GETAdmin/SPV session, org-scoped{auth_type:'midtrans_oauth2', auth_data:{environment,client_id,client_secret}}connection (secrets masked)200/201/422name/code uniqueness per orgv1reused
/v1/ai_agent_toolsPOST/PUT/GETAdmin/SPV sessionaction config (name, description, parameters, credential)action200/201/422per-idv1reused
(internal) ActionExecuteuse caseinternal service auth{action, arguments, room_id}executor resultn/aper refund dedupen/aextended

Inbound webhooks (other services call us)

N/A — no inbound webhook. All four P0 actions are outbound request/response; the PRD names no Midtrans payment-notification receiver in this phase.

External (we call Midtrans) — per-call contract

CallMethodURLTimeoutFailure behaviorRetry
Token mintPOSThttps://api(.sandbox).midtrans.com/v1/oauth/token (per environment)via TokenManagerauth_error event; action abortshandled by TokenManager lock; no app retry
Check statusGET…/v2/{order_id}/status60 s504/408 → timeout; 404 → not-found copynone (read)
Create linkPOST…/v1/payment-links60 s400 → invalid_request; 504 → timeoutnone
CancelPOST…/v2/{order_id}/cancel60 spre-check gates eligibility; 401/403 retry-onceAuthorizedHttpClient 1× on 401/403
RefundPOST…/v2/{order_id}/refund60 sdedupe gates duplicate; refund_key idempotencyAuthorizedHttpClient 1× on 401/403

OQ-3 gate: the v2 base host/path and whether the OAuth2 Bearer token is accepted on /v2/* must be confirmed against Midtrans docs before chunks 3–6. If v2 requires Basic server_key, switch the credential to the midtrans_basic descriptor specified in §2 Decision 8 — the executor does not change (auth is applied by AuthorizedHttpClient).

Per-call request / response schemas

Concrete contracts the executor builds and parses (field names per Midtrans public API docs; confirm exact casing/optionality in OQ-6). All requests carry the auth header injected by AuthorizedHttpClient (Bearer or Basic per Decision 8); Content-Type: application/json.

// Check status — GET /v2/{order_id}/status (no request body)
// 200 response (fields consumed):
{ "order_id": "ORDER-123", "transaction_status": "settlement", // pending|settlement|deny|cancel|expire|failure|authorize
"status_code": "200", "transaction_id": "…", "gross_amount": "150000.00", "payment_type": "…" }
// 404 → { "status_code": "404", "status_message": "Transaction doesn't exist." }

// Create payment link — POST /v1/payment-links
// request:
{ "transaction_details": { "order_id": "ORDER-123", "gross_amount": 150000 },
"item_details": [ { "id": "i1", "name": "Item", "price": 150000, "quantity": 1 } ], // recommended
"customer_details": { "first_name": "…", "email": "…", "phone": "…" }, // optional
"usage_limit": 1, "expiry": { "duration": 24, "unit": "hours" } } // optional
// 201 response (fields consumed):
{ "order_id": "ORDER-123", "payment_url": "https://app.midtrans.com/payment-links/ORDER-123" }
// 400 → { "error_messages": ["transaction_details.gross_amount is required"] }

// Cancel — POST /v2/{order_id}/cancel (no request body)
// 200 → { "status_code": "200", "transaction_status": "cancel", "order_id": "ORDER-123" }
// 412 → { "status_code": "412", "status_message": "Merchant cannot modify the status…" }

// Refund — POST /v2/{order_id}/refund
// request:
{ "refund_key": "ref-<uuid>", // idempotency key, generated per logical refund (Decision 4)
"amount": 150000, // OMIT for full refund (P0 default — OQ-5)
"reason": "customer request" } // optional
// 200 → { "status_code": "200", "transaction_status": "refund", "refund_amount": "150000.00",
// "refund_key": "ref-<uuid>" }
// 412/400 → { "status_code": "412", "status_message": "Cannot refund — invalid status." }

Detail 2.A — UI Contract

The config surface reuses the production action-config components. New work is the credential connect block for midtrans_oauth2.

  • Implementation files (reused): modules/bot-automation/components/drawers/NewActionDrawer.vue, components/forms/CreateActionForm.vue, components/forms/ActionIntegrationForm.vue.
  • New file (credential form): modules/bot-automation/components/forms/MidtransAuthBlock.vue (connect/reauthorize/disconnect for a midtrans_oauth2 connection).
  • Props (MidtransAuthBlock):
interface MidtransAuthBlockProps {
connection?: { id: string; environment: 'sandbox' | 'production'; connected: boolean } | null;
loading?: boolean; // skeleton while connection status fetched
errorMessage?: string; // connect/load failure copy
}
// emits: 'connect' {environment, clientId, clientSecret}, 'reauthorize', 'disconnect'
  • State ownership: store/ai-agent-tool/ (action config) + a connection state read via mainService /v1/api_connections. Connection "connected" = a midtrans_oauth2 OrganizationConnection exists for the org.
  • Event payloads (analytics): FE does not emit Mixpanel directly for these (events are BE-emitted, PRD §10). FE may keep existing form-interaction analytics unchanged.
  • Conditional rendering: not-connected → ConnectButton + credential fields, Save disabled; connected → "Midtrans connected [environment]", Reauthorize + Disconnect, Save enabled.
  • A11y: form labels for each credential field; error text linked via aria-describedby; follows @mekari/pixel3 MpFormControl semantics.

Detail 2.B — Data-Fetching Strategy

  • Library: ofetch via mainService (common/services/main), Pinia for state.
  • Cache key / fetch triggers: action list (/v1/node_registries) fetched on drawer open; connection status fetched on panel mount and after connect/reauthorize/disconnect.
  • SWR: no — explicit fetch + Pinia state (pending/resolved/rejected).
  • Optimistic updates: none — connect/save await server confirmation before state flip.

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Action config panelskeleton while connection status fetchedall fields blank, "Not connected", Save disabled"Failed to load configuration. Try again." + Retry (S01/ERR-3)connected but required dynamic fields missing → Save disabledconnected + required filled → Save enabled (S01/AC-3)
Connect (credentials)spinner on Connectn/a"Connection failed. Check your credentials and try again." (S01/ERR-1)n/a"Midtrans connected [environment]" (S01/AC-2)
In-conversation resulttyping indicator ≤60 sAI Agent prompts for order_id (S02/AC-3)error copy per ERR catalogn/astatus text / CTA-URL button / confirmation result

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failure behaviorIdempotency key + TTLConsistencyDuplicate-event handlingStale-read
Create credential (/v1/api_connections)single-row DB write, auth_data encrypted422 on validation; nothing persistedname/code uniqueness per orgstrong (DB)n/an/a
Token mint/cacheredis SET with TTL 3300 s under locklock timeout → auth_errorredis key per credential.id; lock TTL 15 seventual (cache)second concurrent fetch waits for cached tokenre-mint on miss/expiry
Cancel POSTexternal (Midtrans)status pre-check → no POST if ineligible; 401/403 retry-oncenatural (status pre-check rejects re-cancel)externalsecond confirm hits pre-check → already-cancelled rejectedlive status read each turn
Refund POSTexternal (Midtrans)eligibility check → no POST if ineligiblerefund_key per logical refund + redis dedupe midtrans:refund:dedupe:{conv}:{order} TTL ~120 sexternalduplicate confirm within window → suppressed before POSTlive status read each turn

Detail 2.E — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionOn failure
Redis Midtrans token (per credential.id)concurrent agent actions for same orgthundering herd minting tokensTokenManager lock (SET nx ex 15s) + wait-for-cached polllock timeout → auth_error, action aborts
Refund POST for same (conversation_id, order_id)duplicated WhatsApp "yes"double refundSETNX dedupe key TTL ~120 sduplicate suppressed; single POST
Credential row (org)concurrent Admin editslast-write-wins on auth_dataDB row + paper_trail; token cache invalidated on secret rotationstandard model validation

Detail 2.F — Async Job / Event Consumer Spec

Job/ConsumerTriggerInputRetryDLQConcurrencyIdempotencyTimeoutPoison handling
SendMixpanelEventWorkereach event emission(distinct_id, event_name, params)Sidekiq defaultSidekiq dead setdefault queue (event_tracker)event is fire-and-forget (analytics)shortdropped after retries; analytics non-critical
SendMessageInteractiveWorkerCTA-URL / text result deliverymessage payloadretry: 1 (existing)existingexistingmessage idexistingexisting path

The Midtrans API call itself is synchronous (Decision 5), not a queued job.

Detail 2.F.1 — Responsibility Boundary Matrix

N/A — single squad (BOT) owns both layers. External boundary (Midtrans) is captured in §2.4 external calls + §3.A failure catalog. No cross-squad flow.

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Midtrans connectionconnected (derived: credential exists)not-connected/v1/api_connections create/delete/v1/api_connectionson panel mount
Midtrans transactiontransaction_status (external)n/aMidtransGET /v2/{order_id}/status (per turn)live each call (no caching of status)
Action configexists/enabledn/a/v1/ai_agent_tools/v1/ai_agent_tools, /v1/node_registrieson drawer open

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
GET /v1/node_registriesrows {node_type, name, categories, properties, settings, version}ActionListItem {node_type, name, categories[], properties[], settings, version}yessnake_case already consumed by existing FE constants; 4 new rows fit the shape
POST /v1/api_connections{id, auth_type, auth_data(masked), name, code}connect form expects {id, environment, connected}partialFE must derive environment from auth_data and connected from presence — add a small mapper in the credential service (no BE change)
POST /v1/ai_agent_toolsaction {id, action_type, name, parameters, credentials}create payload mirroryesexisting CRUD contract

The one partial has a mitigation (FE-side mapper); no BE contract change required.

Detail 2.H — End-to-End Data Flow

  • Configure: Admin opens drawer → GET /v1/node_registries (sees 4 midtrans rows) → selects action → MidtransAuthBlock connect → POST /v1/api_connections (auth_data encrypted) → fills dynamic fields → Save → POST /v1/ai_agent_tools. Side effects: paper_trail rows; no events on configure (events are runtime).
  • Execute (status): customer msg → AI Agent → internal ActionExecute (flag/plan gate) → Midtrans::ExecuteAuthorizedHttpClient (token from redis or mint) → GET /v2/.../status → NL reply → midtrans_action_executed/_success events.
  • Execute (link): as above → POST /v1/payment-linkssend_button_message(link:) on WhatsApp / text else → midtrans_payment_link_created.
  • Execute (cancel/refund): status pre-check → confirm → (refund: dedupe) → mutating POST → success reply → events. Ownership: BOT squad both layers; Midtrans owns the payment record.

Detail 2.I — Scope Boundaries

  • BE create: app/core/repositories/node_executions/nodes/midtrans/execute.rb; data migration seeding 4 node_registries rows; specs under spec/core/repositories/node_executions/nodes/midtrans/ and spec/api/internal_service/v1/ai_agent/action_execute/midtrans/.
  • BE modify: node_type_registry.rb (add 'midtrans' => 'Midtrans::Execute'); action_execute.rb (flag/plan gate + dispatch already generic — confirm no Midtrans-special-case needed).
  • BE NOT touched: auth descriptor/token manager (shipped), OrganizationConnection model, hub send use case (reused as-is).
  • FE create: MidtransAuthBlock.vue; a credential service mapper.
  • FE modify: wire MidtransAuthBlock into the config form when action_type starts midtrans_.
  • FE NOT touched: generic dynamic-field renderer, drawer switch logic (4 actions appear automatically from the registry).
  • Shared modules touched: NodeTypeRegistry::GROUP (additive — impacts executor resolution only).

Detail 2.J — Asset Inventory

AssetTypeSourceFormat & sizesPath
Midtrans logo (drawer row icon)iconnew export (PM-provided per anchor brand-asset rule)SVG`chatbot-fe/static

Per the ANCHOR brand-asset rule, the Midtrans logo + hex must be PM-provided and code-gen-ready before the FE row lands. Tracked as OQ-7. If absent, the row falls back to a generic Pixel icon (actionIconConfig.ts default).


3. High-Availability & Security

The execution path is stateless (Rails API pods, HPA-managed); credentials live in Postgres (encrypted) and tokens in Redis (TTL 3300 s). If Midtrans is slow/down, the 60 s timeout returns a graceful in-conversation error and emits midtrans_action_failed reason=timeout; no partial local state is written (Midtrans is the system of record). If Redis is down, token mint falls back to a fresh mint per call (degraded latency, still correct). Full-restart recovery is inherited from the platform (no new stateful component).

Performance Requirement

  • Backend — Per action: bounded by the 60 s budget; expected Midtrans p99 well under that. Volume is low for P0 (target ≥5 CIDs). RPS: negligible incremental; p99 dominated by Midtrans latency. Scalability: existing HPA on API + worker pods; Redis token cache caps token-mint RPS. Load test: n/a — low-volume P0 conversational feature; smoke against Midtrans sandbox in QA.
  • Midtrans rate limits (PRD §7 / REV-3): Midtrans enforces per-endpoint rate limits. Decision: on HTTP 429, the executor does not auto-retry in P0 (a retry inside the 60 s conversational turn risks compounding the limit); it maps to a distinct failure_reason: rate_limited with its own user copy (separate from generic timeout) and emits midtrans_action_failed. (Token-mint 429s are absorbed by the TokenManager lock, which serialises minting.) Auto-backoff/retry is explicitly deferred to a later phase.
  • Frontend — the config panel reuses existing @mekari/pixel3 components and adds no new route bundle of note. Perf budget: n/a — reuses existing Bot & Automation drawer; no new heavy dependency, no LCP/INP/CLS-sensitive surface introduced (verify bundle delta < 5 KB gz in CI if MidtransAuthBlock pulls a new dependency — it should not).
  • Browser support matrix: inherits the production chatbot-fe baseline (last 2 versions of Chrome, Edge, Firefox, Safari + iOS Safari); no Midtrans-specific browser constraint. Config is web-only (NEG-4); mobile renders no config menu.

Monitoring & Alerting

Events (Mixpanel via SendMixpanelEventWorker; field convention cid, conversation_id, action_type, order_id, duration_ms, failure_reason):

EventTriggerKey properties
midtrans_action_executedany action initiatedaction_type, order_id?, cid, conversation_id, timestamp
midtrans_action_successMidtrans 2xxaction_type, order_id, response_status, duration_ms, cid, conversation_id
midtrans_action_failedfailure/timeout/auth/rate-limitaction_type, order_id?, failure_reason ∈ {timeout, auth_error, invalid_request, api_error, rate_limited}, cid, conversation_id
midtrans_payment_link_createdlink deliveredpayment_link_url, channel_type, cid, conversation_id
midtrans_auth_token_refreshedtoken mint by TokenManagerenvironment, duration_ms, cache_hit, cid
  • FE analytics (config panel): the FE reuses the existing Bot & Automation action-builder analytics (action created/edited, credential connected) unchanged — no new FE event is required (runtime events are BE-emitted per PRD §10). If product wants a distinct "midtrans_action_configured" funnel event, it is added at Save in store/ai-agent-tool/; marked optional (REV-5).
  • Alerts (PRD §10): midtrans_action_failed rate > 5 % / 15-min → BOT Slack; 3 consecutive midtrans_auth_token_refreshed failures for same cid → Slack + Eng Lead.
  • Logs: reuse [Auth]… structured logs from AuthorizedHttpClient/TokenManager; executor logs [Midtrans::Execute] node_type=… order_id=… status=…. PII scrub: never log client_secret, full token, or payment_link_url query secrets.
  • "3am" runbook: check token mint logs + Midtrans status page; flip flag OFF as kill-switch.

Logging

  • BE fields: organization_id, conversation_id/room_id, node_type, order_id, failure_reason, duration_ms, level INFO/ERROR.
  • Scrubbed: client_secret, Bearer token, any card data (never received).

Security Implications

  • Threat model: credential theft (client_secret), SSRF via attacker-controlled URLs, unauthorized mutating actions, token leakage in logs.
  • Mitigations:
    • Outbound URL is not user-controlled — paths are built from fixed Midtrans hosts (descriptor token_url_map) + validated order_id; no free-form URL (unlike the generic API action). order_id validated against an allowed charset (alnum + -_).
    • Secrets at rest: auth_data encrypted via Lockbox (PayloadEncryptor), masked on read (mask_sensitive: true). Never returned in plaintext.
    • AuthZ: config endpoints are Admin/SPV + plan + flag gated; execution is internal-service only (no public manual-trigger route — NEG-2). Org-scoped credential lookup (no cross-tenant).
    • Mutating-action guardrails: status pre-check + explicit confirmation (S04/S05); refund idempotency (Decision 4).
    • Static analysis: brakeman (Gemfile), rubocop.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityConstraintAudit
Admin / SPV/v1/node_registries, /v1/api_connections, /v1/ai_agent_toolsGET/POST/PUTown orgconfig drawer (eligible plan + flag, web)single connection (NEG-1)paper_trail
AI Agent (runtime)internal ActionExecuteexecuteown orgn/aonly configured + enabled actionsMixpanel events
CS Agent / humannoneown orgread-only result in conversation (NEG-2)no manual triggern/a

Detail 3.A — Failure Mode & Retry Catalog

External callTimeoutRetriesCircuit breakerDLQCaller behavior on persistent failure
Token mint (/v1/oauth/token)TokenManagernone (lock-serialised)nonen/aauth_error event; action aborts; auth error copy
GET /v2/{order_id}/status60 snonenonen/atimeout copy + timeout; 404 → not-found copy; 429 → rate_limited copy (no retry)
POST /v1/payment-links60 snonenonen/ainvalid_request (400) / timeout; 429 → rate_limited
POST /v2/{order_id}/cancel60 s1× on 401/403 (AuthorizedHttpClient)nonen/atimeout copy + log; pre-check prevents ineligible call; 429 → rate_limited
POST /v2/{order_id}/refund60 s1× on 401/403nonen/atimeout copy; dedupe prevents double POST; 429 → rate_limited

429 policy (REV-3): no auto-retry in P0 (see §3 Performance). AuthorizedHttpClient retries once only on 401/403 (auth), never on 429. A 429 returns the distinct rate_limited user copy below and logs/event-tags failure_reason: rate_limited.

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
Transaction not cancellable (status ≠ pending/authorize)Midtrans::Execute cancel pre-checkcancel endpoint NOT calledmidtrans_action_failed? no — it's a valid branch; log onlyyes ("cannot cancel — status: X")
Transaction not refund-eligible (status ≠ settlement)refund eligibility checkrefund endpoint NOT calledlogyes
Customer declines confirmationconfirmation stepno mutating calllogyes ("no changes made")
Duplicate refund confirm within dedupe windowredis dedupe (Decision 4)second POST suppressedlogno (idempotent — single result)
order_id missingexecutorprompts customer for order_idnoneyes (S02/AC-3)
Flag OFF / plan ineligibleActionExecute gate + registry visibilityaction not executed / not renderednoneno (NEG-3, NEG-4)

Detail 3.B — Error Response Catalog (BE → conversation)

Actionfailure_reasonSourceCustomer message (PRD)
check_statustimeout>60 s"I was unable to check your payment status. Please try again or contact support."
check_statusapi_error404"I couldn't find a payment with that order ID. Please verify and try again."
create_linkinvalid_request400"I was unable to create a payment link. Please contact support."
create_linktimeout>60 s"I was unable to generate a payment link. Please try again or contact support."
cancel(branch)ineligible status"This transaction cannot be cancelled — current status: [status]."
refund(branch)ineligible status"This transaction is not eligible for a refund — current status: [status]."
cancel/refundtimeout>60 s"Unable to [cancel your transaction / process your refund request]…"
anyauth_errortoken/401 after retryauth error copy + log
anyrate_limited429"I'm receiving a lot of requests right now and couldn't complete that. Please try again in a moment." (distinct from timeout — REV-3)

Detail 3.C — Error Message Catalog (FE — config panel)

CodeMessage (i18n key)SurfaceUser-facing?
S01/ERR-1"Connection failed. Check your credentials and try again."inline (auth block)yes
S01/ERR-2"Midtrans connection required before saving."inline (Save)yes
S01/ERR-3"Failed to load configuration. Try again." + Retrybanneryes

Detail 3.D — Compliance & Data Governance

This RFC touches payment-adjacent flows but stores no cardholder/PAN data — Midtrans holds all payment data; we store only the merchant API credential (client_secret).

FieldClassificationLegal basisRetentionEncryptionAccess auditRight-to-delete
organization_connections.auth_data.client_secretsensitive (merchant credential)contract/legitimate interestuntil disconnect (soft-delete)Lockbox at rest + TLS in transitpaper_trail + [Auth] logsdelete connection
payment_link_url (transient, in conversation)sensitive (links to payment)service provisionmessage retention (existing)TLSmessage logsper existing message deletion

No PAN/CVV/cardholder data is processed or stored. PCI scope is limited to passing a merchant credential to Midtrans over TLS.

Detail 3.E — Accessibility

  • WCAG AA. Credential form: labelled inputs, aria-describedby on errors, keyboard-navigable, focus moved to first error on failed connect, contrast via @mekari/pixel3 tokens.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: additive only — new executor + group entry + 4 catalog rows; no endpoint/shape changes; existing actions unaffected.
  • FE: additive — new MidtransAuthBlock + conditional wiring; generic form unchanged.
  • Cross-layer: the one partial contract (api_connections → connect form) is bridged by an FE-side mapper; no BE contract evolution.

Rollout Strategy

  • Deploy order: BE first (executor + registry rows behind flag OFF), then FE (config surface). Rationale: registry rows + executor are inert while the flag is OFF and no FE exposes them; FE can ship after and rely on the catalog already existing.
  • Feature flag: rollout / midtrans_native_action (proposed; OQ-1), default OFF, enabled per account after a Midtrans credential is connected. Single flag governs both registry visibility and execution. Kill-switch: flip OFF → actions disappear from drawer + execution gate denies (PRD §10 rollback: failed-rate >10 % sustained 1 h → PM disables globally).
  • Stages (gate signals; schedule lives in delivery/):
    • Internal QA (sandbox): all 4 actions pass; confirmation + auth lifecycle verified; 0 P0/P1 bugs.
    • Closed Beta (3–5 prod accounts): success rate ≥95 % for 1 week; 0 P0 bugs.
    • GA: gates sustained 2 weeks; success ≥95 %.
  • Rollback: flip flag OFF (instant); revert FE PR if config UI broken; data migration is reversible (set the 4 rows enabled=false / soft-delete). No data written during rollout needs cleanup (no transaction persistence).
  • Blast radius: worst case = an account with the flag ON sees failing actions → graceful in-conversation error; no impact to other actions or accounts.

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend firstOldNewyesregistry rows inert while flag OFF; old FE ignores unknown rows
Frontend firstNewOldnoFE would reference missing executor/rows → avoid; deploy BE first
Both deployedNewNewyestarget
Backend rollbackNewOldpartialflag OFF hides actions; FE handles empty/absent rows gracefully
Frontend rollbackOldNewyesBE inert behind flag

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BErollout / midtrans_native_action (system_preferences)feature flagOFFyesDB / opsno
BEVENDOR_API_TIMEOUT (existing, optional global cap)int (s)unsetnoenvno
BEMidtrans client_id/client_secretper-org auth_data (encrypted)yes (per account)Admin via UIyes
BEGOOGLE_*/Lockbox master key (existing)secretyesvault/envyes

Detail 4.C — Test Plan (commands sourced from repo)

LayerCommand (source)What it must prove
BE unit/integrationRAILS_ENV=test bundle exec rspec spec/core/repositories/node_executions/nodes/midtrans (pattern from bitbucket-pipelines.yml:74; .rspec --require spec_helper)executor dispatch, status mapping, pre-check, dedupe, error mapping (webmock-stubbed Midtrans)
BE API/integrationRAILS_ENV=test bundle exec rspec spec/api/internal_service (source bitbucket-pipelines.yml:144)ActionExecute flag/plan gate + dispatch to Midtrans::Execute
BE lintbundle exec rubocop (.rubocop.yml present)style/lint clean
BE securitybundle exec brakeman (Gemfile brakeman)no new high-severity warnings (SSRF/secret leak)
FE unitpnpm test → vitest (package.json:17)MidtransAuthBlock states; connect/save gating; mapper
FE lintpnpm lint:ts (package.json:13) / pnpm lint (:15)eslint/prettier clean
FE buildnuxt build (package.json:11)builds
Cross-layermanual sandbox run of all 4 actions in a test conversation (Internal QA gate)end-to-end incl. CTA-URL button, confirmation, idempotency

Detail 4.D — Agent Execution Plan

OrderLayerChunkFilesCommandsAcceptance criteria
1BESeed 4 node_registries rows + add 'midtrans' groupdb/migrate/<ts>_seed_midtrans_node_registries.rb; app/core/repositories/node_executions/node_type_registry.rbRAILS_ENV=test bundle exec rails db:migrate; bundle exec rspec4 enabled rows exist with node_type_group='midtrans'; every seeded properties[] entry carries a non-blank type (REV-9); rspec configures a use_ai Midtrans action and asserts SyncToAiService#build_skill_actions does NOT drop it — args_with_unresolved_type?(args)false (REV-9); ActionExecutorFactory.for('midtrans_check_payment_status') resolves to Midtrans::Execute (once chunk 2 lands); migration reversible
2BEMidtrans::Execute skeleton (interface + dispatch + AuthorizedHttpClient helper)app/core/repositories/node_executions/nodes/midtrans/execute.rb; specbundle exec rspec spec/core/.../midtransincludes NodeExecutorInterface; call dispatches on @node_type; unknown type → validation error; spec green
3BEmidtrans_check_payment_status (+events, 60 s, 404/timeout/auth mapping)execute.rb; spec (webmock)bundle exec rspec spec/core/.../midtrans200→NL status + midtrans_action_success; 404→not-found copy; 504→timeout; auth fail→auth_error
4BEmidtrans_create_payment_link (+CTA-URL via send_button_message)execute.rb; specbundle exec rspec …201→URL extracted; WhatsApp path calls send_button_message(link:); other→text; 400→invalid_request; midtrans_payment_link_created emitted
5BEmidtrans_cancel_transaction (status pre-check + confirmation)execute.rb; specbundle exec rspec …ineligible status→cancel endpoint asserted NOT called; decline→no POST; confirm→POST /cancel+success
6BEmidtrans_request_refund (eligibility + confirm + refund_key + dedupe)execute.rb; redis dedupe; specbundle exec rspec …non-settlement→no POST; duplicate confirm in window→single POST; confirm→POST /refund with refund_key
7FEMidtransAuthBlock.vue + credential service mapper + conditional wiringmodules/bot-automation/components/forms/MidtransAuthBlock.vue; common/services/main/v1/*; form wiringpnpm test; pnpm lint:ts; nuxt buildrenders connect/connected states; Save gated on connection; 4 actions appear from registry; vitest green
8bothFlag + plan gate wiring + Mixpanel events end-to-endaction_execute.rb gate; event call sitesbundle exec rspec spec/api/internal_service; sandbox smokeflag OFF→not executed/rendered; events fire with correct props
9bothVerification recipe + sandbox QA of all 4 actions§4.E commandsall gates green; Internal QA gate met

Gate: chunks 3–6 are blocked by OQ-3 (does the OAuth2 Bearer token work on Midtrans /v2/* and is the host/path correct?). Confirm before starting chunk 3.

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (BE): 1) bundle exec rubocop 2) bundle exec brakeman 3) RAILS_ENV=test bundle exec rspec spec/core/repositories/node_executions/nodes/midtrans spec/api/internal_service
  • Pre-merge (FE): 1) pnpm lint:ts 2) pnpm test 3) nuxt build
  • Post-deploy signals: Mixpanel midtrans_action_executed count > 0 after first sandbox run; midtrans_action_failed rate < 5 % / 15-min; [Auth][TokenManager] no error spike; no Rollbar auth_failed surge.
  • Rollback: 1) flip rollout / midtrans_native_action OFF (actions vanish from drawer + execution denied) 2) if FE config broken, revert FE PR 3) if needed, set the 4 node_registries rows enabled=false (or db:rollback the seed migration) 4) confirm midtrans_action_* events stop and error rate normalises within 15 min.

Detail 4.F — Resource & Cost Notes

  • Compute: negligible incremental; synchronous calls hold a pod up to 60 s (low volume).
  • DB: +4 catalog rows + a credential row per account. Redis: small token + dedupe keys.
  • Egress: HTTPS to Midtrans per action. No new infra components.

5. Concern, Questions, or Known Limitations

#TypeQuestion / limitationOwnerStatus
OQ-1Open QuestionFeature-flag name — proposed rollout / midtrans_native_action; confirm with Eng (PRD OQ#1)BOT Eng Leadopen
OQ-2 (REV-7)MajorPRD ⇆ code auth mismatch: PRD says midtrans_oauth with server_key/client_key/partner_id/merchant_id, ~15 min TTL, /v1/access-token. Shipped code is midtrans_oauth2 with environment/client_id/client_secret, 3300 s TTL, /v1/oauth/token. Now coupled to OQ-3: if v2 needs Basic, the PRD's server_key is correct (use midtrans_basic, Decision 8); if Bearer, use the shipped descriptor. The FE credential-form field set follows the chosen descriptor.PM + BOT Engopen
OQ-3 (REV-1)Major (was Blocker)Does the OAuth2 client-credentials Bearer token authenticate Midtrans /v2/{order_id}/status|cancel|refund + /v1/payment-links? No longer blocks scaffolding — §2 Decision 8 specifies both the Bearer (default, shipped) and midtrans_basic paths; the executor is auth-agnostic (AuthorizedHttpClient), so resolving this is a credential/descriptor swap, not a code change. One-line doc confirmation needed before wiring the live header on chunks 3–6.BOT Eng + Midtrans docsopen
OQ-4Open QuestionBespoke MidtransActionConfigPanel (PRD §6 component tree) vs reuse generic dynamic-field form + new MidtransAuthBlock (this RFC's choice). Confirm with design/PM; decide whether a credential-form Figma frame is needed.PM + FE Leadopen
OQ-5Open QuestionPartial vs full refund scope for P0 (amount field). RFC default: full refund only. If partial in scope, add an AC for the partial-amount path (PRD §7, S05).PMopen
OQ-6Open QuestionFinalise the provisional properties[] field lists (names/types/required) against Midtrans API docs (PRD OQ#3/#6).BOT Eng Leadopen
OQ-7Open QuestionMidtrans brand asset (logo SVG + hex) for the drawer row (ANCHOR brand-asset rule).PMopen
OQ-8 (REV-9)MajorSeeded node_registries properties[] must carry a non-blank type per property. As of chatbot HEAD fa6dd8b79 ("fix(service): skip actions with unresolved argument types"), FrontendService::V2::AiAgent::Repositories::SyncToAiService#build_skill_actions filters out any action whose AI-resolvable args resolve to a blank type (args_with_unresolved_type?); for non-api actions the type is read from the matching registry properties[].type (build_non_api_args). Seeding only field → html.element (no type) would silently drop the Midtrans action from the agent's skill pack (configured but non-functional). RESOLVED (2026-06-21): §2.3 now specifies a per-property type column + the grounded JSON shape + the sync-drop rationale, and chunk 1 (§4.D) acceptance now asserts SyncToAiService#build_skill_actions does not drop a use_ai Midtrans action (args_with_unresolved_type?false). (Exact field names still tracked by OQ-6.)BOT Eng Leadresolved
LimitationSynchronous 60 s execution holds a pod per in-flight action; acceptable at P0 volume, revisit if volume grows.BOTaccepted
LimitationPayment links cannot be revoked via this action once created; expiry governed by Midtrans account settings (PRD S03 CANNOT).accepted

6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-20RFC author (initial draft)Resolve OQ-3 (Bearer vs Basic on v2) before backend execution chunks; reconcile OQ-2 auth fields with PM
2026-06-20rfc-reviewer (R1 → R2)R1 scored 6.5 (HOLD). Iterated: added §2 Decision 8 (Bearer + spec'd midtrans_basic fallback → REV-1 no longer blocks scaffolding); pinned per-call Midtrans request/response schemas in §2.4 (REV-2); added 429 rate_limited policy + copy (REV-3); added FE perf/browser specifics (REV-4); FE analytics note (REV-5). R2 score 7.5 (PROCEED with notes). Residual: REV-1/REV-7 doc+PM confirmation. See …-review.md.
2026-06-21rfc-reviewer (R3) + fixR3 re-grounded every citation vs chatbot HEAD fa6dd8b79; surfaced REV-9 (the fix(service): skip actions with unresolved argument types commit now drops any action whose AI-resolvable args lack a registry properties[].type). Fixed here: §2.3 properties[] rewritten with a per-property type column + grounded JSON shape + the SyncToAiService drop rationale; chunk 1 (§4.D) acceptance now asserts the seeded use_ai action survives build_skill_actions. OQ-8/REV-9 → resolved. Residual: REV-1/REV-7 (external), OQ-6 (field names).

7. Ready for agent execution

  • Ready for agent execution: yes — proceed with notes. After the R1→R2 review iteration, the auth-mechanism risk is no longer a design blocker: §2 Decision 8 specifies both the default Bearer path (already shipped under BOT-4243) and a drop-in midtrans_basic fallback, and the executor is auth-agnostic (AuthorizedHttpClient), so the choice is a credential/ descriptor swap rather than a code change. An agent can build chunks 1–9 against the default Bearer path today; only two quick confirmations remain before the live auth header on chunks 3–6 and the FE credential field copy on chunk 7 are finalised.

Outstanding confirmations (notes, not blockers):

  • REV-1 / OQ-3 — one-line Midtrans-docs confirmation that the Bearer token works on /v2/* (else flip the credential to midtrans_basic — already specified). Default path is shipped.
  • REV-7 / OQ-2 — PM reconciles the PRD's auth field names/TTL/endpoint with the chosen descriptor; finalise FE credential-form copy (drives chunk 7 only).
  • OQ-6 — finalise properties[] field names against Midtrans docs (schemas now pinned in §2.4; confirm exact casing/optionality).
  • OQ-4 / OQ-5 / OQ-7 — confirm reuse-vs-bespoke FE form, partial-refund scope, brand asset.

All other gates are satisfied: Infrastructure Topology ✓, Technical Decisions (ADR) ✓, PRD-to-Schema ✓, Detail 1.B/1.C ✓, Repo Reading Guide + Source Verification ✓ (no unverified rows), mermaid diagrams (topology, component, ER, state, sequence ×4 incl. failure paths, branch/skip) ✓, DDL (no new tables; seed rows + per-status note) ✓, APIs (outbound reused + webhooks n/a + external contract) ✓, Cross-Layer Contract Verification ✓, Data Integrity + Concurrency + Async specs ✓, Failure/Branch/Error catalogs ✓, Configuration Contract ✓, Agent Execution Plan ✓, Verification & Rollback Recipe ✓.

Reviewed by rfc-reviewer — see midtrans-phase-1-p0-core-actions-review.md (R2 score 7.5 / Strong / PROCEED with notes).