feat(payments): configurable checkout payments — wallet + Stripe crypto, original-method refunds (add-configurable-payments)

- payment domain: payments/memberships/attempts/receipts/refunds/commands/events, legacy-demo provenance (0024), checkout header-hash idempotency (0025)
- wallet collection all-or-none across shop orders; group-wide cancellation; checkout cutover, status-only payOrder removed
- Stripe crypto-only Checkout adapter, raw-body HMAC webhooks, durable command worker with leases
- original-method refunds with receipt/allocation budgets; refund_pending/refund_failed aftersale states
- admin channel CRUD/revisions/audit/verify + operations list/detail/reconcile; secret-ref allowlist (VMALL_PAYMENT_*), env-fixed demo/sandbox/live
- mall/mobile checkout pay surfaces, admin console pages; shared contract updated
- pre-launch leftovers recorded in docs/TBD-payments.md

cargo test -p vmall-api green twice; four frontend builds green; browser smoke: mall wallet checkout paid, admin channels/operations verified
This commit is contained in:
Chengdong Zhang
2026-10-15 16:14:28 +08:00
parent f439795c37
commit a7cd392bd4
100 changed files with 14251 additions and 848 deletions
+112 -18
View File
@@ -1,5 +1,6 @@
import type {
AccountSummary,
AdminPaymentDetail,
Aftersale,
AftersaleApplyBody,
AftersaleArbitration,
@@ -15,12 +16,15 @@ import type {
BrandInput,
Cart,
Category,
CheckoutResult,
ContentInputByKind,
ContentKind,
Coupon,
CouponTemplate,
CouponTemplateInput,
CreatePaymentAttemptBody,
Currency,
EligibleMethod,
Favorite,
FavoriteKind,
FlashSaleItem,
@@ -54,6 +58,20 @@ import type {
Order,
OrderStatus,
Paged,
Payment,
PaymentAdapterDescriptor,
PaymentAttemptResult,
PaymentChannel,
PaymentChannelAuditEntry,
PaymentChannelCreateBody,
PaymentChannelDetail,
PaymentChannelQuery,
PaymentChannelRevision,
PaymentChannelUpdateBody,
PaymentChannelVerification,
PaymentOperation,
PaymentOperationQuery,
PaymentReconcileCommand,
Product,
ProductStatus,
PublicFlashSaleSession,
@@ -187,6 +205,7 @@ async function request<T>(
path: string,
body?: unknown,
query?: Query,
idempotencyKey?: string,
): Promise<T> {
const url = new URL(opts.baseUrl + path);
if (query) {
@@ -197,6 +216,7 @@ async function request<T>(
const headers: Record<string, string> = { "content-type": "application/json" };
const token = opts.getToken?.();
if (token) headers.authorization = `Bearer ${token}`;
if (idempotencyKey !== undefined) headers["idempotency-key"] = idempotencyKey;
const res = await fetch(url.toString(), {
method,
headers,
@@ -234,18 +254,38 @@ export interface ApiClient {
addCartItem(skuId: string, qty: number): Promise<Cart>;
updateCartItem(skuId: string, qty: number): Promise<Cart>;
removeCartItem(skuId: string): Promise<Cart>;
/**
* Creates the checkout's shop orders and their shared payment in one
* transaction. `idempotencyKey` is required: replaying the same key/payload
* returns the original result, a changed payload is a 409, and a missing key
* is a 400.
*/
checkout(
shippingAddress: Address,
currency: string,
/** At most one owned coupon per generated shop order. */
couponByShop?: Record<string, string>,
/** Optional group-buying intent for one activity SKU at quantity 1. */
groupBuy?: GroupBuyIntent,
): Promise<Order[]>;
couponByShop: Record<string, string> | undefined,
groupBuy: GroupBuyIntent | undefined,
idempotencyKey: string,
): Promise<CheckoutResult>;
listMyOrders(page?: number): Promise<Paged<Order>>;
getOrder(id: string): Promise<Order>;
cancelOrder(id: string): Promise<Order>;
payOrder(id: string): Promise<Order>;
/** Resolves the existing payment of owned pending orders, or creates a singleton. */
resolvePayment(orderIds: string[]): Promise<Payment>;
getPayment(id: string): Promise<Payment>;
/** Eligible enabled channels for this payment; never secrets or provider config. */
listPaymentMethods(id: string): Promise<EligibleMethod[]>;
/**
* Starts one collection attempt; `idempotencyKey` is required and the same
* key/payload replays the same operation while a changed payload is a 409.
*/
createPaymentAttempt(
id: string,
body: CreatePaymentAttemptBody,
idempotencyKey: string,
): Promise<PaymentAttemptResult>;
/** Cancels the whole unpaid payment group; 409 with authoritative state otherwise. */
cancelPayment(id: string): Promise<Payment>;
confirmDelivered(shipmentId: string): Promise<Shipment>;
listMyShipments(): Promise<Shipment[]>;
requestInvoice(
@@ -372,7 +412,8 @@ export interface ApiClient {
approveAftersale(id: string): Promise<Aftersale>;
rejectAftersale(id: string): Promise<Aftersale>;
confirmAftersaleReceipt(id: string): Promise<Aftersale>;
refundAftersale(id: string): Promise<Aftersale>;
/** Submits (or retries after definitive failure) the original-method refund. */
refundAftersale(id: string): Promise<AftersaleDetail>;
addAftersaleMessage(id: string, body: AftersaleMessageBody): Promise<AftersaleMessage>;
listFreightTemplates(): Promise<FreightTemplate[]>;
createFreightTemplate(body: FreightTemplateInput): Promise<FreightTemplate>;
@@ -405,7 +446,8 @@ export interface ApiClient {
): Promise<HomeContent>;
listAftersales(status?: AftersaleStatus): Promise<Aftersale[]>;
getAftersale(id: string): Promise<AftersaleDetail>;
arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise<Aftersale>;
/** Resolves an escalated application via the shared refund path. */
arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise<AftersaleDetail>;
listReviews(page?: number): Promise<Paged<Review>>;
hideReview(id: string): Promise<Review>;
deleteReview(id: string): Promise<void>;
@@ -439,12 +481,40 @@ export interface ApiClient {
updateMemberLevel(id: string, body: MemberLevelInput): Promise<MemberLevel>;
/** Rejected with a 409 when any customer currently holds the level. */
deleteMemberLevel(id: string): Promise<void>;
/** Adapter catalog with capabilities and external activation prerequisites. */
listPaymentAdapters(): Promise<PaymentAdapterDescriptor[]>;
listPaymentChannels(q?: PaymentChannelQuery): Promise<Paged<PaymentChannel>>;
getPaymentChannel(id: string): Promise<PaymentChannelDetail>;
/** Returns the detail superset, including secret references and readiness. */
createPaymentChannel(body: PaymentChannelCreateBody): Promise<PaymentChannelDetail>;
/** 409 when `expected_revision` no longer matches the channel revision. */
updatePaymentChannel(
id: string,
body: PaymentChannelUpdateBody,
): Promise<PaymentChannelDetail>;
/** Archival when the channel is referenced; never destructive removal. */
deletePaymentChannel(id: string): Promise<void>;
listPaymentChannelRevisions(id: string): Promise<PaymentChannelRevision[]>;
listPaymentChannelAudit(id: string): Promise<PaymentChannelAuditEntry[]>;
/** Read-only provider connection/account check; implies no approval. */
verifyPaymentChannel(id: string): Promise<PaymentChannelVerification>;
/** Paginated payment/refund/exception operations. */
listPaymentOperations(q?: PaymentOperationQuery): Promise<Paged<PaymentOperation>>;
/** Recovery detail: authoritative payment plus redacted channel audit history. */
getPayment(id: string): Promise<AdminPaymentDetail>;
/** Durable evidence-fetch command; a repeat never initiates collection. */
reconcilePayment(id: string): Promise<PaymentReconcileCommand>;
};
}
export function createApi(opts: ApiClientOptions): ApiClient {
const r = <T>(m: string, p: string, b?: unknown, q?: Query): Promise<T> =>
request<T>(opts, m, p, b, q);
const r = <T>(
m: string,
p: string,
b?: unknown,
q?: Query,
idempotencyKey?: string,
): Promise<T> => request<T>(opts, m, p, b, q, idempotencyKey);
return {
register: (email, password, displayName) =>
r("POST", "/auth/register", { email, password, display_name: displayName }),
@@ -462,17 +532,28 @@ export function createApi(opts: ApiClientOptions): ApiClient {
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
checkout: (shippingAddress, currency, couponByShop = {}, groupBuy) =>
r("POST", "/orders/checkout", {
shipping_address: shippingAddress,
currency,
coupon_by_shop: couponByShop,
group_buy: groupBuy ?? null,
}),
checkout: (shippingAddress, currency, couponByShop, groupBuy, idempotencyKey) =>
r(
"POST",
"/orders/checkout",
{
shipping_address: shippingAddress,
currency,
coupon_by_shop: couponByShop ?? {},
group_buy: groupBuy ?? null,
},
undefined,
idempotencyKey,
),
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
getOrder: (id) => r("GET", `/orders/${id}`),
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
payOrder: (id) => r("POST", `/orders/${id}/pay`),
resolvePayment: (orderIds) => r("POST", "/payments", { order_ids: orderIds }),
getPayment: (id) => r("GET", `/payments/${id}`),
listPaymentMethods: (id) => r("GET", `/payments/${id}/methods`),
createPaymentAttempt: (id, body, idempotencyKey) =>
r("POST", `/payments/${id}/attempts`, body, undefined, idempotencyKey),
cancelPayment: (id) => r("POST", `/payments/${id}/cancel`),
confirmDelivered: (shipmentId) => r("POST", `/shipments/${shipmentId}/confirm-delivered`),
listMyShipments: () => r("GET", "/shipments"),
quoteShipping: (address, currency) =>
@@ -654,6 +735,19 @@ export function createApi(opts: ApiClientOptions): ApiClient {
createMemberLevel: (body) => r("POST", "/admin/member-levels", body),
updateMemberLevel: (id, body) => r("PUT", `/admin/member-levels/${id}`, body),
deleteMemberLevel: (id) => r("DELETE", `/admin/member-levels/${id}`),
listPaymentAdapters: () => r("GET", "/admin/payments/channels/adapters"),
listPaymentChannels: (q = {}) =>
r("GET", "/admin/payments/channels", undefined, { ...q }),
getPaymentChannel: (id) => r("GET", `/admin/payments/channels/${id}`),
createPaymentChannel: (body) => r("POST", "/admin/payments/channels", body),
updatePaymentChannel: (id, body) => r("PUT", `/admin/payments/channels/${id}`, body),
deletePaymentChannel: (id) => r("DELETE", `/admin/payments/channels/${id}`),
listPaymentChannelRevisions: (id) => r("GET", `/admin/payments/channels/${id}/revisions`),
listPaymentChannelAudit: (id) => r("GET", `/admin/payments/channels/${id}/audit`),
verifyPaymentChannel: (id) => r("POST", `/admin/payments/channels/${id}/verify`),
listPaymentOperations: (q = {}) => r("GET", "/admin/payments", undefined, { ...q }),
getPayment: (id) => r("GET", `/admin/payments/${id}`),
reconcilePayment: (id) => r("POST", `/admin/payments/${id}/reconcile`),
},
};
}
+390 -1
View File
@@ -637,7 +637,11 @@ export type AftersaleStatus =
| "buyer_shipping"
| "merchant_confirmed"
| "refunded"
| "cancelled";
| "cancelled"
/** Refund submitted and awaiting verified provider success. */
| "refund_pending"
/** Refund definitively failed; one guarded retry is permitted. */
| "refund_failed";
export interface Aftersale {
id: string;
@@ -685,6 +689,8 @@ export interface AftersaleDetail extends Aftersale {
item: AftersaleItemSummary;
messages: AftersaleMessage[];
remaining_refundable_minor: number;
/** The current/last refund operation, or null before any refund submission. */
refund_operation: RefundOperation | null;
}
export interface AftersaleApplyBody {
@@ -707,6 +713,385 @@ export interface AftersaleReturnTrackingBody {
export type AftersaleArbitration = "refund" | "reject";
// ---- payments ----
/** Deployment payment environment; immutable per deployment, never toggled. */
export type PaymentEnvironment = "demo" | "sandbox" | "live";
/** The two shipped collection adapters; no other adapter exists. */
export type PaymentAdapter = "wallet" | "stripe_crypto";
/** Local application of a collected payment onto its member orders. */
export type PaymentStatus = "unapplied" | "applied" | "closed";
export type PaymentAttemptStatus =
| "created"
| "pending"
| "unknown"
| "settled"
| "failed"
| "expired";
export type PaymentRefundStatus = "pending" | "unknown" | "succeeded" | "failed";
export type RefundPurpose = "aftersale" | "compensation";
/** Which storefront an attempt returns to; mapped server-side to an allowlisted origin. */
export type ReturnSurface = "mall" | "mobile";
/**
* Safe next action for the caller's surface. `url` is present only for
* `redirect` and is always built from deployment-configured origins.
*/
export interface NextAction {
type: "none" | "redirect" | "wait";
url?: string;
}
/**
* Checkout result. Replaying the same owner-scoped `Idempotency-Key` with an
* unchanged payload returns this original result; a changed payload is a 409.
*/
export interface CheckoutResult {
orders: Order[];
payment_id: string;
}
/** Compact payment echo used by attempt responses. */
export interface PaymentSummary {
id: string;
status: PaymentStatus;
currency: string;
total_minor: number;
}
/** One checkout order's immutable membership in a payment. */
export interface PaymentOrder extends Order {
/** The order's snapshotted total at checkout; sums to the payment total. */
amount_minor: number;
}
/** One collection attempt against a channel. */
export interface PaymentAttempt {
id: string;
channel_id: string;
adapter: PaymentAdapter;
status: PaymentAttemptStatus;
next_action: NextAction;
}
/**
* Response to `createPaymentAttempt`: the attempt identity/status, the payment
* summary and the safe next action (wallet completion or pending/redirect).
*/
export interface PaymentAttemptResult {
id: string;
status: PaymentAttemptStatus;
payment: PaymentSummary;
next_action: NextAction;
}
/** Request body for `createPaymentAttempt`; `return_surface` is persisted on the attempt. */
export interface CreatePaymentAttemptBody {
channel_id: string;
return_surface: ReturnSurface;
}
/** One eligible enabled channel and its safe descriptor; never secrets. */
export interface EligibleMethod {
/** Channel id accepted by `createPaymentAttempt`. */
id: string;
adapter: PaymentAdapter;
label: LocalizedText;
currencies: string[];
min_amount_minor?: number;
max_amount_minor?: number;
}
/** One original-method refund operation; a retry gets a new operation identity. */
export interface RefundOperation {
id: string;
purpose: RefundPurpose;
status: PaymentRefundStatus;
amount_minor: number;
currency: string;
aftersale_id?: string;
/** Adapter that collected the funds; the refund returns by this original method. */
method: PaymentAdapter;
/** Provider operation reference when one exists. */
reference: string | null;
/** Bounded failure detail; null unless the operation failed or is unknown. */
error: string | null;
/** True only on definitive failure when one explicit guarded retry is permitted. */
retry_allowed: boolean;
created_at: string;
updated_at: string;
}
export type PaymentExceptionKind =
| "unapplied_receipt"
| "extra_collection"
| "amount_mismatch"
| "unknown_operation";
export type PaymentExceptionStatus = "open" | "resolved";
/** A collection/application anomaly awaiting operator resolution. */
export interface PaymentException {
id: string;
kind: PaymentExceptionKind;
detail?: LocalizedText | string;
created_at?: string;
}
/**
* Provider-exposed crypto asset details. `base_units` is an integer base-unit
* quantity as a decimal string: asset quantities never use TS number arithmetic
* or `formatMoney`, and absent chain details stay absent.
*/
export interface CryptoAssetMetadata {
network: string | null;
/** Contract address, or null for the network's native asset. */
contract_address: string | null;
base_units: string;
symbol: string | null;
quote_reference: string | null;
quote_expires_at: string | null;
transaction_reference: string | null;
}
/** Immutable evidence of one actual collection. */
export interface PaymentReceipt {
id: string;
payment_id: string;
attempt_id: string | null;
adapter: PaymentAdapter;
environment: PaymentEnvironment;
amount_minor: number;
currency: string;
/** Provider transaction identity within provider account/environment. */
reference: string | null;
crypto: CryptoAssetMetadata | null;
collected_at: string;
}
/** One server-owned payment unit for a checkout's shop orders. */
export interface Payment extends PaymentSummary {
environment: PaymentEnvironment;
orders: PaymentOrder[];
attempts: PaymentAttempt[];
receipts: PaymentReceipt[];
refunds: RefundOperation[];
exceptions: PaymentException[];
next_action: NextAction;
}
/**
* Admin recovery view of a payment: the authoritative detail plus the redacted
* configuration audit of every channel its attempts referenced (admin route
* only; buyers never receive channel configuration history).
*/
export interface AdminPaymentDetail extends Payment {
audit: PaymentChannelAuditEntry[];
}
// ---- payment channels (admin) ----
/** Adapter-level constraints the admin channel editor enforces. */
export interface PaymentAdapterCapabilities {
currencies: string[];
min_amount_minor: number;
max_amount_minor: number | null;
/** Provider-managed choices surfaced informationally; never saved as restrictions. */
provider_managed: string[];
}
/** One shipped adapter in this deployment; probing it never implies approval. */
export interface PaymentAdapterDescriptor {
kind: PaymentAdapter;
label: LocalizedText;
environment: PaymentEnvironment;
capabilities: PaymentAdapterCapabilities;
/** Secret reference slot names a channel revision may configure. */
secret_slots: string[];
/** External activation prerequisites; a successful probe never substitutes for them. */
activation_prerequisites: string[];
}
/** One configured collection channel; credentials live only in its revisions. */
export interface PaymentChannel {
id: string;
adapter: PaymentAdapter;
label: LocalizedText;
display_order: number;
enabled: boolean;
archived: boolean;
supported_currencies: string[];
min_amount_minor: number;
max_amount_minor: number | null;
provider_account_id: string | null;
environment: PaymentEnvironment;
/** Current immutable revision; updates must precondition on it. */
revision: number;
}
/** A channel secret slot and whether its reference is configured; never a value. */
export interface PaymentSecretSlot {
slot: string;
configured: boolean;
reference: string | null;
}
/** Channel detail: the row plus its secret references and readiness context. */
export interface PaymentChannelDetail extends PaymentChannel {
secret_key_ref: string | null;
webhook_secret_ref: string | null;
secret_slots: PaymentSecretSlot[];
unmet_prerequisites: string[];
/** Adapter capabilities echoed for the editor; identical to the catalog entry. */
capabilities: PaymentAdapterCapabilities;
}
export interface PaymentChannelCreateBody {
adapter: PaymentAdapter;
label: LocalizedText;
display_order: number;
enabled: boolean;
supported_currencies: string[];
min_amount_minor: number;
max_amount_minor: number | null;
provider_account_id: string | null;
secret_key_ref: string | null;
webhook_secret_ref: string | null;
}
export interface PaymentChannelUpdateBody extends PaymentChannelCreateBody {
/** Optimistic precondition: the channel's current `revision`. A mismatch is 409. */
expected_revision: number;
}
/** Immutable channel configuration snapshot; secret references, never values. */
export interface PaymentChannelRevision {
id: string;
channel_id: string;
revision: number;
adapter: PaymentAdapter;
/** Adapter contract version recorded at revision creation. */
adapter_contract_version: string;
label: LocalizedText;
display_order: number;
enabled: boolean;
supported_currencies: string[];
min_amount_minor: number;
max_amount_minor: number | null;
provider_account_id: string | null;
secret_key_ref: string | null;
webhook_secret_ref: string | null;
created_at: string;
}
export type PaymentChannelAuditAction =
| "created"
| "updated"
| "enabled"
| "disabled"
| "verified"
| "archived";
/** One redacted audit change; secret values are never recorded. */
export interface PaymentChannelAuditChange {
from: unknown;
to: unknown;
}
/** Append-only configuration audit entry. */
export interface PaymentChannelAuditEntry {
id: string;
channel_id: string;
action: PaymentChannelAuditAction;
revision: number;
actor: string;
changes: Record<string, PaymentChannelAuditChange>;
created_at: string;
}
/** Read-only provider connection/account check; success implies no approval. */
export interface PaymentChannelVerification {
ok: boolean;
checked_at: string;
provider_account: string | null;
unmet_prerequisites: string[];
detail: string | null;
}
export interface PaymentChannelQuery {
page?: number;
per_page?: number;
}
// ---- payment operations (admin) ----
export type PaymentOperationKind = "payment" | "refund" | "exception";
/** One admin operations-list row; discriminated by `kind`. */
export type PaymentOperation =
| PaymentOperationPayment
| PaymentOperationRefund
| PaymentOperationException;
interface PaymentOperationBase {
id: string;
kind: PaymentOperationKind;
payment_id: string;
environment: PaymentEnvironment;
amount_minor: number;
currency: string;
created_at: string;
updated_at: string;
}
export interface PaymentOperationPayment extends PaymentOperationBase {
kind: "payment";
status: PaymentStatus;
attempt_status: PaymentAttemptStatus | null;
}
export interface PaymentOperationRefund extends PaymentOperationBase {
kind: "refund";
status: PaymentRefundStatus;
purpose: RefundPurpose;
aftersale_id: string | null;
retry_allowed: boolean;
}
export interface PaymentOperationException extends PaymentOperationBase {
kind: "exception";
status: PaymentExceptionStatus;
exception_kind: PaymentExceptionKind;
}
export interface PaymentOperationQuery {
page?: number;
per_page?: number;
kind?: PaymentOperationKind;
status?: PaymentStatus | PaymentAttemptStatus | PaymentRefundStatus | PaymentExceptionStatus;
environment?: PaymentEnvironment;
payment_id?: string;
user_id?: string;
}
export type PaymentCommandStatus = "queued" | "running" | "succeeded" | "failed";
/** Durable evidence-fetch command; a repeat request never initiates collection. */
export interface PaymentReconcileCommand {
id: string;
payment_id: string;
status: PaymentCommandStatus;
error: string | null;
created_at: string;
updated_at: string;
}
// ---- freight / shipping ----
export type FreightPricingMethod = "by_piece" | "by_weight";
@@ -832,6 +1217,10 @@ export interface WalletSummary {
available_minor: number;
frozen_minor: number;
currency: string;
/** Deployment payment environment; immutable, never toggled per request. */
environment: PaymentEnvironment;
/** Demo recharge is refused server-side outside `demo`; hide the flow when false. */
demo_recharge_enabled: boolean;
}
/** Which balance bucket a ledger entry moved. */