Files
Chengdong Zhang a7cd392bd4 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
2026-10-15 16:14:28 +08:00

95 lines
3.4 KiB
TypeScript

import type { Payment } from "@vmall/shared";
const LAST_PAYMENT_ID_KEY = "vmall.lastPaymentId";
const CHECKOUT_IDEMPOTENCY_KEY = "vmall.checkout.idem";
const ATTEMPT_KEY_PREFIX = "vmall.pay.attempt.";
export function rememberPaymentId(paymentId: string): void {
if (!import.meta.client || !paymentId) return;
localStorage.setItem(LAST_PAYMENT_ID_KEY, paymentId);
}
export function rememberedPaymentId(): string {
if (!import.meta.client) return "";
return localStorage.getItem(LAST_PAYMENT_ID_KEY) ?? "";
}
export function paymentIdFromQuery(value: unknown): string {
if (typeof value === "string" && value.trim()) return value;
if (Array.isArray(value)) {
const first = value.find((item): item is string => typeof item === "string" && item.trim() !== "");
if (first) return first;
}
return rememberedPaymentId();
}
export function newIdempotencyKey(): string {
if (import.meta.client && typeof crypto.randomUUID === "function") return crypto.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function checkoutIdempotencyRecord(): { key: string; hash: string } | null {
if (!import.meta.client) return null;
const raw = localStorage.getItem(CHECKOUT_IDEMPOTENCY_KEY);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
"key" in parsed &&
"hash" in parsed &&
typeof parsed.key === "string" &&
typeof parsed.hash === "string"
) {
return { key: parsed.key, hash: parsed.hash };
}
} catch {
localStorage.removeItem(CHECKOUT_IDEMPOTENCY_KEY);
}
return null;
}
export function storeCheckoutIdempotency(record: { key: string; hash: string }): void {
if (!import.meta.client) return;
localStorage.setItem(CHECKOUT_IDEMPOTENCY_KEY, JSON.stringify(record));
}
export function clearCheckoutIdempotency(): void {
if (!import.meta.client) return;
localStorage.removeItem(CHECKOUT_IDEMPOTENCY_KEY);
}
export function attemptIdempotencyKey(paymentId: string, channelId: string): string {
if (!import.meta.client) return newIdempotencyKey();
const storageKey = `${ATTEMPT_KEY_PREFIX}${encodeURIComponent(paymentId)}.${encodeURIComponent(channelId)}`;
const existing = localStorage.getItem(storageKey);
if (existing) return existing;
const key = newIdempotencyKey();
localStorage.setItem(storageKey, key);
return key;
}
export function rotateAttemptIdempotencyKey(paymentId: string, channelId: string): void {
if (!import.meta.client) return;
localStorage.removeItem(`${ATTEMPT_KEY_PREFIX}${encodeURIComponent(paymentId)}.${encodeURIComponent(channelId)}`);
}
/** Whether the payment has an unresolved collection attempt. */
export function paymentHasUnresolvedAttempt(payment: Payment): boolean {
return payment.status === "unapplied" && payment.attempts.some(
(attempt) => ["created", "pending", "unknown"].includes(attempt.status),
);
}
export function stableStringify(value: unknown): string {
if (value === undefined) return "null";
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(",")}}`;
}