Mendix Expert Developer study materials
Mendix Expert Developer study resources

Payment Retries That Must Not Charge Twice

Mendix Expert Developer · Put your knowledge to work in context.

All case studies

Payment Retries That Must Not Charge Twice

A checkout microflow named ProcessOrder runs synchronously: it creates an Order, calls a payment provider REST endpoint with a 10-second timeout, sets PaymentStatus, sends a confirmation email, and decrements inventory. Peak load is 1,400 orders per hour; the payment provider's p99 latency is 3.5 seconds and its p99.9 is 14 seconds. When a call times out, the exception handler marks the order Failed, and the customer clicks Pay again. Support has logged 0.7% duplicate charges among retried orders. The email step occasionally sends twice after a runtime restart. Inventory is decremented even when payment later fails. The team wants to keep the UI responsive and avoid holding database locks during the external call. Three runtime instances run behind a load balancer, and the provider does not support cancellation.

1. Redesign ProcessOrder to prevent duplicate charges and keep the UI responsive. Describe transaction boundaries, idempotency and retry behavior.

Read the worked answer

Split the microflow at the external call. The UI microflow should create the Order and a PaymentAttempt with a server-generated idempotency key, commit both, and enqueue an asynchronous task; the page then shows Pending and polls or refreshes. The task queue worker calls the provider with the idempotency key in the request header or body. Because the key is persisted and unique, a retry after a timeout sends the same key and the provider can return the original result instead of charging again. On a timeout, do not mark the order Failed. Mark the attempt Unknown and call the provider's status endpoint with the same key or reference; only a confirmed decline becomes Failed. Use exponential backoff for retryable errors and a maximum attempt count. Keep the database transaction short: commit the attempt before the call, and commit the result in a new transaction. Do not hold an open transaction across a 3.5-second p99 call, because that holds locks and consumes connection pool capacity. Enforce a unique constraint on PaymentAttempt.IdempotencyKey and handle the duplicate-create exception as a signal that another worker already owns the attempt. The UI receives an immediate acknowledgment rather than waiting for the provider.

2. Define the error handling and compensation for inventory and email when payment fails, times out, or succeeds after a retry.

Read the worked answer

Separate the three side effects into explicit states. Inventory should be reserved, not decremented, when the order is created: create an InventoryReservation with an expiry time and a unique order reference. On confirmed payment, convert the reservation to a committed decrement in an idempotent microflow keyed by Order. On a confirmed decline, release the reservation. On Unknown, leave the reservation until reconciliation resolves the payment, then release or commit. This prevents selling stock for an unpaid order and prevents double decrement when a retry succeeds. The confirmation email should not be sent inside the payment transaction. Commit the payment result, then emit an after-commit event or enqueue an email task keyed by Order and event type. Add a unique constraint on an EmailLog record so a runtime restart or duplicate task cannot send twice. Catch errors per step, not with one broad handler: a provider 400 is permanent and should fail the order; a 500, timeout or connection reset is retryable; an inventory conflict is a business exception that needs a user-visible message. Store the provider response code and correlation ID on PaymentAttempt for support. Run a reconciliation job every five minutes for attempts stuck in Unknown longer than two minutes, and alert on the count rather than relying on customer complaints.

3. What data model and monitoring would you add to prove that no duplicate charges occur and to investigate the existing 0.7%?

Read the worked answer

Add PaymentAttempt with Order, idempotency key, attempt number, provider reference, status, amount, currency, request timestamp, response code, response body hash and correlation ID. Keep one row per provider call rather than overwriting a single PaymentStatus field, because the history is what proves whether a duplicate happened. Add a unique constraint on provider reference when it is returned, and a unique constraint on idempotency key. Add PaymentReconciliation with the last checked timestamp and the resolved status. For the existing incidents, join PaymentAttempt to Order and group by amount, customer and provider reference within a short window; the duplicates should show two successful attempts with different provider references or the same reference recorded twice. Build a monitoring page and alerts for attempts in Unknown over two minutes, attempts with more than three retries, and any order with more than one successful attempt. Log the idempotency key in the provider request and in Mendix logs so support can search both sides. Also monitor task queue backlog and failure count, because a silent queue backlog looks like stuck orders. These measures turn the investigation from anecdote into a queryable audit trail and give the team a regression check after the redesign.