# Payments, checkout, orders, and entitlement sync

Read this reference when a frontend sells subscriptions or point packs, resumes a purchase after login, displays payment status, shows orders and receipts, or manages automatic renewal.

## Service boundary

- Browser login and account balances come from `https://auth.starhk.uk`.
- Checkout, products, orders, and invoices come from `https://pay.starhk.uk`.
- Payment credentials, Webhook processing, and Star Auth fulfillment remain server-side.
- A frontend must never call `purchase/fulfill`, grant points, trust a client-submitted amount, or expose a payment, site, or admin secret.

Configure `payBaseUrl` through the host project's Star service configuration. Protected Star Pay requests carry:

```http
Authorization: Bearer <access_token>
X-Site-Id: <site_id>
```

Prefer `@star-auth/account-core` when the host already uses it so authentication, site scoping, and unauthorized handling stay consistent.

## Products and site selection

For pricing pages, call `GET /api/v1/products/public` before login. It requires no Authorization, Cookie, or X-Site-Id. It returns `{ "ok": true, "products": [...] }` with the product fields below, restricted to active global products in the approved public set. Site headers do not expand this set; internal and site-specific products are excluded. Prices and benefits come from Pay's database.

Use `accountClient.listPublicProducts()` with the updated account-core SDK, or:

```ts
const response = await fetch(`${payBaseUrl}/api/v1/products/public`, { credentials: 'omit' });
if (!response.ok) throw new Error('Product catalog unavailable');
const { products } = await response.json();
```

Remove login guards around public pricing loads. Catalog failures should show a retry state, never trigger login or substitute hardcoded prices. A null price is a configuration error, not a free product. Continue applying the site's product allowlist. Require login when the user buys; checkout, orders, invoices, and subscription changes remain protected. Existing `listProducts()` stays authenticated for site-specific purchasable catalogs; it is not automatically redirected to the public endpoint.

`GET /api/v1/products` returns active global products plus products specific to the authenticated site. It is a purchasable catalog, not a requirement to display every item.

The recommended v2 catalog contains ten product IDs:

- Monthly subscriptions: `member-v2-starter-monthly`, `member-v2-pro-monthly`, `member-v2-max-monthly`.
- One-time 31-day memberships: `member-v2-starter-31d`, `member-v2-pro-31d`, `member-v2-max-31d`.
- Permanent point packs: `points-pack-v2-300`, `points-pack-v2-750`, `points-pack-v2-1800`, `points-pack-v2-4500`.

The legacy six-item set remains public for existing integrations: `basic-monthly`, `pro-monthly`, `ultra-monthly`, `points-pack-1800`, `points-pack-7000`, and `points-pack-13000`. New sites should use v2 IDs. Never expose every returned product automatically; keep a site-owned allowlist.

Legacy credits are not permanent. Each grant from `basic-monthly`, `pro-monthly`, or `ultra-monthly`, and each purchase of `points-pack-1800`, `points-pack-7000`, or `points-pack-13000`, expires 365 days after fulfillment. Ending a legacy subscription does not immediately clear grants already issued; the remaining amount stays usable only until its own batch expiry. Only `points-pack-v2-*` products with `points_policy: permanent` are permanent.

Keep an explicit site-owned allowlist such as `SITE_PAY_PRODUCT_IDS`. Filter the catalog before rendering and reject checkout for an ID outside that allowlist. Do not infer IDs from labels or plan names.

Star Pay is authoritative for product existence, mode, price, and entitlements. Each product returned by `GET /api/v1/products` has this frontend contract:

```ts
interface PayProduct {
  id: string;
  name: string;
  description: string | null;
  mode: 'payment' | 'subscription';
  currency: string;
  unit_amount: number | null;
  entitlements: {
    plan?: string;
    plan_duration_days?: number;
    initial_points?: number;
    renewal_points?: number;
    points?: number;
    points_expires_days?: number;
    points_policy?: 'expire_with_plan' | 'subscription_rollover' | 'permanent';
    [key: string]: unknown;
  };
}
```

`unit_amount` is the configured package price in the currency's smallest unit; for example, `{ currency: 'usd', unit_amount: 990 }` means USD 9.90. Format the pair with a currency-aware formatter. If `unit_amount` is `null`, treat the product as misconfigured: do not show its purchase control and alert platform operations.

For subscriptions, display the plan and first/renewal-cycle point benefits from `entitlements.plan`, `initial_points`, and `renewal_points`. For one-time point packs, use `entitlements.points`; a point pack does not change the user's plan. Treat these runtime fields as authoritative and do not copy a dated price or benefit table into reusable frontend logic. Site-owned names, descriptions, and feature copy may remain in i18n or CMS content, but they must not override the catalog price or entitlements.

For v2 pricing copy, disclose the point policy next to the purchase control:

- `expire_with_plan`: the one-time membership and its included points expire together after 31 days; it does not renew automatically.
- `subscription_rollover`: unused monthly points roll over while the subscription remains active, then remaining subscription-granted points expire when the subscription finally ends.
- `permanent`: purchased point-pack credits do not expire and do not require membership.

Expiration affects only the matching membership grant batches. Permanent point-pack balances must never be described as expiring or cleared when a membership ends.

An expired batch is deducted from the displayed balance and cannot be spent again. This does not clear unrelated unexpired or permanent batches, so frontend copy must not say that all account credits are reset unless the expiring batch is the user's entire balance. Star Auth consumes subscription-grant credits first, other expiring credits second, and permanent purchased credits last; expiry and creation time break ties within a category.

For account UI, use `membership.is_member` as the effective membership flag instead of deriving validity from `plan`. A time-limited membership may retain its last plan label after expiry while `is_member` is false. Keep the total `points` visible, and separately render `points_breakdown.membership` as membership credits and `points_breakdown.permanent` as permanent credits; render `other` when non-zero. Use `points_breakdown.membership_expires_at` for the membership-credit expiry label. When `points_expires_at` is non-null, display `points_expiring_soon` with that Unix-second expiry date; multiply by `1000` before constructing a JavaScript `Date`. A null `points_expires_at` means there is no currently expiring batch, not that the balance is zero.

An expired membership is treated as the free tier for model access. It must not invalidate permanent credits: users may spend those credits on model groups without a paid `min_plan`, while member-only groups still require an active qualifying plan. Do not present this state as “all credits unavailable.”

Full point-pack refunds reverse only the unspent batches belonging to that order's `source_ref`. Task failure refunds and estimated-versus-actual settlement differences restore credits to their original batch and category; they must not convert membership credits into permanent credits or vice versa.

## Payment capabilities and frontend presentation

Fetch payment capabilities independently of the product catalog:

```ts
const capability = await accountClient.listPaymentMethods();
```

Equivalent public API:

```http
GET /api/v1/payment-methods/public?site_id=<registered_site_id>
```

The endpoint validates that the site is registered and enabled, then returns the complete platform-allowed capability set. It does not remove methods based on the shopper's country or the page locale.

```ts
interface PaymentCapabilityResponse {
  detected_country_code: string | null;
  /** Backward-compatible alias of detected_country_code. */
  country_code: string | null;
  methods: Array<{
    provider: 'stripe' | 'airwallex';
    payment_method: 'stripe' | 'alipaycn' | 'wechatpay' | 'alipayhk';
    label: string;
    modes: Array<'payment' | 'subscription'>;
  }>;
}
```

Responsibilities are deliberately split:

- Star Pay publishes callable capabilities and enforces the selected provider, method, product mode, registered site, amount, and redirect origin at Checkout creation.
- The site frontend owns the visible subset, order, localized labels, and default method for each market, page, campaign, device, or other product scenario.
- Airwallex Hosted Payment Page performs the final availability filtering from the merchant's enabled methods, shopper country, and transaction currency.
- `detected_country_code` is an edge-derived UX hint. It is not proof of residence, a permission boundary, or a substitute for site product logic. Browser locale is only a language preference.

Compute the UI as `runtime capabilities ∩ site display selection`; never let local configuration reintroduce a method missing from the runtime response:

```ts
const SITE_PAYMENT_METHODS_BY_MARKET: Record<string, readonly string[]> = {
  CN: ['stripe', 'alipaycn', 'wechatpay'],
  HK: ['stripe', 'alipayhk', 'wechatpay'],
  DEFAULT: ['stripe'],
};

const market = siteSelectedMarket ?? capability.detected_country_code ?? 'DEFAULT';
const configured = new Set(SITE_PAYMENT_METHODS_BY_MARKET[market] ?? SITE_PAYMENT_METHODS_BY_MARKET.DEFAULT);
const visibleMethods = capability.methods.filter((method) =>
  configured.has(method.payment_method) && method.modes.includes(product.mode),
);
```

The example market map is site-owned presentation policy, not a Star Pay rule. A different site may choose a different subset without a payment-service deployment. It may localize `label` (for example, render `wechatpay` as “WeChat Pay HK” on an HK page), but must submit the stable `payment_method` identifier unchanged.

Current platform constraints:

- Stripe supports one-time `payment` and `subscription` products.
- Airwallex `alipaycn`, `wechatpay`, and `alipayhk` support one-time `payment` products only.
- FPS is not an exposed platform capability.
- Every registered, enabled Star site can query and use Airwallex when the provider is configured; there is no separate frontend-site allowlist.

## Checkout flow

Create a session with `POST /api/v1/checkout/sessions` using an allowlisted `product_id`, absolute `success_url` and `cancel_url`, and optional page `locale`.

Use `beginCheckout` for the standard browser flow:

```ts
const { successUrl, cancelUrl } = buildCheckoutReturnUrls('/pricing');

await beginCheckout(config, {
  productId,
  successUrl,
  cancelUrl,
  locale,
  returnPath: location.pathname + location.search + location.hash,
});
```

For an Airwallex choice, pass the exact runtime capability plus a shopper country hint. `countryCode` is ISO 3166-1 alpha-2 and is forwarded to Hosted Payment Page; it does not authorize the method.

```ts
await beginCheckout(config, {
  productId,
  successUrl,
  cancelUrl,
  locale,
  provider: 'airwallex',
  paymentMethod: 'alipayhk',
  countryCode: siteSelectedMarket ?? capability.detected_country_code ?? 'HK',
});
```

### Single-method button or aggregated picker

There are two supported presentation patterns:

- **Separate site buttons:** submit one `payment_method`, for example `alipaycn` or `wechatpay`. Hosted Payment Page opens with only that method.
- **One aggregated “WeChat Pay / Alipay” button:** submit the ordered `payment_methods` array. Hosted Payment Page lets the shopper choose between the methods that remain available for the transaction.

For a mainland-China aggregated picker with Alipay first/default and WeChat Pay below it, submit:

```json
{
  "provider": "airwallex",
  "payment_methods": ["alipaycn", "wechatpay"],
  "country_code": "CN"
}
```

Airwallex displays allowed methods in the supplied order, after its final filtering for the merchant account, currency, and shopper country. With the current Hosted Payment Page, the first available entry is initially expanded/selected; therefore keep `alipaycn` first to make Alipay the default. Do not send `payment_method` and `payment_methods` together. Build the array only from the current runtime capability response.

The shared `beginCheckout()` helper currently accepts the single `paymentMethod` form. A site implementing the aggregated picker must call the authenticated Checkout API directly and initialize Hosted Payment Page from its Airwallex response, or use a shared SDK version that explicitly exposes an ordered `paymentMethods` parameter. Do not pass an undocumented property and assume it is forwarded.

Do not submit `provider`, `paymentMethod`, or `countryCode` values invented outside the runtime capability and the site's explicit scenario selection. Never submit an amount from the browser.

If logged out, it saves the pending purchase, including provider selection, and starts Star Auth login. After the OAuth callback exchanges the code, call `resumePendingCheckout(config)` before the ordinary return-path redirect. Stripe can return an opaque URL for the caller to navigate to; Airwallex initializes its Hosted Payment Page redirect and can return `null`, which is not a failure.

Use only return URLs on the registered site origin. Treat a returned `checkout_url` as opaque and do not require it for Airwallex responses. The Checkout creation API currently has no client idempotency key: disable the purchase control while creating a session and do not blindly retry the POST after an ambiguous failure. Webhook and fulfillment deduplication do not make duplicate Checkout Sessions or PaymentIntents desirable.

### Direct Checkout API

Use this only when the frontend cannot use `@star-auth/account-core`. The access token must belong to the current user and `X-Site-Id` must match the registered frontend site. Do not send an amount, currency, points, or plan from the browser.

```http
POST https://pay.starhk.uk/api/v1/checkout/sessions
Authorization: Bearer <access_token>
X-Site-Id: <registered_site_id>
Content-Type: application/json
```

```json
{
  "product_id": "points-pack-7000",
  "provider": "airwallex",
  "payment_methods": ["alipaycn", "wechatpay"],
  "country_code": "CN",
  "locale": "zh-CN",
  "success_url": "https://example.com/pricing/?payment=success",
  "cancel_url": "https://example.com/pricing/?payment=cancelled"
}
```

Stripe returns a non-empty `checkout_url`. An Airwallex response instead contains the Hosted Payment Page initialization data:

```ts
interface AirwallexCheckoutResponse {
  ok: true;
  provider: 'airwallex';
  checkout_url: null;
  payment_intent_id: string;
  client_secret: string;
  env: 'demo' | 'prod';
  currency: string;
  amount: number;
  payment_methods: Array<'alipaycn' | 'wechatpay' | 'alipayhk'>;
  country_code: string;
  success_url: string;
}
```

The SDK entry point loads from `static.airwallex.com`, but at runtime it also loads a bundle from `checkout.airwallex.com`. A custom integration must implement the same flow and the real HTML response must allow both script origins:

```http
Content-Security-Policy: script-src 'self' https://static.airwallex.com https://checkout.airwallex.com; ...
```

Allowing only `static.airwallex.com` produces a browser CSP failure similar to:

```text
Failed to load Airwallex SDK scripts:
https://checkout.airwallex.com/assets/elements.bundle.min.js?...
```

This is a frontend CSP failure, not a Star Pay Checkout, Airwallex credential, or Webhook failure. Check the browser console and Network panel before changing payment credentials. Also add the current Airwallex-required hosts to `connect-src` and `frame-src`. Apply CSP on the real HTML response (for example `public/_headers`, a BFF, an edge Worker, or the hosting platform's header configuration), not only in a static HTML source file. Never expose `AIRWALLEX_API_KEY`, `AIRWALLEX_WEBHOOK_SECRET`, Stripe secrets, or Star internal API keys to the browser.

## Payment return and eventual consistency

`payment=success` means the user returned from the provider; it is not proof that points or a plan are already visible. Webhook fulfillment can lag by several seconds.

1. Refresh account overview with `client.getOverview()`.
2. Refresh site-scoped orders with `client.listOrders()`.
3. If still pending, poll both at a modest interval with a bounded stop, for example every 2 seconds for up to 8 attempts.
4. End in a useful pending state if incomplete; never grant benefits locally.

For `payment=cancelled`, clear loading state without changing account data.

Subscription renewal is fulfilled from `invoice.paid`. A point pack increases points without changing the plan. Refunds, cancellations, and downgrades are server lifecycle events; the frontend only refreshes account and order state.

## Airwallex payment and refund lifecycle

Star Pay grants entitlements only after a signature-verified `payment_intent.succeeded` event. A browser return URL, an SDK redirect, or an unverified client response must never grant points or change a plan.

The Star Admin **full refund** action is asynchronous for Airwallex:

1. Admin calls Star Pay's protected refund route; Star Pay creates a full refund using a stable `refund_<order_id>` request ID.
2. `refund.received` changes the order to `refund_pending`. The original entitlements stay in place while processing.
3. A signature-verified `refund.accepted` or `refund.settled`, with matching full amount and currency, triggers the idempotent Star Auth entitlement reversal and marks the order refunded.
4. `refund.failed` restores the refundable order state and does not remove entitlements.
5. A partial refund enters `partial_refund_review`; the system does not guess how to split plan or point entitlements.

For a point-pack refund, only points granted by that purchase are reversed; an unrelated existing plan is not downgraded. Webhook event and entitlement reversal keys are idempotent, so replaying an event must not deduct twice. The frontend should refresh account overview and orders, render `refund_pending` as processing, and never subtract points locally.

## Orders and receipts

- `GET /api/v1/orders/me?limit=20&cursor=...` lists orders for the authenticated user and current site.
- `GET /api/v1/orders/:order_id/invoice` returns the available invoice or receipt link.
- `package_amount_total` and `package_currency` are the product's USD list price and the source for platform revenue reporting.
- `amount_total` and `currency` are what the customer actually paid in the presentment currency. Render this pair as-is; do not convert it to USD or use it for revenue.
- Stripe Adaptive Pricing can make the package and paid pairs differ. Keep both labels explicit in account and admin interfaces.
- Enable the account `orders` module only when `payBaseUrl` is configured and payments are intentionally exposed.

Subscription orders additionally return:

```json
{
  "mode": "subscription",
  "subscription": {
    "status": "active",
    "cancel_at_period_end": true,
    "current_period_end": 1788192000
  }
}
```

- `subscription` is `null` for one-time products.
- `status` mirrors Stripe and can be `active`, `trialing`, `past_due`, `unpaid`, `paused`, or `canceled`. Use it for order UI only; Star Auth overview remains authoritative for current plan access and points.
- `cancel_at_period_end=true` means future automatic renewal is disabled but the current paid period remains active.
- `current_period_end` is a Unix timestamp in **seconds**, not milliseconds. Render it with `new Date(value * 1000)` in the user's locale.
- Legacy orders can temporarily return `null` status or period end until a subscription Webhook or lifecycle action refreshes them. Accept nulls and never invent an expiry from product names or purchase dates.

## Cancel future renewal without refunding

Use the authenticated Star Pay route:

```http
POST /api/v1/orders/:order_id/subscription/cancel
Authorization: Bearer <access_token>
X-Site-Id: <site_id>
```

The request has no body. The order must belong to the authenticated user and current site, have `payment_status=paid`, `mode=subscription`, provider `stripe`, and a live Stripe subscription ID.

Successful response:

```json
{
  "ok": true,
  "order_id": "ord_xxx",
  "subscription": {
    "status": "active",
    "cancel_at_period_end": true,
    "current_period_end": 1788192000
  }
}
```

This action has a deliberately narrow meaning:

- Keep the amount already paid; do not issue a refund.
- Keep the current plan active through `current_period_end`.
- Keep points already issued for the current billing period.
- Stop automatic renewal after the current paid period.
- Downgrade only when Stripe later emits `customer.subscription.deleted` at the actual period end.

The route is state-idempotent. Repeating it after `cancel_at_period_end` is already true returns the current Stripe state and does not create another action or refund.

Do not implement this button by calling an admin refund route, Stripe directly, `purchase/fulfill`, or any points endpoint. The Star Admin action named **立即退款** is a separate destructive workflow that cancels immediately, refunds, downgrades, and reverses entitlements.

With `@star-auth/account-core`:

```ts
const changed = await client.cancelSubscriptionRenewal(order.id);
const subscription = changed.subscription;
// Update local UI from this response immediately; a list refresh can follow.
```

Before calling, tell the user that no refund is made and access remains until the displayed period end. Disable the button while the request is pending. On failure, keep the previous UI state.

## Resume renewal before expiry

Use:

```http
POST /api/v1/orders/:order_id/subscription/resume
Authorization: Bearer <access_token>
X-Site-Id: <site_id>
```

The request has no body and is state-idempotent. A successful response has the same shape with `cancel_at_period_end=false`. Automatic billing resumes on the subscription's existing schedule.

```ts
const changed = await client.resumeSubscriptionRenewal(order.id);
const subscription = changed.subscription;
```

Resume is available only before the subscription has actually ended. When Stripe status is `canceled`, the API returns `400 INVALID_INPUT`; send the user through a new Checkout instead.

## Renewal UI state machine

Evaluate in this order; do not infer state from product labels or dates:

| Condition | Display | Action |
|---|---|---|
| `mode !== 'subscription'` | Ordinary purchase | None |
| `payment_status !== 'paid'` | Pending, failed, or refunded order | None |
| `order.status === 'canceled'` or `subscription.status === 'canceled'` | Subscription ended | Offer a new purchase |
| `subscription.cancel_at_period_end === true` | `已取消续订，将于 YYYY-MM-DD 到期` | Resume renewal |
| Other paid subscription | Automatic renewal | Cancel renewal |

The shared `@star-auth/account-react` `OrdersList` already implements this state machine, confirmation copy, pending-button lock, cancellation notice, and resume action. A site using that component should update and rebuild both account packages. A custom account UI should use the two `AccountClient` methods above.

Near a billing boundary, Stripe might already have finalized and paid the next invoice before a cancellation request arrives. Treat the returned `current_period_end` and the provider invoice as authoritative; cancellation then applies after the newly paid period. Never promise a refund from the cancel-renewal action.

## Subscription API errors

| HTTP | Code | Meaning | Frontend action |
|---|---|---|---|
| 401 | `UNAUTHORIZED` | Missing or expired user session | Restart or restore Star Auth login |
| 404 | `NOT_FOUND` | Missing order or order belongs to another user/site | Refresh the site-scoped order list; reveal no cross-user detail |
| 400 | `INVALID_INPUT` | Not a paid Stripe subscription, or subscription already ended | Refresh state; offer Checkout when ended |
| 502 | `PAYMENT_ERROR` | Stripe status could not be read or updated | Keep old state and allow a deliberate retry |
| 500 | `CONFIGURATION_ERROR` | Star Pay Stripe configuration is missing | Show a service error and alert platform operations |

Use the structured response `{ ok: false, error: { code, message } }`. Do not switch the UI optimistically when the request fails.

## Subscription Webhook consistency

Star Pay persists lifecycle state from:

- `checkout.session.completed`: subscription ID, initial status, and current period end.
- `invoice.paid`: new period end and renewal entitlement fulfillment.
- `customer.subscription.updated`: cancel/resume and other Stripe state changes.
- `customer.subscription.deleted`: actual subscription end and Star Auth downgrade.

The Stripe endpoint must subscribe to both `customer.subscription.updated` and `customer.subscription.deleted`. The cancel/resume API returns Stripe's state immediately, while Webhooks preserve final consistency for Stripe Dashboard changes and later lifecycle events.

## Error handling

- `401`: restore or restart Star Auth login using the established pending-checkout helper.
- `400`: validate product selection, return URLs, and whether an order is an active paid Stripe subscription.
- `404`: refresh the product catalog; do not retry an obsolete product ID.
- `502`: show a retryable provider error without changing points or membership.
- Payment failure must not break login, AI tasks, account overview, or analytics.

## Source of truth

The canonical human-readable page is `https://doc.starhk.uk/docs/payments/`. AI coding tools should read `https://doc.starhk.uk/docs/payments.md` and start from `https://doc.starhk.uk/skill/SKILL.md` when they need the complete Star integration contract. Star Pay remains the runtime API at `https://pay.starhk.uk`; it is not the documentation source of truth.
