# Hosted Credits Payment Flow

> Canonical path: `specs/billing/hosted-credits-payment.md`
> Status: canonical specification

Related canonical specs:

- [Product Overview](../product/overview.md)
- [Core Storage](../storage/storage.md)
- [API Specs](../api/README.md)
- [agentteam-maas Integration Boundary](../maas/agentteam-maas-integration.md)
- [Hosted Model Onboarding](./hosted-model-onboarding.md)
- [Organization Seat Purchases](./organization-seat-purchases.md)
- [AgentTeam Server Workspace Isolation](../deployment/agentteam-server-workspace-isolation.md)
- `agentteam-maas/specs/maas/hosted-credit-atomic-units.md`

## Purpose

AgentTeam exposes an organization billing screen in the workspace shell. The first
backend-supported billing flow is hosted model credit recharge paid through
Alipay page pay. AgentTeam owns the browser-facing API, user session checks,
organization authorization, Alipay integration, and public Alipay callbacks.
The companion `agentteam-maas` service owns durable payment order records, payment
event records, hosted credit grants, hosted credit batches, and hosted credit
ledger data.

This spec defines the v1 backend contract for the workspace billing UI and for
the AgentTeam-to-`agentteam-maas` billing integration.

Hosted model onboarding, explicit entitlement facts, workspace authorization,
and balance-gated availability are specified separately in
[Hosted Model Onboarding](./hosted-model-onboarding.md). This payment spec
defines order, provider callback, credit grant, and ledger settlement
contracts.

## Scope

In scope:

- Hosted model credit recharge for an organization.
- Alipay PC page-pay redirect.
- Billing summary, credit ledger, payment history, and pricing reads for
  organization owners and organization admins.
- Alipay asynchronous notify handling.
- One-order synchronous repair through Alipay trade query when a user returns
  from payment or refreshes a pending order.
- Durable payment order and event storage in the `agentteam-maas` TiDB database.
- Credit posting through MaaS-owned hosted credit grants and batches.

Out of scope for v1:

- Seat purchase, subscription plan purchase, renewal, downgrade, or invoice
  management. Organization human-seat purchases are specified separately in
  [Organization Seat Purchases](./organization-seat-purchases.md).
- Workspace admin/member billing access. Ordinary workspace admins and
  workspace members cannot view balance, ledger, payments, or pricing through
  the billing API unless they are also organization owners/admins.
- Alipay QR precreate, mobile app pay, WeChat Pay, Stripe, bank transfer, or
  offline remittance.
- Refund product flows and refund API contracts.
- Background polling workers for pending Alipay orders.
- Direct browser calls into `agentteam-maas`.

## Product Decisions

- Billing access is organization-scoped. V1 allows only `org_owner` and
  `org_admin`.
- The workspace billing route may use the current workspace to find the owning
  organization, but billing authorization is checked against organization
  membership, not workspace membership.
- Recharge grants `agentteam_credit`.
- Currency is `CNY`.
- Price is fixed for v1: `100 agentteam_credit = CNY 1.00`.
- Therefore `payable_amount_cents = credit_amount`, where cents are RMB cents
  and `credit_amount` is an integer count of `agentteam_credit`.
- Hosted credit ledger reads and hosted token prices are atom-denominated.
  One `agentteam_credit` equals `1,000,000` credit atoms, so one CNY equals
  `100,000,000` credit atoms for the current CNY recharge product.
- Frontend presets are `3000`, `5000`, and `10000` credits. Custom recharge
  minimum is `100` credits.
- Payment initiation uses Alipay page pay and returns a redirect URL.
- Alipay notify is the primary settlement path. A trusted `return_url` does not
  exist; the return route is browser convenience only.
- `notify_url` and the public return endpoint are built from
  `AGENTTEAM_PUBLIC_BASE_URL`. The optional `AGENTTEAM_BILLING_RETURN_BASE_URL` controls
  where that return endpoint sends the browser afterward, which is useful when
  local API and web origins differ.
- Pending order repair is single-order and user-triggered through a sync/query
  API. V1 does not run a background poller.
- When `AGENTTEAM_BILLING_ENABLED=true`, AgentTeam startup requires Alipay app identity,
  private key, public key, gateway URL, `AGENTTEAM_MAAS_BASE_URL`, and
  `AGENTTEAM_MAAS_INTERNAL_BEARER_TOKEN`. Billing-only deployments do not need
  `AGENTTEAM_MAAS_RUNTIME_BASE_URL` or a MaaS capability private key unless the
  direct MaaS runtime bridge is also configured.

## Ownership Boundaries

### AgentTeam

AgentTeam owns:

- Authenticated browser API routes under `/api/billing/*`.
- Resolving `X-Workspace-Id` to an organization when the billing screen is
  opened from a workspace shell.
- Requiring a verified signed-in user and organization owner/admin membership.
- Feature flag enforcement through `AGENTTEAM_BILLING_ENABLED`.
- Alipay client configuration, signing, page-pay URL creation, notify signature
  and app identity verification, and trade query.
- Calling `agentteam-maas` internal billing APIs after AgentTeam authorization and Alipay
  verification.
- Public Alipay callback routes reachable from `AGENTTEAM_PUBLIC_BASE_URL`.
- Optional browser return redirection to `AGENTTEAM_BILLING_RETURN_BASE_URL` after
  the public Alipay return endpoint receives the browser callback.

AgentTeam must not directly write payment, event, or credit ledger rows in the
`agentteam-maas` database.

### agentteam-maas

`agentteam-maas` owns:

- Hosted credit payment order storage.
- Hosted payment event storage.
- Hosted credit grants, hosted credit batches, credit transactions, and credit
  allocation records.
- Billing summary, ledger, payment-history, and hosted-pricing read models.
- Idempotent settlement from a paid payment order to exactly one credit grant.
- Internal billing API routes under `/internal/maas/v1/billing/*`.

`agentteam-maas` must not expose browser-facing billing APIs for v1, must not verify
browser sessions, and must not keep foreign keys into AgentTeam-owned user,
workspace, or organization tables. AgentTeam identifiers stored in `agentteam-maas` are
opaque external references.

### Alipay

Alipay is the payment provider. AgentTeam verifies provider signatures and queries
provider state. `agentteam-maas` stores normalized provider facts and payload hashes,
not Alipay private keys.

## Browser API In AgentTeam

All authenticated browser billing routes require:

- `Authorization` for the AgentTeam browser session.
- A verified identity.
- `X-Workspace-Id` when resolving the organization from the workspace shell.
- `AGENTTEAM_BILLING_ENABLED=true`.
- Organization owner/admin membership for the resolved organization.

Failure semantics:

- `401` when no valid session exists.
- `403` when the user is not an organization owner/admin.
- `404` when the workspace or organization cannot be resolved for the user.
- `409` for idempotency conflicts or invalid order state transitions.
- `422` for syntactically valid but unsupported billing inputs.
- `503` when billing is disabled or the provider/internal MaaS dependency is
  unavailable.

Billing is an organization-management surface. In deployments with split
central and workspace profiles, these routes stay on the central/control
profile. `X-Workspace-Id` is accepted only to select the organization context;
it must not turn billing into a workspace data-plane route.

### `GET /api/billing/summary`

Returns the org-scoped hosted credit summary:

```json
{
  "organizationId": "org_123",
  "creditUnit": "agentteam_credit",
  "availableCreditAtoms": 180000000000,
  "heldCreditAtoms": 2000000000,
  "consumedCreditAtoms": 31000000000,
  "todaySpendCreditAtoms": 4200000000,
  "monthSpendCreditAtoms": 18800000000,
  "updatedAt": "2026-06-03T10:00:00Z"
}
```

`availableCreditAtoms` and `heldCreditAtoms` describe current non-expired
hosted credit batches. Expired credit batches must not increase the current
reserveable balance. `consumedCreditAtoms` is cumulative settled hosted-credit
consumption, and `todaySpendCreditAtoms` / `monthSpendCreditAtoms` are bounded
transaction aggregates. Browser UI may display these values as human
`agentteam_credit`, but the atom fields are the API source of truth.

### `GET /api/billing/ledger`

Query parameters:

- `month=YYYY-MM`, optional. When omitted, the current month in server time is
  used.
- `limit`, optional, default `50`, max `100`.
- `cursor`, optional opaque pagination cursor.

Returns bounded ledger rows for usage, recharge, and manual adjustment facts.
Rows are ordered by `(occurred_at DESC, id DESC)`.

```json
{
  "items": [
    {
      "id": "txn_123",
      "type": "recharge",
      "creditUnit": "agentteam_credit",
      "creditDeltaAtoms": 3000000000,
      "description": "Alipay recharge",
      "paymentOrderId": "pay_123",
      "occurredAt": "2026-06-03T09:00:00Z"
    }
  ],
  "nextCursor": null
}
```

### `GET /api/billing/model-usage`

Query parameters:

- `month=YYYY-MM`, optional. When omitted, the current month in server time is
  used.

Returns the selected month's hosted-model credit consumption grouped by model.
This is the default bill-detail read model for the browser; it avoids rendering
one row per individual usage transaction.

```json
{
  "month": "2026-06",
  "creditUnit": "agentteam_credit",
  "totalRequestCount": 32,
  "totalConsumedCreditAtoms": 18800000000,
  "items": [
    {
      "modelReference": "agentteam/deepseek-v4-pro",
      "requestCount": 24,
      "creditUnit": "agentteam_credit",
      "consumedCreditAtoms": 15000000000
    }
  ]
}
```

### `GET /api/billing/payments`

Query parameters match `GET /api/billing/ledger`.

Returns org-scoped payment orders:

```json
{
  "items": [
    {
      "id": "pay_123",
      "outTradeNo": "agentteam_20260603_123",
      "provider": "alipay",
      "status": "paid",
      "creditAmount": 3000,
      "currency": "CNY",
      "payableAmountCents": 3000,
      "createdAt": "2026-06-03T08:58:00Z",
      "paidAt": "2026-06-03T09:00:00Z"
    }
  ],
  "nextCursor": null
}
```

### `GET /api/billing/pricing`

Returns the current recharge price snapshot and hosted model pricing display
data:

```json
{
  "creditUnit": "agentteam_credit",
  "currency": "CNY",
  "creditAmountPerCny": 100,
  "creditAtomsPerCredit": 1000000,
  "creditAtomsPerCny": 100000000,
  "rechargePresets": [3000, 5000, 10000],
  "customMinCredits": 100,
  "hostedModels": [
    {
      "id": "agentteam/deepseek-v4-pro",
      "modelReference": "agentteam/deepseek-v4-pro",
      "hostedModelId": "deepseek_v4_pro",
      "displayName": "DeepSeek V4 Pro",
      "effectivePricingVersionId": "hpv_deepseek_v4_pro_discount_90",
      "basePricingVersionId": "hpv_deepseek_v4_pro_2026_06_15",
      "inputCreditAtomsPerMillionTokens": 2000000,
      "cacheReadInputCreditAtomsPerMillionTokens": 200000,
      "outputCreditAtomsPerMillionTokens": 8000000,
      "baseInputCreditAtomsPerMillionTokens": 4000000,
      "baseCacheReadInputCreditAtomsPerMillionTokens": 400000,
      "baseOutputCreditAtomsPerMillionTokens": 16000000,
      "pricingOverride": {
        "id": "hpod_deepseek_v4_pro_campaign",
        "hostedModelId": "deepseek_v4_pro",
        "scopeType": "global",
        "basePricingVersionId": "hpv_deepseek_v4_pro_2026_06_15",
        "effectivePricingVersionId": "hpv_deepseek_v4_pro_discount_90",
        "overrideType": "campaign",
        "displayLabel": "90% campaign",
        "priority": 100,
        "status": "active"
      }
    }
  ]
}
```

Hosted model pricing is read from the MaaS catalog/pricing model. The response
must not be assembled by scanning historical usage events or payment history.
`id` and `modelReference` are the browser-selectable public model reference;
`hostedModelId` is diagnostic only and must not be used by the browser as the
model selector value. Hosted token price fields are measured in credit atoms
per one million tokens. Browser UI may convert them to display credits or CNY
for presentation, but the `*CreditAtomsPerMillionTokens` values are the exact
pricing contract.

When MaaS applies a hosted pricing override, AgentTeam may expose both the effective
price fields and the corresponding `base*` normal-price fields for display.
`pricingOverride` is optional metadata about the active MaaS override; it is not
an entitlement, payment-history proof, or browser-side pricing rule. Browser UI
may use the effective/base price ratio to display a discount badge, but runtime
pricing and settlement remain owned by MaaS.

### `POST /api/billing/credit-recharge-orders`

Creates a pending credit recharge order and returns an Alipay page-pay URL.

Headers:

- `Idempotency-Key`, required. It is scoped by organization and operation.

Request:

```json
{
  "creditAmount": 3000
}
```

Response:

```json
{
  "orderId": "pay_123",
  "outTradeNo": "agentteam_20260603_123",
  "status": "pending",
  "creditUnit": "agentteam_credit",
  "creditAmount": 3000,
  "currency": "CNY",
  "payableAmountCents": 3000,
  "paymentUrl": "https://openapi.alipay.com/gateway.do?...",
  "expiresAt": "2026-06-03T09:28:00Z"
}
```

The AgentTeam handler validates the requested credit amount, asks `agentteam-maas` to
create or return the idempotent order, creates the Alipay page-pay URL with
the returned `outTradeNo`, and returns the URL to the browser.

### `GET /api/billing/credit-recharge-orders/{orderId}`

Returns the current order state for an authorized organization owner/admin.
AgentTeam reads the order from `agentteam-maas`; it does not query Alipay on this read.

### `POST /api/billing/credit-recharge-orders/{orderId}/sync`

Repairs a single pending order by querying Alipay through AgentTeam and forwarding
the normalized result to `agentteam-maas`.

Rules:

- The order must belong to the resolved organization.
- Only `pending` orders are queried.
- `WAIT_BUYER_PAY` keeps the order `pending`.
- `TRADE_SUCCESS` and `TRADE_FINISHED` settle the order as `paid` if amount and
  currency match.
- `TRADE_CLOSED` closes the order if it has not already been paid.
- Replays are idempotent and must not mint duplicate credits.

### `POST /api/billing/alipay/notify`

Unauthenticated public endpoint for Alipay server notifications.

Rules:

- Accept `application/x-www-form-urlencoded` payloads.
- Verify the Alipay signature and configured `app_id` before any state
  mutation.
- Use `out_trade_no` to locate the order through `agentteam-maas`.
- Validate amount and currency against the stored order snapshot.
- Forward a normalized paid or closed event to `agentteam-maas`.
- Return plain text `success` only after the event is durably accepted by
  `agentteam-maas`; otherwise return plain text `fail`.

This endpoint is not scoped by `X-Workspace-Id`.

### `GET /api/billing/alipay/return`

Optional browser convenience endpoint for Alipay return redirects. It redirects
the user back to the workspace billing screen with the returned workspace and
order identifiers when available. By default the redirect is same-origin and
relative to the AgentTeam server. When `AGENTTEAM_BILLING_RETURN_BASE_URL` is configured,
the redirect target is absolute under that browser base URL. The endpoint must
not mark an order paid and must not grant credits.

## System Admin Hosted Credit Grant API

### `POST /api/admin/orgs/{orgId}/hosted-credit-grants`

System-admin endpoint for operator-originated hosted credit grants. This route
requires an AgentTeam platform operator session. It does not use `X-Workspace-Id`,
does not accept organization member authority, and does not write AgentTeam billing
storage.

Request headers:

- `Idempotency-Key`, required. The value is forwarded to `agentteam-maas` and scoped
  to the target organization.

Request:

```json
{
  "creditAmount": 3000,
  "grantReasonRef": "support-ticket-123",
  "grantSourceType": "operator_gift",
  "refundable": false,
  "expiresAt": "2026-07-01T00:00:00Z",
  "sourceRef": "ticket-123"
}
```

Rules:

- Exactly one of `creditAmount` or `creditAmountAtoms` is required. Browser and
  operator tooling should prefer `creditAmount` for human-entered values and
  `creditAmountAtoms` for repair flows that already computed atomic units.
- `grantReasonRef` is required and must identify the durable business reason or
  audit artifact.
- `grantSourceType` defaults to `operator_gift` when omitted.
- AgentTeam sets `organizationId` from `{orgId}`, `creditUnit=agentteam_credit`,
  `operatorIdentity` from the authenticated platform operator user, and the
  forwarded `idempotencyKey` from the header.
- First creation returns `201`; an idempotent replay returns `200`; a replay
  with conflicting grant parameters returns `409`.
- The grant affects hosted credit balance only. It must not create payment
  order rows, payment events, paid-subscription entitlement, or hosted model
  entitlement facts.

Response:

```json
{
  "grantId": "hcg_123",
  "operationId": "hgo_123",
  "created": true,
  "organizationId": "org_123",
  "creditUnit": "agentteam_credit",
  "creditAmount": 3000,
  "creditAmountAtoms": 3000000000,
  "grantSourceType": "operator_gift",
  "grantReasonRef": "support-ticket-123",
  "refundable": false,
  "expiresAt": "2026-07-01T00:00:00Z",
  "operatorIdentity": "user_123",
  "sourceRef": "ticket-123",
  "createdAt": "2026-06-10T08:00:00Z",
  "updatedAt": "2026-06-10T08:00:00Z"
}
```

## Internal Billing API In agentteam-maas

Internal routes use the prefix `/internal/maas/v1/billing/*` and are callable
only by AgentTeam. The transport credential is deployment-specific, but v1 should
support a shared internal bearer token until service-token capability exchange
is available.

AgentTeam passes authenticated actor context as opaque references:

- `organization_id`
- `workspace_id`, optional context used only for audit/debugging
- `created_by_user_id`
- `operator_identity`, for system-admin credit grant operations

### `POST /internal/maas/v1/billing/credit-recharge-orders`

Request:

```json
{
  "organizationId": "org_123",
  "workspaceId": "ws_123",
  "createdByUserId": "user_123",
  "idempotencyKey": "01HX...",
  "creditUnit": "agentteam_credit",
  "creditAmount": 3000,
  "currency": "CNY",
  "payableAmountCents": 3000,
  "priceSnapshot": {
    "creditAmountPerCny": 100,
    "creditAtomsPerCredit": 1000000,
    "creditAtomsPerCny": 100000000,
    "source": "hosted-credit-atoms-v1"
  },
  "provider": "alipay",
  "providerMode": "page_pay",
  "expiresAt": "2026-06-03T09:28:00Z"
}
```

Response:

```json
{
  "orderId": "pay_123",
  "outTradeNo": "agentteam_20260603_123",
  "status": "pending",
  "creditUnit": "agentteam_credit",
  "creditAmount": 3000,
  "currency": "CNY",
  "payableAmountCents": 3000,
  "expiresAt": "2026-06-03T09:28:00Z"
}
```

Idempotency is enforced by `(organization_id, idempotency_key)`. A replay with
the same economic request returns the existing order. `expiresAt` is used when
the first order is inserted; a replay must not conflict solely because AgentTeam
generated a later expiry timestamp for the retried internal call. A replay with
a conflicting amount, unit, currency, provider, actor, or price snapshot returns
`409`.

### `POST /internal/maas/v1/billing/credit-grants`

Creates an operator-originated hosted credit grant and credit batch without a
payment order. AgentTeam uses this route after system-admin authorization; command
line operations may also call the same agentteam-maas grant service directly.

Request:

```json
{
  "organizationId": "org_123",
  "idempotencyKey": "01HX...",
  "creditUnit": "agentteam_credit",
  "creditAmount": 3000,
  "grantSourceType": "operator_gift",
  "grantReasonRef": "support-ticket-123",
  "refundable": false,
  "expiresAt": "2026-07-01T00:00:00Z",
  "operatorIdentity": "user_123",
  "sourceRef": "ticket-123"
}
```

Rules:

- Exactly one of `creditAmount` or `creditAmountAtoms` is required. If
  `creditAmount` is used, `agentteam-maas` converts it to atomic units using the
  canonical `agentteam_credit` atom scale. If `creditAmountAtoms` is used, the value
  is stored exactly.
- `organizationId`, `idempotencyKey`, `creditUnit`, `grantSourceType`,
  `grantReasonRef`, and `operatorIdentity` are required after defaults are
  applied.
- Idempotency is enforced by `(organization_id, idempotency_key)`. A replay with
  the same grant parameters returns the existing operation and grant. A replay
  with conflicting credit unit, amount, source type, reason, refundability,
  expiry, operator, or source reference returns `409`.
- The route inserts a durable grant operation before minting credit rows, then
  creates exactly one hosted credit grant and at least one hosted credit batch.
- Operator grant rows are ledger-visible as manual adjustments and are not
  payment settlements.
- Operator grants must not create or update organization seat subscriptions,
  hosted model entitlements, payment orders, or payment events.

Response:

```json
{
  "grantId": "hcg_123",
  "operationId": "hgo_123",
  "created": true,
  "organizationId": "org_123",
  "creditUnit": "agentteam_credit",
  "creditUnitSource": "credit_atoms",
  "creditAmount": 3000,
  "creditAmountAtoms": 3000000000,
  "grantSourceType": "operator_gift",
  "grantReasonRef": "support-ticket-123",
  "refundable": false,
  "expiresAt": "2026-07-01T00:00:00Z",
  "operatorIdentity": "user_123",
  "sourceRef": "ticket-123",
  "createdAt": "2026-06-10T08:00:00Z",
  "updatedAt": "2026-06-10T08:00:00Z"
}
```

### `GET /internal/maas/v1/billing/credit-recharge-orders/{orderId}`

Returns one order by ID. AgentTeam must pass the expected organization ID; `agentteam-maas`
must reject cross-organization reads.

### `GET /internal/maas/v1/billing/credit-recharge-orders/by-out-trade-no/{outTradeNo}`

Returns one order by provider-facing `out_trade_no`. This route is used only
after AgentTeam has verified an Alipay notify signature and needs to recover the
stored order snapshot before validating amount/currency and forwarding
settlement. It is not a browser API.

### `POST /internal/maas/v1/billing/credit-recharge-orders/{orderId}/confirm-paid`

Confirms a provider-paid order and mints credits exactly once.

Request:

```json
{
  "organizationId": "org_123",
  "provider": "alipay",
  "source": "notify",
  "outTradeNo": "agentteam_20260603_123",
  "providerTradeNo": "202606032200...",
  "providerTradeStatus": "TRADE_SUCCESS",
  "paidAmountCents": 3000,
  "currency": "CNY",
  "paidAt": "2026-06-03T09:00:00Z",
  "rawPayloadSha256": "8f1c..."
}
```

Atomic effects:

- Verify the order belongs to `organizationId`.
- Verify the order amount, currency, provider, and `outTradeNo`.
- Insert a payment event if it has not already been recorded.
- Transition `pending` to `paid`.
- Create one hosted credit grant and one or more hosted credit batches for the
  paid display-credit amount converted to credit atoms by the order price
  snapshot.
- Link the credit grant source to the payment order.
- Return the already-settled result on replay without creating another grant.

### `POST /internal/maas/v1/billing/credit-recharge-orders/{orderId}/close`

Records `TRADE_CLOSED` or a local expiry decision for an unpaid pending order.
Closing a paid order is a no-op replay that returns the paid state.

### Read APIs

`agentteam-maas` provides these bounded org-scoped read APIs for AgentTeam:

- `GET /internal/maas/v1/billing/summary?organization_id=...`
- `GET /internal/maas/v1/billing/ledger?organization_id=...&month=YYYY-MM&limit=...&cursor=...`
- `GET /internal/maas/v1/billing/model-usage?organization_id=...&month=YYYY-MM`
- `GET /internal/maas/v1/billing/payments?organization_id=...&month=YYYY-MM&limit=...&cursor=...`
- `GET /internal/maas/v1/billing/pricing?organization_id=...`

List APIs with `limit` must default to `50`, cap at `100`, and use stable
cursor pagination. Month-scoped aggregate APIs must stay bounded by the
selected organization and month. Read APIs must never perform
cross-organization scans for a browser request.

## agentteam-maas Storage Model

Payment storage lives in the `agentteam-maas` TiDB schema. Existing hosted credit
grant, batch, transaction, reservation, and allocation tables remain the
source of truth for credit balances and spend history. Payment tables record
provider-facing money movement and settlement inputs.

### `hosted_payment_orders`

Required columns:

- `id`
- `organization_id`
- `workspace_id`, optional opaque AgentTeam workspace reference
- `created_by_user_id`
- `credit_unit`
- `credit_amount`
- `currency`
- `payable_amount_cents`
- `price_snapshot_json`
- `payment_provider`
- `provider_mode`
- `out_trade_no`
- `provider_trade_no`, nullable
- `status`
- `idempotency_key`
- `expires_at`
- `paid_at`, nullable
- `closed_at`, nullable
- `failed_at`, nullable
- `created_at`
- `updated_at`

Required constraints and indexes:

- `UNIQUE (out_trade_no)`
- `UNIQUE (organization_id, idempotency_key)`
- `KEY (organization_id, status, paid_at, id)`
- `KEY (organization_id, status, created_at, id)`
- `KEY (organization_id, created_at, id)`

Allowed statuses:

- `pending`
- `paid`
- `closed`
- `failed`

### `hosted_payment_events`

Required columns:

- `id`
- `organization_id`
- `payment_order_id`
- `event_source`
- `payment_provider`
- `provider_trade_status`
- `provider_trade_no`, nullable
- `amount_cents`
- `currency`
- `raw_payload_sha256`
- `occurred_at`
- `created_at`

Required constraints and indexes:

- `UNIQUE (payment_order_id, event_source, raw_payload_sha256)`
- `KEY (organization_id, occurred_at, id)`
- `KEY (payment_order_id, created_at, id)`

Allowed `event_source` values:

- `alipay_notify`
- `alipay_query`
- `agentteam_internal`

The AgentTeam-to-`agentteam-maas` request may use short source aliases such as `notify`
or `query`; `agentteam-maas` normalizes them to the stored values above.

The raw Alipay payload should not be stored by default. Store the normalized
fields required for reconciliation and a SHA-256 hash of the raw payload for
idempotency and audit correlation.

### `hosted_credit_grant_operations`

Required columns:

- `id`
- `organization_id`
- `idempotency_key`
- `credit_unit`
- `grant_source_type`
- `grant_reason_ref`
- `granted_amount_atoms`
- `refundable`
- `expires_at`, nullable
- `operator_identity`
- `source_ref`, nullable
- `grant_id`
- `created_at`
- `updated_at`

Required constraints and indexes:

- `UNIQUE (organization_id, idempotency_key)`
- `KEY (organization_id, created_at, id)`
- `KEY (organization_id, grant_id)`

The operation table is the idempotency and audit guard for operator-originated
credit grants. It records the normalized request before credit rows are minted.
It must not reference AgentTeam identity tables or payment tables through
cross-service foreign keys.

### Credit Grant Linkage

When a payment order is settled as paid, `agentteam-maas` creates hosted credit
records using the existing credit tables:

- `hosted_credit_grants.organization_id = hosted_payment_orders.organization_id`
- `hosted_credit_grants.credit_unit = hosted_payment_orders.credit_unit`
- `hosted_credit_grants.grant_source_type = 'alipay_recharge'`
- `hosted_credit_grants.grant_reason_ref = hosted_payment_orders.id`
- `hosted_credit_batches` receive the purchased amount converted from display
  credits to credit atoms by the order price snapshot.

Payment settlement must not maintain a duplicate balance table. Summary APIs
read from the MaaS credit ledger/read model.

Operator-originated grants use the same hosted credit grant and batch tables
with `grant_source_type = 'operator_gift'` by default and
`grant_reason_ref = hosted_credit_grant_operations.grant_reason_ref`. They are
balance-only manual adjustments: they appear in ledger reads but do not imply
money movement, subscription payment, or hosted model entitlement.

## State Machines

### Payment Order

```text
pending -> paid
pending -> closed
pending -> failed
paid    -> paid    (idempotent replay)
closed  -> closed  (idempotent replay)
failed  -> failed  (idempotent replay)
```

- `pending` is created before redirecting the user to Alipay.
- `paid` is final and grants credits.
- `closed` is final and does not grant credits.
- `failed` is reserved for internal unrecoverable creation/setup failures before
  the user can complete payment.

### Alipay Status Mapping

- `WAIT_BUYER_PAY` maps to `pending`.
- `TRADE_SUCCESS` maps to `paid`.
- `TRADE_FINISHED` maps to `paid`.
- `TRADE_CLOSED` maps to `closed` if the order is not already paid.

`agentteam-maas` must reject a `confirm-paid` call unless the normalized provider
status is `TRADE_SUCCESS` or `TRADE_FINISHED`. It must reject an Alipay close
call unless the normalized provider status is `TRADE_CLOSED`.

Amount and currency mismatch must not settle the order. It should produce a
failed settlement event for operators and return `fail` to Alipay notify so the
provider retries while the mismatch is investigated.

## Security And Privacy

- Alipay private keys stay in AgentTeam process configuration.
- `AGENTTEAM_ALIPAY_GATEWAY_URL` must use HTTPS in production. Development may use
  HTTP only for a local fake Alipay gateway.
- `agentteam-maas` receives normalized provider facts only.
- Return URLs and browser-provided order status are never trusted.
- Notify handlers verify signatures and configured Alipay app identity before
  mutation.
- Logs must not include raw provider payloads, Alipay signatures, private keys,
  authorization headers, or user email addresses.
- Metrics labels must be low-cardinality, such as `provider`, `status`, and
  `outcome`. Do not label metrics by organization ID, user ID, order ID, or
  trade number.
- Trace/log correlation may include `payment_order_id` and `out_trade_no` only
  where access is restricted to operators.

## Amplification Guardrails

- Billing reads are always scoped to one organization.
- Ledger and payment list APIs require month or cursor-bounded pagination and
  must cap `limit` at `100`.
- Summary reads must use indexed org-scoped aggregates or maintained read
  models; they must not scan all usage events for the organization on every
  first paint if usage grows with time.
- Order sync operates on exactly one order.
- Alipay notify lookup is by unique `out_trade_no`.
- No browser request may fan out across all workspaces, all organizations, or
  all hosted usage events.

## Implementation Placement

AgentTeam:

- Browser DTOs belong in `internal/api/dto`.
- Billing handlers belong in an owning `internal/api/billing` package.
- Shared API helpers stay in root `internal/api`.
- Route composition stays in `internal/router`.
- `/api/billing/*` is a central-profile route family; `X-Workspace-Id` selects
  organization context only and must not route billing through workspace
  data-plane propagation.
- Alipay provider code uses `internal/billing/alipay`.
- The `agentteam-maas` client should be a narrow port owned by the billing API
  service/handler boundary.

`agentteam-maas`:

- Billing storage and services belong to the MaaS store/service domain.
- Migrations and bootstrap SQL must be updated together.
- Internal billing routes belong under `/internal/maas/v1/billing/*`.
- Store models represent persistence projections and must not carry JSON tags.

## Acceptance Criteria

- With billing disabled, browser billing APIs fail closed and public config
  reports billing disabled.
- An organization owner/admin can create a hosted credit recharge order and
  receives an Alipay page-pay URL.
- An ordinary workspace admin/member who is not an organization owner/admin
  receives `403` for summary, ledger, payment history, pricing, order create,
  order status, and order sync.
- Alipay notify signature verification happens before settlement.
- Paid notify settlement records the payment event and grants credits exactly
  once, including duplicate notify replay.
- User-triggered sync can repair a pending order from Alipay query without a
  background poller.
- Payment order and event rows are stored in the `agentteam-maas` database.
- Credit grants and batches are stored in existing MaaS credit tables and are
  linked back to the payment order.
- Billing list APIs are org-scoped, bounded, and cursor-paginated.
- Billing summary, ledger, and hosted model pricing reads expose atom-denominated
  balance, transaction, and per-million-token price fields.
