Skip to content

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.updatedVendor.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.com drives 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, IdempotentRoute on 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, PayPal PAYMENT.CAPTURE.REFUNDED, MP refunded / charged_back, Adyen REFUND). The webhook resolves the OnlinePayment by charge id and updates OnlinePayment.status to REFUNDED (2) or PARTIAL_REFUND (4); per-transfer amounts come from refund metadata.transfer_id + refund_fee.

  • Dispute / chargeback

    charge.dispute.created (Stripe), CUSTOMER.DISPUTE.CREATED (PayPal), NOTIFICATION_OF_CHARGEBACK (Adyen), charged_back (Mercado Pago) all funnel into register_dispute_result. Side effect: OnlinePayment.status = CHARGEBACK (3), Transfer.status → CANCELED, Table.status → FREE, and User.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

  • Backendpa_v4/payments/processors/<name>.py declares a PaymentProcessor (Protocol from pa_v4/payments/base.py with name, display_label, sdk_credentials_for). Each module self-registers on import via pa_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_configs JSON, read through EventAccount.get_processor_config(name) which returns a typed config dict.
  • MobilePaymentProcessorRegistry.registerAll() (Android + iOS) maps each ProcessorKind to a ProcessorPresentation. The unified PaymentScreen (Compose) / PaymentVC (SwiftUI) picks the presentation by enum.
  • Admin UIGET /v4/payments/processors (served by payments_meta.py) drives the processor list dynamically; no hardcoded CARD_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:

  1. pa_v4/payments/processors/klarna.py — implements the PaymentProcessor Protocol (~150 LOC).
  2. apps/user_app_android_with_sdk/.../presentations/klarna/Presentation.kt — Compose surface (~120 LOC).
  3. apps/user_app_ios/.../Payments/Processors/KlarnaPresentation.swift — SwiftUI surface (~120 LOC).
  4. Sandbox creds wired into env / Secret Manager.
  5. 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