Skip to content

Commit 9240c5c

Browse files
authored
feat(operational-gateway): Add Stripe migration reference implementation (#1334)
* feat: add Stripe migration reference for operational gateway Demonstrate migrating direct Stripe API calls to the Operational Gateway abstraction layer with manifest-driven configuration. Adds: - Example manifest (stripe_operational_gateway.manifest.json) with: - Outbound mapping (payment-collect-to-stripe): converts amount to cents, lowercases currency, maps customer/payment_method fields, adds confirm=true - Inbound mapping (stripe-response-to-ack): enum maps Stripe PaymentIntent statuses to Meridian instruction statuses - Provider connection (stripe-payments): HTTPS to api.stripe.com/v1 with API key auth, retry policy (3 attempts, exponential backoff), rate limit (25 rps, burst 50) - Instruction route: payment.collect -> stripe-payments with POST /payment_intents - Updated stripe_payment saga v2.0.0: replaces payment_order.send_to_gateway with operational_gateway.dispatch_instruction, delegating payload transformation, auth, retries, and circuit breaking to the gateway * fix: address review feedback on Stripe manifest example - Fix inboundCel amount transform: remove /100 division (both sides use cents) - Add note about Bearer prefix requirement for API key auth --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent a973e8f commit 9240c5c

2 files changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
{
2+
"version": "1.0",
3+
"metadata": {
4+
"name": "Stripe Payments via Operational Gateway",
5+
"industry": "fintech",
6+
"description": "Reference manifest demonstrating Stripe payment integration migrated from direct API calls to the Operational Gateway abstraction layer. Includes outbound/inbound mappings, provider connection, and instruction routing."
7+
},
8+
"instruments": [
9+
{
10+
"code": "GBP",
11+
"name": "British Pound Sterling",
12+
"type": "INSTRUMENT_TYPE_FIAT",
13+
"dimensions": {
14+
"unit": "GBP",
15+
"precision": 2
16+
}
17+
},
18+
{
19+
"code": "USD",
20+
"name": "United States Dollar",
21+
"type": "INSTRUMENT_TYPE_FIAT",
22+
"dimensions": {
23+
"unit": "USD",
24+
"precision": 2
25+
}
26+
},
27+
{
28+
"code": "EUR",
29+
"name": "Euro",
30+
"type": "INSTRUMENT_TYPE_FIAT",
31+
"dimensions": {
32+
"unit": "EUR",
33+
"precision": 2
34+
}
35+
}
36+
],
37+
"accountTypes": [
38+
{
39+
"code": "CUSTOMER_CURRENT",
40+
"name": "Customer Current Account",
41+
"normalBalance": "NORMAL_BALANCE_DEBIT",
42+
"allowedInstruments": ["GBP", "USD", "EUR"],
43+
"policies": {
44+
"validation": "amount > 0",
45+
"bucketing": ""
46+
}
47+
},
48+
{
49+
"code": "PAYMENT_CLEARING",
50+
"name": "Payment Clearing Account",
51+
"normalBalance": "NORMAL_BALANCE_CREDIT",
52+
"allowedInstruments": ["GBP", "USD", "EUR"],
53+
"policies": {
54+
"validation": "",
55+
"bucketing": ""
56+
}
57+
}
58+
],
59+
"valuationRules": [],
60+
"sagas": [
61+
{
62+
"name": "stripe_payment_via_gateway",
63+
"trigger": "api:/v1/payments/stripe",
64+
"script": "# Saga: stripe_payment_via_gateway\n# Version: 1.0.0\n# Previous: none\n# Changed: Migrated from direct Stripe API calls to Operational Gateway dispatch\n# Author: Platform Team\n# Date: 2026-03-02\n#\n# This saga demonstrates the migration from direct payment_order.send_to_gateway\n# calls to operational_gateway.dispatch_instruction. The Operational Gateway\n# handles payload transformation (via MappingDefinitions), authentication,\n# retry logic, and circuit breaking transparently.\n#\n# The key change is Step 3: instead of calling payment_order.send_to_gateway\n# (which directly invokes the Stripe API), we dispatch an instruction of type\n# \"payment.collect\" to the Operational Gateway. The gateway resolves the\n# instruction route, applies the outbound mapping (payment-collect-to-stripe)\n# to transform the payload into Stripe's format, dispatches to the configured\n# provider connection (stripe-payments), and applies the inbound mapping\n# (stripe-response-to-ack) to normalize the response.\n#\n# Steps:\n# 1. get_payment_method - Resolve party's default Stripe payment method\n# 2. create_lien - Reserve funds with payment_attributes\n# 3. dispatch_to_gateway - Send via Operational Gateway (replaces send_to_gateway)\n# 4. post_ledger - Post double-entry ledger records (webhook-triggered)\n# 5. execute_lien - Finalize lien after ledger posted (webhook-triggered)\n#\n# Input parameters (from input_data dict):\n# - party_id: string (required) - party whose default payment method to use\n# - payment_order_id: string (required)\n# - debtor_account_id: string (required)\n# - creditor_reference: string (required)\n# - amount_cents: int64 (required)\n# - currency: string (required, e.g., \"GBP\", \"USD\")\n# - idempotency_key: string (required)\n# - instrument_code: string (optional, for bucket evaluation)\n# - payment_attributes: dict (optional, base attributes for CEL bucket expression)\n# - should_post_ledger: bool (optional, default false - set by webhook)\n# - should_execute_lien: bool (optional, default false - set by webhook)\n# - internal_clearing_enabled: bool (optional, for 4-posting ledger flow)\n\ndef stripe_payment_via_gateway():\n ctx = input_data\n\n # Step 1: Resolve the party's default payment method\n step(name=\"get_payment_method\")\n pm_result = party.get_default_payment_method(\n party_id=ctx.get(\"party_id\"),\n )\n\n # Build payment_attributes by merging resolved payment method details\n payment_attrs = dict(ctx.get(\"payment_attributes\") or {})\n payment_attrs[\"provider\"] = pm_result.provider\n payment_attrs[\"provider_customer_id\"] = pm_result.provider_customer_id\n payment_attrs[\"provider_method_id\"] = pm_result.provider_method_id\n payment_attrs[\"method_type\"] = pm_result.method_type\n\n # Step 2: Reserve funds via lien with payment method attributes\n step(name=\"create_lien\")\n lien_result = payment_order.create_lien(\n account_id=ctx.get(\"debtor_account_id\"),\n amount_cents=ctx.get(\"amount_cents\"),\n currency=ctx.get(\"currency\"),\n payment_order_id=ctx.get(\"payment_order_id\"),\n instrument_code=ctx.get(\"instrument_code\", \"\"),\n payment_attributes=payment_attrs,\n )\n\n lien_id = lien_result.lien_id\n\n # Step 3: Dispatch payment via Operational Gateway\n # This replaces the direct payment_order.send_to_gateway call.\n # The Operational Gateway will:\n # a) Resolve instruction route for \"payment.collect\" -> stripe-payments connection\n # b) Apply outbound mapping \"payment-collect-to-stripe\" (amount -> cents, currency -> lowercase)\n # c) Dispatch HTTPS POST to https://api.stripe.com/v1/payment_intents\n # d) Apply inbound mapping \"stripe-response-to-ack\" (normalize Stripe status)\n # e) Handle retries (3 attempts, exponential backoff) and circuit breaking\n step(name=\"dispatch_to_gateway\")\n gateway_result = operational_gateway.dispatch_instruction(\n instruction_type=\"payment.collect\",\n payload={\n \"payment_order_id\": ctx.get(\"payment_order_id\"),\n \"amount_cents\": ctx.get(\"amount_cents\"),\n \"currency\": ctx.get(\"currency\"),\n \"customer_id\": pm_result.provider_customer_id,\n \"payment_method_id\": pm_result.provider_method_id,\n \"creditor_reference\": ctx.get(\"creditor_reference\"),\n \"metadata\": {\n \"payment_order_id\": ctx.get(\"payment_order_id\"),\n \"debtor_account_id\": ctx.get(\"debtor_account_id\"),\n },\n },\n priority=\"HIGH\",\n correlation_id=ctx.get(\"payment_order_id\"),\n )\n\n instruction_id = gateway_result.instruction_id\n\n result = {\n \"lien_id\": lien_id,\n \"instruction_id\": instruction_id,\n \"gateway_status\": gateway_result.status,\n \"provider\": pm_result.provider,\n \"provider_customer_id\": pm_result.provider_customer_id,\n \"provider_method_id\": pm_result.provider_method_id,\n }\n\n # Step 4: Post ledger entries (conditional - triggered by webhook)\n if ctx.get(\"should_post_ledger\", False):\n step(name=\"post_ledger\")\n ledger_result = payment_order.post_ledger_entries(\n payment_order_id=ctx.get(\"payment_order_id\"),\n debtor_account_id=ctx.get(\"debtor_account_id\"),\n gateway_reference_id=instruction_id,\n amount_cents=ctx.get(\"amount_cents\"),\n currency=ctx.get(\"currency\"),\n idempotency_key=ctx.get(\"idempotency_key\"),\n internal_clearing_enabled=ctx.get(\"internal_clearing_enabled\", False),\n )\n result[\"booking_log_id\"] = ledger_result.booking_log_id\n\n # Step 5: Execute lien (conditional - triggered by webhook after ledger posted)\n if ctx.get(\"should_execute_lien\", False):\n if lien_id:\n step(name=\"execute_lien\")\n execution_result = payment_order.execute_lien(\n lien_id=lien_id,\n )\n result[\"lien_execution_status\"] = execution_result.execution_status\n\n return result\n\n# Execute the saga\noutput = stripe_payment_via_gateway()\n"
65+
}
66+
],
67+
"paymentRails": [],
68+
"mappings": [
69+
{
70+
"id": "00000000-0000-0000-0000-000000000001",
71+
"tenantId": "00000000-0000-0000-0000-000000000000",
72+
"name": "payment-collect-to-stripe",
73+
"targetService": "external.stripe",
74+
"targetRpc": "CreatePaymentIntent",
75+
"version": 1,
76+
"status": "MAPPING_STATUS_ACTIVE",
77+
"externalSchema": "{\"type\":\"object\",\"properties\":{\"amount\":{\"type\":\"integer\"},\"currency\":{\"type\":\"string\"},\"customer\":{\"type\":\"string\"},\"payment_method\":{\"type\":\"string\"},\"confirm\":{\"type\":\"boolean\"},\"metadata\":{\"type\":\"object\"}},\"required\":[\"amount\",\"currency\"]}",
78+
"fields": [
79+
{
80+
"externalPath": "amount",
81+
"internalPath": "amount_cents",
82+
"transform": {
83+
"cel": {
84+
"inboundCel": "int(value)",
85+
"outboundCel": "int(value)"
86+
}
87+
}
88+
},
89+
{
90+
"externalPath": "currency",
91+
"internalPath": "currency",
92+
"transform": {
93+
"cel": {
94+
"inboundCel": "value.upperAscii()",
95+
"outboundCel": "value.lowerAscii()"
96+
}
97+
}
98+
},
99+
{
100+
"externalPath": "customer",
101+
"internalPath": "customer_id"
102+
},
103+
{
104+
"externalPath": "payment_method",
105+
"internalPath": "payment_method_id"
106+
},
107+
{
108+
"externalPath": "metadata.payment_order_id",
109+
"internalPath": "metadata.payment_order_id"
110+
},
111+
{
112+
"externalPath": "metadata.debtor_account_id",
113+
"internalPath": "metadata.debtor_account_id"
114+
}
115+
],
116+
"outboundComputedFields": [
117+
{
118+
"targetPath": "confirm",
119+
"celExpression": "true"
120+
}
121+
],
122+
"inboundComputedFields": [],
123+
"inboundValidationCel": "has(payload.amount_cents) && payload.amount_cents > 0",
124+
"outboundValidationCel": "",
125+
"idempotency": {
126+
"sourceSelector": "metadata.payment_order_id",
127+
"useContentHash": false,
128+
"contentHashFields": []
129+
}
130+
},
131+
{
132+
"id": "00000000-0000-0000-0000-000000000002",
133+
"tenantId": "00000000-0000-0000-0000-000000000000",
134+
"name": "stripe-response-to-ack",
135+
"targetService": "internal.operational_gateway",
136+
"targetRpc": "AcknowledgeInstruction",
137+
"version": 1,
138+
"status": "MAPPING_STATUS_ACTIVE",
139+
"externalSchema": "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"},\"amount\":{\"type\":\"integer\"},\"currency\":{\"type\":\"string\"}}}",
140+
"fields": [
141+
{
142+
"externalPath": "id",
143+
"internalPath": "provider_reference_id"
144+
},
145+
{
146+
"externalPath": "status",
147+
"internalPath": "provider_status",
148+
"transform": {
149+
"enumMapping": {
150+
"values": {
151+
"requires_payment_method": "PENDING",
152+
"requires_confirmation": "PENDING",
153+
"requires_action": "PENDING",
154+
"processing": "PROCESSING",
155+
"requires_capture": "DELIVERED",
156+
"succeeded": "ACKNOWLEDGED",
157+
"canceled": "FAILED"
158+
},
159+
"fallback": "UNKNOWN"
160+
}
161+
}
162+
},
163+
{
164+
"externalPath": "amount",
165+
"internalPath": "amount_cents",
166+
"transform": {
167+
"cel": {
168+
"inboundCel": "int(value)",
169+
"outboundCel": "int(value)"
170+
}
171+
}
172+
},
173+
{
174+
"externalPath": "currency",
175+
"internalPath": "currency",
176+
"transform": {
177+
"cel": {
178+
"inboundCel": "value.upperAscii()",
179+
"outboundCel": "value.lowerAscii()"
180+
}
181+
}
182+
}
183+
],
184+
"inboundComputedFields": [],
185+
"outboundComputedFields": [],
186+
"inboundValidationCel": "has(payload.id) && has(payload.status)",
187+
"outboundValidationCel": ""
188+
}
189+
],
190+
"operationalGateway": {
191+
"providerConnections": [
192+
{
193+
"connectionId": "stripe-payments",
194+
"providerName": "Stripe",
195+
"providerType": "payment_gateway",
196+
"protocol": "PROVIDER_PROTOCOL_HTTPS",
197+
"baseUrl": "https://api.stripe.com/v1",
198+
"auth": {
199+
"apiKey": {
200+
"headerName": "Authorization",
201+
"apiKeySecretRef": "sm://stripe/api_key",
202+
"_note": "Secret must include 'Bearer ' prefix, e.g. 'Bearer sk_live_xxx'. APIKeyAuth sets the header value verbatim."
203+
}
204+
},
205+
"retryPolicy": {
206+
"maxAttempts": 3,
207+
"initialBackoffSeconds": 1,
208+
"maxBackoffSeconds": 30,
209+
"backoffMultiplier": 2.0
210+
},
211+
"rateLimit": {
212+
"requestsPerSecond": 25.0,
213+
"burstSize": 50
214+
}
215+
}
216+
],
217+
"instructionRoutes": [
218+
{
219+
"instructionType": "payment.collect",
220+
"connectionId": "stripe-payments",
221+
"outboundMappingId": "payment-collect-to-stripe",
222+
"inboundMappingId": "stripe-response-to-ack",
223+
"httpMethod": "POST",
224+
"pathTemplate": "/payment_intents"
225+
}
226+
]
227+
}
228+
}

0 commit comments

Comments
 (0)