Simulators
Payment lifecycle
The payment lifecycle is how an order becomes money and how money comes back: an order is approved, captured or authorized, and each capture can be refunded. The simulator keeps the amounts, fee breakdowns, ids and refusals of PayPal’s sandbox along the way, so a checkout integration can exercise every step without a browser. Amounts are decimal strings in the currency’s own unit, as on PayPal, so "25.00" is $25.00. Seed an approved order, capture it and refund part of the capture:
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_order": {"ORDAPPROVED1": {"id": "ORDAPPROVED1", "intent": "CAPTURE", "status": "APPROVED", "create_time": "2026-01-01T00:00:00Z", "update_time": "2026-01-01T00:00:00Z", "purchase_units": [{"reference_id": "default", "amount": {"currency_code": "USD", "value": "25.00"}, "payee": {"merchant_id": "MERCHANT1"}}]}}}'
# -> 204
curl -s -X POST "$RYSTIC_URL/v2/checkout/orders/ORDAPPROVED1/capture" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: cap-1' -d '{}' | jq -c '{status, capture: .purchase_units[0].payments.captures[0].id}'
# -> {"status":"COMPLETED","capture":"C0000000000000001"}
curl -s "$RYSTIC_URL/v2/payments/captures/C0000000000000001" -H 'Authorization: Bearer offline-test' | jq -c '{status, fee: .seller_receivable_breakdown.paypal_fee.value}'
# -> {"status":"COMPLETED","fee":"1.36"}
curl -s "$RYSTIC_URL/v2/payments/captures/C0000000000000001/refund" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: refund-1' -d '{"amount":{"currency_code":"USD","value":"10.00"}}' | jq -c '{id, status, fee: .seller_payable_breakdown.paypal_fee.value}'
# -> {"id":"RF000000000000001","status":"COMPLETED","fee":"0.00"}
curl -s "$RYSTIC_URL/v2/payments/captures/C0000000000000001/refund" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: refund-2' -d '{"amount":{"currency_code":"USD","value":"20.00"}}' | jq -c '{name, issue: .details[0].issue}'
# -> {"name":"UNPROCESSABLE_ENTITY","issue":"REFUND_AMOUNT_EXCEEDED"}
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '[.events[].type]'
# -> ["PAYMENT.CAPTURE.REFUNDED"]
Orders and approval
A wallet order starts CREATED. Buyer approval is a step on paypal.com, so an order reaches APPROVED only through a seed or the Business UI’s Approve, with either intent. design_studio holds no approved order.
A card order can complete when you create it. A card order is one whose payment_source holds a card and whose creating request carries a PayPal-Request-Id. What comes back depends on its intent:
intent | The order comes back |
|---|---|
CAPTURE | COMPLETED, with its capture |
AUTHORIZE | COMPLETED, holding an authorization |
So capturing and voiding an authorization work without a seed. Read an order at GET /v2/checkout/orders/{id} and change its supported fields with PATCH. Capturing an order a second time returns 422 ORDER_ALREADY_CAPTURED.
Authorizations
An authorization holds an amount for 29 days from its creation. Capture some or all of it with POST /v2/payments/authorizations/{authorization_id}/capture:
| You capture | The simulator |
|---|---|
| part of what is left | leaves the authorization PARTIALLY_CAPTURED |
| more than what is left | returns 422 MAX_CAPTURE_AMOUNT_EXCEEDED |
Void it with POST .../void, which returns an empty 204. The example runs against a card AUTHORIZE order, and the log still holds the refund from the first block:
curl -s "$RYSTIC_URL/v2/checkout/orders" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: card-1' -d '{"intent":"AUTHORIZE","purchase_units":[{"amount":{"currency_code":"USD","value":"40.00"}}],"payment_source":{"card":{"number":"4111111111111111","expiry":"2030-12"}}}' | jq -c '{id, status, authorization: .purchase_units[0].payments.authorizations[0].id}'
# -> {"id":"ORD00000000000001","status":"COMPLETED","authorization":"AUT00000000000001"}
curl -s "$RYSTIC_URL/v2/payments/authorizations/AUT00000000000001/capture" -H 'Authorization: Bearer offline-test' -H 'Content-Type: application/json' -H 'PayPal-Request-Id: capture-1' -d '{"amount":{"currency_code":"USD","value":"15.00"},"final_capture":false}' | jq -c '{status}'
# -> {"status":"COMPLETED"}
curl -s "$RYSTIC_URL/v2/payments/authorizations/AUT00000000000001" -H 'Authorization: Bearer offline-test' | jq -c '{id, status}'
# -> {"id":"AUT00000000000001","status":"PARTIALLY_CAPTURED"}
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$RYSTIC_URL/v2/payments/authorizations/AUT00000000000001/void" -H 'Authorization: Bearer offline-test'
# -> 204
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '[.events[].type]'
# -> ["PAYMENT.CAPTURE.REFUNDED","PAYMENT.CAPTURE.COMPLETED","authorization_voided"]
Fees and refunds
A USD capture carries a fixed synthetic fee of 3.49% plus $0.49, rounded to cents. The same fee applies whether the capture comes from a card order, an order capture, an authorization capture or a scenario. The capture’s seller_receivable_breakdown holds the gross, the fee and the net, as the first block shows.
Differs from PayPal: the fee is the simulator’s own fixed pricing, not the sandbox’s, and actual merchant fees vary.
Differs from PayPal: platform-fee return is not modeled.
Differs from PayPal: non-USD pricing is not modeled.
An ordinary USD refund keeps the capture’s fee: its paypal_fee is zero and its net equals the refunded gross. A refund above what the capture has left returns 422 REFUND_AMOUNT_EXCEEDED. A retry with the same PayPal-Request-Id returns the stored refund.
Validation
Creating an order or capturing a payment with an invalid currency, too many decimal places, a negative amount or a malformed body is refused without moving a balance. Retrying a capture or refund keeps the stored payment and the capacity it left.
What the lifecycle does not do
A PENDING capture or refund stays PENDING: nothing settles on its own.
Reauthorizing (POST .../reauthorize) is allowed once, from day 4 to day 29 after the original authorization. Before day 4 it returns 422 REAUTHORIZATION_TOO_SOON. Authorization capacity counts every capture across the reauthorization chain, refunded ones included.
Three parts of reauthorizing are unconfirmed, meaning not yet checked against PayPal’s sandbox, and tests check only that balances are conserved:
- a successful reauthorization from day 4 on;
- the replacement authorization’s amount;
- the exact error for a superseded authorization.