- 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
95 lines
3.4 KiB
TypeScript
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(",")}}`;
|
|
}
|