SStar Developers
Stripe、Airwallex、订单和退款查看 Markdown

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:

ConditionDisplayAction
mode !== 'subscription'Ordinary purchaseNone
payment_status !== 'paid'Pending, failed, or refunded orderNone
order.status === 'canceled' or subscription.status === 'canceled'Subscription endedOffer a new purchase
subscription.cancel_at_period_end === true已取消续订,将于 YYYY-MM-DD 到期Resume renewal
Other paid subscriptionAutomatic renewalCancel 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#

HTTPCodeMeaningFrontend action
401UNAUTHORIZEDMissing or expired user sessionRestart or restore Star Auth login
404NOT_FOUNDMissing order or order belongs to another user/siteRefresh the site-scoped order list; reveal no cross-user detail
400INVALID_INPUTNot a paid Stripe subscription, or subscription already endedRefresh state; offer Checkout when ended
502PAYMENT_ERRORStripe status could not be read or updatedKeep old state and allow a deliberate retry
500CONFIGURATION_ERRORStar Pay Stripe configuration is missingShow 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.