Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ainfera_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
install,
ledger,
openai_compat,
payments,
providers,
routing_policy,
signing,
Expand Down Expand Up @@ -133,6 +134,7 @@
dashboard.router
) # SP-2 AIN-263/264/265 · /v1/usage/daily + /v1/caps/rollup + /v1/agents/{id}/metrics
app.include_router(metrics_router.router) # SP-5 PR-C AIN-238 · /metrics
app.include_router(payments.router) # SP-8 · payment rails (DORMANT until AINFERA_PAYMENTS_LIVE=1)


@app.exception_handler(Exception)
Expand Down
202 changes: 202 additions & 0 deletions ainfera_api/routers/payments.py
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,
},
)
Copy link
Copy Markdown

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_LIVE is on, POST /v1/payments/topup/{rail} still returns detail.code of payments_not_live even though /v1/payments/status reports live: true. Clients and alerts keyed on that code will misread activation state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 12b5ea2. Configure here.



@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",
},
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Webhook reports wrong error for unknown rail

Low Severity

The signature-header lookup dictionary only contains the three known rails. If an unknown rail value is provided, .get(rail) returns None, and the handler raises a misleading 400 missing_signature_header error instead of reaching select_adapter(rail) which would give the correct unknown_rail error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c4026f9. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unknown webhook rail misreported

Low Severity

For POST /v1/payments/webhook/{rail}, an unsupported {rail} path value yields missing_signature_header because header lookup uses .get(rail) before select_adapter. Topup correctly returns unknown_rail for the same mistake.

Fix in Cursor Fix in Web

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Live webhook stub returns 401

Medium Severity

With AINFERA_PAYMENTS_LIVE on, webhook handlers still raise WebhookSignatureError from unwired adapters, and the router maps every such error to 401 webhook_signature_invalid. Processors treat that as a bad signature, not “verification not implemented yet.”

Additional Locations (1)
Fix in Cursor Fix in Web

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}
48 changes: 48 additions & 0 deletions ainfera_api/services/payments/__init__.py
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"]
Loading
Loading