-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): SP-8 · payment rails — DORMANT, re-cut payments-only (founder-gated merge) #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| """SP-8 · payment endpoints (DORMANT — every path 503 when flag OFF). | ||
|
|
||
| Three endpoints, one shape: | ||
| - `POST /v1/payments/topup/{rail}` — tenant tops up parent customer balance | ||
| - `POST /v1/payments/webhook/{rail}` — processor → us callback | ||
| - `GET /v1/payments/status` — returns the activation state (the only | ||
| safe-when-dormant probe; reveals only the flag, no tenant data) | ||
|
|
||
| All three short-circuit with 503 when `AINFERA_PAYMENTS_LIVE` is not | ||
| live. The status endpoint stays 200 either way so the dashboard can | ||
| render an honest "payments not live" pill without paying for a 503. | ||
|
|
||
| ## What this router does NOT expose | ||
|
|
||
| - No card / bank / account-number entry points (the processor's | ||
| hosted-checkout / Stripe.js / Xendit Direct UI handles PII at | ||
| activation time; we never see it). | ||
| - No Connect-class endpoints (transfers / payouts / third-party | ||
| account management). | ||
| - No "set keys" or "accept terms" endpoint — those are founder-only | ||
| actions at the processor dashboard, not API-callable. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Annotated | ||
|
|
||
| from fastapi import APIRouter, Header, HTTPException, Request, status | ||
|
|
||
| from ainfera_api.services.payments.adapter import WebhookSignatureError | ||
| from ainfera_api.services.payments.charge import select_adapter | ||
| from ainfera_api.services.payments.flag import ( | ||
| PaymentsDormantError, | ||
| is_payments_live, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| router = APIRouter(prefix="/v1/payments", tags=["payments"]) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/status", | ||
| summary="Payment activation state (safe to call dormant).", | ||
| ) | ||
| async def payments_status() -> dict[str, object]: | ||
| """Return the activation state of the payments subsystem. | ||
|
|
||
| Reveals only the flag value + the supported rail set; no tenant | ||
| data, no key state. Safe to call dormant — used by the dashboard | ||
| to render the honest "payments not live" indicator. | ||
| """ | ||
| live = is_payments_live() | ||
| return { | ||
| "live": live, | ||
| "rails": ["cdp_x402", "stripe", "xendit"], | ||
| "code": "payments_live" if live else "payments_not_live", | ||
| "activation_runbook": "docs/payment-activation-runbook.md", | ||
| } | ||
|
|
||
|
|
||
| @router.post( | ||
| "/topup/{rail}", | ||
| summary="Top up the parent customer balance for the calling tenant.", | ||
| ) | ||
| async def topup( | ||
| rail: str, | ||
| request: Request, # noqa: ARG001 — body parse lands in activation PR | ||
| ) -> dict[str, object]: | ||
| """Top-up endpoint — proxies to the rail adapter. | ||
|
|
||
| When `AINFERA_PAYMENTS_LIVE=0`: returns 503 BEFORE any external | ||
| SDK call. The `require_live` inside the adapter is the second | ||
| line; this `is_payments_live()` short-circuit is the first | ||
| (cheaper + cleaner 503 surface). | ||
| """ | ||
| if not is_payments_live(): | ||
| # Fail-CLOSED before the adapter is even instantiated. | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail={ | ||
| "code": "payments_not_live", | ||
| "message": ( | ||
| "Payment rails are not active yet. Activation gated on " | ||
| "SG incorporation + processor accounts + Doppler keys + " | ||
| "MAS review per docs/payment-activation-runbook.md." | ||
| ), | ||
| "rail": rail, | ||
| }, | ||
| ) | ||
|
|
||
| # When live: parse the body, validate, dispatch to adapter. | ||
| # Activation PR wires the body schema (Pydantic model) + the | ||
| # tenant resolution + the LedgerEntry write that pairs to the | ||
| # TopupRecord. Until then this surface 503s by design. | ||
| try: | ||
| adapter = select_adapter(rail) | ||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail={"code": "unknown_rail", "message": str(exc)}, | ||
| ) from exc | ||
|
|
||
| # When the activation PR lands, the body schema parse + adapter | ||
| # call happens here. The dormant deploy doesn't reach this branch | ||
| # because the flag check above 503s first. | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail={ | ||
| "code": "payments_not_live", | ||
| "message": "live topup wiring lands in the post-SG activation PR", | ||
| "rail": adapter.rail, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/webhook/{rail}", | ||
| summary="Processor webhook callback (signature-verified).", | ||
| ) | ||
| async def webhook( | ||
| rail: str, | ||
| request: Request, | ||
| x_signature: Annotated[str | None, Header(alias="X-Signature")] = None, | ||
| x_stripe_signature: Annotated[str | None, Header(alias="Stripe-Signature")] = None, | ||
| x_callback_token: Annotated[str | None, Header(alias="x-callback-token")] = None, | ||
| ) -> dict[str, object]: | ||
| """Webhook entry — signature-verified BEFORE side-effects. | ||
|
|
||
| When dormant: returns 503 without parsing the body or verifying | ||
| the signature (which would still be safe but unnecessarily | ||
| expensive). When live: dispatches to the adapter's | ||
| `verify_webhook(...)` and processes the event. | ||
|
|
||
| Header pattern is rail-specific: | ||
| - CDP: `X-Signature` (HMAC) | ||
| - Stripe: `Stripe-Signature` (timestamped HMAC) | ||
| - Xendit: `x-callback-token` (token match) | ||
| """ | ||
| if not is_payments_live(): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail={"code": "payments_not_live", "rail": rail}, | ||
| ) | ||
|
|
||
| # Pick the right signature header for the rail. | ||
| signature_header = { | ||
| "cdp_x402": x_signature, | ||
| "stripe": x_stripe_signature, | ||
| "xendit": x_callback_token, | ||
| }.get(rail) | ||
| if not signature_header: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail={ | ||
| "code": "missing_signature_header", | ||
| "rail": rail, | ||
| "message": f"webhook to {rail} arrived without its rail-specific signature header", | ||
| }, | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Webhook reports wrong error for unknown railLow Severity The signature-header lookup dictionary only contains the three known rails. If an unknown Reviewed by Cursor Bugbot for commit c4026f9. Configure here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unknown webhook rail misreportedLow Severity For Reviewed by Cursor Bugbot for commit 12b5ea2. Configure here. |
||
|
|
||
| raw_body = await request.body() | ||
| try: | ||
| adapter = select_adapter(rail) | ||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail={"code": "unknown_rail", "message": str(exc)}, | ||
| ) from exc | ||
|
|
||
| try: | ||
| event = adapter.verify_webhook( | ||
| raw_body=raw_body, | ||
| signature_header=signature_header, | ||
| ) | ||
| except (PaymentsDormantError, WebhookSignatureError) as exc: | ||
| # PaymentsDormantError shouldn't fire here (flag check above | ||
| # already short-circuited), but defense-in-depth is cheap. | ||
| # WebhookSignatureError → 401 invalid signature. | ||
| if isinstance(exc, PaymentsDormantError): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail={"code": "payments_not_live", "rail": rail}, | ||
| ) from exc | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail={"code": "webhook_signature_invalid", "rail": exc.rail}, | ||
| ) from exc | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Live webhook stub returns 401Medium Severity With Additional Locations (1)Reviewed by Cursor Bugbot for commit 12b5ea2. Configure here. |
||
|
|
||
| # When live: dispatch on event_type. The activation PR wires | ||
| # per-event handlers (topup-confirmed → finalize ledger row; | ||
| # debit-confirmed → mark idempotency complete; chargeback → | ||
| # alarm + investigation hook). Dormant: this code is unreachable | ||
| # because verify_webhook raises while the flag is OFF. | ||
| logger.info( | ||
| "payments webhook received rail=%s event_type=%s event_id=%s", | ||
| event.rail, | ||
| event.event_type, | ||
| event.event_id, | ||
| ) | ||
| return {"received": True, "rail": rail, "event_id": event.event_id} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| """SP-8 · payment rails (DORMANT until AINFERA_PAYMENTS_LIVE=1). | ||
|
|
||
| This package contains the integration code for the three rails the | ||
| founder authorized for launch: | ||
|
|
||
| * CDP / x402 / USDC on Base (primary — agentic-native) | ||
| * Stripe Customer Balance (parent-topup; NO Connect) | ||
| * Xendit Customer Balance (SEA fiat; NO Connect) | ||
|
|
||
| **Every code path is inert until the founder flips | ||
| `AINFERA_PAYMENTS_LIVE=1`.** Inertness is enforced by the master flag | ||
| in `flag.py`; every public entry point checks it FIRST and short- | ||
| circuits with `PaymentsDormantError` (the routers translate to | ||
| `503 {"code": "payments_not_live"}`). | ||
|
|
||
| ## What this package WILL NOT do (the locks) | ||
|
|
||
| - It does not create processor accounts (Aulë cannot — founder only). | ||
| - It does not accept processor terms (founder only). | ||
| - It does not enter, store, or transmit live API keys (Doppler holds | ||
| them; the founder pastes them post-incorporation per | ||
| `docs/payment-activation-runbook.md`). | ||
| - It does not move money, charge a card, initiate a transfer, or | ||
| touch bank/routing/card data anywhere in tests (synthetic fixtures | ||
| only). | ||
| - It does not implement Stripe/Xendit Connect (money-transmission | ||
| risk per the founder settlement lock — Customer Balance pattern | ||
| only). | ||
| - It does not touch the immutable `routing_outcomes` table for | ||
| writes — the metering→charge path is READ-ONLY against §16. | ||
|
|
||
| ## Activation contract | ||
|
|
||
| Code lands dormant; activation is the founder's single switch after | ||
| SG incorporation + processor accounts + key provisioning + MAS PSA | ||
| review. The runbook at `docs/payment-activation-runbook.md` is the | ||
| ordered sequence. After step 6 (`railway env set | ||
| AINFERA_PAYMENTS_LIVE=1`), all rail paths come online; until then, | ||
| deploying this code is provably zero-risk. | ||
| """ | ||
|
|
||
| from ainfera_api.services.payments.flag import ( | ||
| PAYMENTS_LIVE_ENV, | ||
| PaymentsDormantError, | ||
| is_payments_live, | ||
| ) | ||
|
|
||
| __all__ = ["PAYMENTS_LIVE_ENV", "PaymentsDormantError", "is_payments_live"] |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Live topup wrong error code
Low Severity
When
AINFERA_PAYMENTS_LIVEis on,POST /v1/payments/topup/{rail}still returnsdetail.codeofpayments_not_liveeven though/v1/payments/statusreportslive: true. Clients and alerts keyed on that code will misread activation state.Reviewed by Cursor Bugbot for commit 12b5ea2. Configure here.