Payments¶
The platform supports four processors today — Stripe, Mercado Pago,
Adyen, and direct PayPal. The runtime spans two FastAPI services
(personalaccount-api / pa_v4 and cashless-backend / cm_v2), the
shared cashless-core Python library, and the mobile clients (mPOS plus
the cardholder apps via the shared Rust SDK).
This page is the canonical entry point for anyone wiring a new payment flow, debugging webhooks, or planning a fifth processor. The high-level architecture row lives in Architecture › Payments — that's the elevator pitch. Everything below is the runtime detail.
BrainTree was dropped
BrainTree is not on the active processor set. The legacy Flask
pa/api/endpoints/v3/braintree.py endpoint and the
cm/utils/braintree.py SDK wrapper survive only as a 30-day
post-launch fallback for historical transactions
(see braintree-cleanup-plan-2026-05-11.md).
Legacy reports map BrainTree rows to a "PayPal" label via the
elif api == "braintree": return "paypal" shim. Do not add new
BrainTree call sites. The Phase 6 Flask decommission deletes the
code path T+30 days after launch.
Processor matrix¶
| Processor | cm_v2 surface (admin) | pa_v4 surface (cardholder) | mPOS | Inbound webhook | Shared client |
|---|---|---|---|---|---|
| Stripe | routers/stripe_admin.py — terminal locations, ConnectionToken, Connect, charge |
routers/stripe_sdk.py, charge.py, refunds_stripe.py |
Terminal 4.3 | POST /v4/webhooks/stripe |
cashless_core.payments.stripe_client |
| Mercado Pago | (no admin router on cm_v2) | routers/mercadopago_sdk.py |
— | POST /v2/webhooks/mercadopago |
per-event SDK from mercadopago PyPI; helpers in pa_v4/routers/mercadopago_webhook.py |
| Adyen | routers/adyen.py — capture / refund / cancel / payment lookup |
routers/adyen_sdk.py |
— | POST /v2/adyen/webhook |
cashless_core.payments.adyen_client |
| Direct PayPal | routers/paypal.py — order lookup + refund |
routers/paypal_sdk.py |
— | POST /v2/webhooks/paypal |
cashless_core.payments.paypal_client |
Note
cm_v2 has zero inbound webhooks. Every webhook listed above
terminates on pa_v4. cm_v2 only makes outbound calls into each
processor's REST API (charge, capture, refund, lookup) plus an
outbound application webhook (services/webhooks.py, the
company-ticket outbox) — that one signs its own requests with
X-Cashless-Signature: sha256=<hex> and is unrelated to processor
callbacks. Confirmed by grep on
apiserver/cm_v2/.
Inbound webhook endpoints¶
All four processor webhooks live in pa_v4. Each honours the atomicity
invariant declared in
pa_v4/CLAUDE.md:
the entire register_charge_result call runs in a single transaction;
db.commit() happens exactly once at the request boundary.
| Method | Path | Signature header | Handler | Side-effects (models written) |
|---|---|---|---|---|
| POST | /v4/webhooks/stripe |
Stripe-Signature |
stripe_webhook.py |
OnlinePayment, Transfer.status / payment_status, Table.status, Vendor.kyc_status (Connect account.updated), AuditLog, StripeLogs |
| POST | /v2/webhooks/mercadopago |
x-signature (carries ts=…,v1=…) + x-request-id |
mercadopago_webhook.py |
OnlinePayment, Transfer, Table |
| POST | /v2/adyen/webhook |
per-item additionalData.hmacSignature (HMAC-SHA256, base64) |
adyen_webhook.py |
OnlinePayment, Transfer, Table, AdyenLogs, AdyenPILogs |
| POST | /v2/webhooks/paypal |
Paypal-Auth-Algo + -Cert-Url + -Transmission-Id + -Transmission-Sig + -Transmission-Time |
paypal_webhook.py |
OnlinePayment, Transfer |
Each PSP webhook lands on its own per-processor router at the legacy URL above, because Stripe / PayPal / Adyen / Mercado Pago dashboards are configured against those paths.
Stripe account.updated → Vendor.kyc_status¶
The Stripe webhook also handles Stripe Connect's account.updated
event for marketplace-vendor onboarding. _handle_account_updated
(pa_v4/routers/stripe_webhook.py:483) looks up the matching
Vendor by connect_account_id, classifies the new
VendorKycStatus from charges_enabled / payouts_enabled /
requirements.disabled_reason / requirements.currently_due, and
writes both the status flip and an AuditLog row in the same
transaction. Same-state deliveries no-op without writing an audit
row (idempotent re-delivery).
Signature verification¶
| Processor | Mechanism | Canonical helper |
|---|---|---|
| Stripe | stripe.Webhook.construct_event(payload, sig_header, secret, tolerance) — official SDK; validates HMAC + timestamp |
_verify_signature in stripe_webhook.py |
| Mercado Pago | HMAC-SHA256 over the manifest id:{data.id};request-id:{x-request-id};ts:{ts};, hex-compared with hmac.compare_digest |
verify_mercado_pago_signature in mercadopago_webhook.py |
| Adyen | HMAC-SHA256 over pspReference:originalReference:merchantAccountCode:merchantReference:amount.value:amount.currency:eventCode:success, base64 digest, per-item |
inline _verify_hmac helpers in adyen_webhook.py |
| Direct PayPal | Round-trips body + headers through PayPal's /v1/notifications/verify-webhook-signature REST endpoint; rejects on FAILURE |
verify_webhook_signature in cashless_core.payments.paypal_client |
Misconfiguration returns 503 (operator error — secret/key absent).
Signature mismatch returns 400 (Stripe) or 401 (MP / Adyen /
PayPal). Any other failure mode keeps the legacy 200/[accepted]
response for Adyen so retries don't pile up forever.
The PayPal port closed a pre-existing legacy bug: the Flask handler
read the Paypal-* headers but never verified — see "Risks #1" in
the PayPal payment-port brief.
Idempotency model¶
Three independent layers.
1. Customer-side request idempotency. Money-mutating customer
POSTs (charge-create, charge-confirm, reload-create, the Adyen
cancel-session POST) wrap their router with IdempotentRoute from
pa_v4/idempotency.py.
Clients send an Idempotency-Key header; the response is cached for
24 hours in the IdempotencyKey table (cashless-models migration
018). A re-played key with a different body returns 422
idempotency_key_request_mismatch. Webhooks are intentionally not
covered — see the docstring at the top of idempotency.py.
2. Webhook re-delivery dedup. Every processor re-delivers on any
non-200 response. Dedup is enforced by _upsert_online_payment inside
register_charge_result (pa_v4/services/reload/charge.py): an
existing OnlinePayment row with non-NULL status short-circuits with
already_finalised=True and skips all side-effects. The natural key
is the processor's charge id (OnlinePayment.transaction_id).
3. Stripe Connect account.updated idempotency. Same-state
deliveries (old kyc_status equals new) return changed=False and
write no audit row.
The dedup short-circuit is only safe because of the atomicity
invariant: the prior delivery's commit was atomic, so the dangling
"OnlinePayment exists but side-effects didn't run" state cannot occur.
See pa_v4/CLAUDE.md
"Webhook router invariants" for the full contract.
Card top-up — representative flow¶
sequenceDiagram
autonumber
participant App as Cardholder app
participant PA as pa_v4
participant Stripe as Stripe API
participant DB as MySQL
App->>PA: POST /v4/transactions/reload-create<br/>(Idempotency-Key, balance/product/token/access/table)
PA->>DB: INSERT Transfer rows (IN_PROGRESS / IN_PROGRESS)<br/>UPDATE Table (RESERVED)
PA->>Stripe: PaymentIntent.create(amount, metadata={user_id,event_id,transfers,tables,...})
Stripe-->>PA: {client_secret, payment_intent_id}
PA-->>App: { client_secret, payment_intent_id }
App->>Stripe: stripe.confirmCardPayment(client_secret, …)<br/>(3DS if required)
Stripe-->>App: succeeded
Note over Stripe,PA: Authoritative finaliser is the webhook
Stripe->>PA: POST /v4/webhooks/stripe<br/>type=payment_intent.succeeded<br/>Stripe-Signature: t=…,v1=…
PA->>PA: stripe.Webhook.construct_event (HMAC + tolerance)
PA->>DB: BEGIN
PA->>DB: INSERT StripeLogs<br/>UPSERT OnlinePayment (dedup on transaction_id)<br/>UPDATE Transfer (ACTIVE/PAID)<br/>UPDATE Table (RESERVED)
PA->>DB: COMMIT
PA-->>Stripe: 200 { received: true }
The webhook is the authoritative state-change. The optional
POST /v4/transactions/charge-confirm round-trip (admin and
marketplace UIs use it) re-reads the PaymentIntent from Stripe and
reconciles synchronously so the UI doesn't have to wait — but the
webhook still owns the canonical commit.
Refunds¶
Refunds originate in three places:
-
Cardholder one-tap refund
The public refund-ui at
refund.dev2.mycashless.comdrives an anonymous chip → balance refund. The cardholder scans their wristband at the venue exit and the eligible source charges refund automatically.Anonymous endpoints, 30/minute IP rate-limit,
IdempotentRouteon the POST. Service:pa_v4/services/refunds/stripe_oneshot.py. -
Operator refunds
pa_v4/routers/operator_refunds.py— per-chip auto-refund (Stripe Connect-aware) plus per-processor one-tap variants for Mercado Pago / Adyen / PayPal. Some routes proxy to a cm_v2 admin-backend refund-create / verify / resend flow for the legacy refund-code workflow. -
Processor-driven refunds
A refund created out-of-band in the processor dashboard fires the inbound webhook (Stripe
charge.refunded, PayPalPAYMENT.CAPTURE.REFUNDED, MPrefunded/charged_back, AdyenREFUND). The webhook resolves theOnlinePaymentby charge id and updatesOnlinePayment.statustoREFUNDED(2) orPARTIAL_REFUND(4); per-transfer amounts come from refundmetadata.transfer_id+refund_fee. -
Dispute / chargeback
charge.dispute.created(Stripe),CUSTOMER.DISPUTE.CREATED(PayPal),NOTIFICATION_OF_CHARGEBACK(Adyen),charged_back(Mercado Pago) all funnel intoregister_dispute_result. Side effect:OnlinePayment.status = CHARGEBACK(3),Transfer.status → CANCELED,Table.status → FREE, andUser.enable_payment = False— the user is banned from further payments.
Settlement timing is processor-controlled — Stripe and Adyen settle within their normal payout cycle; PayPal pushes back to the buyer's PayPal balance on capture refund; MP credits the original card. The backend writes are immediate (webhook latency).
Stripe Terminal pairing (mPOS)¶
The mPOS Android app uses Stripe Terminal 4.3 to charge physical cards via a Bluetooth / USB reader. The pairing flow is server-backed by one endpoint:
| Method | Path | Auth | Source |
|---|---|---|---|
| POST | /v2/stripe/terminal/token |
Operator (?token=user_id.secret) |
cm_v2/routers/stripe_admin.py:614 (terminal_router) |
The Android client implements ConnectionTokenProvider in
StripeTokenProvider.java,
which calls ApiClient.getStripeToken()
(ApiInterface.java:680)
to hit the endpoint above. The token's secret is short-lived; the
Terminal SDK calls fetchConnectionToken whenever it needs a fresh
one, so the server doesn't track sessions.
The POST /v2/stripe/terminal/locations admin endpoint (same router)
creates the Stripe Terminal Location and persists its id on
EventConfig.stripe_terminal_location_id. This runs once per event
during setup, not per-pairing.
In-flight payment-plugin refactor¶
The 2026-05-11 refactor
(payment-processor-refactor-2026-05-11.md)
introduces a plugin boundary so adding a fifth processor (Klarna,
iDeal, …) is a ~3-day change instead of ~2 weeks. Phases 1–6 +
5.1–5.4 are LANDED on cashless-v2. Phase 7 (sandbox re-test
sweep) and the mobile binary-lib rebuilds are gated on external
inputs (sandbox creds inventory, NDK / Mac access).
Where the boundary lives¶
- Backend —
pa_v4/payments/processors/<name>.pydeclares aPaymentProcessor(Protocol frompa_v4/payments/base.pywithname,display_label,sdk_credentials_for). Each module self-registers on import viapa_v4/payments/registry.py. - Webhooks — each PSP has its own per-processor webhook router mounted at a legacy URL for stability with externally-configured dashboards (see the webhook table above).
- EventAccount credentials — migration 035 dropped the 15+ legacy
per-processor columns; credentials now live in
event_account.processor_configsJSON, read throughEventAccount.get_processor_config(name)which returns a typed config dict. - Mobile —
PaymentProcessorRegistry.registerAll()(Android + iOS) maps eachProcessorKindto aProcessorPresentation. The unifiedPaymentScreen(Compose) /PaymentVC(SwiftUI) picks the presentation by enum. - Admin UI —
GET /v4/payments/processors(served bypayments_meta.py) drives the processor list dynamically; no hardcodedCARD_TYPE_OPTIONS.
Why launch slipped 2026-06-08 → 2026-06-29¶
Refactoring active production payment code under traffic is materially riskier than refactoring pre-launch code. The launch date slipped three weeks to land the refactor before any cutover, so the "add a processor" cost stays ~3 days post-launch rather than re-paying the ~2-week tax every time scope changes (e.g. if business wants BrainTree back for Venmo, or iDeal for EU expansion).
What "~3 days for a 5th processor" means in practice¶
Adding (for example) Klarna requires five files of new code:
pa_v4/payments/processors/klarna.py— implements thePaymentProcessorProtocol (~150 LOC).apps/user_app_android_with_sdk/.../presentations/klarna/Presentation.kt— Compose surface (~120 LOC).apps/user_app_ios/.../Payments/Processors/KlarnaPresentation.swift— SwiftUI surface (~120 LOC).- Sandbox creds wired into env / Secret Manager.
- One line in each mobile
PaymentProcessorRegistry.registerAll().
No schema migration. No admin-UI change. No new column on
event_account. The unified webhook URL is free.
Stripe mobile presentation not yet in the registry
Stripe deliberately is not in the mobile ProcessorPresentation
registry yet — its flow is split across NewCardPaymentScreen,
AddPaymentMethodScreen, and PaymentMethods. Folding it in is
the proposed Phase 4.5
(stripe-phase-4.5-mobile-migration.md)
and is currently planning-only.
See also¶
- Architecture › Payments — one-table summary
- API reference › Personal Account API
— Swagger surface for
pa_v4 .planning/payment-ports/— per-processor research briefs; readpayment3.mdfirst before any payment portpa_v4/CLAUDE.md— webhook router invariants