diff --git a/Cargo.lock b/Cargo.lock index 4389f31..b5bb268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,38 +301,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -437,21 +411,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encoding_rs" -version = "0.8.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" -dependencies = [ - "cfg-if", - "core_detect", - "multiversion", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -512,33 +471,12 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -677,9 +615,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -693,25 +633,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -832,7 +753,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -857,22 +777,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -893,11 +798,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.5", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1128,12 +1031,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.3" @@ -1155,6 +1052,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1203,50 +1106,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1323,49 +1182,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking" version = "2.2.1" @@ -1512,6 +1328,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.5", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -1661,29 +1532,26 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1691,6 +1559,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.9", ] [[package]] @@ -1755,17 +1624,10 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.5" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" @@ -1787,6 +1649,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -1813,44 +1676,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.229" @@ -2005,12 +1836,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "simple_asn1" version = "0.6.4" @@ -2343,40 +2168,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "2.0.20" @@ -2480,16 +2271,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.5" @@ -2765,6 +2546,8 @@ dependencies = [ "argon2", "axum", "chrono", + "hex", + "hmac", "jsonwebtoken", "redis", "reqwest", @@ -2773,6 +2556,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "sqlx", "thiserror", "tokio", @@ -2878,6 +2662,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2947,17 +2741,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/README.md b/README.md index f7e6522..5ad57fd 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,12 @@ fixed-data 适配器使用,作为每个已迁移域的回滚实现。 | `JWT_SECRET` | `vmall-dev-secret-change-me` | | `PORT` | `8080` | | `JWT_TTL_SECS` | `86400` | +| `VMALL_PAYMENT_ENVIRONMENT` | `demo`(`demo`/`sandbox`/`live`,部署期固定,API 不可改) | +| `VMALL_PAYMENT_MALL_ORIGIN` / `VMALL_PAYMENT_MOBILE_ORIGIN` | 无;Stripe 跳转回流的商城/移动端源,仅允许配置值,钱包支付不需要 | +| `VMALL_PAYMENT_STRIPE_API_VERSION` | `2025-08-27.basil` | +| `VMALL_PAYMENT_STRIPE_API_BASE` | Stripe 固定主机;非 live 仅允许 loopback 覆盖(测试 fixture) | +| `VMALL_PAYMENT_WORKER_ENABLED` / `VMALL_PAYMENT_WORKER_POLL_MS` | `true` / 默认轮询间隔 | +| `VMALL_PAYMENT_*`(其余) | 支付渠道密钥引用白名单:渠道只存引用名,密钥本体只从匹配 `VMALL_PAYMENT_[A-Z0-9_]*` 的环境变量解析,永不入库或出现在审计中 | + +支付要点:demo 环境启用演示充值并拒绝外部渠道;sandbox/live 需要独立的空数据库且无任何 demo 账务历史,否则启动拒绝。Stripe Webhook 端点为 +`POST /api/payments/webhooks/stripe/{channel_id}`(原始请求体验签)。 diff --git a/apps/admin/app.vue b/apps/admin/app.vue index 3225459..81ffd7c 100644 --- a/apps/admin/app.vue +++ b/apps/admin/app.vue @@ -15,6 +15,8 @@ const navItems = [ { to: "/users", label: "nav.users" }, { to: "/shops", label: "nav.shops" }, { to: "/orders", label: "nav.orders" }, + { to: "/payments", label: "nav.payments" }, + { to: "/payment-channels", label: "nav.paymentChannels" }, { to: "/aftersales", label: "nav.aftersales" }, { to: "/reviews", label: "nav.reviews" }, { to: "/content", label: "nav.content" }, diff --git a/apps/admin/composables/useRedactedAudit.ts b/apps/admin/composables/useRedactedAudit.ts new file mode 100644 index 0000000..60865a8 --- /dev/null +++ b/apps/admin/composables/useRedactedAudit.ts @@ -0,0 +1,28 @@ +/** + * Renders one audit change entry for display. + * + * The API redacts secret material server-side and only ever exposes + * allowlisted secret *references*. This helper keeps values out of the UI even + * if a sensitive field slips through: reference fields (…_ref) stay visible so + * rotations remain traceable, raw credential keys and credential-shaped values + * render as redacted. + */ +export function useRedactedAudit() { + const { t } = useI18n(); + + function renderValue(value: unknown): string { + if (value === null || value === undefined || value === "") return "—"; + if (typeof value === "string") { + return /^(sk|whsec|rk)_[A-Za-z0-9]/.test(value) ? t("admin.payChannelRedacted") : value; + } + return JSON.stringify(value); + } + + function changeText(key: string, change: { from: unknown; to: unknown }): string { + const sensitiveKey = /secret|key|token|authorization|webhook/i.test(key) && !key.endsWith("_ref"); + if (sensitiveKey) return t("admin.payChannelRedacted"); + return `${renderValue(change.from)} → ${renderValue(change.to)}`; + } + + return { changeText }; +} diff --git a/apps/admin/locales-extra.ts b/apps/admin/locales-extra.ts index 599a614..34a149d 100644 --- a/apps/admin/locales-extra.ts +++ b/apps/admin/locales-extra.ts @@ -16,6 +16,8 @@ export const enExtra = { settlements: "Settlements", merchantApplications: "Merchant applications", memberLevels: "Member levels", + payments: "Payment operations", + paymentChannels: "Payment channels", }, admin: { dashboardTitle: "Platform overview", @@ -124,13 +126,39 @@ export const enExtra = { aftersaleArbitrateRefund: "Arbitrate: refund", aftersaleArbitrateReject: "Arbitrate: reject", aftersaleConfirmRefund: - "Grant a terminal refund to the buyer? This completes the refund and cannot be undone.", + "Grant the refund through the original payment method? Wallet refunds complete immediately; external refunds stay pending until the provider confirms success.", aftersaleConfirmReject: "Reject this dispute terminally? Only the buyer's one-time reopen can resume it.", - aftersaleArbitratedRefund: "Arbitration complete: refunded.", + aftersaleArbitratedRefund: "Refund completed on the original payment method.", + aftersaleArbitratedRefundPending: + "Refund submitted to the original payment method. It stays pending until verified provider success; the customer's internal wallet has not been credited.", aftersaleArbitratedReject: "Arbitration complete: rejected.", aftersaleOutcomeRefunded: - "Refunded: the customer ledger is credited and the order refund total updated.", + "Refunded: the original-method refund completed and the order refund total is updated.", + aftersaleOutcomeRefundPending: + "Refund pending: the original-method refund was submitted and awaits verified provider success. No wallet credit or order refund total change has happened yet.", + aftersaleOutcomeRefundFailed: + "Refund failed: the original-method refund definitively failed. Only a server-authorized retry can start a new refund operation.", + aftersaleRetryRefund: "Retry refund", + aftersaleRetryNotAllowed: + "No retry is permitted right now: only a definitively failed refund operation can be retried.", + aftersaleConfirmRetry: + "Retry the refund through the original payment method? A retry is allowed only after a definitive failure and reserves the refund budget again.", + aftersaleRefundOperation: "Original-method refund", + aftersaleRefundPurpose: "Purpose", + aftersaleRefundProviderRef: "Provider reference", + aftersaleRefundOpHint: + "Refund state is authoritative: pending and unknown operations keep their reserved budget, and the customer's internal wallet and the order refund total change only on verified refund success.", + refundOpStates: { + pending: "Pending", + unknown: "Unknown", + succeeded: "Succeeded", + failed: "Failed", + }, + refundOpPurposes: { + aftersale: "After-sale", + compensation: "Compensation", + }, aftersaleOutcomeRejected: "Rejected: only the buyer's one-time reopen can resume it.", aftersaleOutcomeCancelled: "Cancelled by the buyer.", aftersaleConflict: "The application state changed; refresh and try again", @@ -145,6 +173,8 @@ export const enExtra = { rejected: "Rejected", buyer_shipping: "Buyer shipping", merchant_confirmed: "Return received", + refund_pending: "Refund pending", + refund_failed: "Refund failed", refunded: "Refunded", cancelled: "Cancelled", }, @@ -153,6 +183,175 @@ export const enExtra = { merchant: "Merchant", platform: "Platform", }, + payChannelAdapters: "Installed adapters", + payChannelEnvOwned: + "The payment environment is fixed by the deployment (demo, sandbox or live) and cannot be changed here.", + payChannelCurrencies: "Supported currencies", + payChannelLimits: "Amount limits (minor units)", + payChannelMaxNone: "No configured maximum", + payChannelMinAmount: "Minimum amount (minor units)", + payChannelMaxAmount: "Maximum amount (minor units)", + payChannelSecretSlots: "Credential references", + payChannelProviderManaged: "Provider-managed options", + payChannelProviderManagedHint: + "These choices are made inside the hosted payment flow and cannot be restricted here.", + payChannelPrerequisites: "Activation prerequisites", + payChannelListTitle: "Channels", + payChannelNew: "New channel", + payChannelEdit: "Edit channel", + payChannelView: "Detail", + payChannelDetail: "Channel detail", + payChannelId: "Channel", + payChannelLabel: "Label", + payChannelLabelEn: "Label (English)", + payChannelLabelZh: "Label (中文)", + payChannelAdapter: "Adapter", + payChannelAdapterRequired: "Select an installed adapter.", + payChannelLabelRequired: "Both labels are required.", + payChannelOrderInvalid: "Display order must be a non-negative integer.", + payChannelCurrencyRequired: "Select at least one supported currency.", + payChannelCurrencyUnsupported: "A selected currency is outside this adapter's capability.", + payChannelAmountInvalid: + "Amounts must be positive integers in minor units, and the maximum must not be below the minimum.", + payChannelAmountAboveAdapter: + "The maximum amount exceeds the ceiling supported by this adapter.", + payChannelProviderRequired: "A provider account id is required for this adapter.", + payChannelSecretRequired: "Credential references are required to enable this adapter.", + payChannelOrder: "Display order", + payChannelEnabled: "Enabled", + payChannelDisabled: "Disabled", + payChannelArchived: "Archived", + payChannelEnabledHint: + "Enabling requires a valid configuration and a successful provider account check. Disabling stops new attempts only; existing callbacks, reconciliation and original-method refunds keep processing.", + payChannelProviderAccount: "Provider account id", + payChannelSecretKeyRef: "API key reference", + payChannelWebhookSecretRef: "Webhook signing secret reference", + payChannelSecretRefPlaceholder: "env:VMALL_PAYMENT_…", + payChannelSecretRefHint: + "Enter a deployment-allowlisted secret reference (for example env:VMALL_PAYMENT_STRIPE_API_KEY). Secret values are never sent to or shown in this console. Leave a field blank to keep its current reference.", + payChannelRedacted: "[redacted]", + payChannelSecretConfigured: "Configured", + payChannelSecretMissing: "Missing", + payChannelUnmet: "Unmet prerequisites", + payChannelEnvironment: "Environment", + payChannelRevision: "Revision", + payChannelCreated: "Channel created (revision {revision}).", + payChannelSaved: "Channel saved (revision {revision}).", + payChannelStale: + "This channel changed since you loaded it (HTTP 409). Your input is kept; the authoritative revision context has been refreshed. Review and save again.", + payChannelVerify: "Run read-only verification", + payChannelVerifying: "Verifying…", + payChannelVerifyOk: "Connection check passed", + payChannelVerifyFail: "Connection check reported unmet prerequisites", + payChannelVerifyHint: + "Verification performs a read-only provider connection and account check. A successful probe does not imply provider business approval.", + payChannelCheckedAt: "Checked at", + payChannelLifecycle: "Lifecycle", + payChannelDisableHint: + "Disabling a channel stops new attempts only. Existing verified notifications, reconciliation and original-method refunds continue against their retained revisions.", + payChannelRotateHint: + "To rotate credentials, save new secret references: a new immutable revision is created and used by new attempts, while outstanding callbacks, refunds and historical reconciliation stay associated with their original revision and its retained references.", + payChannelArchiveHint: + "Referenced channels are archived rather than deleted so historical payment records stay inspectable.", + payChannelAudit: "Configuration audit", + payChannelRevisions: "Revision history", + payChannelAdapterContract: "Adapter contract", + payChannelAuditAction: "Action", + payChannelAuditActor: "Actor", + payChannelAuditChanges: "Changes", + payChannelNoAudit: "No audit entries yet.", + payOpsFilterKind: "Row type", + payOpsFilterEnvironment: "Environment", + payOpsFilterPaymentId: "Payment id", + payOpsFilterPaymentIdPlaceholder: "payment id", + payOpsFilterUserId: "User id", + payOpsFilterUserIdPlaceholder: "user id", + payOpsId: "Operation", + payOpsTotal: "Total", + payOpsContext: "Context", + payOpsException: "Exception", + payOpsView: "Detail", + payOpsDetail: "Payment operation", + payOpsChannel: "Channel", + payOpsProviderRef: "Provider reference", + payOpsNextAction: "Next action", + payOpsMethod: "Original method", + payOpsError: "Error detail", + payOpsRetryAllowed: "Retry allowed", + payOpsCompensationNote: + "Received-but-unapplied and additional genuine collections are retained and refunded to the original method; order application stays unchanged.", + payOpsAllocations: "Order allocations", + payOpsShop: "Shop", + payOpsAmount: "Amount", + payOpsRefundedAmount: "Refunded total", + payOpsAttempts: "Attempts", + payOpsAttemptId: "Attempt", + payOpsReceipts: "Receipts", + payOpsReceiptId: "Receipt", + payOpsCollectedAt: "Collected at", + payOpsCrypto: "Asset detail (provider)", + payOpsCryptoBaseUnits: "Base-unit amount", + payOpsCryptoContract: "Contract", + payOpsCryptoTx: "Transaction", + payOpsCryptoQuote: "Quote", + payOpsRefunds: "Refund operations", + payOpsRefundId: "Operation", + payOpsPurpose: "Purpose", + payOpsExceptions: "Exceptions", + payOpsExceptionKind: "Kind", + payOpsExceptionDetail: "Detail", + payOpsAudit: "Channel configuration audit", + payOpsAuditHint: + "Redacted configuration history of every channel this payment's attempts referenced, including enable, disable and credential-rotation events while work was outstanding. Secret values are never recorded.", + payOpsAuditAction: "Action", + payOpsAuditActor: "Actor", + payOpsReconcile: "Request evidence reconciliation", + payOpsReconcileHint: + "Reconciliation queues a durable evidence fetch from the provider and reuses the normal application path. It never marks an operation paid or refunded, never starts another collection and never edits balances. Every request is audited.", + payOpsReconcileQueued: "Evidence lookup queued (command {command}).", + payOpsKinds: { + payment: "Payment", + refund: "Refund", + exception: "Exception", + }, + payOpsApplicationStates: { + unapplied: "Unapplied", + applied: "Applied", + closed: "Closed", + }, + payOpsAttemptStates: { + created: "Created", + pending: "Pending", + unknown: "Unknown", + settled: "Settled", + failed: "Failed", + expired: "Expired", + }, + payOpsRefundStates: { + pending: "Pending", + unknown: "Unknown", + succeeded: "Succeeded", + failed: "Failed", + }, + payOpsExceptionStatuses: { + open: "Open", + resolved: "Resolved", + }, + payOpsPurposes: { + aftersale: "After-sale", + compensation: "Compensation", + }, + payOpsExceptionKinds: { + unapplied_receipt: "Received but unapplied", + extra_collection: "Additional collection", + amount_mismatch: "Amount mismatch (quarantined)", + unknown_operation: "Unknown operation", + }, + payOpsNextActions: { + none: "None", + redirect: "Redirect", + wait: "Wait", + }, withdrawalReviewTitle: "Withdrawal applications", withdrawalId: "Application", withdrawalBuyer: "Buyer email", @@ -363,6 +562,8 @@ export const zhExtra = { settlements: "结算对账", merchantApplications: "商家入驻审核", memberLevels: "会员等级", + payments: "支付运营", + paymentChannels: "支付渠道", }, admin: { dashboardTitle: "平台概览", @@ -470,11 +671,37 @@ export const zhExtra = { aftersaleArbitrate: "仲裁", aftersaleArbitrateRefund: "仲裁退款", aftersaleArbitrateReject: "仲裁驳回", - aftersaleConfirmRefund: "确定仲裁退款给买家吗?退款将立即完成且不可撤销。", + aftersaleConfirmRefund: + "确定按原支付方式退款吗?钱包支付立即完成;外部支付需服务商确认成功后完成。", aftersaleConfirmReject: "确定仲裁驳回吗?仅买家一次重新申请可恢复。", - aftersaleArbitratedRefund: "仲裁完成:已退款。", + aftersaleArbitratedRefund: "退款已在原支付方式完成。", + aftersaleArbitratedRefundPending: + "退款已提交至原支付方式,在服务商确认成功前保持处理中;买家内部钱包尚未入账。", aftersaleArbitratedReject: "仲裁完成:已驳回。", - aftersaleOutcomeRefunded: "已退款:买家账户已入账,订单退款总额已更新。", + aftersaleOutcomeRefunded: "已退款:原路退款已完成,订单退款总额已更新。", + aftersaleOutcomeRefundPending: + "退款处理中:原路退款已提交,等待服务商确认成功。买家钱包未入账,订单退款总额未变化。", + aftersaleOutcomeRefundFailed: + "退款失败:原路退款已确定失败。仅服务端授权的重试才能发起新的退款操作。", + aftersaleRetryRefund: "重试退款", + aftersaleRetryNotAllowed: "当前不允许重试:仅确定失败的退款操作可以重试。", + aftersaleConfirmRetry: + "确定按原支付方式重试退款吗?仅在确定失败后允许重试,且会重新预留退款额度。", + aftersaleRefundOperation: "原路退款", + aftersaleRefundPurpose: "退款用途", + aftersaleRefundProviderRef: "服务商参考号", + aftersaleRefundOpHint: + "退款状态以服务端为准:处理中/未知的操作持续占用退款额度;仅退款成功经确认后,买家内部钱包与订单退款总额才会变化。", + refundOpStates: { + pending: "处理中", + unknown: "未知", + succeeded: "成功", + failed: "失败", + }, + refundOpPurposes: { + aftersale: "售后退款", + compensation: "补偿退款", + }, aftersaleOutcomeRejected: "已驳回:仅买家一次重新申请可恢复。", aftersaleOutcomeCancelled: "买家已取消。", aftersaleConflict: "售后单状态已变化,请刷新后重试", @@ -489,6 +716,8 @@ export const zhExtra = { rejected: "已驳回", buyer_shipping: "买家退货中", merchant_confirmed: "商家已收货", + refund_pending: "退款处理中", + refund_failed: "退款失败", refunded: "已退款", cancelled: "已取消", }, @@ -497,6 +726,169 @@ export const zhExtra = { merchant: "商家", platform: "平台", }, + payChannelAdapters: "已安装适配器", + payChannelEnvOwned: "支付环境由部署配置决定(demo、sandbox 或 live),无法在此修改。", + payChannelCurrencies: "支持币种", + payChannelLimits: "金额限制(最小货币单位)", + payChannelMaxNone: "未设置上限", + payChannelMinAmount: "最小金额(最小货币单位)", + payChannelMaxAmount: "最大金额(最小货币单位)", + payChannelSecretSlots: "凭据引用", + payChannelProviderManaged: "服务商托管选项", + payChannelProviderManagedHint: "这些选项在托管支付流程中由服务商决定,无法在此限制。", + payChannelPrerequisites: "启用前提", + payChannelListTitle: "渠道列表", + payChannelNew: "新建渠道", + payChannelEdit: "编辑渠道", + payChannelView: "详情", + payChannelDetail: "渠道详情", + payChannelId: "渠道", + payChannelLabel: "名称", + payChannelLabelEn: "名称(英文)", + payChannelLabelZh: "名称(中文)", + payChannelAdapter: "适配器", + payChannelAdapterRequired: "请选择已安装的适配器。", + payChannelLabelRequired: "中英文名称均为必填。", + payChannelOrderInvalid: "排序必须是非负整数。", + payChannelCurrencyRequired: "至少选择一个支持币种。", + payChannelCurrencyUnsupported: "所选币种超出该适配器的能力范围。", + payChannelAmountInvalid: "金额必须是正整数(最小货币单位),且上限不得低于下限。", + payChannelAmountAboveAdapter: "上限金额超过该适配器支持的最高限额。", + payChannelProviderRequired: "该适配器必须填写服务商账户 ID。", + payChannelSecretRequired: "启用该适配器必须填写凭据引用。", + payChannelOrder: "排序", + payChannelEnabled: "启用", + payChannelDisabled: "停用", + payChannelArchived: "已归档", + payChannelEnabledHint: + "启用需要有效配置并通过服务商账户检查。停用仅阻止新的支付尝试;已有回调、核对与原路退款仍会继续处理。", + payChannelProviderAccount: "服务商账户 ID", + payChannelSecretKeyRef: "API 密钥引用", + payChannelWebhookSecretRef: "Webhook 签名密钥引用", + payChannelSecretRefPlaceholder: "env:VMALL_PAYMENT_…", + payChannelSecretRefHint: + "填写部署允许清单内的密钥引用(例如 env:VMALL_PAYMENT_STRIPE_API_KEY)。控制台不会发送或显示密钥原文。留空表示保持当前引用。", + payChannelRedacted: "[已脱敏]", + payChannelSecretConfigured: "已配置", + payChannelSecretMissing: "缺失", + payChannelUnmet: "未满足前提", + payChannelEnvironment: "环境", + payChannelRevision: "版本", + payChannelCreated: "渠道已创建(版本 {revision})。", + payChannelSaved: "渠道已保存(版本 {revision})。", + payChannelStale: + "渠道在加载后发生了变更(HTTP 409)。已保留你的输入,并刷新了权威版本信息。请确认后再次保存。", + payChannelVerify: "执行只读验证", + payChannelVerifying: "验证中…", + payChannelVerifyOk: "连接检查通过", + payChannelVerifyFail: "连接检查存在未满足前提", + payChannelVerifyHint: "验证仅执行只读的服务商连接与账户检查。检查通过不代表服务商的商业准入审批。", + payChannelCheckedAt: "检查时间", + payChannelLifecycle: "生命周期", + payChannelDisableHint: + "停用渠道仅阻止新的支付尝试。已验证的通知、核对和原路退款仍按其保留的版本继续处理。", + payChannelRotateHint: + "轮换凭据时保存新的密钥引用:将创建新的不可变版本供新尝试使用;未完成的回调、退款和历史核对仍关联原版本及其保留的引用。", + payChannelArchiveHint: "被引用的渠道只做归档而非删除,历史支付记录保持可查。", + payChannelAudit: "配置审计", + payChannelRevisions: "版本历史", + payChannelAdapterContract: "适配器契约", + payChannelAuditAction: "操作", + payChannelAuditActor: "操作者", + payChannelAuditChanges: "变更", + payChannelNoAudit: "暂无审计记录。", + payOpsFilterKind: "记录类型", + payOpsFilterEnvironment: "环境", + payOpsFilterPaymentId: "支付单 ID", + payOpsFilterPaymentIdPlaceholder: "支付单 ID", + payOpsFilterUserId: "用户 ID", + payOpsFilterUserIdPlaceholder: "用户 ID", + payOpsId: "操作单", + payOpsTotal: "总额", + payOpsContext: "上下文", + payOpsException: "异常", + payOpsView: "详情", + payOpsDetail: "支付操作", + payOpsChannel: "渠道", + payOpsProviderRef: "服务商参考号", + payOpsNextAction: "下一步动作", + payOpsMethod: "原支付方式", + payOpsError: "错误详情", + payOpsRetryAllowed: "可重试", + payOpsCompensationNote: + "已收款未入账及额外的真实收款将保留并按原路退回;订单入账保持不变。", + payOpsAllocations: "订单分摊", + payOpsShop: "店铺", + payOpsAmount: "金额", + payOpsRefundedAmount: "已退总额", + payOpsAttempts: "支付尝试", + payOpsAttemptId: "尝试", + payOpsReceipts: "收款凭证", + payOpsReceiptId: "凭证", + payOpsCollectedAt: "收款时间", + payOpsCrypto: "资产信息(服务商)", + payOpsCryptoBaseUnits: "最小单位数量", + payOpsCryptoContract: "合约地址", + payOpsCryptoTx: "交易哈希", + payOpsCryptoQuote: "报价", + payOpsRefunds: "退款操作", + payOpsRefundId: "操作", + payOpsPurpose: "用途", + payOpsExceptions: "异常记录", + payOpsExceptionKind: "类型", + payOpsExceptionDetail: "详情", + payOpsAudit: "渠道配置审计", + payOpsAuditHint: + "该支付的尝试所引用渠道的脱敏配置历史,包括处理中发生的启用、停用与凭据轮换事件。密钥原文从不记录。", + payOpsAuditAction: "操作", + payOpsAuditActor: "操作者", + payOpsReconcile: "请求证据核对", + payOpsReconcileHint: + "核对会排队一次持久化的服务商证据获取,并复用正常入账流程。它不会标记支付或退款成功、不会发起新的收款,也不会修改余额。每次请求都会记录审计。", + payOpsReconcileQueued: "证据查询已排队(命令 {command})。", + payOpsKinds: { + payment: "支付", + refund: "退款", + exception: "异常", + }, + payOpsApplicationStates: { + unapplied: "未入账", + applied: "已入账", + closed: "已关闭", + }, + payOpsAttemptStates: { + created: "已创建", + pending: "处理中", + unknown: "未知", + settled: "已结算", + failed: "失败", + expired: "已过期", + }, + payOpsRefundStates: { + pending: "处理中", + unknown: "未知", + succeeded: "成功", + failed: "失败", + }, + payOpsExceptionStatuses: { + open: "未解决", + resolved: "已解决", + }, + payOpsPurposes: { + aftersale: "售后退款", + compensation: "补偿退款", + }, + payOpsExceptionKinds: { + unapplied_receipt: "已收款未入账", + extra_collection: "额外收款", + amount_mismatch: "金额不符(已隔离)", + unknown_operation: "操作结果未知", + }, + payOpsNextActions: { + none: "无", + redirect: "跳转", + wait: "等待", + }, withdrawalReviewTitle: "提现申请", withdrawalId: "申请单", withdrawalBuyer: "买家邮箱", diff --git a/apps/admin/pages/aftersales.vue b/apps/admin/pages/aftersales.vue index 573b54e..0eea9a8 100644 --- a/apps/admin/pages/aftersales.vue +++ b/apps/admin/pages/aftersales.vue @@ -8,6 +8,7 @@ import type { AftersaleMessage, AftersaleStatus, Currency, + PaymentRefundStatus, Shop, } from "@vmall/shared"; @@ -46,6 +47,8 @@ const statuses: AftersaleStatus[] = [ "approved", "buyer_shipping", "merchant_confirmed", + "refund_pending", + "refund_failed", "refunded", "rejected", "cancelled", @@ -84,12 +87,18 @@ function formatDate(value: string): string { function statusTone(status: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" { if (status === "refunded") return "green"; - if (status === "rejected") return "red"; + if (status === "rejected" || status === "refund_failed") return "red"; if (status === "cancelled") return "gray"; - if (status === "pending") return "orange"; + if (status === "pending" || status === "refund_pending") return "orange"; return "blue"; } +function refundOpTone(status: PaymentRefundStatus): "green" | "red" | "orange" { + if (status === "succeeded") return "green"; + if (status === "failed" || status === "unknown") return "red"; + return "orange"; +} + function roleTone(role: AftersaleMessage["author_role"]): "green" | "blue" | "gray" { if (role === "buyer") return "green"; if (role === "merchant") return "blue"; @@ -206,21 +215,29 @@ async function toggleDetail(id: string): Promise { async function arbitrate(row: Aftersale, outcome: AftersaleArbitration): Promise { const confirmation = - outcome === "refund" ? t("admin.aftersaleConfirmRefund") : t("admin.aftersaleConfirmReject"); + outcome === "refund" + ? row.status === "refund_failed" + ? t("admin.aftersaleConfirmRetry") + : t("admin.aftersaleConfirmRefund") + : t("admin.aftersaleConfirmReject"); if (!confirm(confirmation)) return; acting.value = true; detailError.value = ""; notice.value = ""; try { - await $api.admin.arbitrateAftersale(row.id, outcome); + const result = await $api.admin.arbitrateAftersale(row.id, outcome); await loadAftersales(); orderCache.delete(row.order_id); await openDetail(row.id); - notice.value = - outcome === "refund" - ? t("admin.aftersaleArbitratedRefund") - : t("admin.aftersaleArbitratedReject"); + if (outcome === "reject") { + notice.value = t("admin.aftersaleArbitratedReject"); + } else if (result.status === "refunded") { + notice.value = t("admin.aftersaleArbitratedRefund"); + } else { + // External submissions stay refund_pending: never claim completion here. + notice.value = t("admin.aftersaleArbitratedRefundPending"); + } } catch (error: unknown) { detailError.value = errorText(error); } finally { @@ -475,6 +492,56 @@ onMounted(() => {

+ +
+

+ {{ $t("admin.aftersaleRefundOperation") }} +

+ {{ + $t(`admin.refundOpStates.${detail.refund_operation.status}`) + }} + + {{ $t("admin.payOpsRetryAllowed") }} + +
+
+
+
{{ $t("admin.aftersaleRefundPurpose") }}
+
+ {{ $t(`admin.refundOpPurposes.${detail.refund_operation.purpose}`) }} +
+
+
+
{{ $t("admin.payOpsMethod") }}
+
{{ detail.refund_operation.method }}
+
+
+
{{ $t("admin.aftersaleAmount") }}
+
+ {{ money(detail.refund_operation.amount_minor, detail.refund_operation.currency) }} +
+
+
+
{{ $t("admin.aftersaleRefundProviderRef") }}
+
+ {{ + detail.refund_operation.reference + }} + — +
+
+
+
{{ $t("admin.payOpsError") }}
+
{{ detail.refund_operation.error || "—" }}
+
+
+
{{ $t("admin.aftersaleUpdated") }}
+
{{ formatDate(detail.refund_operation.updated_at) }}
+
+
+

{{ $t("admin.aftersaleRefundOpHint") }}

+
+
{ >{{ $t("admin.aftersaleArbitrateReject") }}
+ +

{{ $t("admin.aftersaleOutcomeRefunded") }}

diff --git a/apps/admin/pages/payment-channels.vue b/apps/admin/pages/payment-channels.vue new file mode 100644 index 0000000..0b1473c --- /dev/null +++ b/apps/admin/pages/payment-channels.vue @@ -0,0 +1,955 @@ + + + diff --git a/apps/admin/pages/payments.vue b/apps/admin/pages/payments.vue new file mode 100644 index 0000000..86b553e --- /dev/null +++ b/apps/admin/pages/payments.vue @@ -0,0 +1,714 @@ + + + diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index bc944a8..90cd00f 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -30,7 +30,11 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" thiserror = "2" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" [dev-dependencies] -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serial_test = "3" diff --git a/apps/api/migrations/0024_payments.sql b/apps/api/migrations/0024_payments.sql new file mode 100644 index 0000000..d7c4409 --- /dev/null +++ b/apps/api/migrations/0024_payments.sql @@ -0,0 +1,312 @@ +-- Configurable payments: one auditable checkout-wide payment unit with +-- per-order allocations, idempotent attempts, durable provider commands and +-- verified events, receipts, original-method refunds, versioned channel +-- configuration and legacy-demo provenance. +-- +-- Append-only: applied migrations are never edited. State columns are TEXT +-- with CHECK constraints so future states can be added by later migrations. + +-- --------------------------------------------------------------------------- +-- 1) Legacy provenance for historical status-only paid orders. +-- --------------------------------------------------------------------------- +ALTER TABLE orders + ADD COLUMN payment_provenance TEXT NOT NULL DEFAULT 'payment_backed'; + +ALTER TABLE orders + ADD CONSTRAINT orders_payment_provenance_ck + CHECK (payment_provenance IN ('payment_backed', 'legacy_demo')); + +-- Orders that were marked paid without any collected funds are historical +-- demo records. They are never supplied fictional captures; their refunds +-- keep the explicitly labeled legacy demo wallet-credit path. +UPDATE orders + SET payment_provenance = 'legacy_demo' + WHERE status IN ('paid', 'fulfilling', 'shipped', 'completed'); + +CREATE INDEX idx_orders_payment_provenance + ON orders (payment_provenance) + WHERE payment_provenance = 'legacy_demo'; + +-- --------------------------------------------------------------------------- +-- 2) Aftersale states for external original-method refunds. +-- --------------------------------------------------------------------------- +ALTER TYPE aftersale_status ADD VALUE IF NOT EXISTS 'refund_pending'; +ALTER TYPE aftersale_status ADD VALUE IF NOT EXISTS 'refund_failed'; + +-- --------------------------------------------------------------------------- +-- 3) Payment configuration: channels, immutable revisions, append-only audit. +-- --------------------------------------------------------------------------- +CREATE TABLE payment_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + adapter TEXT NOT NULL CHECK (adapter IN ('wallet', 'stripe_crypto')), + label JSONB NOT NULL, + display_order INT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT false, + supported_currencies TEXT[] NOT NULL, + min_amount_minor BIGINT NOT NULL CHECK (min_amount_minor > 0), + max_amount_minor BIGINT CHECK (max_amount_minor IS NULL OR max_amount_minor >= min_amount_minor), + provider_account_id TEXT, + -- Secret REFERENCES only (deployment-allowlisted env-var identifiers). + -- Plaintext credentials are never stored on any row. + secret_key_ref TEXT, + webhook_secret_ref TEXT, + environment TEXT NOT NULL CHECK (environment IN ('demo', 'sandbox', 'live')), + archived BOOLEAN NOT NULL DEFAULT false, + revision INT NOT NULL DEFAULT 1 CHECK (revision >= 1), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_payment_channels_env ON payment_channels (environment, enabled, archived); + +-- Immutable per-revision configuration snapshots; attempts and refunds retain +-- the revision they ran against. +CREATE TABLE payment_channel_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES payment_channels (id) ON DELETE CASCADE, + revision INT NOT NULL CHECK (revision >= 1), + snapshot JSONB NOT NULL, + adapter_contract_version TEXT NOT NULL, + created_by UUID REFERENCES users (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (channel_id, revision) +); + +-- Append-only configuration audit with redacted changes. Reference +-- identifiers may appear; secret values never do. +CREATE TABLE payment_channel_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES payment_channels (id) ON DELETE CASCADE, + revision INT NOT NULL, + action TEXT NOT NULL CHECK ( + action IN ('created', 'updated', 'enabled', 'disabled', 'verified', 'archived') + ), + actor TEXT NOT NULL, + changes JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_payment_channel_audit_channel + ON payment_channel_audit (channel_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- 4) Payments, checkout idempotency and immutable order membership. +-- --------------------------------------------------------------------------- +CREATE TABLE payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users (id), + currency CHAR(3) NOT NULL REFERENCES currencies (code), + -- Checked total: equals SUM(payment_orders.amount) at creation. + total_minor BIGINT NOT NULL CHECK (total_minor > 0), + -- Immutable deployment environment tag. + environment TEXT NOT NULL CHECK (environment IN ('demo', 'sandbox', 'live')), + status TEXT NOT NULL DEFAULT 'unapplied' + CHECK (status IN ('unapplied', 'applied', 'closed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + applied_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ +); + +CREATE INDEX idx_payments_owner ON payments (owner_user_id, created_at DESC); + +-- Immutable checkout-wide membership; each order belongs to at most one +-- payment and its amount equals the snapshotted order total. +CREATE TABLE payment_orders ( + payment_id UUID NOT NULL REFERENCES payments (id), + order_id UUID NOT NULL UNIQUE REFERENCES orders (id), + amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0), + PRIMARY KEY (payment_id, order_id) +); + +CREATE INDEX idx_payment_orders_payment ON payment_orders (payment_id); + +-- Owner-scoped checkout idempotency: the durable replay record for an +-- uncertain checkout response. The request hash covers the full payload +-- (address, currency, coupons, group intent, cart snapshot). +CREATE TABLE checkout_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users (id), + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + payment_id UUID NOT NULL REFERENCES payments (id), + response JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_user_id, idempotency_key) +); + +-- --------------------------------------------------------------------------- +-- 5) Attempts: one durable operation per initiation, idempotency-bound. +-- --------------------------------------------------------------------------- +CREATE TABLE payment_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments (id), + channel_id UUID NOT NULL REFERENCES payment_channels (id), + channel_revision INT NOT NULL, + owner_user_id UUID NOT NULL REFERENCES users (id), + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + return_surface TEXT NOT NULL CHECK (return_surface IN ('mall', 'mobile')), + return_url TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'created' + CHECK (state IN ('created', 'pending', 'unknown', 'settled', 'failed', 'expired')), + -- Stable provider operation identity (Stripe idempotency key, or the + -- internal wallet operation key). Permanent local identity. + provider_operation_key TEXT NOT NULL UNIQUE, + provider_reference TEXT, + provider_intent_reference TEXT, + next_action_type TEXT NOT NULL DEFAULT 'none' + CHECK (next_action_type IN ('none', 'redirect', 'wait')), + next_action_url TEXT, + expires_at TIMESTAMPTZ, + error_detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (payment_id, idempotency_key) +); + +-- At most one unresolved attempt per payment. +CREATE UNIQUE INDEX payment_attempts_one_unresolved + ON payment_attempts (payment_id) + WHERE state IN ('created', 'pending', 'unknown'); + +CREATE INDEX idx_payment_attempts_payment ON payment_attempts (payment_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- 6) Receipts: immutable actual collections, unique provider identity. +-- --------------------------------------------------------------------------- +CREATE TABLE payment_receipts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments (id), + attempt_id UUID REFERENCES payment_attempts (id), + provider_account_id TEXT, + environment TEXT NOT NULL CHECK (environment IN ('demo', 'sandbox', 'live')), + -- Unique provider-account/environment/transaction identity. + provider_transaction TEXT NOT NULL, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + currency CHAR(3) NOT NULL, + method TEXT NOT NULL CHECK (method IN ('wallet', 'stripe_crypto')), + applied BOOLEAN NOT NULL DEFAULT false, + quarantined BOOLEAN NOT NULL DEFAULT false, + quarantine_reason TEXT, + -- Provider evidence and optional crypto metadata (asset identity and + -- integer base-unit quantity as a decimal string; never floats). + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + crypto JSONB, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (provider_account_id, environment, provider_transaction) +); + +CREATE INDEX idx_payment_receipts_payment ON payment_receipts (payment_id); + +-- --------------------------------------------------------------------------- +-- 7) Refunds: original-method operations with reserved budgets. +-- --------------------------------------------------------------------------- +CREATE TABLE payment_refunds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments (id), + receipt_id UUID NOT NULL REFERENCES payment_receipts (id), + -- The allocation being refunded (aftersale refunds); NULL for + -- compensation of receipts that were never applied to any order. + allocation_order_id UUID REFERENCES orders (id), + aftersale_id UUID REFERENCES aftersales (id), + purpose TEXT NOT NULL CHECK (purpose IN ('aftersale', 'compensation')), + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + currency CHAR(3) NOT NULL, + idempotency_key TEXT NOT NULL, + provider_operation_key TEXT NOT NULL UNIQUE, + provider_reference TEXT, + -- The channel revision/credential references used remain fixed here. + channel_revision INT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'unknown', 'succeeded', 'failed')), + error_detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + UNIQUE (payment_id, idempotency_key) +); + +-- A permitted replacement has a new operation identity and must not overlap +-- an unresolved operation on the same target. +CREATE UNIQUE INDEX payment_refunds_one_unresolved + ON payment_refunds ( + receipt_id, + COALESCE(allocation_order_id::text, ''), + COALESCE(aftersale_id::text, '') + ) + WHERE state IN ('pending', 'unknown'); + +CREATE INDEX idx_payment_refunds_payment ON payment_refunds (payment_id, created_at DESC); +CREATE INDEX idx_payment_refunds_aftersale ON payment_refunds (aftersale_id); + +-- --------------------------------------------------------------------------- +-- 8) Durable provider commands and the verified event inbox. +-- --------------------------------------------------------------------------- +CREATE TABLE payment_commands ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kind TEXT NOT NULL CHECK (kind IN ('create_checkout', 'refund', 'reconcile')), + payment_id UUID REFERENCES payments (id), + attempt_id UUID REFERENCES payment_attempts (id), + refund_id UUID REFERENCES payment_refunds (id), + -- Stable provider idempotency key carried through every retry. + operation_key TEXT NOT NULL UNIQUE, + idempotency_key TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'leased', 'succeeded', 'failed', 'cancelled')), + due_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_owner TEXT, + lease_expires_at TIMESTAMPTZ, + attempts_count INT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + UNIQUE (kind, idempotency_key) +); + +CREATE INDEX idx_payment_commands_due + ON payment_commands (state, due_at) + WHERE state IN ('pending', 'leased'); + +-- Materialized exception rows so operations can list and resolve them: +-- quarantined receipts, received-but-unapplied collections, and operations +-- whose outcome remains unknown. Resolution reuses the normal refund / +-- reconciliation paths; nothing here asserts a financial outcome. +CREATE TABLE payment_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments (id), + kind TEXT NOT NULL CHECK ( + kind IN ('unapplied_receipt', 'extra_collection', 'amount_mismatch', 'unknown_operation') + ), + receipt_id UUID REFERENCES payment_receipts (id), + attempt_id UUID REFERENCES payment_attempts (id), + detail TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX idx_payment_exceptions_payment ON payment_exceptions (payment_id, status); + +-- Verified provider notifications, persisted before 2xx acknowledgement and +-- deduplicated by provider account, environment and event id. +CREATE TABLE payment_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES payment_channels (id), + provider_account_id TEXT, + environment TEXT NOT NULL CHECK (environment IN ('demo', 'sandbox', 'live')), + provider_event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + signature_key_id TEXT, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + process_error TEXT, + UNIQUE (environment, provider_account_id, provider_event_id) +); + +CREATE INDEX idx_payment_events_unprocessed + ON payment_events (received_at) + WHERE processed_at IS NULL; diff --git a/apps/api/migrations/0025_checkout_request_header_hash.sql b/apps/api/migrations/0025_checkout_request_header_hash.sql new file mode 100644 index 0000000..c6440cb --- /dev/null +++ b/apps/api/migrations/0025_checkout_request_header_hash.sql @@ -0,0 +1,10 @@ +-- Owner-scoped checkout idempotency needs a stable comparison even when the +-- caller has already cleared their cart between requests. The full request +-- hash covers the cart snapshot, which differs on an empty cart; the header +-- hash is the same input minus the cart so a legitimate replay (cart cleared) +-- still matches while a different currency/address/group intent is rejected +-- as 409. Existing rows default to an empty header hash and continue to +-- allow legacy replay until upgraded. + +ALTER TABLE checkout_requests + ADD COLUMN header_hash TEXT NOT NULL DEFAULT ''; diff --git a/apps/api/src/config.rs b/apps/api/src/config.rs index 9c07bb5..0ef8a0f 100644 --- a/apps/api/src/config.rs +++ b/apps/api/src/config.rs @@ -1,5 +1,76 @@ use anyhow::Context; +use crate::modules::payment::PaymentEnvironment; + +/// Deployment payment configuration. The environment is server-owned and +/// immutable through any API; credential material is resolved only through +/// deployment-allowlisted secret references (prefix `VMALL_PAYMENT_`). +#[derive(Clone, Debug)] +pub struct PaymentConfig { + pub environment: PaymentEnvironment, + /// Allowlisted prefix for payment secret references. A channel may only + /// reference environment variables matching `VMALL_PAYMENT_[A-Z0-9_]*`. + pub secret_ref_prefix: String, + /// Allowlisted storefront return origins for typed return surfaces. + pub mall_origin: Option, + pub mobile_origin: Option, + /// Pinned Stripe API version sent on every provider call. + pub stripe_api_version: String, + /// Fixed provider host. Overridable to a loopback fixture host in + /// non-live environments only; live always uses https://api.stripe.com. + pub stripe_api_base: String, + /// Supervised durable-command worker lifecycle. + pub worker_enabled: bool, + pub worker_poll_ms: u64, +} + +impl Default for PaymentConfig { + fn default() -> Self { + Self { + environment: PaymentEnvironment::Demo, + secret_ref_prefix: "VMALL_PAYMENT_".into(), + mall_origin: None, + mobile_origin: None, + stripe_api_version: "2025-08-27.basil".into(), + stripe_api_base: "https://api.stripe.com".into(), + worker_enabled: true, + worker_poll_ms: 500, + } + } +} + +impl PaymentConfig { + /// Resolve the allowlisted storefront origin for a typed return surface. + /// Surfaces never carry arbitrary client URLs. + pub fn origin_for(&self, surface: crate::modules::payment::ReturnSurface) -> Option<&str> { + match surface { + crate::modules::payment::ReturnSurface::Mall => self.mall_origin.as_deref(), + crate::modules::payment::ReturnSurface::Mobile => self.mobile_origin.as_deref(), + } + } + + /// Resolve a secret reference through the deployment allowlist. Missing + /// variables fail closed; values are never logged or returned. + pub fn resolve_secret_ref(&self, reference: &str) -> anyhow::Result { + let ok = reference.starts_with(&self.secret_ref_prefix) + && reference.len() > self.secret_ref_prefix.len() + && reference + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + anyhow::ensure!( + ok, + "secret reference outside the payment allowlist: {}", + reference + ); + std::env::var(reference) + .with_context(|| format!("payment secret reference is not configured: {reference}")) + } + + pub fn has_secret_ref(&self, reference: &str) -> bool { + self.resolve_secret_ref(reference).is_ok() + } +} + #[derive(Clone, Debug)] pub struct Config { pub database_url: String, @@ -8,6 +79,7 @@ pub struct Config { pub port: u16, /// token TTL in seconds pub jwt_ttl_secs: i64, + pub payment: PaymentConfig, } impl Config { @@ -26,18 +98,87 @@ impl Config { .ok() .and_then(|p| p.parse().ok()) .unwrap_or(86_400); + let payment = payment_from_env()?; let cfg = Self { database_url, redis_url, jwt_secret, port, jwt_ttl_secs, + payment, }; tracing::info!(port = cfg.port, "config loaded"); Ok(cfg) } } +fn payment_from_env() -> anyhow::Result { + let defaults = PaymentConfig::default(); + let environment = match std::env::var("VMALL_PAYMENT_ENVIRONMENT") { + Ok(v) => v.parse()?, + Err(_) => defaults.environment, + }; + let stripe_api_version = std::env::var("VMALL_PAYMENT_STRIPE_API_VERSION") + .unwrap_or(defaults.stripe_api_version); + let stripe_api_base = + std::env::var("VMALL_PAYMENT_STRIPE_API_BASE").unwrap_or(defaults.stripe_api_base); + let cfg = PaymentConfig { + environment, + secret_ref_prefix: defaults.secret_ref_prefix, + mall_origin: std::env::var("VMALL_PAYMENT_MALL_ORIGIN").ok(), + mobile_origin: std::env::var("VMALL_PAYMENT_MOBILE_ORIGIN").ok(), + stripe_api_version, + stripe_api_base, + worker_enabled: std::env::var("VMALL_PAYMENT_WORKER_ENABLED") + .map(|v| v != "false" && v != "0") + .unwrap_or(true), + worker_poll_ms: std::env::var("VMALL_PAYMENT_WORKER_POLL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(defaults.worker_poll_ms), + }; + cfg.validate()?; + Ok(cfg) +} + +impl PaymentConfig { + /// Fail closed on unsafe deployment configuration. Provider hosts are + /// fixed in code; a fixture override is only tolerable off live and only + /// on loopback. + pub fn validate(&self) -> anyhow::Result<()> { + const LIVE_BASE: &str = "https://api.stripe.com"; + if self.environment == PaymentEnvironment::Live { + anyhow::ensure!( + self.stripe_api_base == LIVE_BASE, + "live payments require the fixed Stripe API host {LIVE_BASE}" + ); + } else if self.stripe_api_base != LIVE_BASE { + let loopback = self.stripe_api_base.starts_with("http://127.0.0.1:") + || self.stripe_api_base.starts_with("http://localhost:") + || self.stripe_api_base.starts_with("https://127.0.0.1:") + || self.stripe_api_base.starts_with("https://localhost:"); + anyhow::ensure!( + loopback, + "payment provider base override is limited to loopback fixtures" + ); + } + for origin in [&self.mall_origin, &self.mobile_origin] { + if let Some(origin) = origin { + anyhow::ensure!( + origin.starts_with("https://") || origin.starts_with("http://127.0.0.1:") + || origin.starts_with("http://localhost:"), + "return origin must be an https deployment origin: {origin}" + ); + anyhow::ensure!( + !origin.ends_with('/'), + "return origin must not have a trailing slash: {origin}" + ); + } + } + Ok(()) + } +} + pub fn database_url_for(db: &str) -> anyhow::Result { let base = std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?; Ok(format!("{base}/{db}")) diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index b4c4c4f..98db3eb 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -20,10 +20,26 @@ async fn main() -> anyhow::Result<()> { let state = state::assemble(config.clone(), db).await?; ensure_platform_admin(&state).await?; ensure_demo_points(&state).await?; - let app = build_router(state); + // Live financial activation refuses demo/legacy provenance; demo and + // sandbox databases stay financially isolated deployments. + if state.config.payment.environment == vmall_api::modules::payment::PaymentEnvironment::Live { + vmall_api::modules::payment::service::assert_live_activation_allowed(&state.db).await?; + } + let app = build_router(state.clone()); + + // Supervised durable-command worker lives in the API process; shutdown + // stops new claims and lets in-flight work finish. + let worker = if config.payment.worker_enabled { + Some(vmall_api::modules::payment::worker::spawn(state.clone())) + } else { + None + }; let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.port)).await?; tracing::info!(port = config.port, "vmall-api listening"); axum::serve(listener, app).await?; + if let Some(worker) = worker { + worker.shutdown().await; + } Ok(()) } diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 53a5876..8fbc246 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -181,6 +181,9 @@ pub struct Order { pub refund_total_minor: i64, /// Server-computed per-shop-order shipping fee. pub shipping_fee_minor: i64, + /// Funding provenance: `payment_backed` for funded payments, `legacy_demo` + /// for historical status-only paid orders. + pub payment_provenance: String, /// The coupon this order redeemed, if any. pub coupon_id: Option, /// Group-buying activity this order joined, if any. @@ -226,6 +229,11 @@ pub enum AftersaleStatus { Rejected, BuyerShipping, MerchantConfirmed, + /// External original-method refund submitted; awaiting verified success. + RefundPending, + /// Refund operation definitively failed; an explicit guarded retry is + /// permitted and reserves budget again. + RefundFailed, Refunded, Cancelled, } diff --git a/apps/api/src/modules/aftersale/handlers.rs b/apps/api/src/modules/aftersale/handlers.rs index 2aec87a..2135448 100644 --- a/apps/api/src/modules/aftersale/handlers.rs +++ b/apps/api/src/modules/aftersale/handlers.rs @@ -169,7 +169,7 @@ async fn shop_refund( State(state): State, auth: AuthUser, Path(id): Path, -) -> ApiResult> { +) -> ApiResult> { let shop_id = auth.require_shop()?; Ok(Json(service::refund(&state, shop_id, id).await?)) } @@ -218,7 +218,7 @@ async fn admin_arbitrate( auth: AuthUser, Path(id): Path, Json(body): Json, -) -> ApiResult> { +) -> ApiResult> { auth.require_admin()?; Ok(Json(service::arbitrate(&state, id, &body.outcome).await?)) } diff --git a/apps/api/src/modules/aftersale/service.rs b/apps/api/src/modules/aftersale/service.rs index 7ddc085..5333d09 100644 --- a/apps/api/src/modules/aftersale/service.rs +++ b/apps/api/src/modules/aftersale/service.rs @@ -66,6 +66,10 @@ pub struct AftersaleDetailView { pub item: AftersaleItemSummary, pub messages: Vec, pub remaining_refundable_minor: i64, + /// Authoritative original-method refund operation state, or null when no + /// refund has been submitted (e.g. legacy demo wallet-credit refunds). + pub refund_operation: + Option, } #[derive(Debug, Deserialize)] @@ -196,14 +200,81 @@ async fn detail(db: &mut PgConnection, id: Uuid) -> ApiResult ApiResult> { + use crate::modules::payment::dto::{ + PaymentAdapterKind, RefundOperationView, RefundPurpose, RefundState, + }; + #[derive(sqlx::FromRow)] + struct Row { + id: Uuid, + purpose: String, + state: String, + amount_minor: i64, + currency: String, + aftersale_id: Option, + provider_reference: Option, + error_detail: Option, + created_at: DateTime, + updated_at: DateTime, + method: String, + } + let row = sqlx::query_as::<_, Row>( + "SELECT r.id, r.purpose, r.state, r.amount_minor, r.currency, r.aftersale_id, + r.provider_reference, r.error_detail, r.created_at, r.updated_at, + rec.method + FROM payment_refunds r + JOIN payment_receipts rec ON rec.id = r.receipt_id + WHERE r.aftersale_id = $1 + ORDER BY r.created_at DESC + LIMIT 1", + ) + .bind(aftersale_id) + .fetch_optional(&mut *db) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let status = row + .state + .parse::() + .map_err(ApiError::internal)?; + Ok(Some(RefundOperationView { + id: row.id, + purpose: row + .purpose + .parse::() + .map_err(ApiError::internal)?, + status, + amount_minor: row.amount_minor, + currency: row.currency, + aftersale_id: row.aftersale_id, + method: row + .method + .parse::() + .map_err(ApiError::internal)?, + reference: row.provider_reference, + error: row.error_detail, + retry_allowed: status.retry_allowed(), + created_at: row.created_at, + updated_at: row.updated_at, + })) +} + // --- customer flows --- @@ -458,8 +529,10 @@ pub async fn confirm_receipt(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiRe Ok(out) } -/// The one guarded path to `refunded`: status flip + order total bump + -/// ledger credit in a single transaction; retries hit the status guard. +/// The one guarded path to `refunded` for LEGACY demo orders: status flip + +/// order total bump + labeled demo wallet credit in a single transaction. +/// Historical status-only paid orders keep exactly this behavior; funded +/// payments use the original-method refund path in `modules::payment`. async fn complete_refund(tx: &mut PgConnection, id: Uuid) -> ApiResult { let row = fetch(&mut *tx, id).await?; // refund_only completes from approved; return_refund from merchant_confirmed. @@ -528,17 +601,33 @@ async fn complete_refund(tx: &mut PgConnection, id: Uuid) -> ApiResult ApiResult { - let mut tx = state.db.begin().await?; - let row = fetch(&mut tx, id).await?; - if row.shop_id != shop_id { - return Err(ApiError::NotFound("aftersale".into())); +/// Merchant refund submission (and the explicit retry from `refund_failed`). +/// Funded orders follow the original-method refund path: wallet refunds +/// complete synchronously, external refunds stay `refund_pending` until the +/// provider confirms success. Legacy demo orders keep the labeled wallet +/// credit path. The response is the authoritative aftersale detail. +pub async fn refund( + state: &AppState, + shop_id: Uuid, + id: Uuid, +) -> ApiResult { + { + let mut conn = state.db.acquire().await?; + let row = fetch(&mut conn, id).await?; + if row.shop_id != shop_id { + return Err(ApiError::NotFound("aftersale".into())); + } } - let out = complete_refund(&mut tx, id).await?; - tx.commit().await?; - // Hook point: refund_completed side effects (notifications) attach here. - tracing::info!(aftersale_id = %out.id, "refund_completed"); - Ok(out) + let submission = + crate::modules::payment::refunds::submit_aftersale_refund(state, id).await?; + if let crate::modules::payment::refunds::RefundSubmission::LegacyDemo = submission { + let mut tx = state.db.begin().await?; + let out = complete_refund(&mut tx, id).await?; + tx.commit().await?; + tracing::info!(aftersale_id = %out.id, "refund_completed (legacy demo wallet credit)"); + } + let mut conn = state.db.acquire().await?; + detail(&mut conn, id).await } // --- platform arbitration --- @@ -558,14 +647,16 @@ pub async fn get_any(state: &AppState, id: Uuid) -> ApiResult ApiResult { +pub async fn arbitrate( + state: &AppState, + id: Uuid, + outcome: &str, +) -> ApiResult { match outcome { "reject" => { let mut tx = state.db.begin().await?; transition(&mut tx, id, &[AftersaleStatus::Pending], AftersaleStatus::Rejected).await?; - let out = fetch(&mut tx, id).await?; tx.commit().await?; - Ok(out) } "refund" => { let mut tx = state.db.begin().await?; @@ -595,11 +686,20 @@ pub async fn arbitrate(state: &AppState, id: Uuid, outcome: &str) -> ApiResult {} } - let out = complete_refund(&mut tx, id).await?; tx.commit().await?; - tracing::info!(aftersale_id = %out.id, "refund_completed"); - Ok(out) + // The same original-method refund path as merchant processing; + // external refunds remain refund_pending until provider success. + let submission = + crate::modules::payment::refunds::submit_aftersale_refund(state, id).await?; + if let crate::modules::payment::refunds::RefundSubmission::LegacyDemo = submission { + let mut tx = state.db.begin().await?; + let out = complete_refund(&mut tx, id).await?; + tx.commit().await?; + tracing::info!(aftersale_id = %out.id, "refund_completed (legacy demo wallet credit)"); + } } - _ => Err(ApiError::BadRequest("outcome must be refund or reject".into())), + _ => return Err(ApiError::BadRequest("outcome must be refund or reject".into())), } + let mut conn = state.db.acquire().await?; + detail(&mut conn, id).await } diff --git a/apps/api/src/modules/group_buying/dto.rs b/apps/api/src/modules/group_buying/dto.rs index 0420b67..a4fd34d 100644 --- a/apps/api/src/modules/group_buying/dto.rs +++ b/apps/api/src/modules/group_buying/dto.rs @@ -26,7 +26,7 @@ fn default_enabled() -> bool { /// Checkout intent: one activity SKU at quantity 1, either joining an open /// group or asking to open a new one. -#[derive(Deserialize, Clone, Copy)] +#[derive(Deserialize, Serialize, Clone, Copy)] pub struct GroupBuyIntent { pub activity_id: Uuid, pub sku_id: Uuid, diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 3fd5764..9002bf5 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -19,6 +19,7 @@ pub mod membership; pub mod merchant_onboarding; pub mod messaging; pub mod order; +pub mod payment; pub mod points; pub mod product; pub mod review; @@ -49,6 +50,7 @@ pub fn api_router() -> Router { .merge(group_buying::router()) .merge(freight::router()) .merge(order::router()) + .merge(payment::router()) .merge(points::router()) .merge(shop::router()) .merge(review::router()) diff --git a/apps/api/src/modules/order/dto.rs b/apps/api/src/modules/order/dto.rs index ffb61eb..e2a91f4 100644 --- a/apps/api/src/modules/order/dto.rs +++ b/apps/api/src/modules/order/dto.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::error::{ApiError, ApiResult}; use crate::models::{Order, OrderItem}; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct OrderView { #[serde(flatten)] pub order: Order, diff --git a/apps/api/src/modules/order/handlers.rs b/apps/api/src/modules/order/handlers.rs index f959901..2ecf26a 100644 --- a/apps/api/src/modules/order/handlers.rs +++ b/apps/api/src/modules/order/handlers.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, }; @@ -22,7 +22,6 @@ pub fn customer_router() -> Router { .route("/orders/checkout", post(checkout)) .route("/orders/shipping-quote", post(shipping_quote)) .route("/orders/{id}", get(get_order)) - .route("/orders/{id}/pay", post(pay_order)) .route("/orders/{id}/cancel", post(cancel_order)) } @@ -86,14 +85,19 @@ struct CheckoutBody { async fn checkout( State(state): State, auth: AuthUser, + headers: HeaderMap, Json(body): Json, -) -> ApiResult<(StatusCode, Json>)> { +) -> ApiResult<(StatusCode, Json)> { + // Owner-scoped idempotency key is required; replays return the durable + // original result and a changed payload is rejected with 409. + let idempotency_key = crate::modules::payment::parse_idempotency_key(&headers)?; Ok(( StatusCode::CREATED, Json( service::checkout( &state, auth.id, + &idempotency_key, body.shipping_address, body.currency, body.coupon_by_shop, @@ -104,14 +108,6 @@ async fn checkout( )) } -async fn pay_order( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - Ok(Json(service::pay(&state, auth.id, id).await?)) -} - async fn cancel_order( State(state): State, auth: AuthUser, diff --git a/apps/api/src/modules/order/mod.rs b/apps/api/src/modules/order/mod.rs index 8bf0221..5a752d4 100644 --- a/apps/api/src/modules/order/mod.rs +++ b/apps/api/src/modules/order/mod.rs @@ -1,4 +1,4 @@ -mod dto; +pub mod dto; mod handlers; pub mod repo; pub mod service; @@ -8,7 +8,7 @@ use axum::Router; use crate::state::AppState; pub use dto::AddressBody; -pub use service::{cancel, checkout, pay}; +pub use service::{cancel, checkout}; pub fn router() -> Router { handlers::customer_router() diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index 0a80b38..374bfc9 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use sqlx::{PgConnection, PgPool}; +use sqlx::{PgConnection, PgExecutor, PgPool}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; @@ -9,13 +9,16 @@ use crate::models::{Order, OrderItem, OrderStatus}; use super::dto::{OrderScope, OrderView}; const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor, - discount_minor, refund_total_minor, shipping_fee_minor, coupon_id, group_activity_id, - group_id, shipping_address, created_at, updated_at"; + discount_minor, refund_total_minor, shipping_fee_minor, payment_provenance, coupon_id, + group_activity_id, group_id, shipping_address, created_at, updated_at"; const ORDER_ITEM_COLS: &str = "id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty, flash_sale_item_id, freight_template_id, freight_pricing_method"; -pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> { +pub async fn attach_items<'e, E: PgExecutor<'e>>( + exec: E, + orders: Vec, +) -> ApiResult> { let ids: Vec = orders.iter().map(|o| o.id).collect(); let items = if ids.is_empty() { Vec::new() @@ -24,7 +27,7 @@ pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> = HashMap::new(); @@ -40,6 +43,44 @@ pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult>( + exec: E, + ids: &[Uuid], +) -> ApiResult> { + Ok(sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = ANY($1) ORDER BY id" + )) + .bind(ids) + .fetch_all(exec) + .await?) +} + +/// Lock member orders in sorted id order: the deterministic second step of +/// the global payment lock order. +pub async fn lock_sorted(tx: &mut PgConnection, ids: &[Uuid]) -> ApiResult> { + Ok(sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = ANY($1) ORDER BY id FOR UPDATE" + )) + .bind(ids) + .fetch_all(&mut *tx) + .await?) +} + +/// System-side guarded pending_payment -> cancelled transition used by the +/// shared payment cancellation / compensation paths. +pub async fn cancel_pending(tx: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "UPDATE orders SET status = 'cancelled', updated_at = now() + WHERE id = $1 AND status = 'pending_payment' + RETURNING {ORDER_COLS}" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("order not cancellable (wrong status)".into())) +} + pub async fn count(db: &PgPool, scope: OrderScope, status: Option) -> ApiResult { Ok(match scope { OrderScope::User(user_id) => { diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index 1f4175a..e2a3f37 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -4,9 +4,9 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::http::{clamp_page, clamp_per_page, Paged}; -use crate::models::{MessageKind, OrderStatus}; +use crate::models::OrderStatus; use crate::modules::group_buying::GroupBuyIntent; -use crate::modules::{cart, coupon, flash_sale, freight, group_buying, messaging}; +use crate::modules::{cart, coupon, flash_sale, freight, group_buying}; use crate::money::convert_minor; use crate::state::AppState; @@ -73,14 +73,44 @@ pub async fn get_for_shop(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResul pub async fn checkout( state: &AppState, user_id: Uuid, + idempotency_key: &str, shipping_address: AddressBody, currency: String, coupon_by_shop: HashMap, group_buy: Option, -) -> ApiResult> { +) -> ApiResult { shipping_address.validate()?; let target_currency = currency.to_uppercase(); let entries = cart::service::read_entries(state, user_id).await?; + // Owner-scoped checkout idempotency: the full request hash covers the + // cart snapshot; the header hash is the same input minus the cart so a + // legitimate replay (cart cleared after a successful checkout) still + // matches while a different currency/address/coupon/group intent is + // rejected as 409. + let request_hash = crate::modules::payment::service::request_hash(&serde_json::json!({ + "shipping_address": serde_json::to_value(&shipping_address).map_err(ApiError::internal)?, + "currency": target_currency, + "coupon_by_shop": coupon_by_shop, + "group_buy": group_buy, + "cart": entries, + })); + let header_hash = crate::modules::payment::service::request_hash(&serde_json::json!({ + "shipping_address": serde_json::to_value(&shipping_address).map_err(ApiError::internal)?, + "currency": target_currency, + "coupon_by_shop": coupon_by_shop, + "group_buy": group_buy, + })); + if let Some(replayed) = crate::modules::payment::service::checkout_replay( + state, + user_id, + idempotency_key, + if entries.is_empty() { "" } else { &request_hash }, + &header_hash, + ) + .await? + { + return Ok(replayed); + } if entries.is_empty() { return Err(ApiError::BadRequest("cart is empty".into())); } @@ -243,7 +273,18 @@ pub async fn checkout( locked.into_iter().map(|c| (c.id, c)).collect(); let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?; - let mut created = Vec::new(); + + // Per-shop totals are computed before any write so a computed zero + // payment total is rejected before order, inventory, coupon or payment + // writes. + struct PlannedShop { + shop_id: Uuid, + discount: i64, + shipping_fee: i64, + order_total: i64, + coupon_id: Option, + } + let mut planned: Vec = Vec::new(); for shop_id in shop_order { let shop_lines: Vec<&CheckoutLine> = lines .iter() @@ -291,15 +332,38 @@ pub async fn checkout( ) .await?; + planned.push(PlannedShop { + shop_id, + discount, + shipping_fee, + order_total: subtotal - discount + shipping_fee, + coupon_id: selected_coupon, + }); + } + let payment_total: i64 = planned.iter().map(|p| p.order_total).sum(); + if payment_total <= 0 { + return Err(ApiError::Conflict("payment total must be positive".into())); + } + + let mut created = Vec::new(); + let mut memberships: Vec<(Uuid, i64)> = Vec::new(); + for plan in planned { + let shop_lines: Vec<&CheckoutLine> = lines + .iter() + .filter(|line| line.shop_id == plan.shop_id) + .collect(); + let shop_id = plan.shop_id; + let selected_coupon = plan.coupon_id; + let group = group_by_shop.get(&shop_id); let order = repo::insert_order( &mut tx, shop_id, user_id, &target.code, - subtotal - discount + shipping_fee, - discount, - shipping_fee, + plan.order_total, + plan.discount, + plan.shipping_fee, selected_coupon, group.map(|g| g.activity_id), group.map(|g| g.group_id), @@ -370,12 +434,61 @@ pub async fn checkout( repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty) .await?; } + memberships.push((order.id, plan.order_total)); created.push(order); } + // One server-owned payment unit with immutable per-order memberships, + // created in the same transaction as the stock, pricing, coupon and + // order writes. Membership amounts equal the snapshotted order totals. + let payment = crate::modules::payment::attach_checkout_payment( + &mut tx, + user_id, + &target.code, + &memberships, + state.config.payment.environment, + ) + .await?; + + let views = repo::attach_items(&mut *tx, created).await?; + let response = serde_json::json!({ "orders": views, "payment_id": payment.id }); + if let Err(e) = crate::modules::payment::bind_checkout_request( + &mut tx, + user_id, + idempotency_key, + &request_hash, + &header_hash, + payment.id, + &response, + ) + .await + { + // A concurrent duplicate with the same key aborted our transaction: + // replay the durable winner's original result. + tx.rollback().await?; + return match e { + ApiError::Conflict(_) => { + match crate::modules::payment::service::checkout_replay( + state, + user_id, + idempotency_key, + &request_hash, + &header_hash, + ) + .await? + { + Some(replayed) => Ok(replayed), + None => Err(ApiError::Conflict( + "idempotency key already used with a different request".into(), + )), + } + } + other => Err(other), + }; + } tx.commit().await?; cart::service::clear(state, user_id).await?; - repo::attach_items(&state.db, created).await + Ok(response) } /// Read-only per-shop shipping quote for the current cart and destination. @@ -455,48 +568,33 @@ pub async fn shipping_quote( }) } -pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { - let mut tx = state.db.begin().await?; - let order = repo::lock_for_user(&mut tx, user_id, id).await?; - if order.status != OrderStatus::PendingPayment { - return Err(ApiError::Conflict( - "order not payable (wrong status)".into(), - )); - } - // A group order claims its paid seat in the same transaction as payment. - if let Some(group_id) = order.group_id { - group_buying::service::claim_seat(&mut tx, user_id, order.id, group_id).await?; - } - let order = repo::mark_paid(&mut tx, order.id).await?; - // Payment notifies the buyer; idempotent per order, so a retry is a no-op. - messaging::service::emit_event( - &mut tx, - order.user_id, - MessageKind::OrderPaid, - "order", - order.id, - &order.order_no, - ) - .await?; - tx.commit().await?; - let mut views = repo::attach_items(&state.db, vec![order]).await?; - Ok(views.remove(0)) -} - +/// Cancel one order's entire unpaid payment group through the shared guarded +/// payment service and return the requested order. Sibling members of the +/// same checkout payment are cancelled atomically with it; funded or unknown +/// collection outcomes block cancellation with 409. pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { - let mut tx = state.db.begin().await?; - let order = repo::cancel(&mut tx, user_id, id).await?; - repo::restore_stock(&mut tx, order.id).await?; - // Reserved activity stock, SKU stock, and the redeemed coupon all come back - // in the same transaction. - flash_sale::service::restore_for_order(&mut tx, order.id).await?; - coupon::service::restore_for_order(&mut tx, order.id).await?; - // Cancelling a group order restores SKU stock only: paid seats are never - // rolled back. An unpaid opener leaves an empty group closed. - if let Some(group_id) = order.group_id { - group_buying::service::cancel_opener_group(&mut tx, order.id, group_id).await?; + let memberships = crate::modules::payment::repo::payments_for_orders(&state.db, &[id]).await?; + if let Some(membership) = memberships.into_iter().next() { + crate::modules::payment::cancel_payment_group(state, Some(user_id), membership.payment_id) + .await?; + } else { + // Pending orders with no payment membership yet cancel alone through + // the same resource-restoring path. + let mut tx = state.db.begin().await?; + let order = repo::lock_for_user(&mut tx, user_id, id).await?; + if order.status != OrderStatus::PendingPayment { + return Err(ApiError::Conflict( + "order not cancellable (wrong status)".into(), + )); + } + let cancelled = repo::cancel_pending(&mut tx, order.id).await?; + repo::restore_stock(&mut tx, cancelled.id).await?; + flash_sale::service::restore_for_order(&mut tx, cancelled.id).await?; + coupon::service::restore_for_order(&mut tx, cancelled.id).await?; + if let Some(group_id) = cancelled.group_id { + group_buying::service::cancel_opener_group(&mut tx, cancelled.id, group_id).await?; + } + tx.commit().await?; } - tx.commit().await?; - let mut views = repo::attach_items(&state.db, vec![order]).await?; - Ok(views.remove(0)) + get_for_user(state, user_id, id).await } diff --git a/apps/api/src/modules/payment/adapters.rs b/apps/api/src/modules/payment/adapters.rs new file mode 100644 index 0000000..27b1fa1 --- /dev/null +++ b/apps/api/src/modules/payment/adapters.rs @@ -0,0 +1,640 @@ +//! Provider adapters: the minimal payment-provider boundary. +//! +//! Exactly two adapters ship: `wallet` (internal, synchronous, no external +//! calls) and `stripe_crypto` (Stripe-hosted stablecoin Checkout with fiat +//! settlement). Provider hosts are fixed in code; credentials are resolved +//! from deployment-allowlisted secret references and never logged. + +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::config::PaymentConfig; +use crate::error::{ApiError, ApiResult}; + +use super::dto::{CryptoAssetMetadata, PaymentEnvironment}; + +/// Adapter contract version recorded on channel revisions. +pub const ADAPTER_CONTRACT_VERSION: &str = "vmall-payment-adapter/1"; +pub const STRIPE_ADAPTER_CONTRACT_VERSION: &str = "vmall-stripe-crypto/1"; + +/// Provider call outcomes. A timeout/transport failure is `Unknown`: it is +/// never a definitive failure and never permits blind replacement. +#[derive(Debug, thiserror::Error)] +pub enum AdapterError { + #[error("provider timeout: {0}")] + Timeout(String), + #[error("provider error: {0}")] + Provider(String), + #[error("provider rejected the request: {0}")] + Rejected(String), + #[error("invalid provider configuration: {0}")] + Config(String), +} + +impl AdapterError { + pub fn is_unknown(&self) -> bool { + matches!(self, Self::Timeout(_)) + } +} + +impl From for ApiError { + fn from(e: AdapterError) -> Self { + match e { + AdapterError::Timeout(_) => ApiError::Conflict(format!( + "provider operation outcome unknown: {e}; reconciliation required" + )), + AdapterError::Rejected(m) => ApiError::Conflict(m), + other => ApiError::Internal(anyhow::anyhow!(other)), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreateCheckoutRequest { + pub payment_id: String, + pub attempt_id: String, + pub attempt_operation_key: String, + pub amount_minor: i64, + pub currency: String, + pub description: String, + pub success_url: String, + pub cancel_url: String, + pub environment: PaymentEnvironment, +} + +#[derive(Debug, Clone)] +pub struct CreateCheckoutResult { + pub session_id: String, + pub checkout_url: String, + pub payment_intent_id: Option, + pub expires_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct SessionStatus { + pub session_id: String, + /// Authoritative paid collection established by the provider. + pub paid: bool, + pub expired: bool, + pub payment_intent_id: Option, + pub amount_minor: Option, + pub currency: Option, + pub crypto: Option, + pub raw: Value, +} + +#[derive(Debug, Clone)] +pub struct RefundRequest { + pub refund_operation_key: String, + pub provider_reference: String, + pub payment_intent_id: Option, + pub amount_minor: i64, + pub currency: String, + pub metadata: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +pub struct RefundResult { + pub refund_id: String, + pub status: String, + pub raw: Value, +} + +#[derive(Debug, Clone)] +pub struct AccountCheck { + pub ok: bool, + pub provider_account: Option, + pub unmet_prerequisites: Vec, + pub detail: Option, + /// Provider settlement metadata (e.g. account default currency). Never a + /// claim that settlement is universally USD. + pub settlement_currency: Option, +} + +// --------------------------------------------------------------------------- +// Stripe HTTP client (fixed host, pinned API version). +// --------------------------------------------------------------------------- + +pub struct StripeClient { + http: reqwest::Client, + base: String, + api_version: String, +} + +impl StripeClient { + pub fn new(config: &PaymentConfig) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("reqwest client"), + base: config.stripe_api_base.trim_end_matches('/').to_string(), + api_version: config.stripe_api_version.clone(), + } + } + + fn auth(secret_key: &str) -> ApiResult { + Ok(format!("Bearer {secret_key}")) + } + + async fn call_form( + &self, + secret_key: &str, + method: reqwest::Method, + path: &str, + form: &[(String, String)], + idempotency_key: Option<&str>, + ) -> Result { + let url = format!("{}{}", self.base, path); + let method_for_request = method.clone(); + let mut request = self + .http + .request(method_for_request, &url) + .header("Authorization", Self::auth(secret_key).map_err(|e| AdapterError::Config(e.to_string()))?) + .header("Stripe-Version", &self.api_version) + .header("Content-Type", "application/x-www-form-urlencoded"); + if let Some(key) = idempotency_key { + request = request.header("Idempotency-Key", key); + } + // Stripe reads GET parameters from the query string; form-encoded + // bodies are only valid on mutating methods. + let request = if method == reqwest::Method::GET { + request.query(form) + } else { + request.form(form) + }; + let response = request + .send() + .await + .map_err(|e| { + if e.is_timeout() { + AdapterError::Timeout(e.to_string()) + } else { + AdapterError::Provider(e.to_string()) + } + })?; + let status = response.status(); + let body = response.text().await.map_err(|e| AdapterError::Provider(e.to_string()))?; + let parsed: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + if status.is_success() { + Ok(parsed) + } else { + let message = parsed + .pointer("/error/message") + .and_then(|m| m.as_str()) + .unwrap_or(&body) + .to_string(); + if status.is_server_error() { + Err(AdapterError::Provider(message)) + } else { + Err(AdapterError::Rejected(message)) + } + } + } + + pub async fn create_checkout( + &self, + secret_key: &str, + req: &CreateCheckoutRequest, + ) -> Result { + let mut form: Vec<(String, String)> = vec![ + ("mode".into(), "payment".into()), + ( + "line_items[0][price_data][currency]".into(), + req.currency.to_lowercase(), + ), + ( + "line_items[0][price_data][unit_amount]".into(), + req.amount_minor.to_string(), + ), + ( + "line_items[0][price_data][product_data][name]".into(), + req.description.clone(), + ), + ("line_items[0][quantity]".into(), "1".into()), + // Scoped explicitly to the crypto method: a VMall crypto channel + // must never silently offer cards. + ("payment_method_types[0]".into(), "crypto".into()), + ("success_url".into(), req.success_url.clone()), + ("cancel_url".into(), req.cancel_url.clone()), + ("client_reference_id".into(), req.payment_id.clone()), + ("metadata[vmall_payment_id]".into(), req.payment_id.clone()), + ("metadata[vmall_attempt_id]".into(), req.attempt_id.clone()), + ( + "metadata[vmall_environment]".into(), + req.environment.as_str().into(), + ), + ]; + form.sort(); + let session = self + .call_form( + secret_key, + reqwest::Method::POST, + "/v1/checkout/sessions", + &form, + Some(req.attempt_operation_key.as_str()), + ) + .await?; + let session_id = session + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AdapterError::Provider("create session returned no id".into()))? + .to_string(); + let checkout_url = session + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| AdapterError::Provider("create session returned no url".into()))? + .to_string(); + Ok(CreateCheckoutResult { + session_id, + checkout_url, + payment_intent_id: json_str(&session, "payment_intent"), + expires_at: session + .get("expires_at") + .and_then(|v| v.as_i64()) + .and_then(|ts| chrono::TimeZone::timestamp_opt(&chrono::Utc, ts, 0).single()), + }) + } + + pub async fn retrieve_session( + &self, + secret_key: &str, + session_id: &str, + ) -> Result { + let session = self + .call_form( + secret_key, + reqwest::Method::GET, + &format!("/v1/checkout/sessions/{session_id}"), + &[("expand[]".into(), "payment_intent".into())], + None, + ) + .await?; + let payment_status = session + .get("payment_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let status = session.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let payment_intent_id = session + .get("payment_intent") + .map(|pi| { + if pi.is_string() { + pi.as_str().map(str::to_string) + } else { + json_str(&session, "payment_intent") + } + }) + .flatten() + .or_else(|| session.pointer("/payment_intent/id").and_then(|v| v.as_str()).map(str::to_string)); + Ok(SessionStatus { + session_id: session_id.to_string(), + // Authoritative paid collection only; a completed session or a + // browser redirect never proves collection. + paid: payment_status == "paid", + expired: status == "expired", + payment_intent_id, + amount_minor: session.get("amount_total").and_then(|v| v.as_i64()), + currency: session + .get("currency") + .and_then(|v| v.as_str()) + .map(|s| s.to_uppercase()), + crypto: extract_crypto(&session), + raw: session, + }) + } + + pub async fn create_refund( + &self, + secret_key: &str, + req: &RefundRequest, + ) -> Result { + let mut form: Vec<(String, String)> = vec![ + ("amount".into(), req.amount_minor.to_string()), + ("currency".into(), req.currency.to_lowercase()), + ]; + if let Some(intent) = &req.payment_intent_id { + form.push(("payment_intent".into(), intent.clone())); + } else { + form.push(("charge".into(), req.provider_reference.clone())); + } + for (k, v) in &req.metadata { + form.push((format!("metadata[{k}]"), v.clone())); + } + form.sort(); + let refund = self + .call_form( + secret_key, + reqwest::Method::POST, + "/v1/refunds", + &form, + Some(req.refund_operation_key.as_str()), + ) + .await?; + Ok(refund_result(&refund)?) + } + + pub async fn retrieve_refund( + &self, + secret_key: &str, + refund_id: &str, + ) -> Result { + let refund = self + .call_form( + secret_key, + reqwest::Method::GET, + &format!("/v1/refunds/{refund_id}"), + &[], + None, + ) + .await?; + refund_result(&refund) + } + + /// Read-only connection/account check. A successful probe never + /// substitutes for Stripe business approval. + pub async fn check_account(&self, secret_key: &str) -> Result { + let account = self + .call_form(secret_key, reqwest::Method::GET, "/v1/account", &[], None) + .await?; + let provider_account = account + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_string); + let mut unmet = Vec::new(); + if !account + .get("details_submitted") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + unmet.push("stripe account details not submitted".into()); + } + if !account + .get("charges_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + unmet.push("stripe charges are not enabled".into()); + } + let crypto = account + .pointer("/capabilities/crypto_payments") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if crypto != "active" { + unmet.push( + "stripe crypto_payments capability is not active (business eligibility review required)" + .into(), + ); + } + Ok(AccountCheck { + ok: unmet.is_empty(), + provider_account, + unmet_prerequisites: unmet, + settlement_currency: account + .get("default_currency") + .and_then(|v| v.as_str()) + .map(|s| s.to_uppercase()), + detail: None, + }) + } +} + +fn json_str(value: &Value, key: &str) -> Option { + match value.get(key) { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Object(map)) => map.get("id").and_then(|v| v.as_str()).map(str::to_string), + _ => None, + } +} + +fn refund_result(refund: &Value) -> Result { + let id = refund + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AdapterError::Provider("refund response has no id".into()))? + .to_string(); + let status = refund + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending") + .to_string(); + Ok(RefundResult { + refund_id: id, + status, + raw: refund.clone(), + }) +} + +/// Tolerant extraction of provider-exposed crypto evidence. Fields that the +/// hosted API does not expose stay absent; nothing is derived from fiat +/// totals and quantities stay decimal strings. +pub fn extract_crypto(value: &Value) -> Option { + let crypto = value + .pointer("/payment_method_details/crypto") + .or_else(|| value.pointer("/payment_intent/payment_method_details/crypto")) + .or_else(|| value.pointer("/charge/payment_method_details/crypto")) + .or_else(|| value.pointer("/destination_details/crypto")) + .or_else(|| value.get("crypto"))?; + if !crypto.is_object() { + return None; + } + let base_units = crypto + .get("amount") + .or_else(|| crypto.get("amount_base_units")) + .or_else(|| crypto.get("quantity")) + .map(|v| match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + }) + .unwrap_or_default(); + Some(CryptoAssetMetadata { + network: crypto + .get("network") + .and_then(|v| v.as_str()) + .map(str::to_string), + contract_address: crypto + .get("contract_address") + .or_else(|| crypto.get("token_contract_address")) + .and_then(|v| v.as_str()) + .map(str::to_string), + base_units, + symbol: crypto + .get("asset") + .or_else(|| crypto.get("token")) + .or_else(|| crypto.get("currency")) + .and_then(|v| v.as_str()) + .map(str::to_string), + quote_reference: crypto + .get("quote_reference") + .or_else(|| crypto.get("quote_id")) + .and_then(|v| v.as_str()) + .map(str::to_string), + quote_expires_at: crypto + .get("quote_expires_at") + .and_then(|v| v.as_str()) + .map(str::to_string), + transaction_reference: crypto + .get("transaction_hash") + .or_else(|| crypto.get("transaction_reference")) + .and_then(|v| v.as_str()) + .map(str::to_string), + }) +} + +// --------------------------------------------------------------------------- +// Stripe webhook signature verification (raw body, HMAC-SHA256). +// --------------------------------------------------------------------------- + +pub struct VerifiedSignature { + pub timestamp: i64, + /// Which signing secret verified: the revision's webhook secret reference. + pub key_id: String, +} + +/// Verify `Stripe-Signature` over the unmodified raw body with timestamp +/// tolerance. Multiple v1 values are accepted (rotation). +pub fn verify_stripe_signature( + raw_body: &[u8], + signature_header: &str, + secret: &str, + key_id: &str, + tolerance_secs: i64, +) -> Result { + let mut timestamp: Option = None; + let mut signatures: Vec<&str> = Vec::new(); + for part in signature_header.split(',') { + let part = part.trim(); + if let Some(t) = part.strip_prefix("t=") { + timestamp = t.parse().ok(); + } else if let Some(v) = part.strip_prefix("v1=") { + signatures.push(v); + } + } + let timestamp = timestamp.ok_or_else(|| AdapterError::Rejected( + "stripe-signature header has no timestamp".into(), + ))?; + let now = chrono::Utc::now().timestamp(); + if (now - timestamp).abs() > tolerance_secs { + return Err(AdapterError::Rejected( + "stripe-signature timestamp outside tolerance".into(), + )); + } + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).map_err(|e| AdapterError::Config(e.to_string()))?; + mac.update(format!("{timestamp}.").as_bytes()); + mac.update(raw_body); + let expected = hex::encode(mac.finalize().into_bytes()); + let matched = signatures.iter().any(|candidate| { + candidate.len() == expected.len() + && candidate + .as_bytes() + .iter() + .zip(expected.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 + }); + if !matched { + return Err(AdapterError::Rejected( + "stripe-signature verification failed".into(), + )); + } + Ok(VerifiedSignature { + timestamp, + key_id: key_id.to_string(), + }) +} + +// --------------------------------------------------------------------------- +// Adapter capability descriptors (honest differences, no placeholders). +// --------------------------------------------------------------------------- + +pub fn adapter_currencies( + kind: super::dto::PaymentAdapterKind, + base_currency: &str, +) -> Vec { + use super::dto::PaymentAdapterKind as K; + match kind { + // The wallet is the base-currency account ledger; no implicit FX. + K::Wallet => vec![base_currency.to_string()], + // Stripe crypto Checkout: USD presentment and VMall receipt currency. + K::StripeCrypto => vec!["USD".to_string()], + } +} + +pub fn adapter_max_amount_minor( + kind: super::dto::PaymentAdapterKind, + configured: Option, +) -> Option { + use super::dto::PaymentAdapterKind as K; + match kind { + K::Wallet => configured, + // Provider-documented USD 10,000 per-transaction ceiling; the + // effective limit is the lower of this and the configured limit. + K::StripeCrypto => Some(configured.unwrap_or(1_000_000).min(1_000_000)), + } +} + +pub fn provider_managed(kind: super::dto::PaymentAdapterKind) -> Vec { + use super::dto::PaymentAdapterKind as K; + match kind { + K::Wallet => vec![], + K::StripeCrypto => vec![ + "wallet_connection".into(), + "token_selection".into(), + "network_selection".into(), + "quote_duration".into(), + "underpayment_tolerance".into(), + "refund_destination_wallet".into(), + ], + } +} + +pub fn activation_prerequisites(kind: super::dto::PaymentAdapterKind) -> Vec { + use super::dto::PaymentAdapterKind as K; + match kind { + K::Wallet => vec![], + K::StripeCrypto => vec![ + "approved Stripe account with active crypto_payments capability".into(), + "USD presentment and configured amount limits".into(), + "webhook endpoint registered with the platform signing secret".into(), + "matching Stripe sandbox/live environment credentials".into(), + ], + } +} + +/// Wallet receipts carry an internal operation identity; unique per attempt. +pub fn wallet_transaction_id(operation_key: &str) -> String { + format!("wallet:{operation_key}") +} + +/// Helper for adapters to build the stable provider idempotency key from the +/// durable local operation identity. Retained permanently; automatic retries +/// only use it inside the conservative provider retention window. +pub fn provider_operation_key(prefix: &str, id: uuid::Uuid) -> String { + format!("{prefix}:{}", id.simple()) +} + +pub fn quote_description(payment_id: &str) -> String { + format!("VMall payment {payment_id}") +} + +/// Map provider refund status strings onto our typed states. +pub fn refund_state_from_provider(status: &str) -> Option { + use super::dto::RefundState as R; + match status { + "succeeded" => Some(R::Succeeded), + "failed" | "canceled" | "cancelled" => Some(R::Failed), + "requires_action" | "pending" | "processing" => Some(R::Pending), + _ => None, + } +} + +pub fn env_matches_livemode(environment: PaymentEnvironment, livemode: bool) -> bool { + matches!( + (environment, livemode), + (PaymentEnvironment::Live, true) | (PaymentEnvironment::Demo, false) | (PaymentEnvironment::Sandbox, false) + ) +} diff --git a/apps/api/src/modules/payment/admin.rs b/apps/api/src/modules/payment/admin.rs new file mode 100644 index 0000000..3434f09 --- /dev/null +++ b/apps/api/src/modules/payment/admin.rs @@ -0,0 +1,927 @@ +//! Admin payment configuration (versioned channels, redacted audit, +//! read-only provider verification) and the payment/refund/exception +//! operations console. Platform-admin only; merchant access is denied. + +use serde_json::{json, Value}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::state::AppState; + +use super::adapters; +use super::dto::*; +use super::repo::{self, ChannelRow}; +use super::service; +use super::webhooks; + +// --------------------------------------------------------------------------- +// Adapter catalog and channel views. +// --------------------------------------------------------------------------- + +pub async fn channels_by_ids(db: &PgPool, ids: &[Uuid]) -> ApiResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + Ok(sqlx::query_as::<_, ChannelRow>(&format!( + "SELECT {} FROM payment_channels WHERE id = ANY($1)", + repo::CHANNEL_COLS + )) + .bind(ids) + .fetch_all(db) + .await?) +} + +pub async fn list_adapters(state: &AppState) -> ApiResult> { + let base = crate::modules::account::repo::base_currency(&state.db).await?; + let mut out = Vec::new(); + for kind in [PaymentAdapterKind::Wallet, PaymentAdapterKind::StripeCrypto] { + let currencies = adapters::adapter_currencies(kind, &base); + out.push(PaymentAdapterDescriptor { + kind, + label: match kind { + PaymentAdapterKind::Wallet => { + json!({ "en": "Wallet balance", "zh": "钱包余额" }) + } + PaymentAdapterKind::StripeCrypto => { + json!({ "en": "Crypto (Stripe-hosted)", "zh": "加密支付(Stripe 托管)" }) + } + }, + environment: state.config.payment.environment, + capabilities: AdapterCapabilities { + min_amount_minor: 1, + max_amount_minor: adapters::adapter_max_amount_minor(kind, None), + currencies, + provider_managed: adapters::provider_managed(kind), + }, + secret_slots: match kind { + PaymentAdapterKind::Wallet => vec![], + PaymentAdapterKind::StripeCrypto => { + vec!["secret_key".into(), "webhook_secret".into()] + } + }, + activation_prerequisites: adapters::activation_prerequisites(kind), + }); + } + Ok(out) +} + +fn secret_slot_views(state: &AppState, channel: &ChannelRow) -> Vec { + let mut slots = Vec::new(); + for (slot, reference) in [ + ("secret_key", &channel.secret_key_ref), + ("webhook_secret", &channel.webhook_secret_ref), + ] { + slots.push(SecretSlotView { + slot: slot.to_string(), + configured: reference + .as_deref() + .map(|r| state.config.payment.has_secret_ref(r)) + .unwrap_or(false), + // Reference identifiers only; secret values are never returned. + reference: reference.clone(), + }); + } + slots +} + +/// Concrete unmet activation prerequisites. Enabling never overrides +/// upstream availability and a probe never implies business approval. +pub async fn unmet_prerequisites( + state: &AppState, + channel: &ChannelRow, +) -> ApiResult> { + let mut unmet = Vec::new(); + let kind = channel.adapter_kind()?; + match kind { + PaymentAdapterKind::Wallet => { + let base = crate::modules::account::repo::base_currency(&state.db).await?; + if channel.supported_currencies != vec![base.clone()] { + unmet.push(format!( + "wallet supports only the base wallet currency ({base})" + )); + } + } + PaymentAdapterKind::StripeCrypto => { + if channel.provider_account_id.is_none() { + unmet.push("provider account id is not configured".into()); + } + for (slot, reference) in [ + ("secret key", &channel.secret_key_ref), + ("webhook signing secret", &channel.webhook_secret_ref), + ] { + let ok = reference + .as_deref() + .map(|r| state.config.payment.has_secret_ref(r)) + .unwrap_or(false); + if !ok { + unmet.push(format!("{slot} reference is missing or not configured")); + } + } + if !channel.supported_currencies.iter().all(|c| c == "USD") { + unmet.push("stripe crypto supports USD only".into()); + } + } + } + Ok(unmet) +} + +pub async fn channel_detail_view( + state: &AppState, + channel: &ChannelRow, +) -> ApiResult { + let kind = channel.adapter_kind()?; + let base = crate::modules::account::repo::base_currency(&state.db).await?; + Ok(PaymentChannelDetailView { + channel: service::channel_view(channel), + secret_key_ref: channel.secret_key_ref.clone(), + webhook_secret_ref: channel.webhook_secret_ref.clone(), + secret_slots: secret_slot_views(state, channel), + unmet_prerequisites: unmet_prerequisites(state, channel).await?, + capabilities: AdapterCapabilities { + min_amount_minor: 1, + max_amount_minor: adapters::adapter_max_amount_minor(kind, channel.max_amount_minor), + currencies: adapters::adapter_currencies(kind, &base), + provider_managed: adapters::provider_managed(kind), + }, + }) +} + +// --------------------------------------------------------------------------- +// Validation and redacted audit. +// --------------------------------------------------------------------------- + +fn validate_channel_config( + state: &AppState, + adapter: PaymentAdapterKind, + label: &Value, + supported_currencies: &[String], + min_amount_minor: i64, + max_amount_minor: Option, + secret_key_ref: Option<&str>, + webhook_secret_ref: Option<&str>, +) -> ApiResult<()> { + if label.get("en").and_then(|v| v.as_str()).unwrap_or("").is_empty() + || label.get("zh").and_then(|v| v.as_str()).unwrap_or("").is_empty() + { + return Err(ApiError::BadRequest( + "label requires non-empty en and zh text".into(), + )); + } + if supported_currencies.is_empty() { + return Err(ApiError::BadRequest( + "supported_currencies is required".into(), + )); + } + if min_amount_minor <= 0 { + return Err(ApiError::BadRequest( + "min_amount_minor must be a positive integer".into(), + )); + } + if let Some(max) = max_amount_minor { + if max < min_amount_minor { + return Err(ApiError::BadRequest( + "max_amount_minor must be >= min_amount_minor".into(), + )); + } + } + let base = "USD"; // capability bounds are adapter-owned + let allowed = adapters::adapter_currencies(adapter, base); + for currency in supported_currencies { + if !allowed.iter().any(|c| c == currency) { + return Err(ApiError::BadRequest(format!( + "adapter {adapter} does not support currency {currency}" + ))); + } + } + if let Some(max) = adapters::adapter_max_amount_minor(adapter, max_amount_minor) { + if max_amount_minor.map(|m| m > max).unwrap_or(false) { + return Err(ApiError::BadRequest(format!( + "max_amount_minor exceeds the adapter ceiling of {max}" + ))); + } + } + // Secret references must be deployment-allowlisted; arbitrary host + // variables are rejected without being resolved or revealed. Values are + // only checked for shape here; existence is an activation prerequisite. + for reference in [secret_key_ref, webhook_secret_ref].into_iter().flatten() { + let shape_ok = reference.len() > state.config.payment.secret_ref_prefix.len() + && reference.starts_with(&state.config.payment.secret_ref_prefix) + && reference + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if !shape_ok { + return Err(ApiError::BadRequest(format!( + "secret reference outside the payment allowlist: {reference}" + ))); + } + } + if adapter == PaymentAdapterKind::Wallet { + if secret_key_ref.is_some() || webhook_secret_ref.is_some() { + return Err(ApiError::BadRequest( + "the wallet adapter takes no provider secret references".into(), + )); + } + } + Ok(()) +} + +fn change_entry(from: Option, to: Option) -> Value { + json!({ "from": from, "to": to }) +} + +fn snapshot_of( + adapter: PaymentAdapterKind, + label: &Value, + display_order: i32, + enabled: bool, + supported_currencies: &[String], + min_amount_minor: i64, + max_amount_minor: Option, + provider_account_id: Option<&str>, + secret_key_ref: Option<&str>, + webhook_secret_ref: Option<&str>, +) -> Value { + json!({ + "adapter": adapter.as_str(), + "label": label, + "display_order": display_order, + "enabled": enabled, + "supported_currencies": supported_currencies, + "min_amount_minor": min_amount_minor, + "max_amount_minor": max_amount_minor, + "provider_account_id": provider_account_id, + // Reference identifiers, never values. + "secret_key_ref": secret_key_ref, + "webhook_secret_ref": webhook_secret_ref, + "adapter_contract_version": match adapter { + PaymentAdapterKind::Wallet => adapters::ADAPTER_CONTRACT_VERSION, + PaymentAdapterKind::StripeCrypto => adapters::STRIPE_ADAPTER_CONTRACT_VERSION, + }, + }) +} + +fn contract_version(adapter: PaymentAdapterKind) -> &'static str { + match adapter { + PaymentAdapterKind::Wallet => adapters::ADAPTER_CONTRACT_VERSION, + PaymentAdapterKind::StripeCrypto => adapters::STRIPE_ADAPTER_CONTRACT_VERSION, + } +} + +// --------------------------------------------------------------------------- +// Channel CRUD. +// --------------------------------------------------------------------------- + +pub async fn create_channel( + state: &AppState, + actor: &str, + body: ChannelCreateBody, +) -> ApiResult { + validate_channel_config( + state, + body.adapter, + &body.label, + &body.supported_currencies, + body.min_amount_minor, + body.max_amount_minor, + body.secret_key_ref.as_deref(), + body.webhook_secret_ref.as_deref(), + )?; + // The deployment environment is server-owned; admin cannot pick another. + let environment = state.config.payment.environment; + + let mut tx = state.db.begin().await?; + let channel = repo::insert_channel( + &mut tx, + body.adapter, + &body.label, + body.display_order, + body.enabled, + &body.supported_currencies, + body.min_amount_minor, + body.max_amount_minor, + body.provider_account_id.as_deref(), + body.secret_key_ref.as_deref(), + body.webhook_secret_ref.as_deref(), + environment, + ) + .await?; + let snapshot = snapshot_of( + body.adapter, + &body.label, + body.display_order, + body.enabled, + &body.supported_currencies, + body.min_amount_minor, + body.max_amount_minor, + body.provider_account_id.as_deref(), + body.secret_key_ref.as_deref(), + body.webhook_secret_ref.as_deref(), + ); + repo::insert_channel_revision( + &mut tx, + channel.id, + channel.revision, + &snapshot, + contract_version(body.adapter), + None, + ) + .await?; + let changes = json!({ + "adapter": change_entry(None, Some(json!(body.adapter.as_str()))), + "label": change_entry(None, Some(body.label.clone())), + "display_order": change_entry(None, Some(json!(body.display_order))), + "enabled": change_entry(None, Some(json!(body.enabled))), + "supported_currencies": change_entry(None, Some(json!(body.supported_currencies))), + "min_amount_minor": change_entry(None, Some(json!(body.min_amount_minor))), + "max_amount_minor": change_entry(None, Some(json!(body.max_amount_minor))), + "provider_account_id": change_entry(None, body.provider_account_id.clone().map(Value::String)), + "secret_key_ref": change_entry(None, body.secret_key_ref.clone().map(Value::String)), + "webhook_secret_ref": change_entry(None, body.webhook_secret_ref.clone().map(Value::String)), + }); + repo::insert_audit(&mut tx, channel.id, channel.revision, "created", actor, &changes) + .await?; + tx.commit().await?; + + if body.enabled { + // Enabling requires valid configuration and a successful + // provider/account capability check; fail closed — the channel is + // never left advertised as enabled. + let channel = repo::get_channel(&state.db, channel.id).await?; + let unmet = unmet_prerequisites(state, &channel).await?; + if !unmet.is_empty() { + let mut tx = state.db.begin().await?; + sqlx::query( + "UPDATE payment_channels SET enabled = FALSE, updated_at = now() WHERE id = $1", + ) + .bind(channel.id) + .execute(&mut *tx) + .await?; + repo::insert_audit( + &mut tx, + channel.id, + channel.revision, + "disabled", + actor, + &json!({ "enabled": change_entry(Some(json!(true)), Some(json!(false))) }), + ) + .await?; + tx.commit().await?; + return Err(ApiError::Conflict(format!( + "cannot enable channel: {}", + unmet.join("; ") + ))); + } + } + let channel = repo::get_channel(&state.db, channel.id).await?; + channel_detail_view(state, &channel).await +} + +pub async fn update_channel( + state: &AppState, + actor: &str, + channel_id: Uuid, + body: ChannelUpdateBody, +) -> ApiResult { + let current = repo::get_channel(&state.db, channel_id).await?; + if current.archived { + return Err(ApiError::Conflict("channel is archived".into())); + } + let adapter = body.adapter.unwrap_or(current.adapter_kind()?); + let label = body.label.unwrap_or_else(|| current.label.clone()); + let display_order = body.display_order.unwrap_or(current.display_order); + let enabled = body.enabled.unwrap_or(current.enabled); + let supported_currencies = body + .supported_currencies + .clone() + .unwrap_or_else(|| current.supported_currencies.clone()); + let min_amount_minor = body.min_amount_minor.unwrap_or(current.min_amount_minor); + let max_amount_minor = body.max_amount_minor.unwrap_or(current.max_amount_minor); + let provider_account_id = body + .provider_account_id + .clone() + .unwrap_or_else(|| current.provider_account_id.clone()); + let secret_key_ref = body + .secret_key_ref + .clone() + .unwrap_or_else(|| current.secret_key_ref.clone()); + let webhook_secret_ref = body + .webhook_secret_ref + .clone() + .unwrap_or_else(|| current.webhook_secret_ref.clone()); + + validate_channel_config( + state, + adapter, + &label, + &supported_currencies, + min_amount_minor, + max_amount_minor, + secret_key_ref.as_deref(), + webhook_secret_ref.as_deref(), + )?; + + // Credential reference updates require a new revision; the previous + // revision and its signing secret remain usable for outstanding work. + let mut tx = state.db.begin().await?; + let channel = repo::update_channel( + &mut tx, + channel_id, + body.expected_revision, + &label, + display_order, + enabled, + &supported_currencies, + min_amount_minor, + max_amount_minor, + provider_account_id.as_deref(), + secret_key_ref.as_deref(), + webhook_secret_ref.as_deref(), + ) + .await?; + let snapshot = snapshot_of( + adapter, + &label, + display_order, + enabled, + &supported_currencies, + min_amount_minor, + max_amount_minor, + provider_account_id.as_deref(), + secret_key_ref.as_deref(), + webhook_secret_ref.as_deref(), + ); + repo::insert_channel_revision( + &mut tx, + channel.id, + channel.revision, + &snapshot, + contract_version(adapter), + None, + ) + .await?; + // Redacted audit diff: reference identifiers are shown, values never. + let mut changes = json!({}); + let obj = changes.as_object_mut().expect("changes object"); + if current.label != label { + obj.insert("label".into(), change_entry(Some(current.label.clone()), Some(label.clone()))); + } + if current.display_order != display_order { + obj.insert( + "display_order".into(), + change_entry(Some(json!(current.display_order)), Some(json!(display_order))), + ); + } + if current.enabled != enabled { + obj.insert( + "enabled".into(), + change_entry(Some(json!(current.enabled)), Some(json!(enabled))), + ); + } + if current.supported_currencies != supported_currencies { + obj.insert( + "supported_currencies".into(), + change_entry( + Some(json!(current.supported_currencies)), + Some(json!(supported_currencies)), + ), + ); + } + if current.min_amount_minor != min_amount_minor { + obj.insert( + "min_amount_minor".into(), + change_entry( + Some(json!(current.min_amount_minor)), + Some(json!(min_amount_minor)), + ), + ); + } + if current.max_amount_minor != max_amount_minor { + obj.insert( + "max_amount_minor".into(), + change_entry( + Some(json!(current.max_amount_minor)), + Some(json!(max_amount_minor)), + ), + ); + } + if current.provider_account_id != provider_account_id { + obj.insert( + "provider_account_id".into(), + change_entry( + current.provider_account_id.clone().map(Value::String), + provider_account_id.clone().map(Value::String), + ), + ); + } + if current.secret_key_ref != secret_key_ref { + obj.insert( + "secret_key_ref".into(), + change_entry( + current.secret_key_ref.clone().map(Value::String), + secret_key_ref.clone().map(Value::String), + ), + ); + } + if current.webhook_secret_ref != webhook_secret_ref { + obj.insert( + "webhook_secret_ref".into(), + change_entry( + current.webhook_secret_ref.clone().map(Value::String), + webhook_secret_ref.clone().map(Value::String), + ), + ); + } + let action = if current.enabled != enabled { + if enabled { + "enabled" + } else { + "disabled" + } + } else { + "updated" + }; + repo::insert_audit(&mut tx, channel.id, channel.revision, action, actor, &changes) + .await?; + tx.commit().await?; + + let channel = repo::get_channel(&state.db, channel_id).await?; + if enabled && !current.enabled { + let unmet = unmet_prerequisites(state, &channel).await?; + if !unmet.is_empty() { + // Fail closed: the edit is not left advertised as enabled. + let mut tx = state.db.begin().await?; + sqlx::query( + "UPDATE payment_channels SET enabled = FALSE, updated_at = now() WHERE id = $1", + ) + .bind(channel_id) + .execute(&mut *tx) + .await?; + repo::insert_audit( + &mut tx, + channel_id, + channel.revision, + "disabled", + actor, + &json!({ "enabled": change_entry(Some(json!(true)), Some(json!(false))) }), + ) + .await?; + tx.commit().await?; + return Err(ApiError::Conflict(format!( + "cannot enable channel: {}", + unmet.join("; ") + ))); + } + } + let channel = repo::get_channel(&state.db, channel_id).await?; + channel_detail_view(state, &channel).await +} + +/// Referenced channels are archived, never deleted; archival stops new +/// attempts without stranding existing notifications, reconciliation or +/// refunds. +pub async fn delete_channel(state: &AppState, actor: &str, channel_id: Uuid) -> ApiResult<()> { + let channel = repo::get_channel(&state.db, channel_id).await?; + let referenced: i64 = sqlx::query_scalar( + "SELECT (SELECT count(*) FROM payment_attempts WHERE channel_id = $1) + + (SELECT count(*) FROM payment_events WHERE channel_id = $1)", + ) + .bind(channel_id) + .fetch_one(&state.db) + .await?; + let mut tx = state.db.begin().await?; + if referenced > 0 { + repo::archive_channel(&mut tx, channel_id).await?; + repo::insert_audit( + &mut tx, + channel_id, + channel.revision, + "archived", + actor, + &json!({ "archived": change_entry(Some(json!(false)), Some(json!(true))) }), + ) + .await?; + } else { + // Unreferenced: remove the row; revisions/audit cascade. + sqlx::query("DELETE FROM payment_channels WHERE id = $1") + .bind(channel_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +pub async fn list_channel_revisions( + state: &AppState, + channel_id: Uuid, +) -> ApiResult> { + repo::get_channel(&state.db, channel_id).await?; + let revisions = repo::list_channel_revisions(&state.db, channel_id).await?; + let mut out = Vec::with_capacity(revisions.len()); + for revision in revisions { + let snapshot = &revision.snapshot; + let string = |key: &str| snapshot.get(key).and_then(|v| v.as_str()).map(str::to_string); + out.push(ChannelRevisionView { + id: revision.id, + channel_id: revision.channel_id, + revision: revision.revision, + adapter: string("adapter") + .and_then(|a| a.parse().ok()) + .unwrap_or(PaymentAdapterKind::Wallet), + adapter_contract_version: revision.adapter_contract_version.clone(), + label: snapshot.get("label").cloned().unwrap_or(json!({})), + display_order: snapshot + .get("display_order") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32, + enabled: snapshot + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + supported_currencies: snapshot + .get("supported_currencies") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(), + min_amount_minor: snapshot + .get("min_amount_minor") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + max_amount_minor: snapshot.get("max_amount_minor").and_then(|v| v.as_i64()), + provider_account_id: string("provider_account_id"), + secret_key_ref: string("secret_key_ref"), + webhook_secret_ref: string("webhook_secret_ref"), + created_at: revision.created_at, + }); + } + Ok(out) +} + +/// Redacted configuration history. Reference identifiers may appear; secret +/// values never do (they are not stored anywhere). +pub async fn list_channel_audit( + state: &AppState, + channel_id: Uuid, +) -> ApiResult> { + repo::get_channel(&state.db, channel_id).await?; + let rows = repo::list_channel_audit(&state.db, channel_id).await?; + Ok(rows + .into_iter() + .map(|row| AuditEntryView { + id: row.id, + channel_id: row.channel_id, + revision: row.revision, + action: row.action, + actor: row.actor, + changes: row.changes, + created_at: row.created_at, + }) + .collect()) +} + +/// Read-only provider connection/account check. A successful probe never +/// substitutes for provider business approval. +pub async fn verify_channel( + state: &AppState, + actor: &str, + channel_id: Uuid, +) -> ApiResult { + let channel = repo::get_channel(&state.db, channel_id).await?; + let result = webhooks::verify_channel_account(state, &channel).await?; + let mut tx = state.db.begin().await?; + repo::insert_audit( + &mut tx, + channel_id, + channel.revision, + "verified", + actor, + &json!({ + "verified": change_entry(None, Some(json!(result.ok))), + "unmet_prerequisites": change_entry(None, Some(json!(result.unmet_prerequisites))), + }), + ) + .await?; + tx.commit().await?; + Ok(result) +} + +// --------------------------------------------------------------------------- +// Operations console. +// --------------------------------------------------------------------------- + +pub async fn list_channels( + state: &AppState, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total = repo::count_channels(&state.db).await?; + let rows = repo::list_channels_page(&state.db, per_page, (page - 1) * per_page).await?; + Ok(Paged { + items: rows.iter().map(service::channel_view).collect(), + total, + page, + per_page, + }) +} + +#[derive(sqlx::FromRow)] +struct OperationRow { + kind: String, + id: Uuid, + payment_id: Uuid, + environment: String, + amount_minor: i64, + currency: String, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + payment_status: Option, + attempt_status: Option, + refund_status: Option, + refund_purpose: Option, + aftersale_id: Option, + exception_status: Option, + exception_kind: Option, +} + +const OPERATION_SELECT: &str = " + SELECT 'payment' AS kind, p.id, p.id AS payment_id, p.environment, p.total_minor AS amount_minor, + p.currency, p.created_at, p.updated_at, + p.status AS payment_status, + (SELECT a.state FROM payment_attempts a WHERE a.payment_id = p.id + ORDER BY a.created_at DESC LIMIT 1) AS attempt_status, + NULL::text AS refund_status, NULL::text AS refund_purpose, NULL::uuid AS aftersale_id, + NULL::text AS exception_status, NULL::text AS exception_kind + FROM payments p + UNION ALL + SELECT 'refund', r.id, r.payment_id, pay.environment, r.amount_minor, r.currency, + r.created_at, r.updated_at, + NULL, NULL, r.state AS refund_status, r.purpose AS refund_purpose, r.aftersale_id, + NULL, NULL + FROM payment_refunds r JOIN payments pay ON pay.id = r.payment_id + UNION ALL + SELECT 'exception', e.id, e.payment_id, pay.environment, 0, pay.currency, + e.created_at, e.updated_at, + NULL, NULL, NULL, NULL, NULL, + e.status AS exception_status, e.kind AS exception_kind + FROM payment_exceptions e JOIN payments pay ON pay.id = e.payment_id"; + +pub async fn list_operations( + state: &AppState, + query: &OperationQuery, +) -> ApiResult> { + let page = clamp_page(query.page); + let per_page = clamp_per_page(query.per_page); + let kind_filter = query.kind.clone(); + let status_filter = query.status.clone(); + let environment_filter = query.environment.map(|e| e.as_str().to_string()); + let payment_filter = query.payment_id; + let user_filter = query.user_id; + + let rows: Vec = sqlx::query_as::<_, OperationRow>(&format!( + "SELECT * FROM ({OPERATION_SELECT}) ops + WHERE ($1::text IS NULL OR ops.kind = $1) + AND ($2::text IS NULL + OR ops.payment_status = $2 OR ops.refund_status = $2 + OR ops.exception_status = $2 OR ops.attempt_status = $2) + AND ($3::text IS NULL OR ops.environment = $3) + AND ($4::uuid IS NULL OR ops.payment_id = $4) + AND ($5::uuid IS NULL + OR ops.payment_id IN (SELECT id FROM payments WHERE owner_user_id = $5)) + ORDER BY ops.created_at DESC + LIMIT $6 OFFSET $7" + )) + .bind(kind_filter) + .bind(status_filter) + .bind(environment_filter) + .bind(payment_filter) + .bind(user_filter) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(&state.db) + .await?; + + let total: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM ({OPERATION_SELECT}) ops + WHERE ($1::text IS NULL OR ops.kind = $1) + AND ($2::text IS NULL + OR ops.payment_status = $2 OR ops.refund_status = $2 + OR ops.exception_status = $2 OR ops.attempt_status = $2) + AND ($3::text IS NULL OR ops.environment = $3) + AND ($4::uuid IS NULL OR ops.payment_id = $4) + AND ($5::uuid IS NULL + OR ops.payment_id IN (SELECT id FROM payments WHERE owner_user_id = $5))" + )) + .bind(query.kind.clone()) + .bind(query.status.clone()) + .bind(query.environment.map(|e| e.as_str().to_string())) + .bind(query.payment_id) + .bind(query.user_id) + .fetch_one(&state.db) + .await?; + + let items = rows + .into_iter() + .map(|row| { + let detail = match row.kind.as_str() { + "payment" => OperationDetail::Payment(OperationPayment { + status: row + .payment_status + .as_deref() + .unwrap_or("unapplied") + .parse() + .unwrap_or(PaymentState::Unapplied), + attempt_status: row + .attempt_status + .as_deref() + .and_then(|s| s.parse().ok()), + }), + "refund" => OperationDetail::Refund(OperationRefund { + status: row + .refund_status + .as_deref() + .unwrap_or("pending") + .parse() + .unwrap_or(RefundState::Pending), + purpose: row + .refund_purpose + .as_deref() + .unwrap_or("aftersale") + .parse() + .unwrap_or(RefundPurpose::Aftersale), + aftersale_id: row.aftersale_id, + retry_allowed: row + .refund_status + .as_deref() + .map(|s| s == "failed") + .unwrap_or(false), + }), + _ => OperationDetail::Exception(OperationException { + status: row + .exception_status + .as_deref() + .unwrap_or("open") + .parse() + .unwrap_or(ExceptionStatus::Open), + exception_kind: row + .exception_kind + .as_deref() + .unwrap_or("unknown_operation") + .parse() + .unwrap_or(ExceptionKind::UnknownOperation), + }), + }; + OperationRowView { + kind: match row.kind.as_str() { + "payment" => "payment", + "refund" => "refund", + _ => "exception", + }, + id: row.id, + payment_id: row.payment_id, + environment: row.environment.parse().unwrap_or(PaymentEnvironment::Demo), + amount_minor: row.amount_minor, + currency: row.currency, + created_at: row.created_at, + updated_at: row.updated_at, + detail, + } + }) + .collect(); + + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Admin payment detail: the customer payment detail plus the redacted +/// configuration audit of every channel the payment referenced. +pub async fn admin_payment_detail( + state: &AppState, + payment_id: Uuid, +) -> ApiResult { + let payment = repo::get_payment(&state.db, payment_id).await?; + let detail = service::payment_detail_view(&state.db, &payment).await?; + let channel_ids: Vec = sqlx::query_scalar( + "SELECT DISTINCT channel_id FROM payment_attempts WHERE payment_id = $1", + ) + .bind(payment_id) + .fetch_all(&state.db) + .await?; + let mut audit: Vec = Vec::new(); + for channel_id in channel_ids { + audit.extend(list_channel_audit(state, channel_id).await?); + } + audit.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + let mut value = serde_json::to_value(&detail).map_err(ApiError::internal)?; + value + .as_object_mut() + .expect("payment detail object") + .insert("audit".into(), serde_json::to_value(&audit).map_err(ApiError::internal)?); + Ok(value) +} diff --git a/apps/api/src/modules/payment/dto.rs b/apps/api/src/modules/payment/dto.rs new file mode 100644 index 0000000..f23eae7 --- /dev/null +++ b/apps/api/src/modules/payment/dto.rs @@ -0,0 +1,555 @@ +//! Payment domain types: wire DTOs and typed state enums. +//! +//! Database rows store state columns as `TEXT`; these enums carry the typed +//! wire contract and the state-machine guards. Fiat amounts are integer minor +//! units; crypto base-unit quantities are decimal strings, never numbers. + +use std::fmt; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::modules::order::dto::OrderView; + +macro_rules! text_enum { + ($name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum $name { + $($variant,)+ + } + + impl $name { + pub fn as_str(self) -> &'static str { + match self { $(Self::$variant => $wire,)+ } + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = ApiError; + fn from_str(s: &str) -> Result { + match s { + $($wire => Ok(Self::$variant),)+ + other => Err(ApiError::Internal(anyhow::anyhow!( + concat!(stringify!($name), " has no value {other}") + ))), + } + } + } + }; +} + +text_enum!(PaymentEnvironment { + Demo => "demo", + Sandbox => "sandbox", + Live => "live", +}); + +text_enum!(PaymentAdapterKind { + Wallet => "wallet", + StripeCrypto => "stripe_crypto", +}); + +text_enum!(PaymentState { + Unapplied => "unapplied", + Applied => "applied", + Closed => "closed", +}); + +text_enum!(AttemptState { + Created => "created", + Pending => "pending", + Unknown => "unknown", + Settled => "settled", + Failed => "failed", + Expired => "expired", +}); + +impl AttemptState { + /// Unresolved outcomes block replacement collection, method switching and + /// cancellation until authoritative evidence settles them. + pub fn is_unresolved(self) -> bool { + matches!(self, Self::Created | Self::Pending | Self::Unknown) + } + + /// Conclusively non-collecting states permit group-safe cancellation. + pub fn is_non_collecting(self) -> bool { + matches!(self, Self::Failed | Self::Expired) + } +} + +text_enum!(RefundState { + Pending => "pending", + Unknown => "unknown", + Succeeded => "succeeded", + Failed => "failed", +}); + +impl RefundState { + /// Only a definitive failure releases its reserved budget and permits an + /// explicit guarded replacement operation. + pub fn retry_allowed(self) -> bool { + matches!(self, Self::Failed) + } +} + +text_enum!(RefundPurpose { + Aftersale => "aftersale", + Compensation => "compensation", +}); + +text_enum!(NextActionType { + None => "none", + Redirect => "redirect", + Wait => "wait", +}); + +text_enum!(ReturnSurface { + Mall => "mall", + Mobile => "mobile", +}); + +text_enum!(CommandKind { + CreateCheckout => "create_checkout", + Refund => "refund", + Reconcile => "reconcile", +}); + +text_enum!(CommandState { + Pending => "pending", + Leased => "leased", + Succeeded => "succeeded", + Failed => "failed", + Cancelled => "cancelled", +}); + +text_enum!(ExceptionKind { + UnappliedReceipt => "unapplied_receipt", + ExtraCollection => "extra_collection", + AmountMismatch => "amount_mismatch", + UnknownOperation => "unknown_operation", +}); + +text_enum!(ExceptionStatus { + Open => "open", + Resolved => "resolved", +}); + +// --------------------------------------------------------------------------- +// Typed wire values. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct NextAction { + #[serde(rename = "type")] + pub kind: NextActionType, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +impl NextAction { + pub fn none() -> Self { + Self { + kind: NextActionType::None, + url: None, + } + } + + pub fn redirect(url: String) -> Self { + Self { + kind: NextActionType::Redirect, + url: Some(url), + } + } + + pub fn wait() -> Self { + Self { + kind: NextActionType::Wait, + url: None, + } + } +} + +/// Provider-exposed asset evidence. `base_units` is an integer base-unit +/// quantity as a decimal string (may exceed IEEE-754 / JS exact-integer +/// range); never a float. Missing details stay absent. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CryptoAssetMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_address: Option, + pub base_units: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_expires_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_reference: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentOrderView { + #[serde(flatten)] + pub order: OrderView, + pub amount_minor: i64, + pub applied: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttemptSummaryView { + pub id: Uuid, + pub channel_id: Uuid, + pub adapter: PaymentAdapterKind, + pub status: AttemptState, + pub next_action: NextAction, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentReceiptView { + pub id: Uuid, + pub payment_id: Uuid, + pub attempt_id: Option, + pub adapter: PaymentAdapterKind, + pub environment: PaymentEnvironment, + pub amount_minor: i64, + pub currency: String, + pub reference: Option, + pub crypto: Option, + pub collected_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RefundOperationView { + pub id: Uuid, + pub purpose: RefundPurpose, + pub status: RefundState, + pub amount_minor: i64, + pub currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub aftersale_id: Option, + pub method: PaymentAdapterKind, + pub reference: Option, + pub error: Option, + pub retry_allowed: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentExceptionView { + pub id: Uuid, + pub kind: ExceptionKind, + pub status: ExceptionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentSummary { + pub id: Uuid, + pub status: PaymentState, + pub currency: String, + pub total_minor: i64, +} + +/// Own-only payment detail: authoritative orders/allocations, attempt +/// summary, refund/exception status and the safe next action. +#[derive(Debug, Clone, Serialize)] +pub struct PaymentView { + pub id: Uuid, + pub status: PaymentState, + pub currency: String, + pub total_minor: i64, + pub environment: PaymentEnvironment, + pub orders: Vec, + pub attempts: Vec, + pub receipts: Vec, + pub refunds: Vec, + pub exceptions: Vec, + pub next_action: NextAction, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl PaymentView { + pub fn summary(&self) -> PaymentSummary { + PaymentSummary { + id: self.id, + status: self.status, + currency: self.currency.clone(), + total_minor: self.total_minor, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttemptResultView { + pub id: Uuid, + pub channel_id: Uuid, + pub adapter: PaymentAdapterKind, + pub status: AttemptState, + pub payment: PaymentSummary, + pub next_action: NextAction, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Safe descriptor of an eligible enabled channel. No secrets or +/// provider-internal configuration. +#[derive(Debug, Clone, Serialize)] +pub struct EligibleMethod { + pub id: Uuid, + pub adapter: PaymentAdapterKind, + pub label: serde_json::Value, + pub currencies: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_amount_minor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_amount_minor: Option, +} + +// --------------------------------------------------------------------------- +// Request bodies. +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct ResolveBody { + pub order_ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AttemptBody { + pub channel_id: Uuid, + /// Typed surface mapped server-side to an allowlisted deployment origin. + /// Defaults to `mall` for bare callers; the effective value is part of + /// the idempotency request hash. + #[serde(default)] + pub return_surface: Option, +} + +// --------------------------------------------------------------------------- +// Admin channel configuration. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct AdapterCapabilities { + pub currencies: Vec, + pub min_amount_minor: i64, + pub max_amount_minor: Option, + pub provider_managed: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentAdapterDescriptor { + pub kind: PaymentAdapterKind, + pub label: serde_json::Value, + pub environment: PaymentEnvironment, + pub capabilities: AdapterCapabilities, + pub secret_slots: Vec, + pub activation_prerequisites: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SecretSlotView { + pub slot: String, + pub configured: bool, + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentChannelView { + pub id: Uuid, + pub adapter: PaymentAdapterKind, + pub label: serde_json::Value, + pub display_order: i32, + pub enabled: bool, + pub archived: bool, + pub supported_currencies: Vec, + pub min_amount_minor: i64, + pub max_amount_minor: Option, + pub provider_account_id: Option, + pub environment: PaymentEnvironment, + pub revision: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentChannelDetailView { + #[serde(flatten)] + pub channel: PaymentChannelView, + pub secret_key_ref: Option, + pub webhook_secret_ref: Option, + pub secret_slots: Vec, + pub unmet_prerequisites: Vec, + pub capabilities: AdapterCapabilities, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChannelRevisionView { + pub id: Uuid, + pub channel_id: Uuid, + pub revision: i32, + pub adapter: PaymentAdapterKind, + pub adapter_contract_version: String, + pub label: serde_json::Value, + pub display_order: i32, + pub enabled: bool, + pub supported_currencies: Vec, + pub min_amount_minor: i64, + pub max_amount_minor: Option, + pub provider_account_id: Option, + pub secret_key_ref: Option, + pub webhook_secret_ref: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuditEntryView { + pub id: Uuid, + pub channel_id: Uuid, + pub revision: i32, + pub action: String, + pub actor: String, + pub changes: serde_json::Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VerificationView { + pub channel_id: Uuid, + pub revision: i32, + pub ok: bool, + pub checked_at: DateTime, + pub provider_account: Option, + pub unmet_prerequisites: Vec, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReconcileCommandView { + pub id: Uuid, + pub payment_id: Uuid, + /// Wire vocabulary for the durable evidence-fetch command: + /// queued | running | succeeded | failed. + pub status: String, + pub error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct ChannelCreateBody { + pub adapter: PaymentAdapterKind, + pub label: serde_json::Value, + pub display_order: i32, + pub enabled: bool, + pub supported_currencies: Vec, + pub min_amount_minor: i64, + pub max_amount_minor: Option, + pub provider_account_id: Option, + pub secret_key_ref: Option, + pub webhook_secret_ref: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ChannelUpdateBody { + #[serde(default)] + pub adapter: Option, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub display_order: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub supported_currencies: Option>, + #[serde(default)] + pub min_amount_minor: Option, + #[serde(default)] + pub max_amount_minor: Option>, + #[serde(default)] + pub provider_account_id: Option>, + #[serde(default)] + pub secret_key_ref: Option>, + #[serde(default)] + pub webhook_secret_ref: Option>, + /// Optimistic edit precondition; stale updates return 409. + pub expected_revision: i32, +} + +// --------------------------------------------------------------------------- +// Admin operations listing. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct OperationPayment { + pub status: PaymentState, + pub attempt_status: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OperationRefund { + pub status: RefundState, + pub purpose: RefundPurpose, + pub aftersale_id: Option, + pub retry_allowed: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OperationException { + pub status: ExceptionStatus, + pub exception_kind: ExceptionKind, +} + +/// Discriminated operations row: `kind` selects the flattened payload, so +/// the JSON shape matches the shared `PaymentOperation` contract exactly. +#[derive(Debug, Clone, Serialize)] +pub struct OperationRowView { + pub kind: &'static str, + pub id: Uuid, + pub payment_id: Uuid, + pub environment: PaymentEnvironment, + pub amount_minor: i64, + pub currency: String, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(flatten)] + pub detail: OperationDetail, +} + +/// Flat per-kind payload; untagged so its fields merge into the row. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum OperationDetail { + Payment(OperationPayment), + Refund(OperationRefund), + Exception(OperationException), +} + +#[derive(Debug, Default, Deserialize)] +pub struct OperationQuery { + pub page: Option, + pub per_page: Option, + pub kind: Option, + pub status: Option, + pub environment: Option, + pub payment_id: Option, + pub user_id: Option, +} diff --git a/apps/api/src/modules/payment/handlers.rs b/apps/api/src/modules/payment/handlers.rs new file mode 100644 index 0000000..4ebf757 --- /dev/null +++ b/apps/api/src/modules/payment/handlers.rs @@ -0,0 +1,262 @@ +//! Payment HTTP surface: customer payment lifecycle, Stripe webhook +//! receiver (raw-body authenticated, no JWT), and platform-admin +//! configuration/operations routes. + +use axum::{ + body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + routing::{delete, get, post, put}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::{ApiError, ApiResult}; +use crate::http::{PageQuery, Paged}; +use crate::state::AppState; + +use super::admin; +use super::dto::*; +use super::repo; +use super::service; +use super::webhooks; + +pub fn customer_router() -> Router { + Router::new() + .route("/payments", post(resolve_payment)) + .route("/payments/{id}", get(get_payment)) + .route("/payments/{id}/methods", get(list_methods)) + .route("/payments/{id}/attempts", post(start_attempt)) + .route("/payments/{id}/cancel", post(cancel_payment)) +} + +pub fn webhook_router() -> Router { + Router::new().route("/payments/webhooks/stripe/{channel_id}", post(stripe_webhook)) +} + +pub fn admin_router() -> Router { + Router::new() + .route("/admin/payments/channels/adapters", get(list_adapters)) + .route("/admin/payments/channels", get(list_channels).post(create_channel)) + .route( + "/admin/payments/channels/{id}", + get(get_channel).put(update_channel).delete(delete_channel), + ) + .route("/admin/payments/channels/{id}/revisions", get(channel_revisions)) + .route("/admin/payments/channels/{id}/audit", get(channel_audit)) + .route("/admin/payments/channels/{id}/verify", post(verify_channel)) + .route("/admin/payments", get(list_operations)) + .route("/admin/payments/{id}", get(get_payment_admin)) + .route("/admin/payments/{id}/reconcile", post(reconcile_payment)) +} + +// --------------------------------------------------------------------------- +// Customer. +// --------------------------------------------------------------------------- + +/// Resolve or create the payment for owned pending orders. Never accepts +/// browser amounts; returns the full payment detail. +async fn resolve_payment( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_customer()?; + let view = service::resolve_payment(&state, auth.id, &body.order_ids).await?; + Ok((StatusCode::CREATED, Json(view))) +} + +async fn get_payment( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_customer()?; + let (_, view) = service::get_owned_payment(&state, auth.id, id).await?; + Ok(Json(view)) +} + +async fn list_methods( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json(service::eligible_methods(&state, auth.id, id).await?)) +} + +async fn start_attempt( + State(state): State, + auth: AuthUser, + Path(id): Path, + headers: HeaderMap, + Json(body): Json, +) -> ApiResult> { + auth.require_customer()?; + let key = service::parse_idempotency_key(&headers)?; + Ok(Json( + service::initiate_attempt(&state, auth.id, id, &body, &key).await?, + )) +} + +/// Cancel the entire unpaid payment group; returns the authoritative detail. +async fn cancel_payment( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_customer()?; + Ok(Json( + service::cancel_payment_group(&state, Some(auth.id), id).await?, + )) +} + +// --------------------------------------------------------------------------- +// Webhook (raw body + Stripe signature; no customer bearer token). +// --------------------------------------------------------------------------- + +async fn stripe_webhook( + State(state): State, + Path(channel_id): Path, + headers: HeaderMap, + body: Bytes, +) -> ApiResult { + let signature = headers + .get("stripe-signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::Unauthorized("missing stripe-signature header".into()))?; + // Verified against the unmodified body and durably recorded before 2xx; + // persistence failure returns non-2xx rather than losing the event. + webhooks::verify_and_record_event(&state, channel_id, &body, signature).await?; + Ok(StatusCode::OK) +} + +// --------------------------------------------------------------------------- +// Admin: channel configuration. +// --------------------------------------------------------------------------- + +fn actor(auth: &AuthUser) -> String { + auth.id.to_string() +} + +async fn list_adapters( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(admin::list_adapters(&state).await?)) +} + +async fn list_channels( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(admin::list_channels(&state, q.page, q.per_page).await?)) +} + +async fn create_channel( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_admin()?; + let view = admin::create_channel(&state, &actor(&auth), body).await?; + Ok((StatusCode::CREATED, Json(view))) +} + +async fn get_channel( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + let channel = repo::get_channel(&state.db, id).await?; + Ok(Json(admin::channel_detail_view(&state, &channel).await?)) +} + +async fn update_channel( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json( + admin::update_channel(&state, &actor(&auth), id, body).await?, + )) +} + +async fn delete_channel( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + auth.require_admin()?; + admin::delete_channel(&state, &actor(&auth), id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn channel_revisions( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(admin::list_channel_revisions(&state, id).await?)) +} + +async fn channel_audit( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(admin::list_channel_audit(&state, id).await?)) +} + +async fn verify_channel( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json( + admin::verify_channel(&state, &actor(&auth), id).await?, + )) +} + +// --------------------------------------------------------------------------- +// Admin: operations and recovery. +// --------------------------------------------------------------------------- + +async fn list_operations( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(admin::list_operations(&state, &q).await?)) +} + +async fn get_payment_admin( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(admin::admin_payment_detail(&state, id).await?)) +} + +/// Durable evidence-fetch command only: never sets paid/refunded, initiates +/// collection, edits balances, or bypasses amount/ownership guards. +async fn reconcile_payment( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(webhooks::request_reconcile(&state, id).await?)) +} diff --git a/apps/api/src/modules/payment/mod.rs b/apps/api/src/modules/payment/mod.rs new file mode 100644 index 0000000..5578c8b --- /dev/null +++ b/apps/api/src/modules/payment/mod.rs @@ -0,0 +1,33 @@ +//! Checkout-wide payments: collection, allocations, adapters, recovery, +//! crypto processing, refunds, versioned channel configuration and the +//! legacy-demo cutover. + +pub mod adapters; +pub mod admin; +pub mod dto; +pub mod handlers; +pub mod refunds; +pub mod repo; +pub mod service; +pub mod webhooks; +pub mod worker; + +use axum::Router; + +use crate::state::AppState; + +pub use dto::{ + AttemptState, ExceptionKind, NextActionType, PaymentAdapterKind, PaymentEnvironment, + PaymentState, RefundPurpose, RefundState, ReturnSurface, +}; +pub use service::{ + attach_checkout_payment, bind_checkout_request, cancel_payment_group, checkout_replay, + initiate_attempt, parse_idempotency_key, resolve_payment, +}; +pub use worker::WorkerHandle; + +pub fn router() -> Router { + handlers::customer_router() + .merge(handlers::webhook_router()) + .merge(handlers::admin_router()) +} diff --git a/apps/api/src/modules/payment/refunds.rs b/apps/api/src/modules/payment/refunds.rs new file mode 100644 index 0000000..f9bf163 --- /dev/null +++ b/apps/api/src/modules/payment/refunds.rs @@ -0,0 +1,573 @@ +//! Original-method refunds with reserved budgets, compensation for receipts +//! that cannot be applied, and verified-success completion. +//! +//! Budgets reserve pending, unknown AND succeeded operations against both +//! the receipt and the affected allocation. Definitive failure releases the +//! reservation and permits an explicit guarded replacement; unknown never +//! does. Wallet refunds commit their account credit, immutable entry and +//! business completion atomically in one transaction; external refunds only +//! complete on verified provider success. + +use serde_json::json; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{AccountKind, MessageKind}; +use crate::modules::{account, messaging, order}; +use crate::state::AppState; + +use super::dto::*; +use super::repo::{self, RefundRow}; + +pub const WALLET_COMPENSATION_REASON: &str = "payment_compensation_refund"; + +/// Outcome of a refund submission request for the aftersale service. +#[derive(Debug)] +pub enum RefundSubmission { + /// The refund completed synchronously (wallet); aftersale is `refunded`. + Completed(RefundRow), + /// External submission; the aftersale is `refund_pending` until verified. + Pending(RefundRow), + /// Historical status-only order: the explicitly labeled legacy demo + /// wallet-credit path (never a provider refund). + LegacyDemo, +} + +/// Lock every resource row the shared cancellation path will restore, in +/// deterministic (sorted) order. +async fn lock_restorable_resources( + tx: &mut PgConnection, + order_ids: &[Uuid], +) -> ApiResult<()> { + let mut sku_ids: Vec = + sqlx::query_scalar("SELECT sku_id FROM order_items WHERE order_id = ANY($1)") + .bind(order_ids) + .fetch_all(&mut *tx) + .await?; + let mut flash_ids: Vec = sqlx::query_scalar( + "SELECT flash_sale_item_id FROM order_items + WHERE order_id = ANY($1) AND flash_sale_item_id IS NOT NULL", + ) + .bind(order_ids) + .fetch_all(&mut *tx) + .await?; + let mut coupon_ids: Vec = sqlx::query_scalar( + "SELECT coupon_id FROM orders WHERE id = ANY($1) AND coupon_id IS NOT NULL", + ) + .bind(order_ids) + .fetch_all(&mut *tx) + .await?; + let mut group_ids: Vec = sqlx::query_scalar( + "SELECT group_id FROM orders WHERE id = ANY($1) AND group_id IS NOT NULL", + ) + .bind(order_ids) + .fetch_all(&mut *tx) + .await?; + for ids in [&mut sku_ids, &mut flash_ids, &mut coupon_ids, &mut group_ids] { + ids.sort_unstable(); + ids.dedup(); + } + repo::lock_ids_sorted(tx, "skus", &sku_ids).await?; + repo::lock_ids_sorted(tx, "flash_sale_items", &flash_ids).await?; + repo::lock_ids_sorted(tx, "coupons", &coupon_ids).await?; + repo::lock_ids_sorted(tx, "collective_groups", &group_ids).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Aftersale refund submission. +// --------------------------------------------------------------------------- + +struct AftersaleRow { + id: Uuid, + order_id: Uuid, + user_id: Uuid, + kind: String, + status: String, + amount_minor: i64, +} + +async fn load_aftersale(tx: &mut PgConnection, id: Uuid) -> ApiResult { + #[derive(sqlx::FromRow)] + struct Row { + id: Uuid, + order_id: Uuid, + user_id: Uuid, + kind: String, + status: String, + amount_minor: i64, + } + let row = sqlx::query_as::<_, Row>( + "SELECT id, order_id, user_id, kind::text AS kind, status::text AS status, amount_minor + FROM aftersales WHERE id = $1", + ) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("aftersale".into()))?; + Ok(AftersaleRow { + id: row.id, + order_id: row.order_id, + user_id: row.user_id, + kind: row.kind, + status: row.status, + amount_minor: row.amount_minor, + }) +} + +/// Merchant/platform refund submission (and explicit retry from +/// `refund_failed`). Validates prerequisites, reserves budget, creates one +/// refund operation, and either completes synchronously (wallet) or submits +/// to the provider through the durable command layer. +pub async fn submit_aftersale_refund( + state: &AppState, + aftersale_id: Uuid, +) -> ApiResult { + let mut tx = state.db.begin().await?; + let aftersale = load_aftersale(&mut tx, aftersale_id).await?; + + // Guarded source states: refund_only starts from approved, return_refund + // from merchant_confirmed; refund_failed permits one explicit retry. + let allowed_sources: &[&str] = match (aftersale.kind.as_str(), aftersale.status.as_str()) { + ("refund_only", "approved") => &["approved"], + ("return_refund", "merchant_confirmed") => &["merchant_confirmed"], + (_, "refund_failed") => &["refund_failed"], + _ => { + return Err(ApiError::Conflict( + "aftersale is not refundable in its current status".into(), + )) + } + }; + + let memberships = repo::payments_for_orders(&state.db, &[aftersale.order_id]).await?; + let membership = memberships + .into_iter() + .next() + .ok_or_else(|| ApiError::Conflict("order has no payment membership".into()))?; + + // Global lock order: payment first, then the member order. + let payment = repo::lock_payment(&mut tx, membership.payment_id).await?; + let order = order::repo::lock_sorted(&mut tx, &[aftersale.order_id]).await?; + let order = order + .into_iter() + .next() + .ok_or_else(|| ApiError::NotFound("order".into()))?; + + // Historical status-only paid orders keep the explicitly labeled legacy + // demo wallet-credit path; live provider refunds reject such orders. + if order.payment_provenance == "legacy_demo" { + if state.config.payment.environment != PaymentEnvironment::Demo { + return Err(ApiError::Conflict( + "legacy demo orders cannot use provider refunds".into(), + )); + } + return Ok(RefundSubmission::LegacyDemo); + } + + let receipts = repo::list_receipts(&state.db, payment.id).await?; + let receipt = receipts + .iter() + .find(|r| r.applied && !r.quarantined) + .ok_or_else(|| ApiError::Conflict("no applied receipt backs this order".into()))? + .clone(); + let method = receipt.method()?; + + // Reserved budget counts pending, unknown and succeeded operations on + // both the receipt and this allocation. + let reserved_alloc = + repo::reserved_on_receipt(&mut tx, receipt.id, Some(order.id)).await?; + let reserved_receipt = repo::reserved_on_receipt(&mut tx, receipt.id, None).await?; + let alloc_remaining = membership.amount_minor - reserved_alloc; + let receipt_remaining = receipt.amount_minor - reserved_receipt; + if aftersale.amount_minor > alloc_remaining || aftersale.amount_minor > receipt_remaining { + return Err(ApiError::Conflict( + "refund exceeds the unreserved captured allocation".into(), + )); + } + + let attempt_no: i64 = sqlx::query_scalar( + "SELECT count(*) FROM payment_refunds WHERE aftersale_id = $1", + ) + .bind(aftersale.id) + .fetch_one(&mut *tx) + .await?; + // Retain the original channel revision/credential references. + let channel_revision: i32 = sqlx::query_scalar( + "SELECT channel_revision FROM payment_attempts + WHERE payment_id = $1 ORDER BY created_at LIMIT 1", + ) + .bind(payment.id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(1); + let refund = repo::insert_refund( + &mut tx, + payment.id, + receipt.id, + Some(order.id), + Some(aftersale.id), + RefundPurpose::Aftersale, + aftersale.amount_minor, + &receipt.currency, + &format!("aftersale:{}:{}", aftersale.id, attempt_no + 1), + &super::adapters::provider_operation_key("rf", Uuid::new_v4()), + channel_revision, + ) + .await?; + + // Move the application to refund_pending before any external work. + let sources: Vec = allowed_sources.iter().map(|s| s.to_string()).collect(); + sqlx::query( + "UPDATE aftersales SET status = 'refund_pending', updated_at = now() + WHERE id = $1 AND status::text = ANY($2)", + ) + .bind(aftersale.id) + .bind(&sources) + .execute(&mut *tx) + .await?; + + repo::enqueue_command( + &mut tx, + CommandKind::Refund, + Some(payment.id), + None, + Some(refund.id), + &refund.provider_operation_key, + &format!("refund:{}", refund.id), + &json!({ "refund_id": refund.id }), + ) + .await?; + tx.commit().await?; + + // Wallet refunds complete synchronously through the same execution path + // the worker uses; external refunds run under a durable lease. + if method == PaymentAdapterKind::Wallet { + let command_id: Uuid = sqlx::query_scalar( + "SELECT id FROM payment_commands WHERE refund_id = $1 AND kind = 'refund'", + ) + .bind(refund.id) + .fetch_one(&state.db) + .await?; + super::worker::execute_command_by_id(state, command_id).await?; + let refund = repo::get_refund(&state.db, refund.id).await?; + return Ok(match refund.state()? { + RefundState::Succeeded => RefundSubmission::Completed(refund), + _ => RefundSubmission::Pending(refund), + }); + } + + let command_id: Uuid = sqlx::query_scalar( + "SELECT id FROM payment_commands WHERE refund_id = $1 AND kind = 'refund'", + ) + .bind(refund.id) + .fetch_one(&state.db) + .await?; + super::worker::execute_command_by_id(state, command_id).await?; + let refund = repo::get_refund(&state.db, refund.id).await?; + Ok(match refund.state()? { + RefundState::Succeeded => RefundSubmission::Completed(refund), + RefundState::Failed => RefundSubmission::Pending(refund), + _ => RefundSubmission::Pending(refund), + }) +} + +// --------------------------------------------------------------------------- +// Compensation for receipts that cannot be applied. +// --------------------------------------------------------------------------- + +/// Retain a valid collection that cannot be applied and schedule a full +/// original-method compensating refund. Also materializes the exception. +pub async fn schedule_compensation( + state: &AppState, + receipt_id: Uuid, + kind: ExceptionKind, + detail: &str, +) -> ApiResult<()> { + let receipt = repo::get_receipt(&state.db, receipt_id).await?; + let payment = repo::get_payment(&state.db, receipt.payment_id).await?; + + let mut tx = state.db.begin().await?; + repo::insert_exception( + &mut tx, + payment.id, + kind, + Some(receipt.id), + receipt.attempt_id, + Some(detail), + ) + .await?; + let channel_revision: i32 = match receipt.attempt_id { + Some(attempt_id) => sqlx::query_scalar( + "SELECT channel_revision FROM payment_attempts WHERE id = $1", + ) + .bind(attempt_id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(1), + None => 1, + }; + let refund = match repo::insert_refund( + &mut tx, + payment.id, + receipt.id, + None, + None, + RefundPurpose::Compensation, + receipt.amount_minor, + &receipt.currency, + &format!("comp:{}", receipt.id), + &super::adapters::provider_operation_key("rf", Uuid::new_v4()), + channel_revision, + ) + .await + { + Ok(refund) => refund, + Err(e) => { + // An unresolved compensation for this receipt already exists. + tx.rollback().await?; + return match e { + ApiError::Conflict(_) => Ok(()), + other => Err(other), + }; + } + }; + repo::enqueue_command( + &mut tx, + CommandKind::Refund, + Some(payment.id), + None, + Some(refund.id), + &refund.provider_operation_key, + &format!("refund:{}", refund.id), + &json!({ "refund_id": refund.id }), + ) + .await?; + tx.commit().await?; + + let command_id: Uuid = sqlx::query_scalar( + "SELECT id FROM payment_commands WHERE refund_id = $1 AND kind = 'refund'", + ) + .bind(refund.id) + .fetch_one(&state.db) + .await?; + super::worker::execute_command_by_id(state, command_id).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Verified refund success / definitive failure. +// --------------------------------------------------------------------------- + +/// Atomically apply verified refund success exactly once: the wallet credit +/// (for wallet receipts), the immutable entry, the guarded order refund-total +/// increment, the aftersale terminal transition and the idempotent +/// `refund_completed` message all commit together. +pub async fn apply_refund_success( + state: &AppState, + refund_id: Uuid, + provider_reference: Option<&str>, +) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + let refund = repo::get_refund(&state.db, refund_id).await?; + if refund.state()? == RefundState::Succeeded { + return Ok(()); // duplicate success evidence observes the completed result + } + let receipt = repo::get_receipt(&state.db, refund.receipt_id).await?; + let method = receipt.method()?; + + // The only transition into succeeded; losers observe it and stop. + if !repo::transition_refund( + &mut tx, + refund.id, + RefundState::Succeeded, + provider_reference, + None, + ) + .await? + { + return Ok(()); + } + + // Wallet credit only when captured funds were actually taken: an + // unapplied wallet receipt never debited the account. + if method == PaymentAdapterKind::Wallet && receipt.applied { + // Original-method wallet refund: guarded credit with one immutable + // ledger entry. Never an internal-wallet fallback for external money. + let user_id = refund_owner(&mut tx, &refund).await?; + account::service::ensure_monetary_account( + &mut tx, + user_id, + AccountKind::Available, + &refund.currency, + ) + .await?; + let reason = if refund.aftersale_id.is_some() { + "aftersale_refund" + } else { + WALLET_COMPENSATION_REASON + }; + let reference: Option<(&str, Uuid)> = match refund.aftersale_id { + Some(aftersale_id) => Some(("aftersale", aftersale_id)), + None => Some(("payment", refund.payment_id)), + }; + account::service::credit( + &mut tx, + user_id, + AccountKind::Available, + Some(refund.currency.as_str()), + refund.amount_minor, + reason, + reference, + ) + .await?; + } + + match refund.purpose()? { + RefundPurpose::Aftersale => { + let order_id = refund + .allocation_order_id + .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("aftersale refund without allocation")))?; + let order = order::repo::lock_sorted(&mut tx, &[order_id]).await?; + let order = order + .into_iter() + .next() + .ok_or_else(|| ApiError::NotFound("order".into()))?; + // Guarded increment: the completed refund total never exceeds the + // order total and only verified success increments it. + let result = sqlx::query( + "UPDATE orders SET refund_total_minor = refund_total_minor + $2, updated_at = now() + WHERE id = $1 AND refund_total_minor + $2 <= total_minor", + ) + .bind(order.id) + .bind(refund.amount_minor) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict( + "refund would exceed the order refund total".into(), + )); + } + if let Some(aftersale_id) = refund.aftersale_id { + sqlx::query( + "UPDATE aftersales SET status = 'refunded', updated_at = now() + WHERE id = $1 AND status::text IN ('refund_pending', 'refund_failed')", + ) + .bind(aftersale_id) + .execute(&mut *tx) + .await?; + messaging::service::emit_event( + &mut tx, + order.user_id, + MessageKind::RefundCompleted, + "aftersale", + aftersale_id, + &order.order_no, + ) + .await?; + } + } + RefundPurpose::Compensation => { + let receipt = repo::get_receipt(&state.db, refund.receipt_id).await?; + if !receipt.applied { + // Full compensation for a never-applied payment: close the + // payment and cancel/restore its still-pending members once. + let membership = repo::lock_membership_sorted(&mut tx, refund.payment_id).await?; + let order_ids: Vec = membership.iter().map(|m| m.order_id).collect(); + let orders = order::repo::lock_sorted(&mut tx, &order_ids).await?; + lock_restorable_resources(&mut tx, &order_ids).await?; + super::service::cancel_member_orders(&mut tx, &orders).await?; + repo::mark_payment_closed(&mut tx, refund.payment_id).await?; + } + // Compensation of an extra receipt on an applied payment leaves + // the valid paid orders and fulfillment untouched. + repo::resolve_exceptions_for_receipt(&mut tx, refund.receipt_id).await?; + } + } + tx.commit().await?; + Ok(()) +} + +async fn refund_owner(tx: &mut PgConnection, refund: &RefundRow) -> ApiResult { + Ok(sqlx::query_scalar("SELECT owner_user_id FROM payments WHERE id = $1") + .bind(refund.payment_id) + .fetch_one(&mut *tx) + .await?) +} + +/// A definitive refund failure releases its reserved budget and permits an +/// explicit guarded retry; it never credits any wallet or totals. +pub async fn mark_refund_failed( + state: &AppState, + refund_id: Uuid, + detail: &str, + provider_reference: Option<&str>, +) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + let refund = repo::get_refund(&state.db, refund_id).await?; + if !repo::transition_refund( + &mut tx, + refund.id, + RefundState::Failed, + provider_reference, + Some(detail), + ) + .await? + { + return Ok(()); // terminal states never regress + } + if let Some(aftersale_id) = refund.aftersale_id { + sqlx::query( + "UPDATE aftersales SET status = 'refund_failed', updated_at = now() + WHERE id = $1 AND status::text = 'refund_pending'", + ) + .bind(aftersale_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +/// A provider timeout is not a failure: the operation becomes unknown, keeps +/// its budget reservation, and blocks replacement until evidence settles it. +pub async fn mark_refund_unknown( + state: &AppState, + refund_id: Uuid, + detail: &str, +) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + let refund = repo::get_refund(&state.db, refund_id).await?; + repo::transition_refund(&mut tx, refund.id, RefundState::Unknown, None, Some(detail)).await?; + repo::insert_exception( + &mut tx, + refund.payment_id, + ExceptionKind::UnknownOperation, + Some(refund.receipt_id), + None, + Some(detail), + ) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Latest refund operation for an aftersale, as shown on aftersale detail. +pub async fn aftersale_refund_operation( + state: &AppState, + aftersale_id: Uuid, +) -> ApiResult> { + let refunds = repo::list_refunds_for_aftersale(&state.db, aftersale_id).await?; + let Some(refund) = refunds.into_iter().next() else { + return Ok(None); + }; + let receipt = repo::get_receipt(&state.db, refund.receipt_id).await?; + Ok(Some(super::service::refund_view( + &refund, + receipt.method()?, + )?)) +} + +pub fn provider_retry_allowed(created_at: chrono::DateTime) -> bool { + chrono::Utc::now() - created_at < super::service::provider_retry_window() +} diff --git a/apps/api/src/modules/payment/repo.rs b/apps/api/src/modules/payment/repo.rs new file mode 100644 index 0000000..3a8b0dc --- /dev/null +++ b/apps/api/src/modules/payment/repo.rs @@ -0,0 +1,1355 @@ +//! Payment persistence: typed rows and guarded transitions. +//! +//! Conventions: integer minor units, append-only receipts/audit/events, +//! guarded state updates (WHERE state guards) so transitions are monotonic, +//! and deterministic lock acquisition helpers that lock rows in id order. + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::error::{unique_conflict, ApiError, ApiResult}; + +use super::dto::{ + AttemptState, CommandKind, CommandState, ExceptionKind, ExceptionStatus, PaymentAdapterKind, + PaymentEnvironment, PaymentState, RefundPurpose, RefundState, +}; + +pub const PAYMENT_COLS: &str = "id, owner_user_id, currency, total_minor, environment, status, + created_at, updated_at, applied_at, closed_at"; +pub const ATTEMPT_COLS: &str = "id, payment_id, channel_id, channel_revision, owner_user_id, + idempotency_key, request_hash, return_surface, return_url, state, provider_operation_key, + provider_reference, provider_intent_reference, next_action_type, next_action_url, + expires_at, error_detail, created_at, updated_at"; +pub const RECEIPT_COLS: &str = "id, payment_id, attempt_id, provider_account_id, environment, + provider_transaction, amount_minor, currency, method, applied, quarantined, + quarantine_reason, evidence, crypto, received_at"; +pub const REFUND_COLS: &str = "id, payment_id, receipt_id, allocation_order_id, aftersale_id, + purpose, amount_minor, currency, idempotency_key, provider_operation_key, + provider_reference, channel_revision, state, error_detail, created_at, updated_at, + completed_at"; +pub const COMMAND_COLS: &str = "id, kind, payment_id, attempt_id, refund_id, operation_key, + idempotency_key, payload, state, due_at, lease_owner, lease_expires_at, attempts_count, + last_error, created_at, updated_at, finished_at"; +pub const CHANNEL_COLS: &str = "id, adapter, label, display_order, enabled, supported_currencies, + min_amount_minor, max_amount_minor, provider_account_id, secret_key_ref, + webhook_secret_ref, environment, archived, revision, created_at, updated_at"; +pub const EVENT_COLS: &str = "id, channel_id, provider_account_id, environment, provider_event_id, + event_type, payload, signature_key_id, received_at, processed_at, process_error"; +pub const EXCEPTION_COLS: &str = "id, payment_id, kind, receipt_id, attempt_id, detail, status, + created_at, updated_at, resolved_at"; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PaymentRow { + pub id: Uuid, + pub owner_user_id: Uuid, + pub currency: String, + pub total_minor: i64, + pub environment: String, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub applied_at: Option>, + pub closed_at: Option>, +} + +impl PaymentRow { + pub fn state(&self) -> ApiResult { + self.status.parse() + } + pub fn environment(&self) -> ApiResult { + self.environment.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PaymentOrderRow { + pub payment_id: Uuid, + pub order_id: Uuid, + pub amount_minor: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct AttemptRow { + pub id: Uuid, + pub payment_id: Uuid, + pub channel_id: Uuid, + pub channel_revision: i32, + pub owner_user_id: Uuid, + pub idempotency_key: String, + pub request_hash: String, + pub return_surface: String, + pub return_url: String, + pub state: String, + pub provider_operation_key: String, + pub provider_reference: Option, + pub provider_intent_reference: Option, + pub next_action_type: String, + pub next_action_url: Option, + pub expires_at: Option>, + pub error_detail: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl AttemptRow { + pub fn state(&self) -> ApiResult { + self.state.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ReceiptRow { + pub id: Uuid, + pub payment_id: Uuid, + pub attempt_id: Option, + pub provider_account_id: Option, + pub environment: String, + pub provider_transaction: String, + pub amount_minor: i64, + pub currency: String, + pub method: String, + pub applied: bool, + pub quarantined: bool, + pub quarantine_reason: Option, + pub evidence: Value, + pub crypto: Option, + pub received_at: DateTime, +} + +impl ReceiptRow { + pub fn method(&self) -> ApiResult { + self.method.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct RefundRow { + pub id: Uuid, + pub payment_id: Uuid, + pub receipt_id: Uuid, + pub allocation_order_id: Option, + pub aftersale_id: Option, + pub purpose: String, + pub amount_minor: i64, + pub currency: String, + pub idempotency_key: String, + pub provider_operation_key: String, + pub provider_reference: Option, + pub channel_revision: i32, + pub state: String, + pub error_detail: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +impl RefundRow { + pub fn state(&self) -> ApiResult { + self.state.parse() + } + pub fn purpose(&self) -> ApiResult { + self.purpose.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct CommandRow { + pub id: Uuid, + pub kind: String, + pub payment_id: Option, + pub attempt_id: Option, + pub refund_id: Option, + pub operation_key: String, + pub idempotency_key: String, + pub payload: Value, + pub state: String, + pub due_at: DateTime, + pub lease_owner: Option, + pub lease_expires_at: Option>, + pub attempts_count: i32, + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub finished_at: Option>, +} + +impl CommandRow { + pub fn kind(&self) -> ApiResult { + self.kind.parse() + } + pub fn state(&self) -> ApiResult { + self.state.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ChannelRow { + pub id: Uuid, + pub adapter: String, + pub label: Value, + pub display_order: i32, + pub enabled: bool, + pub supported_currencies: Vec, + pub min_amount_minor: i64, + pub max_amount_minor: Option, + pub provider_account_id: Option, + pub secret_key_ref: Option, + pub webhook_secret_ref: Option, + pub environment: String, + pub archived: bool, + pub revision: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ChannelRow { + pub fn adapter_kind(&self) -> ApiResult { + self.adapter.parse() + } + pub fn environment(&self) -> ApiResult { + self.environment.parse() + } +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ChannelRevisionRow { + pub id: Uuid, + pub channel_id: Uuid, + pub revision: i32, + pub snapshot: Value, + pub adapter_contract_version: String, + pub created_by: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct AuditRow { + pub id: Uuid, + pub channel_id: Uuid, + pub revision: i32, + pub action: String, + pub actor: String, + pub changes: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct CheckoutRequestRow { + pub id: Uuid, + pub owner_user_id: Uuid, + pub idempotency_key: String, + pub request_hash: String, + pub header_hash: String, + pub payment_id: Uuid, + pub response: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct EventRow { + pub id: Uuid, + pub channel_id: Uuid, + pub provider_account_id: Option, + pub environment: String, + pub provider_event_id: String, + pub event_type: String, + pub payload: Value, + pub signature_key_id: Option, + pub received_at: DateTime, + pub processed_at: Option>, + pub process_error: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExceptionRow { + pub id: Uuid, + pub payment_id: Uuid, + pub kind: String, + pub receipt_id: Option, + pub attempt_id: Option, + pub detail: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub resolved_at: Option>, +} + +// --------------------------------------------------------------------------- +// Payments and membership. +// --------------------------------------------------------------------------- + +pub async fn insert_payment( + tx: &mut PgConnection, + owner_user_id: Uuid, + currency: &str, + total_minor: i64, + environment: PaymentEnvironment, +) -> ApiResult { + if total_minor <= 0 { + return Err(ApiError::Conflict( + "payment total must be positive".into(), + )); + } + Ok(sqlx::query_as::<_, PaymentRow>(&format!( + "INSERT INTO payments (owner_user_id, currency, total_minor, environment) + VALUES ($1, $2, $3, $4) + RETURNING {PAYMENT_COLS}" + )) + .bind(owner_user_id) + .bind(currency) + .bind(total_minor) + .bind(environment.as_str()) + .fetch_one(&mut *tx) + .await?) +} + +/// Immutable membership rows; amounts are checked against the order snapshot +/// by the caller and must sum to the payment total. +pub async fn insert_payment_order( + tx: &mut PgConnection, + payment_id: Uuid, + order_id: Uuid, + amount_minor: i64, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO payment_orders (payment_id, order_id, amount_minor) VALUES ($1, $2, $3)", + ) + .bind(payment_id) + .bind(order_id) + .bind(amount_minor) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn get_payment(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, PaymentRow>(&format!( + "SELECT {PAYMENT_COLS} FROM payments WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("payment".into())) +} + +/// Per-payment row lock: serializes payment, cancellation and refund-budget +/// changes. Always the first lock in any payment-state transaction. +pub async fn lock_payment(tx: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, PaymentRow>(&format!( + "SELECT {PAYMENT_COLS} FROM payments WHERE id = $1 FOR UPDATE" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("payment".into())) +} + +pub async fn payments_for_orders(db: &PgPool, order_ids: &[Uuid]) -> ApiResult> { + Ok(sqlx::query_as::<_, PaymentOrderRow>( + "SELECT payment_id, order_id, amount_minor FROM payment_orders WHERE order_id = ANY($1)", + ) + .bind(order_ids) + .fetch_all(db) + .await?) +} + +pub async fn membership_for_payment( + db: &PgPool, + payment_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, PaymentOrderRow>( + "SELECT payment_id, order_id, amount_minor FROM payment_orders + WHERE payment_id = $1 ORDER BY order_id", + ) + .bind(payment_id) + .fetch_all(db) + .await?) +} + +pub async fn lock_membership_sorted( + tx: &mut PgConnection, + payment_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, PaymentOrderRow>( + "SELECT payment_id, order_id, amount_minor FROM payment_orders + WHERE payment_id = $1 ORDER BY order_id FOR UPDATE", + ) + .bind(payment_id) + .fetch_all(&mut *tx) + .await?) +} + +/// Guarded unapplied -> applied transition; returns false when already done. +pub async fn mark_payment_applied(tx: &mut PgConnection, id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE payments SET status = 'applied', applied_at = now(), updated_at = now() + WHERE id = $1 AND status = 'unapplied'", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Guarded close (never-applied payment whose full compensation succeeded). +pub async fn mark_payment_closed(tx: &mut PgConnection, id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE payments SET status = 'closed', closed_at = now(), updated_at = now() + WHERE id = $1 AND status = 'unapplied'", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() == 1) +} + +// --------------------------------------------------------------------------- +// Checkout idempotency. +// --------------------------------------------------------------------------- + +pub async fn find_checkout_request( + db: &PgPool, + owner_user_id: Uuid, + idempotency_key: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CheckoutRequestRow>( + "SELECT id, owner_user_id, idempotency_key, request_hash, header_hash, payment_id, + response, created_at + FROM checkout_requests WHERE owner_user_id = $1 AND idempotency_key = $2", + ) + .bind(owner_user_id) + .bind(idempotency_key) + .fetch_optional(db) + .await?) +} + +pub async fn insert_checkout_request( + tx: &mut PgConnection, + owner_user_id: Uuid, + idempotency_key: &str, + request_hash: &str, + header_hash: &str, + payment_id: Uuid, + response: &Value, +) -> ApiResult { + sqlx::query_as::<_, CheckoutRequestRow>( + "INSERT INTO checkout_requests + (owner_user_id, idempotency_key, request_hash, header_hash, payment_id, response) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, owner_user_id, idempotency_key, request_hash, header_hash, payment_id, + response, created_at", + ) + .bind(owner_user_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(header_hash) + .bind(payment_id) + .bind(response) + .fetch_one(&mut *tx) + .await + .map_err(|e| unique_conflict(e, "checkout idempotency key already used")) +} + +// --------------------------------------------------------------------------- +// Attempts. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_attempt( + tx: &mut PgConnection, + payment_id: Uuid, + channel_id: Uuid, + channel_revision: i32, + owner_user_id: Uuid, + idempotency_key: &str, + request_hash: &str, + return_surface: &str, + return_url: &str, + provider_operation_key: &str, +) -> ApiResult { + sqlx::query_as::<_, AttemptRow>(&format!( + "INSERT INTO payment_attempts + (payment_id, channel_id, channel_revision, owner_user_id, idempotency_key, + request_hash, return_surface, return_url, provider_operation_key) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING {ATTEMPT_COLS}" + )) + .bind(payment_id) + .bind(channel_id) + .bind(channel_revision) + .bind(owner_user_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(return_surface) + .bind(return_url) + .bind(provider_operation_key) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + unique_conflict( + e, + "an unresolved payment attempt already exists or the idempotency key differs", + ) + }) +} + +pub async fn get_attempt(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, AttemptRow>(&format!( + "SELECT {ATTEMPT_COLS} FROM payment_attempts WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("payment attempt".into())) +} + +pub async fn find_attempt_by_key( + db: &PgPool, + payment_id: Uuid, + idempotency_key: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, AttemptRow>(&format!( + "SELECT {ATTEMPT_COLS} FROM payment_attempts + WHERE payment_id = $1 AND idempotency_key = $2" + )) + .bind(payment_id) + .bind(idempotency_key) + .fetch_optional(db) + .await?) +} + +pub async fn list_attempts(db: &PgPool, payment_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, AttemptRow>(&format!( + "SELECT {ATTEMPT_COLS} FROM payment_attempts + WHERE payment_id = $1 ORDER BY created_at" + )) + .bind(payment_id) + .fetch_all(db) + .await?) +} + +pub async fn find_attempt_by_provider_reference( + db: &PgPool, + reference: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, AttemptRow>(&format!( + "SELECT {ATTEMPT_COLS} FROM payment_attempts + WHERE provider_reference = $1 OR provider_intent_reference = $1 + ORDER BY created_at DESC LIMIT 1" + )) + .bind(reference) + .fetch_optional(db) + .await?) +} + +/// Guarded monotonic attempt transition: only forward moves are accepted and +/// settled/failed/expired can never regress. +pub async fn transition_attempt( + tx: &mut PgConnection, + id: Uuid, + to: AttemptState, + provider_reference: Option<&str>, + provider_intent_reference: Option<&str>, + next_action_type: &str, + next_action_url: Option<&str>, + expires_at: Option>, + error_detail: Option<&str>, +) -> ApiResult { + let from_guard: Vec = match to { + AttemptState::Pending => vec!["created".into(), "pending".into()], + AttemptState::Unknown => vec!["created".into(), "pending".into()], + AttemptState::Settled => vec![ + "created".into(), + "pending".into(), + "unknown".into(), + "settled".into(), + ], + AttemptState::Failed | AttemptState::Expired => { + vec!["created".into(), "pending".into(), "unknown".into()] + } + AttemptState::Created => vec!["created".into()], + }; + let result = sqlx::query( + "UPDATE payment_attempts + SET state = $2, + provider_reference = COALESCE($3, provider_reference), + provider_intent_reference = COALESCE($4, provider_intent_reference), + next_action_type = $5, + next_action_url = COALESCE($6, next_action_url), + expires_at = COALESCE($7, expires_at), + error_detail = COALESCE($8, error_detail), + updated_at = now() + WHERE id = $1 AND state = ANY($9)", + ) + .bind(id) + .bind(to.as_str()) + .bind(provider_reference) + .bind(provider_intent_reference) + .bind(next_action_type) + .bind(next_action_url) + .bind(expires_at) + .bind(error_detail) + .bind(&from_guard) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() == 1) +} + +// --------------------------------------------------------------------------- +// Receipts. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_receipt( + tx: &mut PgConnection, + payment_id: Uuid, + attempt_id: Option, + provider_account_id: Option<&str>, + environment: PaymentEnvironment, + provider_transaction: &str, + amount_minor: i64, + currency: &str, + method: PaymentAdapterKind, + quarantined: bool, + quarantine_reason: Option<&str>, + evidence: &Value, + crypto: Option<&Value>, +) -> ApiResult { + sqlx::query_as::<_, ReceiptRow>(&format!( + "INSERT INTO payment_receipts + (payment_id, attempt_id, provider_account_id, environment, provider_transaction, + amount_minor, currency, method, quarantined, quarantine_reason, evidence, crypto) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING {RECEIPT_COLS}" + )) + .bind(payment_id) + .bind(attempt_id) + .bind(provider_account_id) + .bind(environment.as_str()) + .bind(provider_transaction) + .bind(amount_minor) + .bind(currency) + .bind(method.as_str()) + .bind(quarantined) + .bind(quarantine_reason) + .bind(evidence) + .bind(crypto) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + unique_conflict( + e, + "a receipt with this provider transaction identity already exists", + ) + }) +} + +pub async fn get_receipt(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, ReceiptRow>(&format!( + "SELECT {RECEIPT_COLS} FROM payment_receipts WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("payment receipt".into())) +} + +pub async fn list_receipts(db: &PgPool, payment_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, ReceiptRow>(&format!( + "SELECT {RECEIPT_COLS} FROM payment_receipts WHERE payment_id = $1 ORDER BY received_at" + )) + .bind(payment_id) + .fetch_all(db) + .await?) +} + +pub async fn find_receipt_by_transaction( + db: &PgPool, + provider_account_id: Option<&str>, + environment: PaymentEnvironment, + provider_transaction: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ReceiptRow>(&format!( + "SELECT {RECEIPT_COLS} FROM payment_receipts + WHERE provider_account_id IS NOT DISTINCT FROM $1 + AND environment = $2 AND provider_transaction = $3" + )) + .bind(provider_account_id) + .bind(environment.as_str()) + .bind(provider_transaction) + .fetch_optional(db) + .await?) +} + +pub async fn mark_receipt_applied(tx: &mut PgConnection, id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE payment_receipts SET applied = TRUE WHERE id = $1 AND applied = FALSE", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() == 1) +} + +// --------------------------------------------------------------------------- +// Refunds. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_refund( + tx: &mut PgConnection, + payment_id: Uuid, + receipt_id: Uuid, + allocation_order_id: Option, + aftersale_id: Option, + purpose: RefundPurpose, + amount_minor: i64, + currency: &str, + idempotency_key: &str, + provider_operation_key: &str, + channel_revision: i32, +) -> ApiResult { + sqlx::query_as::<_, RefundRow>(&format!( + "INSERT INTO payment_refunds + (payment_id, receipt_id, allocation_order_id, aftersale_id, purpose, amount_minor, + currency, idempotency_key, provider_operation_key, channel_revision, state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending') + RETURNING {REFUND_COLS}" + )) + .bind(payment_id) + .bind(receipt_id) + .bind(allocation_order_id) + .bind(aftersale_id) + .bind(purpose.as_str()) + .bind(amount_minor) + .bind(currency) + .bind(idempotency_key) + .bind(provider_operation_key) + .bind(channel_revision) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + unique_conflict( + e, + "an unresolved refund operation already exists for this target", + ) + }) +} + +pub async fn get_refund(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, RefundRow>(&format!( + "SELECT {REFUND_COLS} FROM payment_refunds WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("refund operation".into())) +} + +pub async fn list_refunds(db: &PgPool, payment_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, RefundRow>(&format!( + "SELECT {REFUND_COLS} FROM payment_refunds WHERE payment_id = $1 ORDER BY created_at" + )) + .bind(payment_id) + .fetch_all(db) + .await?) +} + +pub async fn list_refunds_for_aftersale( + db: &PgPool, + aftersale_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, RefundRow>(&format!( + "SELECT {REFUND_COLS} FROM payment_refunds + WHERE aftersale_id = $1 ORDER BY created_at DESC" + )) + .bind(aftersale_id) + .fetch_all(db) + .await?) +} + +pub async fn find_refund_by_provider_reference( + db: &PgPool, + reference: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, RefundRow>(&format!( + "SELECT {REFUND_COLS} FROM payment_refunds + WHERE provider_reference = $1 OR provider_operation_key = $1 + ORDER BY created_at DESC LIMIT 1" + )) + .bind(reference) + .fetch_optional(db) + .await?) +} + +/// Budget already reserved against a receipt: pending, unknown and succeeded +/// operations all count; definitively failed ones released their reservation. +pub async fn reserved_on_receipt( + tx: &mut PgConnection, + receipt_id: Uuid, + allocation_order_id: Option, +) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COALESCE(sum(amount_minor), 0)::bigint FROM payment_refunds + WHERE receipt_id = $1 + AND state IN ('pending', 'unknown', 'succeeded') + AND ($2::uuid IS NULL OR allocation_order_id = $2)", + ) + .bind(receipt_id) + .bind(allocation_order_id) + .fetch_one(&mut *tx) + .await?) +} + +/// Guarded monotonic refund transition. `succeeded` and `failed` are terminal; +/// `unknown` never releases budget. +pub async fn transition_refund( + tx: &mut PgConnection, + id: Uuid, + to: RefundState, + provider_reference: Option<&str>, + error_detail: Option<&str>, +) -> ApiResult { + let from_guard: Vec = match to { + RefundState::Pending => vec!["pending".into()], + RefundState::Unknown => vec!["pending".into()], + RefundState::Succeeded => vec![ + "pending".into(), + "unknown".into(), + "succeeded".into(), + ], + RefundState::Failed => vec!["pending".into(), "unknown".into()], + }; + let result = sqlx::query(&format!( + "UPDATE payment_refunds + SET state = $2, + provider_reference = COALESCE($3, provider_reference), + error_detail = COALESCE($4, error_detail), + completed_at = CASE WHEN $2 = 'succeeded' THEN now() ELSE completed_at END, + updated_at = now() + WHERE id = $1 AND state = ANY($5)" + )) + .bind(id) + .bind(to.as_str()) + .bind(provider_reference) + .bind(error_detail) + .bind(&from_guard) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() == 1) +} + +// --------------------------------------------------------------------------- +// Durable commands. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn enqueue_command( + tx: &mut PgConnection, + kind: CommandKind, + payment_id: Option, + attempt_id: Option, + refund_id: Option, + operation_key: &str, + idempotency_key: &str, + payload: &Value, +) -> ApiResult { + sqlx::query_as::<_, CommandRow>(&format!( + "INSERT INTO payment_commands + (kind, payment_id, attempt_id, refund_id, operation_key, idempotency_key, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING {COMMAND_COLS}" + )) + .bind(kind.as_str()) + .bind(payment_id) + .bind(attempt_id) + .bind(refund_id) + .bind(operation_key) + .bind(idempotency_key) + .bind(payload) + .fetch_one(&mut *tx) + .await + .map_err(|e| unique_conflict(e, "command already enqueued")) +} + +pub async fn get_command(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, CommandRow>(&format!( + "SELECT {COMMAND_COLS} FROM payment_commands WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("payment command".into())) +} + +pub async fn outstanding_command( + db: &PgPool, + kind: CommandKind, + payment_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CommandRow>(&format!( + "SELECT {COMMAND_COLS} FROM payment_commands + WHERE kind = $1 AND payment_id = $2 AND state IN ('pending', 'leased') + ORDER BY created_at DESC LIMIT 1" + )) + .bind(kind.as_str()) + .bind(payment_id) + .fetch_optional(db) + .await?) +} + +/// Reclaim expired leases so work resumes after a crash/restart. +pub async fn reclaim_expired_leases(db: &PgPool) -> ApiResult { + let result = sqlx::query( + "UPDATE payment_commands + SET state = 'pending', lease_owner = NULL, lease_expires_at = NULL, updated_at = now() + WHERE state = 'leased' AND lease_expires_at < now()", + ) + .execute(db) + .await?; + Ok(result.rows_affected()) +} + +/// Claim the next due command with a recoverable lease. `SKIP LOCKED` keeps +/// concurrent API instances from duplicating unrelated work. +pub async fn claim_due_command( + tx: &mut PgConnection, + lease_owner: &str, + lease_seconds: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CommandRow>(&format!( + "UPDATE payment_commands + SET state = 'leased', lease_owner = $1, + lease_expires_at = now() + make_interval(secs => $2), + attempts_count = attempts_count + 1, updated_at = now() + WHERE id = ( + SELECT id FROM payment_commands + WHERE state = 'pending' AND due_at <= now() + ORDER BY due_at + LIMIT 1 + FOR UPDATE SKIP LOCKED) + RETURNING {COMMAND_COLS}" + )) + .bind(lease_owner) + .bind(lease_seconds as f64) + .fetch_optional(&mut *tx) + .await?) +} + +pub async fn claim_command( + tx: &mut PgConnection, + id: Uuid, + lease_owner: &str, + lease_seconds: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CommandRow>(&format!( + "UPDATE payment_commands + SET state = 'leased', lease_owner = $2, + lease_expires_at = now() + make_interval(secs => $3), + attempts_count = attempts_count + 1, updated_at = now() + WHERE id = $1 AND state = 'pending' AND due_at <= now() + RETURNING {COMMAND_COLS}" + )) + .bind(id) + .bind(lease_owner) + .bind(lease_seconds as f64) + .fetch_optional(&mut *tx) + .await?) +} + +pub async fn finish_command( + tx: &mut PgConnection, + id: Uuid, + state: CommandState, + error: Option<&str>, + retry_at: Option>, +) -> ApiResult<()> { + sqlx::query( + "UPDATE payment_commands + SET state = $2, + last_error = COALESCE($3, last_error), + due_at = COALESCE($4, due_at), + lease_owner = NULL, lease_expires_at = NULL, + finished_at = CASE WHEN $2 IN ('succeeded', 'failed', 'cancelled') + THEN now() ELSE finished_at END, + updated_at = now() + WHERE id = $1", + ) + .bind(id) + .bind(state.as_str()) + .bind(error) + .bind(retry_at) + .execute(&mut *tx) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Verified event inbox. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_event( + tx: &mut PgConnection, + channel_id: Uuid, + provider_account_id: Option<&str>, + environment: PaymentEnvironment, + provider_event_id: &str, + event_type: &str, + payload: &Value, + signature_key_id: Option<&str>, +) -> ApiResult { + sqlx::query_as::<_, EventRow>(&format!( + "INSERT INTO payment_events + (channel_id, provider_account_id, environment, provider_event_id, event_type, + payload, signature_key_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING {EVENT_COLS}" + )) + .bind(channel_id) + .bind(provider_account_id) + .bind(environment.as_str()) + .bind(provider_event_id) + .bind(event_type) + .bind(payload) + .bind(signature_key_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| unique_conflict(e, "duplicate provider event")) +} + +pub async fn mark_event_processed( + tx: &mut PgConnection, + id: Uuid, + error: Option<&str>, +) -> ApiResult<()> { + sqlx::query( + "UPDATE payment_events + SET processed_at = COALESCE(processed_at, now()), + process_error = COALESCE($2, process_error) + WHERE id = $1", + ) + .bind(id) + .bind(error) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn list_unprocessed_events(db: &PgPool, limit: i64) -> ApiResult> { + Ok(sqlx::query_as::<_, EventRow>(&format!( + "SELECT {EVENT_COLS} FROM payment_events + WHERE processed_at IS NULL ORDER BY received_at LIMIT $1" + )) + .bind(limit) + .fetch_all(db) + .await?) +} + +// --------------------------------------------------------------------------- +// Channels, revisions, audit. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_channel( + tx: &mut PgConnection, + adapter: PaymentAdapterKind, + label: &Value, + display_order: i32, + enabled: bool, + supported_currencies: &[String], + min_amount_minor: i64, + max_amount_minor: Option, + provider_account_id: Option<&str>, + secret_key_ref: Option<&str>, + webhook_secret_ref: Option<&str>, + environment: PaymentEnvironment, +) -> ApiResult { + sqlx::query_as::<_, ChannelRow>(&format!( + "INSERT INTO payment_channels + (adapter, label, display_order, enabled, supported_currencies, min_amount_minor, + max_amount_minor, provider_account_id, secret_key_ref, webhook_secret_ref, environment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING {CHANNEL_COLS}" + )) + .bind(adapter.as_str()) + .bind(label) + .bind(display_order) + .bind(enabled) + .bind(supported_currencies) + .bind(min_amount_minor) + .bind(max_amount_minor) + .bind(provider_account_id) + .bind(secret_key_ref) + .bind(webhook_secret_ref) + .bind(environment.as_str()) + .fetch_one(&mut *tx) + .await + .map_err(ApiError::from) +} + +pub async fn get_channel(db: &PgPool, id: Uuid) -> ApiResult { + sqlx::query_as::<_, ChannelRow>(&format!( + "SELECT {CHANNEL_COLS} FROM payment_channels WHERE id = $1" + )) + .bind(id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("payment channel".into())) +} + +/// Lock a channel row (configuration edit serialization). +pub async fn lock_channel(tx: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, ChannelRow>(&format!( + "SELECT {CHANNEL_COLS} FROM payment_channels WHERE id = $1 FOR UPDATE" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("payment channel".into())) +} + +pub async fn list_channels_page( + db: &PgPool, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ChannelRow>(&format!( + "SELECT {CHANNEL_COLS} FROM payment_channels + ORDER BY display_order, created_at LIMIT $1 OFFSET $2" + )) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count_channels(db: &PgPool) -> ApiResult { + Ok(sqlx::query_scalar("SELECT count(*) FROM payment_channels") + .fetch_one(db) + .await?) +} + +pub async fn list_enabled_channels(db: &PgPool) -> ApiResult> { + Ok(sqlx::query_as::<_, ChannelRow>(&format!( + "SELECT {CHANNEL_COLS} FROM payment_channels + WHERE enabled = TRUE AND archived = FALSE ORDER BY display_order" + )) + .fetch_all(db) + .await?) +} + +/// Optimistic edit: only succeeds when the current revision matches. +#[allow(clippy::too_many_arguments)] +pub async fn update_channel( + tx: &mut PgConnection, + id: Uuid, + expected_revision: i32, + label: &Value, + display_order: i32, + enabled: bool, + supported_currencies: &[String], + min_amount_minor: i64, + max_amount_minor: Option, + provider_account_id: Option<&str>, + secret_key_ref: Option<&str>, + webhook_secret_ref: Option<&str>, +) -> ApiResult { + sqlx::query_as::<_, ChannelRow>(&format!( + "UPDATE payment_channels + SET label = $3, display_order = $4, enabled = $5, supported_currencies = $6, + min_amount_minor = $7, max_amount_minor = $8, provider_account_id = $9, + secret_key_ref = $10, webhook_secret_ref = $11, + revision = revision + 1, updated_at = now() + WHERE id = $1 AND revision = $2 AND archived = FALSE + RETURNING {CHANNEL_COLS}" + )) + .bind(id) + .bind(expected_revision) + .bind(label) + .bind(display_order) + .bind(enabled) + .bind(supported_currencies) + .bind(min_amount_minor) + .bind(max_amount_minor) + .bind(provider_account_id) + .bind(secret_key_ref) + .bind(webhook_secret_ref) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("stale channel revision".into())) +} + +pub async fn archive_channel(tx: &mut PgConnection, id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE payment_channels SET archived = TRUE, enabled = FALSE, updated_at = now() + WHERE id = $1", + ) + .bind(id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn insert_channel_revision( + tx: &mut PgConnection, + channel_id: Uuid, + revision: i32, + snapshot: &Value, + adapter_contract_version: &str, + created_by: Option, +) -> ApiResult { + sqlx::query_as::<_, ChannelRevisionRow>( + "INSERT INTO payment_channel_revisions + (channel_id, revision, snapshot, adapter_contract_version, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, channel_id, revision, snapshot, adapter_contract_version, created_by, created_at", + ) + .bind(channel_id) + .bind(revision) + .bind(snapshot) + .bind(adapter_contract_version) + .bind(created_by) + .fetch_one(&mut *tx) + .await + .map_err(|e| unique_conflict(e, "channel revision already exists")) +} + +pub async fn list_channel_revisions( + db: &PgPool, + channel_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ChannelRevisionRow>( + "SELECT id, channel_id, revision, snapshot, adapter_contract_version, created_by, created_at + FROM payment_channel_revisions WHERE channel_id = $1 ORDER BY revision DESC", + ) + .bind(channel_id) + .fetch_all(db) + .await?) +} + +pub async fn insert_audit( + tx: &mut PgConnection, + channel_id: Uuid, + revision: i32, + action: &str, + actor: &str, + changes: &Value, +) -> ApiResult { + sqlx::query_as::<_, AuditRow>( + "INSERT INTO payment_channel_audit (channel_id, revision, action, actor, changes) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, channel_id, revision, action, actor, changes, created_at", + ) + .bind(channel_id) + .bind(revision) + .bind(action) + .bind(actor) + .bind(changes) + .fetch_one(&mut *tx) + .await + .map_err(ApiError::from) +} + +pub async fn list_channel_audit(db: &PgPool, channel_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, AuditRow>( + "SELECT id, channel_id, revision, action, actor, changes, created_at + FROM payment_channel_audit WHERE channel_id = $1 ORDER BY created_at DESC", + ) + .bind(channel_id) + .fetch_all(db) + .await?) +} + +// --------------------------------------------------------------------------- +// Exceptions. +// --------------------------------------------------------------------------- + +pub async fn insert_exception( + tx: &mut PgConnection, + payment_id: Uuid, + kind: ExceptionKind, + receipt_id: Option, + attempt_id: Option, + detail: Option<&str>, +) -> ApiResult { + sqlx::query_as::<_, ExceptionRow>(&format!( + "INSERT INTO payment_exceptions (payment_id, kind, receipt_id, attempt_id, detail) + VALUES ($1, $2, $3, $4, $5) + RETURNING {EXCEPTION_COLS}" + )) + .bind(payment_id) + .bind(kind.as_str()) + .bind(receipt_id) + .bind(attempt_id) + .bind(detail) + .fetch_one(&mut *tx) + .await + .map_err(ApiError::from) +} + +pub async fn list_exceptions(db: &PgPool, payment_id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, ExceptionRow>(&format!( + "SELECT {EXCEPTION_COLS} FROM payment_exceptions + WHERE payment_id = $1 ORDER BY created_at" + )) + .bind(payment_id) + .fetch_all(db) + .await?) +} + +pub async fn resolve_exceptions_for_receipt( + tx: &mut PgConnection, + receipt_id: Uuid, +) -> ApiResult<()> { + sqlx::query( + "UPDATE payment_exceptions SET status = 'resolved', resolved_at = now(), updated_at = now() + WHERE receipt_id = $1 AND status = 'open'", + ) + .bind(receipt_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Deterministic lock helpers. +// --------------------------------------------------------------------------- + +/// Lock arbitrary id sets in deterministic (sorted) order before touching +/// them. Table names are code-selected, never user input. +pub async fn lock_ids_sorted( + tx: &mut PgConnection, + table: &str, + ids: &[Uuid], +) -> ApiResult<()> { + if ids.is_empty() { + return Ok(()); + } + let mut sorted: Vec = ids.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + match table { + "skus" | "flash_sale_items" | "coupons" | "collective_groups" | "orders" => {} + other => { + return Err(ApiError::Internal(anyhow::anyhow!( + "unsupported lock table {other}" + ))) + } + } + sqlx::query(&format!( + "SELECT id FROM {table} WHERE id = ANY($1) ORDER BY id FOR UPDATE" + )) + .bind(&sorted) + .fetch_all(&mut *tx) + .await?; + Ok(()) +} + +/// Lock the owner's account rows (deterministic order) before debits/credits. +pub async fn lock_owner_accounts(tx: &mut PgConnection, user_id: Uuid) -> ApiResult<()> { + crate::modules::account::repo::lock_for_user(tx, user_id).await?; + Ok(()) +} diff --git a/apps/api/src/modules/payment/service.rs b/apps/api/src/modules/payment/service.rs new file mode 100644 index 0000000..ab62e92 --- /dev/null +++ b/apps/api/src/modules/payment/service.rs @@ -0,0 +1,1214 @@ +//! Payment orchestration: checkout payment units, wallet collection, atomic +//! order application, group-safe cancellation and receipt handling. +//! +//! Global lock order for every state-changing transaction: +//! payment → member orders (sorted by id) → relevant group rows (sorted) → +//! affected SKU / flash-sale item / coupon rows (sorted, before any +//! restoration) → account rows (sorted). + +use chrono::Utc; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{AccountKind, MessageKind, Order, OrderStatus}; +use crate::modules::account; +use crate::modules::coupon; +use crate::modules::flash_sale; +use crate::modules::group_buying; +use crate::modules::messaging; +use crate::modules::order; +use crate::state::AppState; + +use super::adapters::{ + self, provider_operation_key, wallet_transaction_id, AdapterError, +}; +use super::dto::*; +use super::repo::{self, AttemptRow, ChannelRow, PaymentRow, ReceiptRow, RefundRow}; + +pub const WALLET_DEBIT_REASON: &str = "payment_wallet_debit"; +pub const SIGNATURE_TOLERANCE_SECS: i64 = 300; + +/// Conservative automatic-retry window for provider operations (Stripe +/// idempotency keys are guaranteed far longer, but never assume it). +pub fn provider_retry_window() -> chrono::Duration { + chrono::Duration::hours(12) +} + +// --------------------------------------------------------------------------- +// Hashing and validation helpers. +// --------------------------------------------------------------------------- + +pub fn request_hash(value: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.to_string().as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn parse_idempotency_key(headers: &axum::http::HeaderMap) -> ApiResult { + headers + .get("Idempotency-Key") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty() && v.len() <= 200) + .map(str::to_string) + .ok_or_else(|| ApiError::BadRequest("Idempotency-Key header is required".into())) +} + +// --------------------------------------------------------------------------- +// Channel eligibility and views. +// --------------------------------------------------------------------------- + +/// Server-authoritative eligibility: only enabled, configured, +/// environment-matching channels whose currency and amount constraints cover +/// the payment and whose adapter capability covers the currency. +pub fn channel_is_eligible( + channel: &ChannelRow, + payment: &PaymentRow, + base_currency: &str, +) -> ApiResult { + if !channel.enabled || channel.archived { + return Ok(false); + } + if channel.environment()? != payment.environment()? { + return Ok(false); + } + if !channel.supported_currencies.iter().any(|c| c == &payment.currency) { + return Ok(false); + } + if payment.total_minor < channel.min_amount_minor { + return Ok(false); + } + if let Some(max) = adapters::adapter_max_amount_minor(channel.adapter_kind()?, channel.max_amount_minor) + { + if payment.total_minor > max { + return Ok(false); + } + } + // Adapter capability constraints: wallet is base-currency only, stripe + // crypto is USD-only. No implicit FX, ever. + let allowed = adapters::adapter_currencies(channel.adapter_kind()?, base_currency); + Ok(allowed.iter().any(|c| c == &payment.currency)) +} + +fn method_view(channel: &ChannelRow) -> EligibleMethod { + EligibleMethod { + id: channel.id, + adapter: channel.adapter_kind().unwrap_or(PaymentAdapterKind::Wallet), + label: channel.label.clone(), + currencies: channel.supported_currencies.clone(), + min_amount_minor: Some(channel.min_amount_minor), + max_amount_minor: channel.max_amount_minor, + } +} + +pub fn channel_view(channel: &ChannelRow) -> PaymentChannelView { + PaymentChannelView { + id: channel.id, + adapter: channel.adapter_kind().unwrap_or(PaymentAdapterKind::Wallet), + label: channel.label.clone(), + display_order: channel.display_order, + enabled: channel.enabled, + archived: channel.archived, + supported_currencies: channel.supported_currencies.clone(), + min_amount_minor: channel.min_amount_minor, + max_amount_minor: channel.max_amount_minor, + provider_account_id: channel.provider_account_id.clone(), + environment: channel.environment().unwrap_or(PaymentEnvironment::Demo), + revision: channel.revision, + created_at: channel.created_at, + updated_at: channel.updated_at, + } +} + +// --------------------------------------------------------------------------- +// View assembly. +// --------------------------------------------------------------------------- + +fn receipt_view(receipt: &ReceiptRow) -> ApiResult { + Ok(PaymentReceiptView { + id: receipt.id, + payment_id: receipt.payment_id, + attempt_id: receipt.attempt_id, + adapter: receipt.method()?, + environment: receipt.environment.parse()?, + amount_minor: receipt.amount_minor, + currency: receipt.currency.clone(), + reference: Some(receipt.provider_transaction.clone()), + crypto: match &receipt.crypto { + Some(value) => serde_json::from_value(value.clone()).map_err(ApiError::internal)?, + None => None, + }, + collected_at: receipt.received_at, + }) +} + +pub fn refund_view(refund: &RefundRow, method: PaymentAdapterKind) -> ApiResult { + Ok(RefundOperationView { + id: refund.id, + purpose: refund.purpose()?, + status: refund.state()?, + amount_minor: refund.amount_minor, + currency: refund.currency.clone(), + aftersale_id: refund.aftersale_id, + method, + reference: refund.provider_reference.clone(), + error: refund.error_detail.clone(), + retry_allowed: refund.state()?.retry_allowed(), + created_at: refund.created_at, + updated_at: refund.updated_at, + }) +} + +fn attempt_next_action(attempt: &AttemptRow) -> NextAction { + match attempt.next_action_type.as_str() { + "redirect" => match &attempt.next_action_url { + Some(url) => NextAction::redirect(url.clone()), + None => NextAction::wait(), + }, + "wait" => NextAction::wait(), + _ => NextAction::none(), + } +} + +/// Build the authoritative own-only payment detail. +pub async fn payment_detail_view(db: &PgPool, payment: &PaymentRow) -> ApiResult { + let membership = repo::membership_for_payment(db, payment.id).await?; + let order_ids: Vec = membership.iter().map(|m| m.order_id).collect(); + let orders = order::repo::list_by_ids(db, &order_ids).await?; + let mut order_views = order::repo::attach_items(db, orders).await?; + order_views.sort_by_key(|v| v.order.id); + + let mut payments_orders = Vec::with_capacity(membership.len()); + for member in &membership { + if let Some(index) = order_views.iter().position(|v| v.order.id == member.order_id) { + let view = order_views.remove(index); + payments_orders.push(PaymentOrderView { + order: view, + amount_minor: member.amount_minor, + applied: payment.status == PaymentState::Applied.as_str(), + }); + } + } + + let attempts = repo::list_attempts(db, payment.id).await?; + let channels = super::admin::channels_by_ids(db, &attempts.iter().map(|a| a.channel_id).collect::>()).await?; + let mut attempt_views = Vec::with_capacity(attempts.len()); + for attempt in &attempts { + let adapter = channels + .iter() + .find(|c| c.id == attempt.channel_id) + .and_then(|c| c.adapter_kind().ok()) + .unwrap_or(PaymentAdapterKind::Wallet); + attempt_views.push(AttemptSummaryView { + id: attempt.id, + channel_id: attempt.channel_id, + adapter, + status: attempt.state()?, + next_action: attempt_next_action(attempt), + created_at: attempt.created_at, + updated_at: attempt.updated_at, + }); + } + + let receipts = repo::list_receipts(db, payment.id).await?; + let receipt_views: ApiResult> = receipts.iter().map(receipt_view).collect(); + let receipt_views = receipt_views?; + + let refunds = repo::list_refunds(db, payment.id).await?; + let mut refund_views = Vec::with_capacity(refunds.len()); + for refund in &refunds { + let method = receipts + .iter() + .find(|r| r.id == refund.receipt_id) + .and_then(|r| r.method().ok()) + .unwrap_or(PaymentAdapterKind::Wallet); + refund_views.push(refund_view(refund, method)?); + } + + let exceptions = repo::list_exceptions(db, payment.id).await?; + let exception_views = exceptions + .iter() + .map(|e| PaymentExceptionView { + id: e.id, + kind: e.kind.parse().unwrap_or(ExceptionKind::UnknownOperation), + status: e.status.parse().unwrap_or(ExceptionStatus::Open), + detail: e.detail.clone(), + created_at: e.created_at, + }) + .collect(); + + let next_action = attempts + .iter() + .rev() + .find(|a| a.state().map(|s| s.is_unresolved()).unwrap_or(false)) + .map(attempt_next_action) + .unwrap_or_else(NextAction::none); + + Ok(PaymentView { + id: payment.id, + status: payment.state()?, + currency: payment.currency.clone(), + total_minor: payment.total_minor, + environment: payment.environment()?, + orders: payments_orders, + attempts: attempt_views, + receipts: receipt_views, + refunds: refund_views, + exceptions: exception_views, + next_action, + created_at: payment.created_at, + updated_at: payment.updated_at, + }) +} + +pub async fn attempt_result_view( + db: &PgPool, + attempt: &AttemptRow, + payment: &PaymentRow, +) -> ApiResult { + let channel = repo::get_channel(db, attempt.channel_id).await?; + Ok(AttemptResultView { + id: attempt.id, + channel_id: attempt.channel_id, + adapter: channel.adapter_kind()?, + status: attempt.state()?, + payment: PaymentSummary { + id: payment.id, + status: payment.state()?, + currency: payment.currency.clone(), + total_minor: payment.total_minor, + }, + next_action: attempt_next_action(attempt), + created_at: attempt.created_at, + updated_at: attempt.updated_at, + }) +} + +// --------------------------------------------------------------------------- +// Checkout: payment membership + owner-scoped idempotency. +// --------------------------------------------------------------------------- + +/// Fast-path replay lookup. `Some(response)` is the durable original result. +pub async fn checkout_replay( + state: &AppState, + user_id: Uuid, + idempotency_key: &str, + request_hash: &str, + header_hash: &str, +) -> ApiResult> { + match repo::find_checkout_request(&state.db, user_id, idempotency_key).await? { + Some(row) => { + // The full hash covers the cart snapshot; a cleared cart makes + // the current request hash empty. The header hash covers every + // other input, so we still detect a changed currency, address, + // coupon or group intent and return 409. + if !request_hash.is_empty() && row.request_hash != request_hash { + return Err(ApiError::Conflict( + "idempotency key already used with a different request".into(), + )); + } + if !header_hash.is_empty() && row.header_hash != header_hash { + return Err(ApiError::Conflict( + "idempotency key already used with a different request".into(), + )); + } + Ok(Some(row.response)) + } + None => Ok(None), + } +} + +/// Create the checkout payment and immutable memberships inside the caller's +/// checkout transaction. The checked total equals the sum of membership +/// amounts and must be positive (zero totals are rejected before any write). +pub async fn attach_checkout_payment( + tx: &mut PgConnection, + owner_user_id: Uuid, + currency: &str, + memberships: &[(Uuid, i64)], + environment: PaymentEnvironment, +) -> ApiResult { + let mut total: i64 = 0; + for (_, amount) in memberships { + total = total + .checked_add(*amount) + .ok_or_else(|| ApiError::BadRequest("payment total overflow".into()))?; + } + if total <= 0 { + return Err(ApiError::Conflict( + "payment total must be positive".into(), + )); + } + let payment = repo::insert_payment(tx, owner_user_id, currency, total, environment).await?; + for (order_id, amount) in memberships { + repo::insert_payment_order(tx, payment.id, *order_id, *amount).await?; + } + Ok(payment) +} + +/// Durably bind the checkout request to its result inside the same +/// transaction as the order/inventory writes. A concurrent duplicate aborts +/// the whole transaction and replays the winner. +pub async fn bind_checkout_request( + tx: &mut PgConnection, + owner_user_id: Uuid, + idempotency_key: &str, + request_hash: &str, + header_hash: &str, + payment_id: Uuid, + response: &Value, +) -> ApiResult<()> { + repo::insert_checkout_request( + tx, + owner_user_id, + idempotency_key, + request_hash, + header_hash, + payment_id, + response, + ) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Payment resolution (POST /payments). +// --------------------------------------------------------------------------- + +pub async fn resolve_payment( + state: &AppState, + user_id: Uuid, + order_ids: &[Uuid], +) -> ApiResult { + if order_ids.is_empty() { + return Err(ApiError::BadRequest("order_ids is required".into())); + } + let mut requested: Vec = order_ids.to_vec(); + requested.sort_unstable(); + requested.dedup(); + + // Own-only: another customer's orders are simply not found. + let orders = order::repo::list_by_ids(&state.db, &requested).await?; + let owned: Vec<&Order> = orders.iter().filter(|o| o.user_id == user_id).collect(); + if owned.len() != requested.len() { + return Err(ApiError::NotFound("order".into())); + } + for order in &owned { + if order.status != OrderStatus::PendingPayment { + return Err(ApiError::Conflict( + "only pending-payment orders can resolve a payment".into(), + )); + } + } + + let memberships = repo::payments_for_orders(&state.db, &requested).await?; + if memberships.is_empty() { + // Existing ungrouped pending orders acquire singleton membership on + // demand; no historical multi-shop grouping is invented. + if requested.len() != 1 { + return Err(ApiError::Conflict( + "ungrouped orders resolve one at a time; no original checkout grouping exists" + .into(), + )); + } + let order = owned[0]; + let mut tx = state.db.begin().await?; + let payment = attach_checkout_payment( + &mut tx, + user_id, + &order.currency, + &[(order.id, order.total_minor)], + state.config.payment.environment, + ) + .await?; + tx.commit().await?; + let payment = repo::get_payment(&state.db, payment.id).await?; + return payment_detail_view(&state.db, &payment).await; + } + + let mut payment_ids: Vec = memberships.iter().map(|m| m.payment_id).collect(); + payment_ids.sort_unstable(); + payment_ids.dedup(); + if payment_ids.len() != 1 { + return Err(ApiError::Conflict( + "orders already belong to different payments".into(), + )); + } + let payment = repo::get_payment(&state.db, payment_ids[0]).await?; + if payment.owner_user_id != user_id { + return Err(ApiError::NotFound("payment".into())); + } + payment_detail_view(&state.db, &payment).await +} + +pub async fn get_owned_payment( + state: &AppState, + user_id: Uuid, + payment_id: Uuid, +) -> ApiResult<(PaymentRow, PaymentView)> { + let payment = repo::get_payment(&state.db, payment_id).await?; + if payment.owner_user_id != user_id { + return Err(ApiError::NotFound("payment".into())); + } + let view = payment_detail_view(&state.db, &payment).await?; + Ok((payment, view)) +} + +pub async fn eligible_methods( + state: &AppState, + user_id: Uuid, + payment_id: Uuid, +) -> ApiResult> { + let (payment, _) = get_owned_payment(state, user_id, payment_id).await?; + if payment.status != PaymentState::Unapplied.as_str() { + return Ok(Vec::new()); + } + let base = account::repo::base_currency(&state.db).await?; + let channels = repo::list_enabled_channels(&state.db).await?; + let mut methods = Vec::new(); + for channel in channels { + if channel_is_eligible(&channel, &payment, &base)? { + methods.push(method_view(&channel)); + } + } + Ok(methods) +} + +// --------------------------------------------------------------------------- +// Attempt initiation. +// --------------------------------------------------------------------------- + +pub fn return_urls( + state: &AppState, + surface: ReturnSurface, + payment_id: Uuid, +) -> ApiResult<(String, String)> { + let origin = state + .config + .payment + .origin_for(surface) + .ok_or_else(|| { + ApiError::BadRequest(format!( + "no deployment origin is configured for the {surface} return surface" + )) + })?; + let (success_path, cancel_path) = match surface { + ReturnSurface::Mall => ("/checkout/success", "/checkout/cancel"), + ReturnSurface::Mobile => ("/orders/pay/success", "/orders/pay/cancel"), + }; + Ok(( + format!("{origin}{success_path}?payment_id={payment_id}"), + format!("{origin}{cancel_path}?payment_id={payment_id}"), + )) +} + +/// Start a payment attempt on one eligible channel. Owner-scoped +/// idempotency: the same key+payload returns the same operation; a different +/// payload is 409. At most one unresolved attempt can exist per payment. +pub async fn initiate_attempt( + state: &AppState, + user_id: Uuid, + payment_id: Uuid, + body: &AttemptBody, + idempotency_key: &str, +) -> ApiResult { + let surface = body.return_surface.unwrap_or(ReturnSurface::Mall); + let hash = request_hash(&json!({ + "channel_id": body.channel_id, + "return_surface": surface.as_str(), + })); + + if let Some(existing) = + repo::find_attempt_by_key(&state.db, payment_id, idempotency_key).await? + { + if existing.request_hash != hash { + return Err(ApiError::Conflict( + "idempotency key already used with a different request".into(), + )); + } + let payment = repo::get_payment(&state.db, payment_id).await?; + return attempt_result_view(&state.db, &existing, &payment).await; + } + + let mut tx = state.db.begin().await?; + let payment = repo::lock_payment(&mut tx, payment_id).await?; + if payment.owner_user_id != user_id { + return Err(ApiError::NotFound("payment".into())); + } + if payment.status != PaymentState::Unapplied.as_str() { + return Err(ApiError::Conflict( + "payment is already applied or closed".into(), + )); + } + + // Unresolved outcomes block replacement collection, switching and any + // new attempt. + for attempt in repo::list_attempts(&state.db, payment_id).await? { + if attempt.state()?.is_unresolved() { + return Err(ApiError::Conflict( + "an unresolved payment attempt exists; reconciliation required".into(), + )); + } + } + // A settled receipt whose application/refund is unresolved also blocks. + for receipt in repo::list_receipts(&state.db, payment_id).await? { + if !receipt.applied { + return Err(ApiError::Conflict( + "an unapplied collection exists; compensation or reconciliation required".into(), + )); + } + } + + let channel = repo::get_channel(&state.db, body.channel_id).await?; + let base = account::repo::base_currency(&state.db).await?; + if !channel_is_eligible(&channel, &payment, &base)? { + return Err(ApiError::Conflict( + "payment channel is not eligible for this payment".into(), + )); + } + let adapter = channel.adapter_kind()?; + // Return URLs exist only for redirect-based providers; wallet attempts + // settle inline and never leave the app, so an unconfigured deployment + // origin must not block them. + let return_url = match adapter { + PaymentAdapterKind::Wallet => String::new(), + PaymentAdapterKind::StripeCrypto => return_urls(state, surface, payment_id)?.0, + }; + let operation_key = provider_operation_key( + match adapter { + PaymentAdapterKind::Wallet => "wallet", + PaymentAdapterKind::StripeCrypto => "cs", + }, + Uuid::new_v4(), + ); + let attempt = repo::insert_attempt( + &mut tx, + payment_id, + channel.id, + channel.revision, + user_id, + idempotency_key, + &hash, + surface.as_str(), + &return_url, + &operation_key, + ) + .await?; + + match adapter { + PaymentAdapterKind::Wallet => { + wallet_collect(&mut tx, &payment, &attempt, &channel, &base).await?; + tx.commit().await?; + let attempt = repo::get_attempt(&state.db, attempt.id).await?; + let payment = repo::get_payment(&state.db, payment_id).await?; + attempt_result_view(&state.db, &attempt, &payment).await + } + PaymentAdapterKind::StripeCrypto => { + // Durable create-session command committed before any external + // call; executed under lease outside SQL transactions. + repo::enqueue_command( + &mut tx, + CommandKind::CreateCheckout, + Some(payment_id), + Some(attempt.id), + None, + &operation_key, + &format!("create:{}", attempt.id), + &json!({ "attempt_id": attempt.id }), + ) + .await?; + tx.commit().await?; + super::worker::execute_command_by_id(state, attempt_command_id(state, attempt.id).await?) + .await?; + let attempt = repo::get_attempt(&state.db, attempt.id).await?; + let payment = repo::get_payment(&state.db, payment_id).await?; + attempt_result_view(&state.db, &attempt, &payment).await + } + } +} + +async fn attempt_command_id(state: &AppState, attempt_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM payment_commands WHERE attempt_id = $1 AND kind = 'create_checkout' + ORDER BY created_at DESC LIMIT 1", + ) + .bind(attempt_id) + .fetch_one(&state.db) + .await?) +} + +// --------------------------------------------------------------------------- +// Wallet collection: one transaction, all-or-none. +// --------------------------------------------------------------------------- + +async fn wallet_collect( + tx: &mut PgConnection, + payment: &PaymentRow, + attempt: &AttemptRow, + channel: &ChannelRow, + base_currency: &str, +) -> ApiResult<()> { + // Wallet is eligible only for the base-currency wallet; never convert. + if payment.currency != base_currency { + return Err(ApiError::Conflict( + "wallet payments require the base wallet currency".into(), + )); + } + let membership = repo::lock_membership_sorted(tx, payment.id).await?; + let order_ids: Vec = membership.iter().map(|m| m.order_id).collect(); + let orders = order::repo::lock_sorted(tx, &order_ids).await?; + + // Deterministic group lock order before any seat claim. + let mut group_ids: Vec = orders.iter().filter_map(|o| o.group_id).collect(); + group_ids.sort_unstable(); + repo::lock_ids_sorted(tx, "collective_groups", &group_ids).await?; + + // Account rows last in the global order. + repo::lock_owner_accounts(tx, payment.owner_user_id).await?; + + // Revalidate every member before any effect. + for order in &orders { + if order.status != OrderStatus::PendingPayment { + return Err(ApiError::Conflict( + "a member order is no longer payable".into(), + )); + } + } + + // One guarded debit of the available account, one ledger entry. + account::service::ensure_monetary_account( + tx, + payment.owner_user_id, + AccountKind::Available, + &payment.currency, + ) + .await?; + account::service::debit( + tx, + payment.owner_user_id, + AccountKind::Available, + Some(payment.currency.as_str()), + payment.total_minor, + WALLET_DEBIT_REASON, + Some(("payment", payment.id)), + ) + .await?; + + // Claim every required group seat; any failure rolls everything back. + for order in &orders { + if let Some(group_id) = order.group_id { + group_buying::service::claim_seat(tx, payment.owner_user_id, order.id, group_id) + .await?; + } + } + + // One immutable settled receipt, then atomic order application. + let receipt = repo::insert_receipt( + tx, + payment.id, + Some(attempt.id), + None, + payment.environment()?, + &wallet_transaction_id(&attempt.provider_operation_key), + payment.total_minor, + &payment.currency, + PaymentAdapterKind::Wallet, + false, + None, + &json!({ "channel_id": channel.id, "channel_revision": channel.revision }), + None, + ) + .await?; + repo::mark_receipt_applied(tx, receipt.id).await?; + + for order in &orders { + let paid = order::repo::mark_paid(tx, order.id).await?; + messaging::service::emit_event( + tx, + paid.user_id, + MessageKind::OrderPaid, + "order", + paid.id, + &paid.order_no, + ) + .await?; + } + + repo::mark_payment_applied(tx, payment.id).await?; + repo::transition_attempt( + tx, + attempt.id, + AttemptState::Settled, + Some(receipt.provider_transaction.as_str()), + None, + NextActionType::None.as_str(), + None, + None, + None, + ) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Atomic application of an external receipt. +// --------------------------------------------------------------------------- + +/// Record actual collection durably first (its own transaction). Valid +/// evidence that cannot be applied is retained and scheduled for +/// compensation; mismatches are quarantined, never auto-credited. +pub async fn record_receipt( + state: &AppState, + payment_id: Uuid, + attempt_id: Option, + provider_account_id: Option<&str>, + provider_transaction: &str, + amount_minor: i64, + currency: &str, + method: PaymentAdapterKind, + evidence: &Value, + crypto: Option<&Value>, +) -> ApiResult { + let payment = repo::get_payment(&state.db, payment_id).await?; + let environment = payment.environment()?; + + if let Some(existing) = repo::find_receipt_by_transaction( + &state.db, + provider_account_id, + environment, + provider_transaction, + ) + .await? + { + // Duplicate notifications describe one collection; the receipt is + // unique by provider-account/environment/transaction identity. + return Ok(existing); + } + + let mismatch = amount_minor != payment.total_minor || currency != payment.currency; + let mut tx = state.db.begin().await?; + let receipt = repo::insert_receipt( + &mut tx, + payment_id, + attempt_id, + provider_account_id, + environment, + provider_transaction, + amount_minor, + currency, + method, + mismatch, + mismatch.then_some("amount or currency differs from the immutable payment"), + evidence, + crypto, + ) + .await?; + if mismatch { + // Quarantined: never fulfilled, never auto-credited. + repo::insert_exception( + &mut tx, + payment_id, + ExceptionKind::AmountMismatch, + Some(receipt.id), + attempt_id, + Some("receipt amount/currency differs from the payment"), + ) + .await?; + } + tx.commit().await?; + Ok(receipt) +} + +/// Apply a valid receipt to every member order in one transaction. On +/// rejection (closed payment, lost group capacity) the receipt is retained +/// and a full original-method compensating refund is scheduled — never a +/// partial application. +pub async fn apply_receipt(state: &AppState, receipt_id: Uuid) -> ApiResult<()> { + let receipt = repo::get_receipt(&state.db, receipt_id).await?; + if receipt.quarantined { + return Ok(()); + } + if receipt.applied { + return Ok(()); + } + + let mut tx = state.db.begin().await?; + let payment = repo::lock_payment(&mut tx, receipt.payment_id).await?; + let membership = repo::lock_membership_sorted(&mut tx, payment.id).await?; + let order_ids: Vec = membership.iter().map(|m| m.order_id).collect(); + let orders = order::repo::lock_sorted(&mut tx, &order_ids).await?; + let mut group_ids: Vec = orders.iter().filter_map(|o| o.group_id).collect(); + group_ids.sort_unstable(); + repo::lock_ids_sorted(&mut tx, "collective_groups", &group_ids).await?; + + // An already applied payment never accepts another application; an + // additional genuine collection is retained and compensated. + if payment.status != PaymentState::Unapplied.as_str() { + drop(orders); + tx.commit().await?; + super::refunds::schedule_compensation( + state, + receipt.id, + ExceptionKind::ExtraCollection, + "collection concerns an already applied or closed payment", + ) + .await?; + return Ok(()); + } + + // Every member must still be payable; no subset ever becomes paid. + let all_pending = orders + .iter() + .all(|o| o.status == OrderStatus::PendingPayment); + if !all_pending { + drop(orders); + tx.commit().await?; + super::refunds::schedule_compensation( + state, + receipt.id, + ExceptionKind::UnappliedReceipt, + "a member order is no longer payable", + ) + .await?; + return Ok(()); + } + + // Group seats are claimed at payment time; a lost seat rolls back every + // local effect and requests full compensation. + for order in &orders { + if let Some(group_id) = order.group_id { + if let Err(e) = + group_buying::service::claim_seat(&mut tx, payment.owner_user_id, order.id, group_id) + .await + { + tx.rollback().await?; + super::refunds::schedule_compensation( + state, + receipt.id, + ExceptionKind::UnappliedReceipt, + &format!("group seat unavailable: {e}"), + ) + .await?; + return Ok(()); + } + } + } + + repo::mark_receipt_applied(&mut tx, receipt.id).await?; + for order in &orders { + let paid = order::repo::mark_paid(&mut tx, order.id).await?; + messaging::service::emit_event( + &mut tx, + paid.user_id, + MessageKind::OrderPaid, + "order", + paid.id, + &paid.order_no, + ) + .await?; + } + repo::mark_payment_applied(&mut tx, payment.id).await?; + if let Some(attempt_id) = receipt.attempt_id { + repo::transition_attempt( + &mut tx, + attempt_id, + AttemptState::Settled, + Some(receipt.provider_transaction.as_str()), + None, + NextActionType::None.as_str(), + None, + None, + None, + ) + .await?; + } + tx.commit().await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Group-wide safe cancellation. +// --------------------------------------------------------------------------- + +fn member_resource_ids(db_rows: &[Order]) -> (Vec, Vec) { + let mut coupon_ids = Vec::new(); + let mut group_ids = Vec::new(); + for order in db_rows { + if let Some(coupon_id) = order.coupon_id { + coupon_ids.push(coupon_id); + } + if let Some(group_id) = order.group_id { + group_ids.push(group_id); + } + } + (coupon_ids, group_ids) +} + +/// Cancel one order's entire unpaid payment group atomically, or a whole +/// payment when `payment_id` is targeted directly. Blocked (409) while any +/// attempt is unresolved or a collection exists. Restores SKU/activity +/// stock and coupons exactly once and closes an empty opener group. +pub async fn cancel_payment_group( + state: &AppState, + user_id: Option, + payment_id: Uuid, +) -> ApiResult { + let mut tx = state.db.begin().await?; + let payment = repo::lock_payment(&mut tx, payment_id).await?; + if let Some(user_id) = user_id { + if payment.owner_user_id != user_id { + return Err(ApiError::NotFound("payment".into())); + } + } + if payment.status != PaymentState::Unapplied.as_str() { + return Err(ApiError::Conflict( + "payment has been applied or closed and cannot be cancelled".into(), + )); + } + + let attempts = repo::list_attempts(&state.db, payment_id).await?; + for attempt in &attempts { + if attempt.state()?.is_unresolved() { + return Err(ApiError::Conflict( + "an unresolved collection outcome blocks cancellation".into(), + )); + } + } + for receipt in repo::list_receipts(&state.db, payment_id).await? { + if !receipt.applied { + return Err(ApiError::Conflict( + "an unapplied collection exists; cancellation blocked".into(), + )); + } + } + + let membership = repo::lock_membership_sorted(&mut tx, payment.id).await?; + let order_ids: Vec = membership.iter().map(|m| m.order_id).collect(); + let orders = order::repo::lock_sorted(&mut tx, &order_ids).await?; + let (coupon_ids, group_ids) = member_resource_ids(&orders); + + // Collect per-order SKU and flash-sale ids for deterministic locking. + let mut sku_ids: Vec = sqlx::query_scalar( + "SELECT sku_id FROM order_items WHERE order_id = ANY($1)", + ) + .bind(&order_ids) + .fetch_all(&mut *tx) + .await?; + let mut flash_ids: Vec = sqlx::query_scalar( + "SELECT flash_sale_item_id FROM order_items + WHERE order_id = ANY($1) AND flash_sale_item_id IS NOT NULL", + ) + .bind(&order_ids) + .fetch_all(&mut *tx) + .await?; + sku_ids.sort_unstable(); + flash_ids.sort_unstable(); + + // Lock every affected resource row in id order before any restoration. + repo::lock_ids_sorted(&mut tx, "skus", &sku_ids).await?; + repo::lock_ids_sorted(&mut tx, "flash_sale_items", &flash_ids).await?; + repo::lock_ids_sorted(&mut tx, "coupons", &coupon_ids).await?; + let mut sorted_groups = group_ids.clone(); + sorted_groups.sort_unstable(); + repo::lock_ids_sorted(&mut tx, "collective_groups", &sorted_groups).await?; + + cancel_member_orders(&mut tx, &orders).await?; + repo::mark_payment_closed(&mut tx, payment.id).await?; + tx.commit().await?; + + let payment = repo::get_payment(&state.db, payment_id).await?; + payment_detail_view(&state.db, &payment).await +} + +/// Cancel the given (already locked, sorted) member orders and restore their +/// resources exactly once through the shared cancellation path. +pub async fn cancel_member_orders(tx: &mut PgConnection, orders: &[Order]) -> ApiResult<()> { + for order in orders { + if order.status == OrderStatus::Cancelled { + continue; + } + let cancelled = order::repo::cancel_pending(tx, order.id).await?; + order::repo::restore_stock(tx, cancelled.id).await?; + flash_sale::service::restore_for_order(tx, cancelled.id).await?; + coupon::service::restore_for_order(tx, cancelled.id).await?; + if let Some(group_id) = cancelled.group_id { + group_buying::service::cancel_opener_group(tx, cancelled.id, group_id).await?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Environment / legacy cutover guards. +// --------------------------------------------------------------------------- + +/// Live financial activation refuses non-live provenance: demo recharge or +/// monetary seed history, legacy-demo paid records, or non-live payment rows. +pub async fn assert_live_activation_allowed(db: &PgPool) -> ApiResult<()> { + let legacy: i64 = sqlx::query_scalar( + "SELECT count(*) FROM orders + WHERE payment_provenance = 'legacy_demo' + AND status IN ('paid', 'fulfilling', 'shipped', 'completed')", + ) + .fetch_one(db) + .await?; + if legacy > 0 { + return Err(ApiError::Conflict(format!( + "live activation refused: {legacy} legacy demo paid orders in this database" + ))); + } + let demo_credits: i64 = sqlx::query_scalar("SELECT count(*) FROM wallet_recharges") + .fetch_one(db) + .await?; + if demo_credits > 0 { + return Err(ApiError::Conflict( + "live activation refused: demo monetary credits exist in this database".into(), + )); + } + let non_live: i64 = sqlx::query_scalar( + "SELECT count(*) FROM payments WHERE environment <> 'live'", + ) + .fetch_one(db) + .await?; + if non_live > 0 { + return Err(ApiError::Conflict( + "live activation refused: non-live payment records exist in this database".into(), + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Demo wallet channel seeding (demo deployments only). +// --------------------------------------------------------------------------- + +/// Demo deployments retain one explicitly labeled demo wallet channel; no +/// enabled external channel is ever created by default. +pub async fn ensure_demo_wallet_channel(state: &AppState) -> ApiResult<()> { + if state.config.payment.environment != PaymentEnvironment::Demo { + return Ok(()); + } + let wallet_channels: i64 = + sqlx::query_scalar("SELECT count(*) FROM payment_channels WHERE adapter = 'wallet'") + .fetch_one(&state.db) + .await?; + if wallet_channels > 0 { + return Ok(()); + } + let base = account::repo::base_currency(&state.db).await?; + let mut tx = state.db.begin().await?; + let channel = repo::insert_channel( + &mut tx, + PaymentAdapterKind::Wallet, + &json!({ "en": "Wallet balance (demo)", "zh": "钱包余额(演示)" }), + 10, + true, + &[base.clone()], + 1, + None, + None, + None, + None, + PaymentEnvironment::Demo, + ) + .await?; + repo::insert_channel_revision( + &mut tx, + channel.id, + channel.revision, + &json!({ "adapter": "wallet", "note": "demo wallet channel" }), + adapters::ADAPTER_CONTRACT_VERSION, + None, + ) + .await?; + repo::insert_audit( + &mut tx, + channel.id, + channel.revision, + "created", + "system", + &json!({ "enabled": { "from": null, "to": true } }), + ) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Small helper used by worker/reconcile paths that need the raw pool. +pub fn pool(state: &AppState) -> &PgPool { + &state.db +} + +pub fn now() -> chrono::DateTime { + Utc::now() +} + +// Re-exports used across the payment module. +pub use super::repo::get_receipt as load_receipt; +pub use super::repo::get_refund as load_refund; + +/// Record that an operation's outcome remains unknown: no failure is +/// asserted, no budget/receipt is released, and replacement collection stays +/// blocked until authoritative evidence settles it. +pub async fn mark_attempt_unknown(state: &AppState, attempt_id: Uuid) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + let attempt = repo::get_attempt(&state.db, attempt_id).await?; + repo::transition_attempt( + &mut tx, + attempt.id, + AttemptState::Unknown, + None, + None, + NextActionType::Wait.as_str(), + None, + None, + Some("provider outcome unknown; reconciliation required"), + ) + .await?; + repo::insert_exception( + &mut tx, + attempt.payment_id, + ExceptionKind::UnknownOperation, + None, + Some(attempt.id), + Some("operation outcome unknown; reconciliation required"), + ) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Bounded error text for state columns. +pub fn bounded_error(e: &impl std::fmt::Display) -> String { + e.to_string().chars().take(500).collect() +} + +/// Map adapter errors onto attempt state: timeouts produce `unknown`, never +/// failure; a definitive rejection fails the attempt. +pub async fn note_attempt_outcome( + tx: &mut PgConnection, + attempt_id: Uuid, + error: &AdapterError, +) -> ApiResult<()> { + let to = if error.is_unknown() { + AttemptState::Unknown + } else { + AttemptState::Failed + }; + repo::transition_attempt( + tx, + attempt_id, + to, + None, + None, + NextActionType::Wait.as_str(), + None, + None, + Some(bounded_error(error).as_str()), + ) + .await?; + Ok(()) +} diff --git a/apps/api/src/modules/payment/webhooks.rs b/apps/api/src/modules/payment/webhooks.rs new file mode 100644 index 0000000..a667f03 --- /dev/null +++ b/apps/api/src/modules/payment/webhooks.rs @@ -0,0 +1,811 @@ +//! Durable provider execution: webhook verification/inbox, event +//! processing shared with reconciliation, and the reconcile evidence fetch. + +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +use super::adapters::{ + self, env_matches_livemode, refund_state_from_provider, AdapterError, CreateCheckoutRequest, + RefundRequest, StripeClient, +}; +use super::dto::*; +use super::repo::{self, AttemptRow, ChannelRow, EventRow, RefundRow}; +use super::service; + +/// Resolve the Stripe secret key for a channel revision (original revision +/// references stay usable for outstanding events, refunds and reconciliation +/// after rotation). Fails closed when the reference is missing/unconfigured. +pub async fn stripe_secret_for( + state: &AppState, + channel: &ChannelRow, + revision: Option, +) -> ApiResult<(String, String)> { + let reference: Option = match revision { + Some(revision) if revision != channel.revision => { + sqlx::query_scalar::<_, Option>( + "SELECT snapshot ->> 'secret_key_ref' FROM payment_channel_revisions + WHERE channel_id = $1 AND revision = $2", + ) + .bind(channel.id) + .bind(revision) + .fetch_optional(&state.db) + .await? + .flatten() + } + _ => channel.secret_key_ref.clone(), + }; + let reference = reference + .ok_or_else(|| ApiError::Conflict("channel has no secret key reference".into()))?; + let secret = state + .config + .payment + .resolve_secret_ref(&reference) + .map_err(|e| ApiError::Conflict(e.to_string()))?; + Ok((secret, reference)) +} + +// --------------------------------------------------------------------------- +// Webhook verification and durable inbox. +// --------------------------------------------------------------------------- + +/// Verify the raw body against every retained webhook signing secret for the +/// channel, validate account/environment, then persist the verified event +/// BEFORE acknowledging. A persistence failure returns non-2xx; duplicates +/// are acknowledged without reprocessing. +pub async fn verify_and_record_event( + state: &AppState, + channel_id: Uuid, + raw_body: &[u8], + signature_header: &str, +) -> ApiResult { + let channel = repo::get_channel(&state.db, channel_id).await?; + + // Old signing references remain usable for outstanding events after + // rotation: try the current revision first, then retained revisions. + let mut candidates: Vec<(String, String)> = Vec::new(); + if let Some(reference) = &channel.webhook_secret_ref { + if let Ok(secret) = state.config.payment.resolve_secret_ref(reference) { + candidates.push((secret, reference.clone())); + } + } + let revisions = repo::list_channel_revisions(&state.db, channel.id).await?; + for revision in revisions { + if let Some(reference) = revision.snapshot.get("webhook_secret_ref").and_then(|v| v.as_str()) { + if candidates.iter().any(|(_, r)| r == reference) { + continue; + } + if let Ok(secret) = state.config.payment.resolve_secret_ref(reference) { + candidates.push((secret, reference.to_string())); + } + } + } + if candidates.is_empty() { + return Err(ApiError::Conflict( + "no webhook signing secret is configured for this channel".into(), + )); + } + + let mut verified = None; + for (secret, reference) in &candidates { + if adapters::verify_stripe_signature( + raw_body, + signature_header, + secret, + reference, + service::SIGNATURE_TOLERANCE_SECS, + ) + .is_ok() + { + verified = Some(reference.clone()); + break; + } + } + let key_id = verified.ok_or_else(|| ApiError::Unauthorized( + "stripe-signature verification failed".into(), + ))?; + + let payload: Value = serde_json::from_slice(raw_body) + .map_err(|_| ApiError::BadRequest("invalid webhook payload".into()))?; + + // Account/environment checks: wrong account or wrong livemode causes no + // order, balance, receipt application, or refund completion. + let livemode = payload.get("livemode").and_then(|v| v.as_bool()).unwrap_or(false); + if !env_matches_livemode( + state.config.payment.environment, + livemode, + ) { + return Err(ApiError::Unauthorized( + "webhook livemode does not match the deployment environment".into(), + )); + } + let event_account = payload + .get("account") + .and_then(|v| v.as_str()) + .map(str::to_string); + if let Some(expected) = &channel.provider_account_id { + if let Some(actual) = &event_account { + if actual != expected { + return Err(ApiError::Unauthorized( + "webhook account does not match the channel provider account".into(), + )); + } + } + } + let provider_event_id = payload + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| ApiError::BadRequest("webhook has no event id".into()))?; + let event_type = payload + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let mut tx = state.db.begin().await?; + let event = match repo::insert_event( + &mut tx, + channel.id, + event_account.as_deref(), + state.config.payment.environment, + provider_event_id, + &event_type, + &payload, + Some(key_id.as_str()), + ) + .await + { + Ok(event) => event, + Err(ApiError::Conflict(_)) => { + // Deduplicated by provider account, environment and event id. + tx.rollback().await?; + let existing: EventRow = sqlx::query_as( + "SELECT id, channel_id, provider_account_id, environment, provider_event_id, + event_type, payload, signature_key_id, received_at, processed_at, process_error + FROM payment_events + WHERE environment = $1 AND provider_account_id IS NOT DISTINCT FROM $2 + AND provider_event_id = $3", + ) + .bind(state.config.payment.environment.as_str()) + .bind(event_account.as_deref()) + .bind(provider_event_id) + .fetch_one(&state.db) + .await?; + return Ok(existing); + } + Err(e) => { + tx.rollback().await?; + // Database failure at receipt: do not acknowledge and lose it. + return Err(e); + } + }; + tx.commit().await?; + + // Process through the same monotonic application path as reconciliation. + // A processing failure is recorded; the event stays durable and is + // reprocessed by the worker. + if let Err(e) = process_event(state, &event).await { + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, Some(service::bounded_error(&e).as_str())).await?; + tx.commit().await?; + } + Ok(event) +} + +// --------------------------------------------------------------------------- +// Event processing (shared monotonic application path). +// --------------------------------------------------------------------------- + +pub async fn process_event(state: &AppState, event: &EventRow) -> ApiResult<()> { + let payload = &event.payload; + let event_type = payload + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(&event.event_type) + .to_string(); + + match event_type.as_str() { + "checkout.session.completed" | "checkout.session.async_payment_succeeded" => { + let session = payload + .pointer("/data/object") + .cloned() + .ok_or_else(|| ApiError::BadRequest("event has no session object".into()))?; + let session_id = session + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let attempt = find_attempt_for_session(state, &session, &session_id).await?; + let Some(attempt) = attempt else { + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, Some("no matching attempt")).await?; + tx.commit().await?; + return Ok(()); + }; + let payment_id = attempt.payment_id; + let payment_status = session + .get("payment_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if payment_status != "paid" { + // A completed session without paid confirmation is not a + // collection; keep waiting for authoritative evidence. + return Ok(()); + } + let amount = session + .get("amount_total") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let currency = session + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_uppercase(); + let provider_transaction = session + .get("payment_intent") + .map(|pi| { + if pi.is_string() { + pi.as_str().map(str::to_string) + } else { + pi.get("id").and_then(|v| v.as_str()).map(str::to_string) + } + }) + .flatten() + .unwrap_or_else(|| session_id.clone()); + let crypto = adapters::extract_crypto(&session); + let receipt = service::record_receipt( + state, + payment_id, + Some(attempt.id), + event.provider_account_id.as_deref(), + &provider_transaction, + amount, + ¤cy, + PaymentAdapterKind::StripeCrypto, + &session, + crypto.as_ref().and_then(|c| serde_json::to_value(c).ok()).as_ref(), + ) + .await?; + service::apply_receipt(state, receipt.id).await?; + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, None).await?; + tx.commit().await?; + Ok(()) + } + "checkout.session.async_payment_failed" | "checkout.session.expired" => { + let session = payload + .pointer("/data/object") + .cloned() + .unwrap_or(Value::Null); + let session_id = session + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if let Some(attempt) = find_attempt_for_session(state, &session, &session_id).await? { + // A provider-expired unpaid session can close its attempt; + // late actual collection still follows receipt/compensation. + let to = if event_type.ends_with("expired") { + AttemptState::Expired + } else { + AttemptState::Failed + }; + let mut tx = state.db.begin().await?; + repo::transition_attempt( + &mut tx, + attempt.id, + to, + None, + None, + NextActionType::None.as_str(), + None, + None, + Some("provider reported unpaid session outcome"), + ) + .await?; + repo::mark_event_processed(&mut tx, event.id, None).await?; + tx.commit().await?; + } + Ok(()) + } + "charge.refunded" | "refund.updated" | "charge.refund.updated" => { + let object = payload + .pointer("/data/object") + .cloned() + .unwrap_or(Value::Null); + handle_refund_object(state, event, &object).await + } + _ => { + // Recorded, verified, and intentionally unhandled. + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, None).await?; + tx.commit().await?; + Ok(()) + } + } +} + +async fn find_attempt_for_session( + state: &AppState, + session: &Value, + session_id: &str, +) -> ApiResult> { + if let Some(attempt_id) = session + .pointer("/metadata/vmall_attempt_id") + .and_then(|v| v.as_str()) + { + if let Ok(id) = Uuid::parse_str(attempt_id) { + if let Ok(attempt) = repo::get_attempt(&state.db, id).await { + return Ok(Some(attempt)); + } + } + } + if !session_id.is_empty() { + if let Some(attempt) = + repo::find_attempt_by_provider_reference(&state.db, session_id).await? + { + return Ok(Some(attempt)); + } + } + Ok(None) +} + +async fn handle_refund_object( + state: &AppState, + event: &EventRow, + object: &Value, +) -> ApiResult<()> { + let reference = object + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_string); + let mut refund: Option = None; + if let Some(refund_id) = object + .pointer("/metadata/vmall_refund_id") + .and_then(|v| v.as_str()) + { + if let Ok(id) = Uuid::parse_str(refund_id) { + refund = repo::get_refund(&state.db, id).await.ok(); + } + } + if refund.is_none() { + if let Some(reference) = &reference { + refund = repo::find_refund_by_provider_reference(&state.db, reference).await?; + } + } + let Some(refund) = refund else { + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, Some("no matching refund")).await?; + tx.commit().await?; + return Ok(()); + }; + let status = object + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + match refund_state_from_provider(status) { + Some(RefundState::Succeeded) => { + super::refunds::apply_refund_success(state, refund.id, reference.as_deref()).await?; + } + Some(RefundState::Failed) => { + super::refunds::mark_refund_failed( + state, + refund.id, + "provider reported definitive refund failure", + reference.as_deref(), + ) + .await?; + } + // Pending/processing: keep the reservation and wait for evidence. + _ => {} + } + let mut tx = state.db.begin().await?; + repo::mark_event_processed(&mut tx, event.id, None).await?; + tx.commit().await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Reconciliation: durable evidence fetch, never a collection. +// --------------------------------------------------------------------------- + +/// Queue a reconcile command. Repeated requests return the outstanding +/// command instead of stacking duplicates; a reconcile never initiates +/// collection. +pub async fn request_reconcile( + state: &AppState, + payment_id: Uuid, +) -> ApiResult { + let payment = repo::get_payment(&state.db, payment_id).await?; + if let Some(existing) = + repo::outstanding_command(&state.db, CommandKind::Reconcile, payment.id).await? + { + return Ok(reconcile_view(&existing)); + } + let mut tx = state.db.begin().await?; + let operation_key = adapters::provider_operation_key("rec", Uuid::new_v4()); + let sequence: i64 = sqlx::query_scalar( + "SELECT count(*) FROM payment_commands WHERE payment_id = $1 AND kind = 'reconcile'", + ) + .bind(payment.id) + .fetch_one(&mut *tx) + .await?; + let command = repo::enqueue_command( + &mut tx, + CommandKind::Reconcile, + Some(payment.id), + None, + None, + &operation_key, + &format!("reconcile:{}:{}", payment.id, sequence + 1), + &json!({ "payment_id": payment.id }), + ) + .await?; + tx.commit().await?; + Ok(reconcile_view(&command)) +} + +fn reconcile_view(command: &repo::CommandRow) -> ReconcileCommandView { + let status = match command.state().unwrap_or(CommandState::Pending) { + CommandState::Pending => "queued", + CommandState::Leased => "running", + CommandState::Succeeded => "succeeded", + CommandState::Failed | CommandState::Cancelled => "failed", + }; + ReconcileCommandView { + id: command.id, + payment_id: command.payment_id.unwrap_or_default(), + status: status.to_string(), + error: command.last_error.clone(), + created_at: command.created_at, + updated_at: command.updated_at, + } +} + +/// Fetch authoritative provider evidence and reuse the normal application +/// path. Never asserts paid/refunded directly and never creates a charge. +pub async fn run_reconcile(state: &AppState, payment_id: Uuid) -> ApiResult<()> { + let attempts = repo::list_attempts(&state.db, payment_id).await?; + for attempt in &attempts { + if attempt.state()? == AttemptState::Settled { + continue; + } + let Some(session_id) = attempt.provider_reference.clone() else { + // No known provider object: absence cannot be established, so the + // operation stays unknown and blocks new collection. + super::service::mark_attempt_unknown(state, attempt.id).await?; + continue; + }; + let channel = repo::get_channel(&state.db, attempt.channel_id).await?; + let (secret, _) = stripe_secret_for(state, &channel, Some(attempt.channel_revision)).await?; + let client = StripeClient::new(&state.config.payment); + match client.retrieve_session(&secret, &session_id).await { + Ok(status) => { + if status.paid { + let payment = repo::get_payment(&state.db, payment_id).await?; + let crypto = adapters::extract_crypto(&status.raw); + let provider_transaction = status + .payment_intent_id + .clone() + .unwrap_or_else(|| session_id.clone()); + let receipt = service::record_receipt( + state, + payment_id, + Some(attempt.id), + channel.provider_account_id.as_deref(), + &provider_transaction, + status.amount_minor.unwrap_or(payment.total_minor), + &status.currency.clone().unwrap_or(payment.currency.clone()), + PaymentAdapterKind::StripeCrypto, + &status.raw, + crypto.as_ref().and_then(|c| serde_json::to_value(c).ok()).as_ref(), + ) + .await?; + service::apply_receipt(state, receipt.id).await?; + } else if status.expired { + let mut tx = state.db.begin().await?; + repo::transition_attempt( + &mut tx, + attempt.id, + AttemptState::Expired, + None, + None, + NextActionType::None.as_str(), + None, + None, + Some("provider session expired unpaid"), + ) + .await?; + tx.commit().await?; + } + } + Err(e) if e.is_unknown() => { + super::service::mark_attempt_unknown(state, attempt.id).await?; + } + Err(e) => { + let mut tx = state.db.begin().await?; + repo::transition_attempt( + &mut tx, + attempt.id, + AttemptState::Unknown, + None, + None, + NextActionType::Wait.as_str(), + None, + None, + Some(service::bounded_error(&e).as_str()), + ) + .await?; + tx.commit().await?; + } + } + } + + let refunds = repo::list_refunds(&state.db, payment_id).await?; + for refund in &refunds { + if !matches!( + refund.state()?, + RefundState::Pending | RefundState::Unknown + ) { + continue; + } + let Some(reference) = refund.provider_reference.clone() else { + continue; + }; + let receipt = repo::get_receipt(&state.db, refund.receipt_id).await?; + if receipt.method()? != PaymentAdapterKind::StripeCrypto { + continue; + } + let channel = repo::get_channel(&state.db, { + let attempt_id: Option = receipt.attempt_id; + match attempt_id { + Some(id) => repo::get_attempt(&state.db, id).await?.channel_id, + None => continue, + } + }) + .await?; + let (secret, _) = stripe_secret_for(state, &channel, Some(refund.channel_revision)).await?; + let client = StripeClient::new(&state.config.payment); + match client.retrieve_refund(&secret, &reference).await { + Ok(result) => match refund_state_from_provider(&result.status) { + Some(RefundState::Succeeded) => { + super::refunds::apply_refund_success(state, refund.id, Some(result.refund_id.as_str())) + .await?; + } + Some(RefundState::Failed) => { + super::refunds::mark_refund_failed( + state, + refund.id, + "provider reported definitive refund failure", + Some(result.refund_id.as_str()), + ) + .await?; + } + _ => {} + }, + Err(e) if e.is_unknown() => { + super::refunds::mark_refund_unknown(state, refund.id, &service::bounded_error(&e)) + .await?; + } + Err(e) => { + super::refunds::mark_refund_unknown(state, refund.id, &service::bounded_error(&e)) + .await?; + } + } + } + Ok(()) +} + +/// Account verification used by the admin verify endpoint. Read-only; a +/// successful probe never substitutes for Stripe business approval. +pub async fn verify_channel_account( + state: &AppState, + channel: &ChannelRow, +) -> ApiResult { + let checked_at = chrono::Utc::now(); + let mut view = VerificationView { + channel_id: channel.id, + revision: channel.revision, + ok: false, + checked_at, + provider_account: channel.provider_account_id.clone(), + unmet_prerequisites: Vec::new(), + detail: None, + }; + if channel.adapter_kind()? != PaymentAdapterKind::StripeCrypto { + // The wallet adapter has no external account to verify. + view.ok = true; + view.detail = Some("wallet adapter has no external provider account".into()); + return Ok(view); + } + let (secret, _) = match stripe_secret_for(state, channel, None).await { + Ok(pair) => pair, + Err(e) => { + view.unmet_prerequisites.push(service::bounded_error(&e)); + return Ok(view); + } + }; + let client = StripeClient::new(&state.config.payment); + match client.check_account(&secret).await { + Ok(check) => { + if let Some(account_id) = &check.provider_account { + if let Some(expected) = &channel.provider_account_id { + if account_id != expected { + view.unmet_prerequisites.push(format!( + "credentials belong to {account_id}, not {expected}" + )); + } + } + } + view.unmet_prerequisites.extend(check.unmet_prerequisites); + view.ok = view.unmet_prerequisites.is_empty(); + view.provider_account = check.provider_account.or(view.provider_account); + view.detail = check + .settlement_currency + .map(|c| format!("provider balance settlement currency: {c}")); + } + Err(e) => { + view.unmet_prerequisites.push(service::bounded_error(&e)); + } + } + Ok(view) +} + +// --------------------------------------------------------------------------- +// Create-session command execution (used by worker + inline initiation). +// --------------------------------------------------------------------------- + +pub async fn execute_create_checkout( + state: &AppState, + attempt: &AttemptRow, +) -> Result<(), AdapterError> { + let channel = repo::get_channel(&state.db, attempt.channel_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let (secret, _reference) = stripe_secret_for(state, &channel, Some(attempt.channel_revision)).await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let payment = repo::get_payment(&state.db, attempt.payment_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let surface: ReturnSurface = attempt + .return_surface + .parse() + .map_err(|_| AdapterError::Config("unknown return surface".into()))?; + let (success_url, cancel_url) = super::service::return_urls( + state, + surface, + payment.id, + ) + .map_err(|e| AdapterError::Config(e.to_string()))?; + + let client = StripeClient::new(&state.config.payment); + let result = client + .create_checkout( + &secret, + &CreateCheckoutRequest { + payment_id: payment.id.to_string(), + attempt_id: attempt.id.to_string(), + attempt_operation_key: attempt.provider_operation_key.clone(), + amount_minor: payment.total_minor, + currency: payment.currency.clone(), + description: adapters::quote_description(&payment.id.to_string()), + success_url, + cancel_url, + environment: payment + .environment() + .map_err(|e| AdapterError::Config(e.to_string()))?, + }, + ) + .await?; + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + repo::transition_attempt( + &mut tx, + attempt.id, + AttemptState::Pending, + Some(result.session_id.as_str()), + result.payment_intent_id.as_deref(), + NextActionType::Redirect.as_str(), + Some(result.checkout_url.as_str()), + result.expires_at, + None, + ) + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + tx.commit() + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + Ok(()) +} + +/// Execute a refund command for the Stripe adapter. +pub async fn execute_stripe_refund( + state: &AppState, + refund: &RefundRow, +) -> Result<(), AdapterError> { + let receipt = repo::get_receipt(&state.db, refund.receipt_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let attempt_id = receipt.attempt_id.ok_or_else(|| { + AdapterError::Config("receipt has no attempt to identify the provider charge".into()) + })?; + let attempt = repo::get_attempt(&state.db, attempt_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let channel = repo::get_channel(&state.db, attempt.channel_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + let (secret, _reference) = stripe_secret_for(state, &channel, Some(refund.channel_revision)).await + .map_err(|e| AdapterError::Config(e.to_string()))?; + + let mut metadata = vec![ + ("vmall_refund_id".to_string(), refund.id.to_string()), + ("vmall_payment_id".to_string(), refund.payment_id.to_string()), + ]; + if let Some(aftersale_id) = refund.aftersale_id { + metadata.push(("vmall_aftersale_id".to_string(), aftersale_id.to_string())); + } + let client = StripeClient::new(&state.config.payment); + let result = client + .create_refund( + &secret, + &RefundRequest { + refund_operation_key: refund.provider_operation_key.clone(), + provider_reference: refund + .provider_reference + .clone() + .or_else(|| attempt.provider_reference.clone()) + .unwrap_or_default(), + payment_intent_id: attempt.provider_intent_reference.clone(), + amount_minor: refund.amount_minor, + currency: refund.currency.clone(), + metadata, + }, + ) + .await?; + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + sqlx::query( + "UPDATE payment_refunds SET provider_reference = $2, updated_at = now() WHERE id = $1", + ) + .bind(refund.id) + .bind(&result.refund_id) + .execute(&mut *tx) + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + tx.commit() + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + + match refund_state_from_provider(&result.status) { + Some(RefundState::Succeeded) => { + super::refunds::apply_refund_success(state, refund.id, Some(result.refund_id.as_str())) + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + Ok(()) + } + Some(RefundState::Failed) => { + super::refunds::mark_refund_failed( + state, + refund.id, + "provider rejected the refund", + Some(result.refund_id.as_str()), + ) + .await + .map_err(|e| AdapterError::Provider(e.to_string()))?; + Ok(()) + } + // Pending: tracked until verified success; never a false completion. + _ => Ok(()), + } +} diff --git a/apps/api/src/modules/payment/worker.rs b/apps/api/src/modules/payment/worker.rs new file mode 100644 index 0000000..a39641e --- /dev/null +++ b/apps/api/src/modules/payment/worker.rs @@ -0,0 +1,282 @@ +//! Supervised durable-command worker. +//! +//! Runs inside the API process: claims due commands with recoverable +//! database leases, executes provider calls OUTSIDE SQL transactions, and +//! persists outcomes. Concurrent API instances share work safely through +//! `FOR UPDATE SKIP LOCKED`; expired leases are reclaimable after a crash. +//! Shutdown stops new claims; in-flight work finishes. + +use std::sync::Arc; +use std::future::Future; + +use tokio::sync::watch; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::state::AppState; + +use super::adapters::AdapterError; +use super::dto::{AttemptState, CommandKind, CommandState, RefundState, PaymentAdapterKind}; +use super::repo; +use super::service; +use super::webhooks; + +const LEASE_SECONDS: i64 = 60; +const MAX_ATTEMPTS: i32 = 20; +const BACKOFF_BASE_MS: u64 = 1_000; +const BACKOFF_CAP_MS: u64 = 300_000; + +pub struct WorkerHandle { + shutdown: watch::Sender, + join: tokio::task::JoinHandle<()>, +} + +impl WorkerHandle { + /// Stop claiming new work and wait for the loop to exit. + pub async fn shutdown(self) { + let _ = self.shutdown.send(true); + let _ = self.join.await; + } +} + +/// Spawn the supervised worker. Disabled by configuration in deployments +/// that run workers separately. +pub fn spawn(state: AppState) -> WorkerHandle { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let join = tokio::spawn(run(Arc::new(state), shutdown_rx)); + WorkerHandle { + shutdown: shutdown_tx, + join, + } +} + +async fn run(state: Arc, mut shutdown: watch::Receiver) { + let lease_owner = format!("{}:{}", std::process::id(), Uuid::new_v4().simple()); + let poll = std::time::Duration::from_millis(state.config.payment.worker_poll_ms.max(50)); + loop { + if *shutdown.borrow() { + break; + } + // Recover expired leases first so restart resumes outstanding work. + if let Err(e) = repo::reclaim_expired_leases(&state.db).await { + tracing::warn!(error = %e, "payment worker: lease reclaim failed"); + } + match claim_next(&state, &lease_owner).await { + Ok(Some(command)) => { + if let Err(e) = execute_command(&state, command.id).await { + tracing::warn!(error = %e, "payment worker: command execution failed"); + } + // Keep draining due work without sleeping between commands. + continue; + } + Ok(None) => {} + Err(e) => tracing::warn!(error = %e, "payment worker: claim failed"), + } + // Recover unprocessed verified events (persistence/processing gaps). + if let Ok(events) = repo::list_unprocessed_events(&state.db, 10).await { + for event in events { + if *shutdown.borrow() { + break; + } + if let Err(e) = webhooks::process_event(&state, &event).await { + tracing::warn!(error = %e, "payment worker: event reprocessing failed"); + } + } + } + tokio::select! { + _ = tokio::time::sleep(poll) => {} + _ = shutdown.changed() => {} + } + } +} + +async fn claim_next(state: &AppState, lease_owner: &str) -> ApiResult> { + let mut tx = state.db.begin().await?; + let command = repo::claim_due_command(&mut tx, lease_owner, LEASE_SECONDS).await?; + tx.commit().await?; + Ok(command) +} + +/// Execute one command under its lease. Used by the worker loop and by the +/// inline execution path (attempt initiation, wallet refunds, compensation), +/// which claims the specific command it just enqueued. +pub fn execute_command_by_id( + state: &AppState, + command_id: Uuid, +) -> std::pin::Pin> + Send + '_>> { + Box::pin(async move { + let lease_owner = format!("inline:{}", Uuid::new_v4().simple()); + let mut tx = state.db.begin().await?; + let claimed = repo::claim_command(&mut tx, command_id, &lease_owner, LEASE_SECONDS).await?; + tx.commit().await?; + match claimed { + Some(command) => execute_command(state, command.id).await, + // Already claimed/executed elsewhere: its outcome is durable. + None => Ok(()), + } + }) +} + +pub async fn execute_command(state: &AppState, command_id: Uuid) -> ApiResult<()> { + let command = repo::get_command(&state.db, command_id).await?; + if command.state()? != CommandState::Leased { + return Ok(()); + } + let kind = command.kind()?; + let result: Result<(), AdapterError> = match kind { + CommandKind::CreateCheckout => { + let attempt_id = command + .attempt_id + .ok_or_else(|| AdapterError::Config("create command without attempt".into()))?; + let attempt = repo::get_attempt(&state.db, attempt_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + if !matches!( + attempt.state().unwrap_or(AttemptState::Failed), + AttemptState::Created | AttemptState::Pending | AttemptState::Unknown + ) { + // Settled/failed/expired attempts never re-run creation. + return finish(state, command_id, CommandState::Succeeded, None, None).await; + } + webhooks::execute_create_checkout(state, &attempt).await + } + CommandKind::Refund => { + let refund_id = command + .refund_id + .ok_or_else(|| AdapterError::Config("refund command without refund".into()))?; + let refund = repo::get_refund(&state.db, refund_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + if !matches!( + refund.state().unwrap_or(super::dto::RefundState::Failed), + super::dto::RefundState::Pending | super::dto::RefundState::Unknown + ) { + return finish(state, command_id, CommandState::Succeeded, None, None).await; + } + let receipt = repo::get_receipt(&state.db, refund.receipt_id) + .await + .map_err(|e| AdapterError::Config(e.to_string()))?; + match receipt.method().map_err(|e| AdapterError::Config(e.to_string()))? { + super::dto::PaymentAdapterKind::Wallet => { + // Internal, synchronous, atomic: no external call. + match super::refunds::apply_refund_success( + state, + refund.id, + Some(refund.provider_operation_key.as_str()), + ) + .await + { + Ok(()) => Ok(()), + Err(e) => { + super::refunds::mark_refund_failed( + state, + refund.id, + &service::bounded_error(&e), + None, + ) + .await?; + Ok(()) + } + } + } + super::dto::PaymentAdapterKind::StripeCrypto => { + webhooks::execute_stripe_refund(state, &refund).await + } + } + } + CommandKind::Reconcile => { + let payment_id = command + .payment_id + .ok_or_else(|| AdapterError::Config("reconcile command without payment".into()))?; + match webhooks::run_reconcile(state, payment_id).await { + Ok(()) => Ok(()), + Err(e) => Err(AdapterError::Provider(service::bounded_error(&e))), + } + } + }; + + match result { + Ok(()) => finish(state, command_id, CommandState::Succeeded, None, None).await, + Err(e) if e.is_unknown() => { + // A provider timeout is never a failure. Keep the operation + // unknown, retain its budget, and retry only inside the + // conservative provider idempotency window; after that, evidence + // lookup replaces blind replay. + let created_at = command.created_at; + if kind == CommandKind::Refund { + if let Some(refund_id) = command.refund_id { + super::refunds::mark_refund_unknown( + state, + refund_id, + &service::bounded_error(&e), + ) + .await?; + } + } else if kind == CommandKind::CreateCheckout { + if let Some(attempt_id) = command.attempt_id { + service::mark_attempt_unknown(state, attempt_id).await?; + } + } + if super::refunds::provider_retry_allowed(created_at) + && command.attempts_count < MAX_ATTEMPTS + { + let delay = backoff_ms(command.attempts_count); + let retry_at = chrono::Utc::now() + + chrono::Duration::milliseconds(delay as i64); + finish( + state, + command_id, + CommandState::Pending, + Some(service::bounded_error(&e).as_str()), + Some(retry_at), + ) + .await + } else { + // Beyond the retention window: preserve unknown and surface + // it; never blindly reissue an ambiguous operation. + finish( + state, + command_id, + CommandState::Failed, + Some(service::bounded_error(&e).as_str()), + None, + ) + .await + } + } + Err(e) => { + let error = service::bounded_error(&e); + if command.attempts_count < MAX_ATTEMPTS && kind == CommandKind::Reconcile { + let retry_at = chrono::Utc::now() + + chrono::Duration::milliseconds(backoff_ms(command.attempts_count) as i64); + finish( + state, + command_id, + CommandState::Pending, + Some(error.as_str()), + Some(retry_at), + ) + .await + } else { + finish(state, command_id, CommandState::Failed, Some(error.as_str()), None).await + } + } + } +} + +async fn finish( + state: &AppState, + command_id: Uuid, + status: CommandState, + error: Option<&str>, + retry_at: Option>, +) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + repo::finish_command(&mut tx, command_id, status, error, retry_at).await?; + tx.commit().await?; + Ok(()) +} + +fn backoff_ms(attempts: i32) -> u64 { + (BACKOFF_BASE_MS << attempts.min(8)).min(BACKOFF_CAP_MS) +} diff --git a/apps/api/src/modules/wallet/dto.rs b/apps/api/src/modules/wallet/dto.rs index 8d10f67..1a490f2 100644 --- a/apps/api/src/modules/wallet/dto.rs +++ b/apps/api/src/modules/wallet/dto.rs @@ -6,12 +6,16 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::models::{AccountKind, WalletRechargeStatus, WalletWithdrawalStatus}; -/// Available/frozen minor units of the caller's monetary account. +/// Available/frozen minor units of the caller's monetary account, plus the +/// safe environment capability flags wallet surfaces need. #[derive(Debug, Serialize)] pub struct WalletSummary { pub available_minor: i64, pub frozen_minor: i64, pub currency: String, + pub environment: crate::modules::payment::PaymentEnvironment, + /// Server-side environment gate: demo recharge is refused outside demo. + pub demo_recharge_enabled: bool, } /// One ledger entry mapped to the wallet's account-kind vocabulary. diff --git a/apps/api/src/modules/wallet/service.rs b/apps/api/src/modules/wallet/service.rs index e9413a2..0f1cf5e 100644 --- a/apps/api/src/modules/wallet/service.rs +++ b/apps/api/src/modules/wallet/service.rs @@ -27,7 +27,11 @@ pub async fn summary(state: &AppState, user_id: Uuid) -> ApiResult ApiResult { + if state.config.payment.environment != crate::modules::payment::PaymentEnvironment::Demo { + return Err(ApiError::Forbidden( + "simulated demo recharge is unavailable outside demo deployments".into(), + )); + } if body.amount_minor <= 0 { return Err(ApiError::BadRequest("amount_minor must be positive".into())); } @@ -210,6 +220,8 @@ fn summarize(accounts: &[CustomerAccount]) -> ApiResult { available_minor: available.balance_minor, frozen_minor: frozen.balance_minor, currency, + environment: crate::modules::payment::PaymentEnvironment::Demo, + demo_recharge_enabled: true, }) } diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs index d30a587..f154fdf 100644 --- a/apps/api/src/state.rs +++ b/apps/api/src/state.rs @@ -16,5 +16,9 @@ pub async fn build_state(config: &crate::config::Config) -> anyhow::Result anyhow::Result { let client = redis::Client::open(config.redis_url.clone())?; let redis = redis::aio::ConnectionManager::new(client).await?; - Ok(AppState { db, redis, config }) + let state = AppState { db, redis, config }; + // Demo deployments retain exactly one explicitly labeled demo wallet + // channel; no enabled external channel is created by default. + crate::modules::payment::service::ensure_demo_wallet_channel(&state).await?; + Ok(state) } diff --git a/apps/api/tests/aftersales.rs b/apps/api/tests/aftersales.rs index 749cbce..fd00f8a 100644 --- a/apps/api/tests/aftersales.rs +++ b/apps/api/tests/aftersales.rs @@ -1,8 +1,8 @@ mod common; use common::{ - checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer, - setup_sellable, spawn_app, TestApp, + checkout, client, create_shop, login_admin, make_shop_owner, mark_paid_legacy_demo, pay, + register_customer, setup_sellable, spawn_app, TestApp, }; use serial_test::serial; @@ -470,6 +470,7 @@ async fn refund_in_order_currency_creates_missing_account() { let res = client() .post(app.url("/api/orders/checkout")) .bearer_auth(&customer) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": { "recipient": "FX Recipient", @@ -486,8 +487,9 @@ async fn refund_in_order_currency_creates_missing_account() { .await .unwrap(); assert_eq!(res.status(), 201, "{:?}", res.text().await); - let order = res.json::>().await.unwrap().remove(0); - pay(&app, &customer, order["id"].as_str().unwrap()).await; + let body: serde_json::Value = res.json().await.unwrap(); + let order = body["orders"].as_array().unwrap()[0].clone(); + mark_paid_legacy_demo(&app, order["id"].as_str().unwrap()).await; let detail: serde_json::Value = client() .get(app.url(&format!("/api/orders/{}", order["id"].as_str().unwrap()))) diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index cb14d98..8f658c8 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -23,6 +23,11 @@ pub async fn spawn_state() -> vmall_api::state::AppState { jwt_secret: "test-secret".into(), port: 0, jwt_ttl_secs: 3600, + payment: vmall_api::config::PaymentConfig { + mall_origin: Some("http://localhost:3000".into()), + mobile_origin: Some("http://localhost:3001".into()), + ..Default::default() + }, }; let db = sqlx::PgPool::connect(TEST_DB_URL).await.unwrap(); sqlx::migrate!("./migrations").run(&db).await.unwrap(); @@ -299,6 +304,7 @@ pub async fn checkout(app: &TestApp, token: &str) -> Vec { let res = client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": { "recipient": "Test Recipient", @@ -315,15 +321,97 @@ pub async fn checkout(app: &TestApp, token: &str) -> Vec { .await .unwrap(); assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); - res.json::>().await.unwrap() + let body: serde_json::Value = res.json().await.unwrap(); + body["orders"].as_array().unwrap().clone() } pub async fn pay(app: &TestApp, token: &str, order_id: &str) { let res = client() - .post(app.url(&format!("/api/orders/{order_id}/pay"))) + .post(app.url("/api/payments")) + .bearer_auth(token) + .json(&serde_json::json!({ "order_ids": [order_id] })) + .send() + .await + .unwrap(); + + let payment: serde_json::Value = match res.status() { + reqwest::StatusCode::CREATED => res.json().await.unwrap(), + reqwest::StatusCode::CONFLICT => { + let status: String = sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1::uuid") + .bind(order_id) + .fetch_one(&app.db) + .await + .unwrap(); + if status == "paid" { + return; + } + panic!("resolve payment failed: {:?}", res.text().await); + } + _ => panic!("resolve payment failed: {:?}", res.text().await), + }; + + if payment["status"] == "applied" { + return; + } + + let total_minor = payment["total_minor"].as_i64().unwrap(); + let wallet = client() + .get(app.url("/api/wallet")) .bearer_auth(token) .send() .await .unwrap(); - assert_eq!(res.status(), 200, "pay: {:?}", res.text().await); + assert_eq!(wallet.status(), 200, "wallet: {:?}", wallet.text().await); + let wallet: serde_json::Value = wallet.json().await.unwrap(); + let available_minor = wallet["available_minor"].as_i64().unwrap(); + if available_minor < total_minor && payment["currency"] == wallet["currency"] { + let recharge = client() + .post(app.url("/api/wallet/recharges")) + .bearer_auth(token) + .json(&serde_json::json!({ "amount_minor": total_minor - available_minor })) + .send() + .await + .unwrap(); + assert_eq!(recharge.status(), 201, "recharge: {:?}", recharge.text().await); + } + + let methods = client() + .get(app.url(&format!("/api/payments/{}/methods", payment["id"].as_str().unwrap()))) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(methods.status(), 200, "methods: {:?}", methods.text().await); + let methods: Vec = methods.json().await.unwrap(); + let channel_id = methods + .iter() + .find(|m| m["adapter"] == "wallet") + .and_then(|m| m["id"].as_str()) + .expect("wallet channel missing"); + + let attempt = client() + .post(app.url(&format!("/api/payments/{}/attempts", payment["id"].as_str().unwrap()))) + .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) + .json(&serde_json::json!({ + "channel_id": channel_id, + "return_surface": "mall" + })) + .send() + .await + .unwrap(); + assert_eq!(attempt.status(), 200, "attempt: {:?}", attempt.text().await); + let attempt: serde_json::Value = attempt.json().await.unwrap(); + assert_eq!(attempt["status"], "settled"); +} + +pub async fn mark_paid_legacy_demo(app: &TestApp, order_id: &str) { + let res = sqlx::query( + "UPDATE orders SET status = 'paid', payment_provenance = 'legacy_demo' WHERE id = $1::uuid", + ) + .bind(order_id) + .execute(&app.db) + .await + .unwrap(); + assert_eq!(res.rows_affected(), 1); } diff --git a/apps/api/tests/coupons.rs b/apps/api/tests/coupons.rs index 98e3746..f43f4a7 100644 --- a/apps/api/tests/coupons.rs +++ b/apps/api/tests/coupons.rs @@ -68,6 +68,7 @@ async fn checkout_with( client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": { "recipient": "Test Recipient", @@ -212,7 +213,8 @@ async fn checkout_discounts_only_the_issuing_shop() { ) .await; assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); assert_eq!(orders.len(), 2); let discounted = orders @@ -337,7 +339,8 @@ async fn cancelling_a_pending_order_restores_its_coupon() { add_to_cart(&app, &token, &sku, 2).await; let res = checkout_with(&app, &token, HashMap::from([(shop_id, coupon_id.clone())])).await; assert_eq!(res.status(), 201); - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); let order_id = orders[0]["id"].as_str().unwrap().to_string(); let res = client() diff --git a/apps/api/tests/flash_sales.rs b/apps/api/tests/flash_sales.rs index aecf5be..ef3c025 100644 --- a/apps/api/tests/flash_sales.rs +++ b/apps/api/tests/flash_sales.rs @@ -68,6 +68,7 @@ async fn checkout_with( client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": { "recipient": "Test", "phone": "1", "country": "US", @@ -169,7 +170,8 @@ async fn inactive_window_falls_back_to_normal_price() { add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::new()).await; assert_eq!(res.status(), 201); - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); let item = &orders[0]["items"][0]; assert_eq!(item["unit_price_minor"], 1000, "normal price applies"); assert!(item["flash_sale_item_id"].is_null()); @@ -299,7 +301,8 @@ async fn per_customer_limit_splits_the_line() { add_to_cart(&app, &token, &sku, 3).await; let res = checkout_with(&app, &token, HashMap::new()).await; assert_eq!(res.status(), 201); - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); let items = orders[0]["items"].as_array().unwrap(); assert_eq!(items.len(), 2, "one activity line and one standard line"); let activity: Vec<&serde_json::Value> = items @@ -324,7 +327,8 @@ async fn per_customer_limit_splits_the_line() { // The allowance is spent, so a second checkout is standard-priced. add_to_cart(&app, &token, &sku, 1).await; let res = checkout_with(&app, &token, HashMap::new()).await; - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null()); assert_eq!( activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, @@ -388,7 +392,8 @@ async fn cancel_restores_reserved_stock_and_coupon() { add_to_cart(&app, &token, &sku_b, 1).await; let res = checkout_with(&app, &token, HashMap::from([(shop_b, coupon.clone())])).await; assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); - let orders: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let orders = body["orders"].as_array().unwrap().clone(); assert_eq!(orders.len(), 2); let (reserved_after, sold_after) = flash_item_state(&app, &item_id).await; @@ -398,14 +403,18 @@ async fn cancel_restores_reserved_stock_and_coupon() { for order in &orders { let res = client() .post(app.url(&format!( - "/api/orders/{}/cancel", - order["id"].as_str().unwrap() + "/api/payments/{}/cancel", + body["payment_id"].as_str().unwrap() ))) .bearer_auth(&token) .send() .await .unwrap(); - assert_eq!(res.status(), 200); + assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await); + // The first cancel covers the whole payment group; repeat + // attempts 409 with authoritative state. One successful cancel + // restores every member's reserved stock, sold count and coupon. + break; } let (reserved, sold) = flash_item_state(&app, &item_id).await; diff --git a/apps/api/tests/group_buying.rs b/apps/api/tests/group_buying.rs index 4187160..8757dc5 100644 --- a/apps/api/tests/group_buying.rs +++ b/apps/api/tests/group_buying.rs @@ -71,6 +71,7 @@ async fn checkout_group( client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": address(), "currency": "USD", @@ -96,6 +97,7 @@ async fn checkout_with_coupon( client() .post(app.url("/api/orders/checkout")) .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": address(), "currency": "USD", @@ -108,9 +110,78 @@ async fn checkout_with_coupon( } async fn pay(app: &TestApp, token: &str, order_id: &str) -> reqwest::StatusCode { - client() - .post(app.url(&format!("/api/orders/{order_id}/pay"))) + let res = client() + .post(app.url("/api/payments")) .bearer_auth(token) + .json(&serde_json::json!({ "order_ids": [order_id] })) + .send() + .await + .unwrap(); + let status = res.status(); + let payment: serde_json::Value = match status { + reqwest::StatusCode::CREATED => res.json().await.unwrap(), + reqwest::StatusCode::CONFLICT => { + let order_status: String = sqlx::query_scalar( + "SELECT status::text FROM orders WHERE id = $1::uuid", + ) + .bind(order_id) + .fetch_one(&app.db) + .await + .unwrap(); + if order_status == "paid" { + return reqwest::StatusCode::OK; + } + return status; + } + _ => return status, + }; + if payment["status"] == "applied" { + return reqwest::StatusCode::OK; + } + + let total_minor = payment["total_minor"].as_i64().unwrap(); + let wallet = client() + .get(app.url("/api/wallet")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(wallet.status(), 200, "wallet: {:?}", wallet.text().await); + let wallet: serde_json::Value = wallet.json().await.unwrap(); + let available_minor = wallet["available_minor"].as_i64().unwrap(); + if available_minor < total_minor && payment["currency"] == wallet["currency"] { + let recharge = client() + .post(app.url("/api/wallet/recharges")) + .bearer_auth(token) + .json(&serde_json::json!({ "amount_minor": total_minor - available_minor })) + .send() + .await + .unwrap(); + assert_eq!(recharge.status(), 201, "recharge: {:?}", recharge.text().await); + } + + let payment_id = payment["id"].as_str().unwrap(); + let methods = client() + .get(app.url(&format!("/api/payments/{payment_id}/methods"))) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(methods.status(), 200, "methods: {:?}", methods.text().await); + let methods: Vec = methods.json().await.unwrap(); + let channel_id = methods + .iter() + .find(|m| m["adapter"] == "wallet") + .and_then(|m| m["id"].as_str()) + .expect("wallet channel missing"); + client() + .post(app.url(&format!("/api/payments/{payment_id}/attempts"))) + .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) + .json(&serde_json::json!({ + "channel_id": channel_id, + "return_surface": "mall" + })) .send() .await .unwrap() @@ -153,7 +224,7 @@ async fn place( ) -> String { let res = checkout_group(app, token, sku, activity, group_id).await; assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); - res.json::().await.unwrap()[0]["id"] + res.json::().await.unwrap()["orders"][0]["id"] .as_str() .unwrap() .to_string() @@ -210,7 +281,8 @@ async fn open_then_join_completes_a_group() { add_to_cart(&app, &token_a, &sku, 1).await; let res = checkout_group(&app, &token_a, &sku, &activity, None).await; assert_eq!(res.status(), 201, "open: {:?}", res.text().await); - let opened: Vec = res.json().await.unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + let opened = body["orders"].as_array().unwrap().clone(); let order_a = opened[0]["id"].as_str().unwrap().to_string(); let group_id = opened[0]["group_id"].as_str().unwrap().to_string(); assert_eq!(opened[0]["total_minor"], 700, "group price is snapshotted"); diff --git a/apps/api/tests/messaging.rs b/apps/api/tests/messaging.rs index d3ae3bd..2a473d7 100644 --- a/apps/api/tests/messaging.rs +++ b/apps/api/tests/messaging.rs @@ -177,8 +177,9 @@ async fn payment_emits_one_order_paid_message() { // A retried payment conflicts and cannot duplicate the message. let res = client() - .post(app.url(&format!("/api/orders/{order_id}/pay"))) + .post(app.url("/api/payments")) .bearer_auth(&shop.customer) + .json(&serde_json::json!({ "order_ids": [order_id] })) .send() .await .unwrap(); diff --git a/apps/api/tests/order_service.rs b/apps/api/tests/order_service.rs index f5f8cb5..f8f5198 100644 --- a/apps/api/tests/order_service.rs +++ b/apps/api/tests/order_service.rs @@ -75,6 +75,7 @@ async fn checkout_rejects_empty_cart() { let err = order::checkout( &state, user_id, + &uuid::Uuid::new_v4().to_string(), address(), "USD".into(), Default::default(), @@ -116,6 +117,7 @@ async fn checkout_rejects_insufficient_stock() { let err = order::checkout( &state, user_id, + &uuid::Uuid::new_v4().to_string(), address(), "USD".into(), Default::default(), @@ -139,9 +141,10 @@ async fn checkout_splits_per_shop() { cart::service::add_item(&state, user_id, sku_b, 1) .await .unwrap(); - let orders = order::checkout( + let body = order::checkout( &state, user_id, + &uuid::Uuid::new_v4().to_string(), address(), "USD".into(), Default::default(), @@ -149,8 +152,12 @@ async fn checkout_splits_per_shop() { ) .await .unwrap(); + let orders = body["orders"].as_array().unwrap(); assert_eq!(orders.len(), 2); - let totals: Vec = orders.iter().map(|o| o.order.total_minor).collect(); + let totals: Vec = orders + .iter() + .map(|o| o["total_minor"].as_i64().unwrap()) + .collect(); assert!(totals.contains(&2000) && totals.contains(&2000)); } @@ -163,9 +170,10 @@ async fn pay_and_cancel_require_pending_payment() { cart::service::add_item(&state, user_id, sku_id, 1) .await .unwrap(); - let orders = order::checkout( + let body = order::checkout( &state, user_id, + &uuid::Uuid::new_v4().to_string(), address(), "USD".into(), Default::default(), @@ -173,9 +181,50 @@ async fn pay_and_cancel_require_pending_payment() { ) .await .unwrap(); - let id = orders[0].order.id; - order::service::pay(&state, user_id, id).await.unwrap(); - let err = order::service::pay(&state, user_id, id).await.unwrap_err(); + let orders = body["orders"].as_array().unwrap(); + let id = uuid::Uuid::parse_str(orders[0]["id"].as_str().unwrap()).unwrap(); + vmall_api::modules::wallet::service::recharge( + &state, + user_id, + vmall_api::modules::wallet::dto::RechargeBody { amount_minor: 500 }, + ) + .await + .unwrap(); + let payment = vmall_api::modules::payment::service::resolve_payment(&state, user_id, &[id]) + .await + .unwrap(); + let channel_id = sqlx::query_scalar::<_, uuid::Uuid>( + "SELECT id FROM payment_channels WHERE adapter = 'wallet' LIMIT 1", + ) + .fetch_one(&state.db) + .await + .unwrap(); + let body = vmall_api::modules::payment::dto::AttemptBody { + channel_id, + return_surface: None, + }; + let attempt = vmall_api::modules::payment::service::initiate_attempt( + &state, + user_id, + payment.id, + &body, + &uuid::Uuid::new_v4().to_string(), + ) + .await + .unwrap(); + assert_eq!( + attempt.status, + vmall_api::modules::payment::dto::AttemptState::Settled + ); + let err = vmall_api::modules::payment::service::initiate_attempt( + &state, + user_id, + payment.id, + &body, + &uuid::Uuid::new_v4().to_string(), + ) + .await + .unwrap_err(); assert!(matches!(err, ApiError::Conflict(_))); let err = order::service::cancel(&state, user_id, id) .await diff --git a/apps/api/tests/orders.rs b/apps/api/tests/orders.rs index 3100b86..969e428 100644 --- a/apps/api/tests/orders.rs +++ b/apps/api/tests/orders.rs @@ -97,6 +97,7 @@ async fn checkout_insufficient_stock_rolls_back() { let res = client() .post(app.url("/api/orders/checkout")) .bearer_auth(&buyer) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) .json(&serde_json::json!({ "shipping_address": { "recipient": "R", "phone": "1", "country": "US", "region": "CA", diff --git a/apps/api/tests/payments.rs b/apps/api/tests/payments.rs new file mode 100644 index 0000000..88df064 --- /dev/null +++ b/apps/api/tests/payments.rs @@ -0,0 +1,746 @@ +mod common; + +use common::{add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp}; +use serial_test::serial; +use uuid::Uuid; + +fn address() -> serde_json::Value { + serde_json::json!({ + "recipient": "Payment Test", "phone": "1", "country": "US", + "region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131" + }) +} + +async fn checkout_payment(app: &TestApp, token: &str) -> serde_json::Value { + let res = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(token) + .header("Idempotency-Key", uuid::Uuid::new_v4().to_string()) + .json(&serde_json::json!({ + "shipping_address": address(), + "currency": "USD", + "coupon_by_shop": {}, + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); + res.json().await.unwrap() +} + +async fn recharge(app: &TestApp, token: &str, amount_minor: i64) { + let res = client() + .post(app.url("/api/wallet/recharges")) + .bearer_auth(token) + .json(&serde_json::json!({ "amount_minor": amount_minor })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "recharge: {:?}", res.text().await); +} + +async fn wallet_balance(app: &TestApp, token: &str) -> i64 { + let res = client() + .get(app.url("/api/wallet")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + res.json::().await.unwrap()["available_minor"] + .as_i64() + .unwrap() +} + +async fn methods(app: &TestApp, token: &str, payment_id: &str) -> Vec { + let res = client() + .get(app.url(&format!("/api/payments/{payment_id}/methods"))) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "methods: {:?}", res.text().await); + res.json().await.unwrap() +} + +fn wallet_method(methods: &[serde_json::Value]) -> serde_json::Value { + methods + .iter() + .find(|m| m["adapter"] == "wallet") + .cloned() + .expect("wallet method must be available") +} + +async fn attempt( + app: &TestApp, + token: &str, + payment_id: &str, + channel_id: &str, + key: &str, +) -> reqwest::Response { + client() + .post(app.url(&format!("/api/payments/{payment_id}/attempts"))) + .bearer_auth(token) + .header("Idempotency-Key", key) + .json(&serde_json::json!({ "channel_id": channel_id, "return_surface": "mall" })) + .send() + .await + .unwrap() +} + +async fn order_status(app: &TestApp, order_id: &str) -> String { + sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1") + .bind(Uuid::parse_str(order_id).unwrap()) + .fetch_one(&app.db) + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn checkout_returns_one_payment_for_all_shop_orders() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-split-a", 1000, 5).await; + let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-split-b", 2000, 5).await; + let (buyer, _) = register_customer(&app, "pm-split").await; + add_to_cart(&app, &buyer, &sku_a, 2).await; + add_to_cart(&app, &buyer, &sku_b, 1).await; + + let result = checkout_payment(&app, &buyer).await; + assert!(result.is_object(), "checkout must return a payment wrapper"); + let payment_id = result["payment_id"].as_str().unwrap(); + let orders = result["orders"].as_array().unwrap(); + assert_eq!(orders.len(), 2); + + let res = client() + .get(app.url(&format!("/api/payments/{payment_id}"))) + .bearer_auth(&buyer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "detail: {:?}", res.text().await); + let payment: serde_json::Value = res.json().await.unwrap(); + assert_eq!(payment["currency"], "USD"); + assert_eq!(payment["total_minor"], 4000); + assert_eq!(payment["orders"].as_array().unwrap().len(), 2); + assert_eq!(payment["status"], "unapplied"); + + let (other, _) = register_customer(&app, "pm-other").await; + let res = client() + .get(app.url(&format!("/api/payments/{payment_id}"))) + .bearer_auth(&other) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); +} + +#[tokio::test] +#[serial] +async fn wallet_payment_applies_every_shop_order_and_debits_once() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-wallet-a", 1000, 5).await; + let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-wallet-b", 2000, 5).await; + let (buyer, _) = register_customer(&app, "pm-wallet").await; + add_to_cart(&app, &buyer, &sku_a, 1).await; + add_to_cart(&app, &buyer, &sku_b, 1).await; + recharge(&app, &buyer, 4000).await; + + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let order_ids: Vec = checkout["orders"] + .as_array() + .unwrap() + .iter() + .map(|o| o["id"].as_str().unwrap().to_string()) + .collect(); + let methods = methods(&app, &buyer, payment_id).await; + let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string(); + + let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-wallet-1").await; + assert_eq!(res.status(), 200, "pay: {:?}", res.text().await); + let attempt: serde_json::Value = res.json().await.unwrap(); + assert_eq!(attempt["status"], "settled"); + assert_eq!(attempt["payment"]["status"], "applied"); + + assert_eq!(wallet_balance(&app, &buyer).await, 1000); + for order_id in &order_ids { + assert_eq!(order_status(&app, order_id).await, "paid"); + } + let entries: serde_json::Value = client() + .get(app.url("/api/wallet/entries")) + .bearer_auth(&buyer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let payment_debits = entries["items"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["reason"] == "payment_wallet_debit") + .count(); + assert_eq!(payment_debits, 1); +} + +#[tokio::test] +#[serial] +async fn insufficient_wallet_payment_rolls_back_all_orders() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-short-a", 1000, 5).await; + let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-short-b", 2000, 5).await; + let (buyer, _) = register_customer(&app, "pm-short").await; + add_to_cart(&app, &buyer, &sku_a, 1).await; + add_to_cart(&app, &buyer, &sku_b, 1).await; + recharge(&app, &buyer, 2999).await; + + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let order_ids: Vec = checkout["orders"] + .as_array() + .unwrap() + .iter() + .map(|o| o["id"].as_str().unwrap().to_string()) + .collect(); + let methods = methods(&app, &buyer, payment_id).await; + let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string(); + + let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-short-1").await; + assert_eq!(res.status(), 409); + assert_eq!(wallet_balance(&app, &buyer).await, 2999); + for order_id in &order_ids { + assert_eq!(order_status(&app, order_id).await, "pending_payment"); + } +} + +#[tokio::test] +#[serial] +async fn payment_attempt_idempotency_reuses_only_identical_requests() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem", 1000, 5).await; + let (buyer, _) = register_customer(&app, "pm-idem").await; + add_to_cart(&app, &buyer, &sku, 1).await; + recharge(&app, &buyer, 1000).await; + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let methods = methods(&app, &buyer, payment_id).await; + let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string(); + + let first = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await; + assert_eq!(first.status(), 200); + let first: serde_json::Value = first.json().await.unwrap(); + let second = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await; + assert_eq!(second.status(), 200); + let second: serde_json::Value = second.json().await.unwrap(); + assert_eq!(first["id"], second["id"]); + assert_eq!(wallet_balance(&app, &buyer).await, 0); + + let res = client() + .post(app.url(&format!("/api/payments/{payment_id}/attempts"))) + .bearer_auth(&buyer) + .header("Idempotency-Key", "pm-idem-1") + .json(&serde_json::json!({ "channel_id": Uuid::new_v4() })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409); +} + +#[tokio::test] +#[serial] +async fn admin_channel_revisions_are_audited_and_redacted() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let key_ref = format!("VMALL_PAYMENT_TEST_{}", Uuid::new_v4().simple()); + let body = serde_json::json!({ + "adapter": "stripe_crypto", + "label": {"en": "Crypto", "zh": "加密支付"}, + "display_order": 20, + "enabled": false, + "supported_currencies": ["USD"], + "min_amount_minor": 100, + "max_amount_minor": 1000000, + "provider_account_id": "acct_test", + "secret_key_ref": key_ref, + "webhook_secret_ref": format!("{key_ref}_WEBHOOK") + }); + + let res = client() + .post(app.url("/api/admin/payments/channels")) + .bearer_auth(&admin) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "create: {:?}", res.text().await); + let channel: serde_json::Value = res.json().await.unwrap(); + assert_eq!(channel["adapter"], "stripe_crypto"); + assert_eq!(channel["revision"], 1); + assert!(channel.get("secret_key").is_none()); + assert!(channel.get("secret_key_ref").is_some()); + assert!(channel.get("webhook_secret_ref").is_some()); + let channel_id = channel["id"].as_str().unwrap(); + + let mut update = body.clone(); + update["enabled"] = serde_json::json!(false); + update["display_order"] = serde_json::json!(21); + update["expected_revision"] = serde_json::json!(1); + let res = client() + .put(app.url(&format!("/api/admin/payments/channels/{channel_id}"))) + .bearer_auth(&admin) + .json(&update) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "update: {:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["revision"], 2); + + update["expected_revision"] = serde_json::json!(1); + let res = client() + .put(app.url(&format!("/api/admin/payments/channels/{channel_id}"))) + .bearer_auth(&admin) + .json(&update) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409); + + let res = client() + .get(app.url(&format!("/api/admin/payments/channels/{channel_id}/audit"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let audit: serde_json::Value = res.json().await.unwrap(); + assert_eq!(audit.as_array().unwrap().len(), 2); + assert!(audit.to_string().contains(&key_ref)); + assert!(!audit.to_string().contains("sk_test_")); +} + +#[tokio::test] +#[serial] +async fn forged_stripe_webhook_is_rejected_without_payment_effects() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-hook", 1000, 5).await; + let (buyer, _) = register_customer(&app, "pm-hook").await; + add_to_cart(&app, &buyer, &sku, 1).await; + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let order_id = checkout["orders"][0]["id"].as_str().unwrap(); + + let res = client() + .post(app.url(&format!( + "/api/payments/webhooks/stripe/{}", + Uuid::new_v4() + ))) + .header("stripe-signature", "t=1,v1=forged") + .body(format!(r#"{{"id":"evt_forged","type":"payment_intent.succeeded","data":{{"object":{{"metadata":{{"payment_id":"{payment_id}"}}}}}}}}"#)) + .send() + .await + .unwrap(); + assert!(res.status().is_client_error()); + assert_eq!(order_status(&app, order_id).await, "pending_payment"); +} + +#[tokio::test] +#[serial] +async fn checkout_idempotency_replays_and_rejects_changed_payload() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem-checkout", 1000, 5).await; + let (buyer, _) = register_customer(&app, "pm-idem-checkout").await; + add_to_cart(&app, &buyer, &sku, 1).await; + + let key = Uuid::new_v4().to_string(); + let first = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(&buyer) + .header("Idempotency-Key", &key) + .json(&serde_json::json!({ + "shipping_address": address(), + "currency": "USD", + "coupon_by_shop": {}, + })) + .send() + .await + .unwrap(); + assert_eq!(first.status(), 201, "checkout: {:?}", first.text().await); + let first: serde_json::Value = first.json().await.unwrap(); + let payment_id = first["payment_id"].as_str().unwrap(); + let first_order_ids: Vec = first["orders"] + .as_array() + .unwrap() + .iter() + .map(|o| o["id"].as_str().unwrap().to_string()) + .collect(); + + // Replay with the same key and payload returns the original result. + let replay = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(&buyer) + .header("Idempotency-Key", &key) + .json(&serde_json::json!({ + "shipping_address": address(), + "currency": "USD", + "coupon_by_shop": {}, + })) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), 201, "replay: {:?}", replay.text().await); + let replay: serde_json::Value = replay.json().await.unwrap(); + assert_eq!(replay["payment_id"], payment_id); + let replay_order_ids: Vec = replay["orders"] + .as_array() + .unwrap() + .iter() + .map(|o| o["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(first_order_ids, replay_order_ids); + + // The cart was cleared once; replaying did not decrement stock again. + let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid") + .bind(&sku) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(stock, 4); + + // Reusing the key with a changed payload is rejected. + let conflict = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(&buyer) + .header("Idempotency-Key", &key) + .json(&serde_json::json!({ + "shipping_address": address(), + "currency": "JPY", + "coupon_by_shop": {}, + })) + .send() + .await + .unwrap(); + assert_eq!(conflict.status(), 409); + + // A checkout without the required idempotency key is rejected. + let missing = client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(&buyer) + .json(&serde_json::json!({ + "shipping_address": address(), + "currency": "USD", + "coupon_by_shop": {}, + })) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), 400); +} + +#[tokio::test] +#[serial] +async fn cancelling_one_member_cancels_the_whole_unpaid_payment_group() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-cancel-a", 1000, 5).await; + let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-cancel-b", 2000, 5).await; + let (buyer, _) = register_customer(&app, "pm-cancel").await; + add_to_cart(&app, &buyer, &sku_a, 1).await; + add_to_cart(&app, &buyer, &sku_b, 1).await; + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let order_ids: Vec = checkout["orders"] + .as_array() + .unwrap() + .iter() + .map(|o| o["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(order_ids.len(), 2); + + // Cancelling any single member cancels the entire group atomically. + let res = client() + .post(app.url(&format!("/api/orders/{}/cancel", order_ids[0]))) + .bearer_auth(&buyer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await); + + for order_id in &order_ids { + assert_eq!(order_status(&app, order_id).await, "cancelled"); + } + + // Stock is restored exactly once for both members. + let stock_a: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid") + .bind(&sku_a) + .fetch_one(&app.db) + .await + .unwrap(); + let stock_b: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid") + .bind(&sku_b) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(stock_a, 5); + assert_eq!(stock_b, 5); + + // The payment detail now reports the closed group. + let res = client() + .get(app.url(&format!("/api/payments/{payment_id}"))) + .bearer_auth(&buyer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let payment: serde_json::Value = res.json().await.unwrap(); + assert_eq!(payment["status"], "closed"); +} + +#[tokio::test] +#[serial] +async fn wallet_aftersale_refund_completes_on_original_method() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, shop_id, _, sku) = setup_sellable(&app, &admin, "pm-refund", 1000, 5).await; + let (buyer, user_id) = register_customer(&app, "pm-refund").await; + add_to_cart(&app, &buyer, &sku, 1).await; + recharge(&app, &buyer, 1000).await; + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let order = checkout["orders"][0].clone(); + let order_id = order["id"].as_str().unwrap(); + let methods = methods(&app, &buyer, payment_id).await; + let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string(); + let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-refund-1").await; + assert_eq!(res.status(), 200, "pay: {:?}", res.text().await); + assert_eq!(wallet_balance(&app, &buyer).await, 0); + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/orders/{order_id}"))) + .bearer_auth(&buyer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap(); + + let apply = client() + .post(app.url("/api/aftersales")) + .bearer_auth(&buyer) + .json(&serde_json::json!({ + "order_item_id": item_id, + "kind": "refund_only", + "reason": {"en": "not as described", "zh": "与描述不符"}, + "amount_minor": 1000, + "evidence": ["https://example.com/evidence.png"] + })) + .send() + .await + .unwrap(); + assert_eq!(apply.status(), 201, "apply: {:?}", apply.text().await); + let aftersale_id = apply.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // The merchant token for this shop. + let admin2 = login_admin(&app).await; + let owner_token = common::make_shop_owner(&app, &admin2, &shop_id).await; + + let approve = client() + .post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/approve"))) + .bearer_auth(&owner_token) + .send() + .await + .unwrap(); + assert_eq!(approve.status(), 200, "approve: {:?}", approve.text().await); + + let refund = client() + .post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/refund"))) + .bearer_auth(&owner_token) + .send() + .await + .unwrap(); + assert_eq!(refund.status(), 200, "refund: {:?}", refund.text().await); + let refund_body: serde_json::Value = refund.json().await.unwrap(); + // Wallet refunds complete synchronously on the original method. + assert_eq!(refund_body["status"], "refunded"); + assert_eq!(wallet_balance(&app, &buyer).await, 1000); + + let refund_total: i64 = sqlx::query_scalar( + "SELECT refund_total_minor FROM orders WHERE id = $1::uuid", + ) + .bind(order_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(refund_total, 1000); + + let entries: serde_json::Value = client() + .get(app.url("/api/wallet/entries")) + .bearer_auth(&buyer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let refund_credits = entries["items"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["reason"] == "aftersale_refund") + .count(); + assert_eq!(refund_credits, 1); + let _ = user_id; +} + +#[tokio::test] +#[serial] +async fn admin_operations_list_detail_and_reconcile_are_exposed() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-ops", 1000, 5).await; + let (buyer, _) = register_customer(&app, "pm-ops").await; + add_to_cart(&app, &buyer, &sku, 1).await; + recharge(&app, &buyer, 1000).await; + let checkout = checkout_payment(&app, &buyer).await; + let payment_id = checkout["payment_id"].as_str().unwrap(); + let methods = methods(&app, &buyer, payment_id).await; + let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string(); + let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-ops-1").await; + assert_eq!(res.status(), 200, "pay: {:?}", res.text().await); + + // Paginated operations listing discriminates payment rows. + let res = client() + .get(app.url("/api/admin/payments")) + .query(&[("kind", "payment"), ("per_page", "50")]) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "ops: {:?}", res.text().await); + let ops: serde_json::Value = res.json().await.unwrap(); + let found = ops["items"] + .as_array() + .unwrap() + .iter() + .any(|row| row["payment_id"] == payment_id && row["kind"] == "payment"); + assert!(found, "expected a payment operation row for {payment_id}"); + + // Admin detail matches the customer detail shape plus channel audit. + let res = client() + .get(app.url(&format!("/api/admin/payments/{payment_id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "admin detail: {:?}", res.text().await); + let detail: serde_json::Value = res.json().await.unwrap(); + assert_eq!(detail["status"], "applied"); + assert!(detail.get("audit").is_some()); + + // Reconcile queues a durable evidence-fetch command without collection. + let res = client() + .post(app.url(&format!("/api/admin/payments/{payment_id}/reconcile"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "reconcile: {:?}", res.text().await); + let command: serde_json::Value = res.json().await.unwrap(); + assert_eq!(command["payment_id"], payment_id); + assert!(matches!( + command["status"].as_str().unwrap(), + "queued" | "running" | "succeeded" | "failed" + )); + + // Merchants cannot reach platform recovery endpoints. + let owner = common::make_shop_owner( + &app, + &admin, + &detail["orders"][0]["shop_id"].as_str().unwrap(), + ) + .await; + let res = client() + .get(app.url("/api/admin/payments")) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); +} + +#[tokio::test] +#[serial] +async fn demo_recharge_is_refused_outside_demo_environment() { + // A sandbox-configured API refuses the simulated recharge server-side. + let config = vmall_api::config::Config { + database_url: common::TEST_DB_URL.into(), + redis_url: common::TEST_REDIS_URL.into(), + jwt_secret: "test-secret".into(), + port: 0, + jwt_ttl_secs: 3600, + payment: vmall_api::config::PaymentConfig { + environment: vmall_api::modules::payment::PaymentEnvironment::Sandbox, + ..Default::default() + }, + }; + let db = sqlx::PgPool::connect(common::TEST_DB_URL).await.unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + let state = vmall_api::state::assemble(config, db).await.unwrap(); + vmall_api::seed::ensure_platform_admin(&state).await.unwrap(); + let app = vmall_api::build_router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base = format!("http://127.0.0.1:{port}"); + + let email = format!("pm-sandbox-{}@test.local", uuid::Uuid::new_v4()); + let res = client() + .post(format!("{base}/api/auth/register")) + .json(&serde_json::json!({ + "email": email, + "password": "password123", + "display_name": "pm-sandbox", + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201); + let token = res.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + + let res = client() + .post(format!("{base}/api/wallet/recharges")) + .bearer_auth(&token) + .json(&serde_json::json!({ "amount_minor": 5000 })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403); + let balance = client() + .get(format!("{base}/api/wallet")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(balance["demo_recharge_enabled"], false); + assert_eq!(balance["environment"], "sandbox"); +} diff --git a/apps/mall/locales/checkout.ts b/apps/mall/locales/checkout.ts index c7ff6af..310e53f 100644 --- a/apps/mall/locales/checkout.ts +++ b/apps/mall/locales/checkout.ts @@ -37,6 +37,9 @@ export default { noPendingOrders: "There are no orders waiting for payment.", backHome: "Back to home", payError: "Unable to complete payment.", + cancelPayment: "Cancel payment", + cancelError: "This payment cannot be cancelled while it is being confirmed.", + groupCancelWarning: "Cancelling this payment cancels every unpaid order in this payment group.", successTitle: "Payment successful", successMessage: "Your order has been paid successfully.", viewOrders: "View orders", @@ -81,9 +84,12 @@ export default { noPendingOrders: "暂无待支付订单。", backHome: "返回首页", payError: "支付失败,请稍后重试。", + cancelPayment: "取消支付", + cancelError: "支付确认期间不能取消,请稍后再试。", + paymentPending: "支付仍在确认中,请保持页面打开或稍后回来。", successTitle: "订单支付成功", successMessage: "您的订单已支付成功。", - viewOrders: "查看订单", + groupCancelWarning: "取消本次支付会同时取消支付组内所有未付款订单。", continueShopping: "继续购物", }, }, diff --git a/apps/mall/locales/user.ts b/apps/mall/locales/user.ts index 9902691..171bf4e 100644 --- a/apps/mall/locales/user.ts +++ b/apps/mall/locales/user.ts @@ -64,7 +64,8 @@ export default { returnShipping: "Return shipping", submitReturnTracking: "Submit tracking", aftersaleRefunded: "Refund completed", - aftersaleRefundResult: "Refunded amount", + aftersaleRefundPending: "Original-method refund pending", + aftersaleRefundFailed: "Original-method refund failed; retry is being reviewed", aftersaleCarrier: "Carrier", aftersaleTrackingNo: "Tracking number", aftersaleReasonHint: "Enter the same reason in both languages", @@ -83,8 +84,9 @@ export default { aftersaleStatus_rejected: "Rejected", aftersaleStatus_buyer_shipping: "Buyer shipping", aftersaleStatus_merchant_confirmed: "Merchant confirmed", + aftersaleStatus_refund_pending: "Refund pending", + aftersaleStatus_refund_failed: "Refund failed", aftersaleStatus_refunded: "Refunded", - aftersaleStatus_cancelled: "Cancelled", recentOrders: "Recent orders", favoriteProducts: "Favorite products", viewAll: "View all", @@ -229,7 +231,8 @@ export default { returnShipping: "退货物流", submitReturnTracking: "提交物流", aftersaleRefunded: "退款已完成", - aftersaleRefundResult: "退款金额", + aftersaleRefundPending: "原路退款确认中", + aftersaleRefundFailed: "原路退款失败,正在审核重试", aftersaleCarrier: "承运商", aftersaleTrackingNo: "物流单号", aftersaleReasonHint: "中英文输入相同内容即可", @@ -248,8 +251,9 @@ export default { aftersaleStatus_rejected: "已拒绝", aftersaleStatus_buyer_shipping: "买家寄回中", aftersaleStatus_merchant_confirmed: "商家已收货", + aftersaleStatus_refund_pending: "退款处理中", + aftersaleStatus_refund_failed: "退款失败", aftersaleStatus_refunded: "已退款", - aftersaleStatus_cancelled: "已取消", recentOrders: "最近订单", favoriteProducts: "收藏的宝贝", viewAll: "查看全部", diff --git a/apps/mall/locales/wallet.ts b/apps/mall/locales/wallet.ts index edf0895..3f01761 100644 --- a/apps/mall/locales/wallet.ts +++ b/apps/mall/locales/wallet.ts @@ -19,7 +19,7 @@ export default { rechargeDemoBadge: "Simulated · demo", rechargeDemoNote: "This is a demo recharge: no payment provider is contacted and no real money moves.", - rechargeAmount: "Recharge amount ({currency})", + rechargeUnavailable: "Demo recharge is unavailable in this deployment environment.", rechargeAmountHint: "Enter a major-unit amount in {currency}; it is credited to your available balance immediately.", rechargeSubmit: "Simulate recharge", @@ -87,7 +87,7 @@ export default { rechargeDemoBadge: "模拟 · 演示", rechargeDemoNote: "这是模拟充值:不会接入任何支付渠道,也不会发生真实资金流转。", rechargeAmount: "充值金额({currency})", - rechargeAmountHint: "请输入 {currency} 主单位金额,将立即计入可用余额。", + rechargeUnavailable: "当前部署环境不支持模拟充值。", rechargeSubmit: "模拟充值", rechargeSuccess: "模拟充值已到账:{amount}。", rechargeFailed: "充值失败,请重试。", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index b8cdcc6..b91944e 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -19,9 +19,11 @@ import type { AuthTokens, Cart, CartItem, + CheckoutResult, Coupon, CouponTemplate, CouponTemplateInput, + CreatePaymentAttemptBody, Favorite, FavoriteListQuery, FavoriteProductSummary, @@ -54,8 +56,21 @@ import type { MessageKind, MessageListQuery, NextMemberLevel, + NextAction, Order, Paged, + Payment, + PaymentAdapter, + PaymentAttempt, + PaymentAttemptResult, + PaymentAttemptStatus, + PaymentEnvironment, + PaymentException, + PaymentOrder, + PaymentReceipt, + PaymentStatus, + EligibleMethod, + RefundOperation, Product, PublicFlashSaleSession, RedeemPointsBody, @@ -101,6 +116,43 @@ import { storeById, } from "./data"; +/** One order's immutable membership in a mock payment. */ +interface MockPaymentMember { + order_id: string; + amount_minor: number; +} + +/** A mock attempt plus the idempotency bookkeeping behind it. */ +interface MockPaymentAttempt extends PaymentAttempt { + idempotency_key: string; + request_hash: string; + /** Deterministic pending-external progress: settles on the third status read. */ + poll_count: number; + return_surface: "mall" | "mobile"; +} + +/** Fixed-data payment unit: deterministic wallet success and pending external. */ +interface MockPayment { + id: string; + user_id: string; + status: PaymentStatus; + currency: string; + total_minor: number; + environment: PaymentEnvironment; + members: MockPaymentMember[]; + attempts: MockPaymentAttempt[]; + receipts: PaymentReceipt[]; + refunds: RefundOperation[]; + exceptions: PaymentException[]; + created_at: string; +} + +interface MockCheckoutRequest { + key: string; + hash: string; + payment_id: string; +} + interface MockState { token: string | null; cart: CartItem[]; @@ -131,6 +183,12 @@ interface MockState { growthLogs: GrowthLogEntry[]; /** Buyer system messages; soft deletion is a mock-only marker. */ messages: MockMessage[]; + /** Payment units with their order membership and attempts. */ + payments: MockPayment[]; + /** Owner-scoped checkout idempotency records, replayed on the same key. */ + checkoutRequests: MockCheckoutRequest[]; + /** Aftersale refund operations, original-method progress shown to the buyer. */ + refundOperations: RefundOperation[]; addressSeq: number; favoriteSeq: number; orderSeq: number; @@ -144,6 +202,7 @@ interface MockState { merchantApplicationSeq: number; growthLogSeq: number; messageSeq: number; + paymentSeq: number; } /** A message plus the soft-delete marker the shared contract never exposes. */ @@ -151,8 +210,9 @@ interface MockMessage extends Message { deleted_at: string | null; } -// v9: membership growth logs and buyer messages joined the persisted rollback state. -const STORAGE_KEY = "vmall.mock.state.v9"; +// v10: payment units, checkout idempotency and refund operations joined the +// persisted rollback state. +const STORAGE_KEY = "vmall.mock.state.v10"; type PersistedState = Pick< MockState, @@ -184,6 +244,10 @@ type PersistedState = Pick< | "messages" | "growthLogSeq" | "messageSeq" + | "payments" + | "checkoutRequests" + | "refundOperations" + | "paymentSeq" >; // Load cart/order session state persisted by a previous page load (client only). @@ -213,6 +277,8 @@ function loadPersisted(): PersistedState | null { return null; if (!Array.isArray(p.growthLogs) || typeof p.growthLogSeq !== "number") return null; if (!Array.isArray(p.messages) || typeof p.messageSeq !== "number") return null; + if (!Array.isArray(p.payments) || !Array.isArray(p.checkoutRequests)) return null; + if (!Array.isArray(p.refundOperations) || typeof p.paymentSeq !== "number") return null; return p as PersistedState; } catch { return null; @@ -425,7 +491,9 @@ function seedAftersales(orders: Order[]): Aftersale[] { const pending = item("o1", "o1-it1"); const approved = item("o1", "o1-it2"); const refunded = item("o2", "o2-it1"); + const refundPending = item("o2", "o2-it1"); const rejected = item("o2", "o2-it2"); + const refundFailed = item("o2", "o2-it2"); if (pending) rows.push({ id: "as-demo-pending", @@ -474,7 +542,7 @@ function seedAftersales(orders: Order[]): Aftersale[] { kind: "refund_only", status: "refunded", reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" }, - amount_minor: refunded.line.unit_price_minor * refunded.line.qty, + amount_minor: Math.min(500, refunded.line.unit_price_minor * refunded.line.qty), currency: refunded.order.currency, evidence: [], reopened: false, @@ -483,6 +551,25 @@ function seedAftersales(orders: Order[]): Aftersale[] { created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z", }); + if (refundPending) + rows.push({ + id: "as-demo-refund-pending", + order_id: refundPending.order.id, + order_item_id: refundPending.line.id, + shop_id: refundPending.order.shop_id, + user_id: MOCK_USER.id, + kind: "refund_only", + status: "refund_pending", + reason: { en: "The original-method refund is being confirmed.", zh: "原路退款正在确认中。" }, + amount_minor: Math.min(400, refundPending.line.unit_price_minor * refundPending.line.qty - 500), + currency: refundPending.order.currency, + evidence: [], + reopened: false, + return_carrier: null, + return_tracking_no: null, + created_at: "2026-09-13T17:00:00.000Z", + updated_at: "2026-09-14T10:00:00.000Z", + }); if (rejected) rows.push({ id: "as-demo-rejected", @@ -502,9 +589,61 @@ function seedAftersales(orders: Order[]): Aftersale[] { created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z", }); + if (refundFailed) + rows.push({ + id: "as-demo-refund-failed", + order_id: refundFailed.order.id, + order_item_id: refundFailed.line.id, + shop_id: refundFailed.order.shop_id, + user_id: MOCK_USER.id, + kind: "refund_only", + status: "refund_failed", + reason: { en: "The provider rejected this refund; retry is available.", zh: "支付服务商拒绝了退款,可重试。" }, + amount_minor: Math.min(300, refundFailed.line.unit_price_minor * refundFailed.line.qty), + currency: refundFailed.order.currency, + evidence: [], + reopened: false, + return_carrier: null, + return_tracking_no: null, + created_at: "2026-09-13T18:00:00.000Z", + updated_at: "2026-09-14T11:00:00.000Z", + }); return rows; } +function seedRefundOperations(aftersales: Aftersale[]): RefundOperation[] { + const make = ( + row: Aftersale, + status: RefundOperation["status"], + method: PaymentAdapter, + error: string | null, + retryAllowed: boolean, + ): RefundOperation => ({ + id: `ro-${row.id}`, + purpose: "aftersale", + status, + amount_minor: row.amount_minor, + currency: row.currency, + aftersale_id: row.id, + method, + reference: status === "succeeded" ? `refund-${row.id}` : null, + error, + retry_allowed: retryAllowed, + created_at: row.created_at, + updated_at: row.updated_at, + }); + return aftersales.flatMap((row) => { + if (row.id === "as-demo-refunded") return [make(row, "succeeded", "wallet", null, false)]; + if (row.id === "as-demo-refund-pending") { + return [make(row, "pending", "stripe_crypto", null, false)]; + } + if (row.id === "as-demo-refund-failed") { + return [make(row, "failed", "stripe_crypto", "provider rejected the refund", true)]; + } + return []; + }); +} + function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] { const refunded = aftersales.find((row) => row.id === "as-demo-refunded"); return refunded @@ -975,6 +1114,10 @@ function initialState(): MockState { pointsProducts: seedPointsProducts(), redemptions: [], redemptionSeq: 0, + payments: persisted.payments ?? [], + checkoutRequests: persisted.checkoutRequests ?? [], + refundOperations: persisted.refundOperations ?? [], + paymentSeq: persisted.paymentSeq ?? 0, }; } const seed = seedOrders(MOCK_USER.id); @@ -1015,6 +1158,9 @@ function initialState(): MockState { ...seedGrowthLogs(seed.orders), ...seedMessages(seed.orders), merchantApplications: seedMerchantApplications(), + payments: [], + checkoutRequests: [], + refundOperations: seedRefundOperations(aftersales), addressSeq: 100, favoriteSeq: 200, orderSeq: 100, @@ -1024,6 +1170,7 @@ function initialState(): MockState { reviewSeq: 1, aftersaleMessageSeq: 100, merchantApplicationSeq: 1, + paymentSeq: 0, }; } @@ -1099,6 +1246,10 @@ export function createMockApi(): ApiClient { messages: state.messages, growthLogSeq: state.growthLogSeq, messageSeq: state.messageSeq, + payments: state.payments, + checkoutRequests: state.checkoutRequests, + refundOperations: state.refundOperations, + paymentSeq: state.paymentSeq, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { @@ -1119,6 +1270,11 @@ export function createMockApi(): ApiClient { state.orderSeq += 1; return `VM20260917${String(state.orderSeq).padStart(3, "0")}`; } + + /** Stable payload digest for idempotent checkout/attempt replay. */ + function hashPayload(parts: unknown[]): string { + return JSON.stringify(parts); + } function findAftersale(id: string): Aftersale { const row = state.aftersales.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id); if (!row) throw new ApiError(404, "NOT_FOUND", "Aftersale not found"); @@ -1142,10 +1298,15 @@ export function createMockApi(): ApiClient { } function remainingFor(orderItemId: string, lineAmount: number): number { - const refunded = state.aftersales - .filter((row) => row.order_item_id === orderItemId && row.status === "refunded") + // Refunded and pending refunds reserve budget; a definitive failure releases it. + const reserved = state.aftersales + .filter( + (row) => + row.order_item_id === orderItemId && + (row.status === "refunded" || row.status === "refund_pending"), + ) .reduce((sum, row) => sum + row.amount_minor, 0); - return Math.max(0, lineAmount - refunded); + return Math.max(0, lineAmount - reserved); } function copyAftersale(row: Aftersale): Aftersale { @@ -1178,6 +1339,10 @@ export function createMockApi(): ApiClient { row.order_item_id, found.item.unit_price_minor * found.item.qty, ), + refund_operation: + state.refundOperations + .filter((operation) => operation.aftersale_id === row.id) + .sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0] ?? null, }; } @@ -1348,6 +1513,112 @@ export function createMockApi(): ApiClient { ); } + const copyPayment = (payment: MockPayment): Payment => { + const orders: PaymentOrder[] = payment.members.flatMap((member) => { + const order = state.orders.find((entry) => entry.id === member.order_id); + return order ? [{ ...order, amount_minor: member.amount_minor }] : []; + }); + const activeAttempt = [...payment.attempts] + .reverse() + .find((attempt) => ["created", "pending", "unknown"].includes(attempt.status)); + return { + id: payment.id, + status: payment.status, + currency: payment.currency, + total_minor: payment.total_minor, + environment: payment.environment, + orders, + attempts: payment.attempts.map(({ id, channel_id, adapter, status, next_action }) => ({ + id, + channel_id, + adapter, + status, + next_action, + })), + receipts: payment.receipts.map((receipt) => ({ ...receipt })), + refunds: payment.refunds.map((refund) => ({ ...refund })), + exceptions: payment.exceptions.map((exception) => ({ ...exception })), + next_action: activeAttempt?.next_action ?? { type: "none" }, + }; + }; + + const findPayment = (id: string): MockPayment => { + const payment = state.payments.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id); + if (!payment) throw new ApiError(404, "NOT_FOUND", "Payment not found"); + return payment; + }; + + const applyPayment = (payment: MockPayment, adapter: PaymentAdapter, attemptId: string): void => { + if (payment.status === "applied") return; + for (const member of payment.members) { + const order = state.orders.find((entry) => entry.id === member.order_id); + if (order) order.status = "paid"; + } + payment.status = "applied"; + payment.receipts.push({ + id: `receipt-${payment.id}-${payment.receipts.length + 1}`, + payment_id: payment.id, + attempt_id: attemptId, + adapter, + environment: payment.environment, + amount_minor: payment.total_minor, + currency: payment.currency, + reference: adapter === "wallet" ? `wallet-${payment.id}` : `provider-${payment.id}`, + crypto: null, + collected_at: new Date().toISOString(), + }); + }; + + const progressPayment = (payment: MockPayment): void => { + const attempt = payment.attempts.find( + (entry) => entry.adapter === "stripe_crypto" && entry.status === "pending", + ); + if (!attempt) return; + attempt.poll_count += 1; + if (attempt.poll_count < 3) return; + attempt.status = "settled"; + attempt.next_action = { type: "none" }; + applyPayment(payment, "stripe_crypto", attempt.id); + persist(); + }; + + const paymentMethods = (): EligibleMethod[] => [ + { + id: "pm-wallet", + adapter: "wallet", + label: { en: "Wallet balance", zh: "钱包余额" }, + currencies: [BASE_CURRENCY], + min_amount_minor: 1, + }, + { + id: "pm-demo-external", + adapter: "stripe_crypto", + label: { en: "External payment (simulated)", zh: "外部支付(模拟)" }, + currencies: [BASE_CURRENCY], + min_amount_minor: 1, + }, + ]; + + const createPaymentForOrders = (orders: Order[]): MockPayment => { + if (orders.length === 0) throw new ApiError(409, "CONFLICT", "No unpaid orders"); + const payment: MockPayment = { + id: `pay-${++state.paymentSeq}`, + user_id: MOCK_USER.id, + status: "unapplied", + currency: orders[0].currency, + total_minor: orders.reduce((sum, order) => sum + order.total_minor, 0), + environment: "demo", + members: orders.map((order) => ({ order_id: order.id, amount_minor: order.total_minor })), + attempts: [], + receipts: [], + refunds: [], + exceptions: [], + created_at: new Date().toISOString(), + }; + state.payments.push(payment); + return payment; + }; + return { register: () => Promise.resolve(tokens()), login: () => Promise.resolve(tokens()), @@ -1437,9 +1708,32 @@ export function createMockApi(): ApiClient { checkout: ( shippingAddress: Address, currency: string, - couponByShop: Record = {}, - groupBuy?: GroupBuyIntent, + couponByShop: Record | undefined, + groupBuy: GroupBuyIntent | undefined, + idempotencyKey: string, ) => { + if (!idempotencyKey) { + return Promise.reject( + new ApiError(400, "BAD_REQUEST", "Idempotency-Key is required"), + ); + } + const payloadHash = hashPayload([shippingAddress, currency, couponByShop ?? {}, groupBuy ?? null]); + const prior = state.checkoutRequests.find((entry) => entry.key === idempotencyKey); + if (prior) { + if (prior.hash !== payloadHash) { + return Promise.reject( + new ApiError(409, "CONFLICT", "Idempotency key was already used with a different payload"), + ); + } + const payment = findPayment(prior.payment_id); + return Promise.resolve({ + orders: payment.members.flatMap((member) => { + const order = state.orders.find((entry) => entry.id === member.order_id); + return order ? [{ ...order }] : []; + }), + payment_id: payment.id, + }); + } if (state.cart.length === 0) { return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty")); } @@ -1465,7 +1759,7 @@ export function createMockApi(): ApiClient { for (const [shopId, items] of Object.entries(byShop)) { const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0); const groupHere = activity && groupBuy && items.some((i) => i.sku_id === groupBuy.sku_id); - const couponId = couponByShop[shopId]; + const couponId = couponByShop?.[shopId]; let discount = 0; if (couponId && groupHere) { return Promise.reject( @@ -1523,10 +1817,19 @@ export function createMockApi(): ApiClient { } created.push(order); } + const total = created.reduce((sum, order) => sum + order.total_minor, 0); + if (total <= 0) { + return Promise.reject(new ApiError(409, "CONFLICT", "Zero-total checkout is rejected")); + } + const payment = createPaymentForOrders(created); state.orders = [...created, ...state.orders]; state.cart = []; + state.checkoutRequests.push({ key: idempotencyKey, hash: payloadHash, payment_id: payment.id }); persist(); - return Promise.resolve(created); + return Promise.resolve({ + orders: created.map((order) => ({ ...order })), + payment_id: payment.id, + }); }, listMyOrders: (page = 1) => { @@ -1554,26 +1857,183 @@ export function createMockApi(): ApiClient { new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"), ); } - order.status = "cancelled"; - const coupon = state.coupons.find((c) => c.order_id === order.id); - if (coupon) { - coupon.status = "claimed"; - coupon.order_id = null; - coupon.redeemed_at = null; + const payment = state.payments.find( + (entry) => entry.status !== "closed" && entry.members.some((m) => m.order_id === order.id), + ); + const targets = payment + ? payment.members.flatMap((m) => { + const member = state.orders.find((o) => o.id === m.order_id); + return member && member.status === "pending_payment" ? [member] : []; + }) + : [order]; + for (const target of targets) { + target.status = "cancelled"; + const coupon = state.coupons.find((c) => c.order_id === target.id); + if (coupon) { + coupon.status = "claimed"; + coupon.order_id = null; + coupon.redeemed_at = null; + } } + if (payment) payment.status = "closed"; persist(); return Promise.resolve(order); }, - payOrder: (id) => { - const order = state.orders.find((o) => o.id === id); - if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); - if (order.status !== "pending_payment") { + resolvePayment: (orderIds) => { + if (orderIds.length === 0) { + return Promise.reject(new ApiError(400, "BAD_REQUEST", "order_ids cannot be empty")); + } + const orders = orderIds.flatMap((id) => { + const order = state.orders.find((o) => o.id === id && o.user_id === MOCK_USER.id); + return order ? [order] : []; + }); + if (orders.length !== orderIds.length) { + return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found")); + } + if (orders.some((order) => order.status !== "pending_payment")) { return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be paid")); } - order.status = "paid"; + const existing = state.payments.find( + (entry) => + entry.status === "unapplied" && + orders.every((order) => entry.members.some((m) => m.order_id === order.id)), + ); + const payment = existing ?? createPaymentForOrders(orders); persist(); - return Promise.resolve(order); + return Promise.resolve(copyPayment(payment)); + }, + + getPayment: (id) => { + const payment = findPayment(id); + progressPayment(payment); + return Promise.resolve(copyPayment(payment)); + }, + + listPaymentMethods: (id) => { + const payment = findPayment(id); + if (payment.status !== "unapplied") { + return Promise.reject(new ApiError(409, "INVALID_STATE", "Payment is not payable")); + } + return Promise.resolve(paymentMethods()); + }, + + createPaymentAttempt: (id, body, idempotencyKey) => { + if (!idempotencyKey) { + return Promise.reject(new ApiError(400, "BAD_REQUEST", "Idempotency-Key is required")); + } + const payment = findPayment(id); + if (payment.status !== "unapplied") { + return Promise.reject(new ApiError(409, "INVALID_STATE", "Payment is not payable")); + } + const requestHash = hashPayload([body.channel_id, body.return_surface]); + const prior = payment.attempts.find((attempt) => attempt.idempotency_key === idempotencyKey); + if (prior) { + if (prior.request_hash !== requestHash) { + return Promise.reject( + new ApiError(409, "CONFLICT", "Idempotency key was already used with a different payload"), + ); + } + progressPayment(payment); + return Promise.resolve({ + id: prior.id, + status: prior.status, + payment: { + id: payment.id, + status: payment.status, + currency: payment.currency, + total_minor: payment.total_minor, + }, + next_action: prior.next_action, + }); + } + const unresolved = payment.attempts.find((attempt) => + ["created", "pending", "unknown"].includes(attempt.status), + ); + if (unresolved) { + return Promise.reject( + new ApiError(409, "CONFLICT", "An unresolved attempt blocks switching channels"), + ); + } + const method = paymentMethods().find((entry) => entry.id === body.channel_id); + if (!method) { + return Promise.reject(new ApiError(409, "CONFLICT", "Method is not available")); + } + if (method.adapter === "wallet" && payment.total_minor > state.walletAvailableMinor) { + return Promise.reject( + new ApiError(409, "CONFLICT", "insufficient available balance"), + ); + } + state.paymentSeq += 1; + const now = new Date().toISOString(); + const settled = method.adapter === "wallet"; + const attempt: MockPaymentAttempt = { + id: `att-${state.paymentSeq}`, + channel_id: body.channel_id, + adapter: method.adapter, + status: settled ? "settled" : "pending", + next_action: settled ? { type: "none" } : { type: "wait" }, + idempotency_key: idempotencyKey, + request_hash: requestHash, + poll_count: 0, + return_surface: body.return_surface, + }; + payment.attempts.push(attempt); + if (settled) { + state.walletAvailableMinor -= payment.total_minor; + state.walletEntrySeq += 1; + state.walletEntries.push({ + id: `we-${state.walletEntrySeq}`, + account_kind: "available", + delta_minor: -payment.total_minor, + balance_minor: state.walletAvailableMinor, + reason: "order_payment", + reference_type: "payment", + reference_id: payment.id, + created_at: now, + }); + applyPayment(payment, "wallet", attempt.id); + } + persist(); + return Promise.resolve({ + id: attempt.id, + status: attempt.status, + payment: { + id: payment.id, + status: payment.status, + currency: payment.currency, + total_minor: payment.total_minor, + }, + next_action: attempt.next_action, + }); + }, + + cancelPayment: (id) => { + const payment = findPayment(id); + if (payment.status !== "unapplied") { + return Promise.reject(new ApiError(409, "INVALID_STATE", "Payment cannot be cancelled")); + } + const blocking = payment.attempts.find((attempt) => + ["created", "pending", "unknown", "settled"].includes(attempt.status), + ); + if (blocking) { + return Promise.reject(new ApiError(409, "INVALID_STATE", "An attempt is not concluded")); + } + for (const member of payment.members) { + const order = state.orders.find((o) => o.id === member.order_id); + if (order && order.status === "pending_payment") { + order.status = "cancelled"; + const coupon = state.coupons.find((c) => c.order_id === order.id); + if (coupon) { + coupon.status = "claimed"; + coupon.order_id = null; + coupon.redeemed_at = null; + } + } + } + payment.status = "closed"; + persist(); + return Promise.resolve(copyPayment(payment)); }, confirmDelivered: (shipmentId) => { @@ -1658,7 +2118,9 @@ export function createMockApi(): ApiClient { const active = state.aftersales.some( (row) => row.order_item_id === body.order_item_id && - ["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status), + ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refund_pending", "refund_failed"].includes( + row.status, + ), ); if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item"); @@ -2117,6 +2579,8 @@ export function createMockApi(): ApiClient { available_minor: state.walletAvailableMinor, frozen_minor: state.walletFrozenMinor, currency: BASE_CURRENCY, + environment: "demo", + demo_recharge_enabled: true, }), listWalletEntries: (page = 1) => { diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index c98c203..ff2aeb4 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -22,6 +22,7 @@ export default defineNuxtConfig({ "account", "cart", "orders", + "payments", "shipments", "invoices", "addresses", diff --git a/apps/mall/pages/checkout/index.vue b/apps/mall/pages/checkout/index.vue index bfe075a..2262992 100644 --- a/apps/mall/pages/checkout/index.vue +++ b/apps/mall/pages/checkout/index.vue @@ -23,7 +23,9 @@ const { locale, t } = useI18n(); const { currency } = usePrefs(); const cartStore = useCartStore(); const router = useRouter(); -const pendingOrderIds = useState("checkout-orders", () => []); +const pendingPaymentId = useState("checkout-payment-id", () => null); +const checkoutIdempotencyKey = useState("checkout-idempotency-key", () => ""); +const CHECKOUT_IDEMPOTENCY_STORAGE = "vmall.checkout.idempotency"; /** Group-buying intent chosen on the group-buying page, if any. */ const groupIntent = useState("checkout-group-intent", () => null); @@ -42,6 +44,17 @@ const shippingQuote = ref(null); const quoteFailed = ref(false); let quoteTimer: ReturnType | undefined; let quoteSeq = 0; +function getCheckoutIdempotencyKey(): string { + if (checkoutIdempotencyKey.value) return checkoutIdempotencyKey.value; + if (import.meta.client) { + checkoutIdempotencyKey.value = + localStorage.getItem(CHECKOUT_IDEMPOTENCY_STORAGE) ?? `checkout-${crypto.randomUUID()}`; + localStorage.setItem(CHECKOUT_IDEMPOTENCY_STORAGE, checkoutIdempotencyKey.value); + } else { + checkoutIdempotencyKey.value = `checkout-${Date.now()}`; + } + return checkoutIdempotencyKey.value; +} // Inline manual form, used only when the customer has no saved address. const manual = reactive
({ @@ -210,18 +223,19 @@ async function submitOrder(): Promise { for (const [shopId, couponId] of Object.entries(selectedCoupon)) { if (couponId) couponByShop[shopId] = couponId; } - const orders = await $api.checkout( + const result = await $api.checkout( address, currency.value, couponByShop, groupIntent.value ?? undefined, + getCheckoutIdempotencyKey(), ); groupIntent.value = null; - pendingOrderIds.value = orders.map((order) => order.id); + pendingPaymentId.value = result.payment_id; const snapshot = await $api.getCart(); items.value = snapshot.items; await cartStore.refresh(); - await router.push("/checkout/pay"); + await router.push({ path: "/checkout/pay", query: { payment_id: result.payment_id } }); } catch { error.value = t("checkout.submitError"); } finally { diff --git a/apps/mall/pages/checkout/pay.vue b/apps/mall/pages/checkout/pay.vue index fe463d2..70217ac 100644 --- a/apps/mall/pages/checkout/pay.vue +++ b/apps/mall/pages/checkout/pay.vue @@ -1,29 +1,22 @@ diff --git a/apps/mall/pages/checkout/success.vue b/apps/mall/pages/checkout/success.vue index fb06deb..0ee6325 100644 --- a/apps/mall/pages/checkout/success.vue +++ b/apps/mall/pages/checkout/success.vue @@ -1,76 +1,46 @@ - diff --git a/apps/mall/pages/user/aftersales/[id].vue b/apps/mall/pages/user/aftersales/[id].vue index 08fb64c..6a1c30c 100644 --- a/apps/mall/pages/user/aftersales/[id].vue +++ b/apps/mall/pages/user/aftersales/[id].vue @@ -21,6 +21,7 @@ const progress: AftersaleStatus[] = [ "approved", "buyer_shipping", "merchant_confirmed", + "refund_pending", "refunded", ]; @@ -28,6 +29,7 @@ const id = computed(() => String(route.params.id ?? "")); function tone(status: AftersaleStatus): "green" | "blue" | "red" | "orange" { if (status === "refunded") return "green"; + if (status === "refund_failed") return "red"; if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue"; if (["rejected", "cancelled"].includes(status)) return "red"; return "orange"; @@ -240,6 +242,13 @@ onMounted(() => void load()); {{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}: +
+ {{ t("user.aftersaleRefundPending") }} +
+
+ {{ t("user.aftersaleRefundFailed") }} + : {{ detail.refund_operation.error }} +
- ["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status), + ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refund_pending", "refund_failed"].includes( + row.status, + ), ) .map((row) => row.order_item_id), ), @@ -44,6 +46,21 @@ function canApply(itemId: string): boolean { !activeAftersaleItemIds.value.has(itemId), ); } +const paymentWorking = ref(false); +async function openPayment(): Promise { + if (!order.value) return; + paymentWorking.value = true; + try { + const payment = await $api.resolvePayment([order.value.id]); + await navigateTo({ path: "/checkout/pay", query: { payment_id: payment.id } }); + } finally { paymentWorking.value = false; } +} +async function cancelOrder(): Promise { + if (!order.value) return; + paymentWorking.value = true; + try { await $api.cancelOrder(order.value.id); await load(); } + finally { paymentWorking.value = false; } +} async function load(): Promise { loading.value = true; @@ -115,7 +132,13 @@ onMounted(() => { {{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}

- +
+ + +
diff --git a/apps/mall/pages/user/orders/index.vue b/apps/mall/pages/user/orders/index.vue index f7a0617..ff15945 100644 --- a/apps/mall/pages/user/orders/index.vue +++ b/apps/mall/pages/user/orders/index.vue @@ -80,8 +80,8 @@ async function loadOrders(nextPage = page.value): Promise { async function pay(order: Order): Promise { workingId.value = order.id; try { - await $api.payOrder(order.id); - await loadOrders(); + const payment = await $api.resolvePayment([order.id]); + await navigateTo({ path: "/checkout/pay", query: { payment_id: payment.id } }); } finally { workingId.value = ""; } diff --git a/apps/mall/pages/user/wallet.vue b/apps/mall/pages/user/wallet.vue index 5048d4c..4b2f426 100644 --- a/apps/mall/pages/user/wallet.vue +++ b/apps/mall/pages/user/wallet.vue @@ -272,7 +272,7 @@ onMounted(() => void load()); -
+

{{ t("wallet.rechargeTitle") }}

{{ t("wallet.rechargeDemoBadge") }} @@ -281,21 +281,14 @@ onMounted(() => void load());
-

- {{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }} -

+

{{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }}

- -

- {{ rechargeNotice }} -

- - {{ t("wallet.rechargeSubmit") }} - + +

{{ rechargeNotice }}

+ {{ t("wallet.rechargeSubmit") }}
+

{{ t("wallet.rechargeUnavailable") }}

{{ t("wallet.withdrawTitle") }}

diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index 9f47592..4f8fced 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -17,6 +17,7 @@ type LiveDomain = | "brands" | "cart" | "orders" + | "payments" | "shipments" | "invoices" | "addresses" @@ -60,10 +61,16 @@ const LIVE_PICKS = { listMyOrders: a.listMyOrders, getOrder: a.getOrder, cancelOrder: a.cancelOrder, - payOrder: a.payOrder, quoteShipping: a.quoteShipping, listShippingCompanies: a.listShippingCompanies, }), + payments: (a: ApiClient) => ({ + resolvePayment: a.resolvePayment, + getPayment: a.getPayment, + listPaymentMethods: a.listPaymentMethods, + createPaymentAttempt: a.createPaymentAttempt, + cancelPayment: a.cancelPayment, + }), shipments: (a: ApiClient) => ({ confirmDelivered: a.confirmDelivered, listMyShipments: a.listMyShipments, @@ -152,6 +159,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [ "account", "cart", "orders", + "payments", "shipments", "invoices", "addresses", @@ -181,6 +189,17 @@ export default defineNuxtPlugin(() => { KNOWN_DOMAINS.includes(domain as LiveDomain), ); + // Cart, order and payment state cross-reference each other at runtime, so + // mixing fixed and live selection between them would split one checkout's + // state across two stores. Refuse a mixed selection at startup. + const transactional: LiveDomain[] = ["cart", "orders", "payments"]; + const liveTransactional = transactional.filter((domain) => liveDomains.includes(domain)); + if (liveTransactional.length !== 0 && liveTransactional.length !== transactional.length) { + throw new Error( + `vmall: cart/orders/payments must be all live or all fixed; got ${liveTransactional.join(", ")}`, + ); + } + // The fixed-data adapter is the base object, so an unmigrated domain cannot // regress and a live domain can be rolled back by removing one entry. let api: ApiClient = mock; diff --git a/apps/mobile/locales-extra.ts b/apps/mobile/locales-extra.ts index 6b71586..6b51e83 100644 --- a/apps/mobile/locales-extra.ts +++ b/apps/mobile/locales-extra.ts @@ -77,6 +77,8 @@ export const enExtra = { empty: "No orders yet", loadFailed: "Unable to load orders. Please try again.", actionFailed: "The action failed. Please try again.", + actionConflict: "The server refused this action: {message}", + cancelGroupConfirm: "This payment group has multiple unpaid orders. Cancelling will cancel all of them: {orders}. Continue?", viewDetail: "View detail", pageInfo: "Page {page} of {pages}", refreshRelease: "Release to refresh", @@ -117,9 +119,22 @@ export const enExtra = { status_rejected: "Rejected", status_buyer_shipping: "Return in transit", status_merchant_confirmed: "Return received", + status_refund_pending: "Refund processing", + status_refund_failed: "Refund failed", status_refunded: "Refunded", status_cancelled: "Cancelled", - refundedNote: "The refund has been credited to your wallet.", + refundedNote: "The refund has been returned via the original payment method.", + refundProgressTitle: "Refund progress", + refundOriginalMethod: "Refunds return via the original payment method.", + refundMethodLabel: "Refund method", + refundMethodWallet: "Wallet", + refundMethodStripe: "External payment", + refundStatus_pending: "Pending", + refundStatus_unknown: "Awaiting provider confirmation", + refundStatus_succeeded: "Completed", + refundStatus_failed: "Failed", + refundError: "Provider error: {error}", + refundRetryMerchant: "Retry eligibility is informational; retry is handled by the merchant.", cancel: "Cancel request", reopen: "Reopen", returnTrackingTitle: "Return shipping", @@ -188,6 +203,7 @@ export const enExtra = { rechargeSuccess: "Recharged {amount}", rechargeFailed: "Unable to recharge. Please try again.", rechargeConflict: "The balance changed; the page has been refreshed.", + rechargeUnavailableNote: "Simulated recharge is available in the demo environment only.", withdrawTitle: "Withdraw", withdrawHint: "The withdrawal amount is frozen until platform review finishes.", withdrawAmount: "Amount ({currency})", @@ -322,6 +338,42 @@ export const enExtra = { loadError: "Unable to load the order.", submitError: "Unable to place the order.", addressRequired: "Please select or fill in a shipping address.", + submitConflict: "The server refused this order. Please review the cart and try again.", + }, + pay: { + title: "Pay for order group", + orderSummary: "Orders in this payment", + methodsTitle: "Payment method", + total: "Total", + payNow: "Pay now", + refresh: "Refresh payment", + pending: "Payment is being processed. This page will update when the server confirms it.", + attemptInProgress: "Another payment attempt is still in progress. Please wait for it to finish.", + attemptUnknown: "The payment provider status is unknown. Refresh before trying again.", + redirecting: "Redirecting to the secure payment provider…", + redirectFallback: "If you are not redirected, continue securely here.", + conflict: "The server refused this payment attempt. The available methods were refreshed.", + attemptFailed: "The payment attempt failed. You can try again with a new attempt.", + loadFailed: "Unable to load this payment. Please try again.", + methodsFailed: "Unable to load payment methods. Refresh to try again.", + noMethods: "No eligible payment methods are available for this payment.", + appliedTitle: "Payment complete", + appliedNote: "This payment has been applied to the orders below.", + closedTitle: "Payment cancelled", + closedNote: "This payment group is closed. No further payment can be collected.", + cancel: "Cancel payment group", + cancelGroupConfirm: "Cancelling this payment will cancel all unpaid member orders: {orders}. Continue?", + cancelRefused: "The server refused cancellation. {message}", + retry: "Retry payment", + noPaymentId: "No payment was selected. Open a pending order to continue.", + successTitle: "Payment result", + successPending: "Payment received; waiting for the server to apply it to the orders.", + successApplied: "Payment confirmed", + successClosed: "Payment was cancelled before it was applied.", + cancelTitle: "Payment return", + cancelPending: "The payment is still pending. Refresh to see the latest server state.", + cancelApplied: "This payment is already complete.", + cancelClosed: "This payment group is cancelled.", }, } as const; @@ -358,6 +410,8 @@ export const zhExtra = { empty: "还没有订单", loadFailed: "订单加载失败,请重试。", actionFailed: "操作失败,请重试。", + actionConflict: "服务器拒绝了该操作:{message}", + cancelGroupConfirm: "该支付包含多个未付款订单,取消将同时取消全部订单:{orders}。确定继续吗?", viewDetail: "查看详情", pageInfo: "第 {page} / {pages} 页", refreshRelease: "松开刷新", @@ -398,9 +452,22 @@ export const zhExtra = { status_rejected: "已驳回", status_buyer_shipping: "退货中", status_merchant_confirmed: "商家已收货", + status_refund_pending: "退款处理中", + status_refund_failed: "退款失败", status_refunded: "已退款", status_cancelled: "已撤销", - refundedNote: "退款已打入钱包余额。", + refundedNote: "退款已按原支付方式退回。", + refundProgressTitle: "退款进度", + refundOriginalMethod: "退款将按原支付方式退回。", + refundMethodLabel: "退款方式", + refundMethodWallet: "钱包", + refundMethodStripe: "外部支付方式", + refundStatus_pending: "处理中", + refundStatus_unknown: "等待支付服务商确认", + refundStatus_succeeded: "已完成", + refundStatus_failed: "失败", + refundError: "服务商错误:{error}", + refundRetryMerchant: "重试权限仅供参考,重试由商家处理。", cancel: "撤销申请", reopen: "重新申请", returnTrackingTitle: "退货物流", @@ -469,6 +536,7 @@ export const zhExtra = { rechargeSuccess: "充值成功 {amount}", rechargeFailed: "充值失败,请重试。", rechargeConflict: "余额已变动,页面已刷新。", + rechargeUnavailableNote: "模拟充值仅在演示环境提供。", withdrawTitle: "提现申请", withdrawHint: "提现金额将被冻结,等待平台审核。", withdrawAmount: "提现金额({currency})", @@ -603,5 +671,41 @@ export const zhExtra = { loadError: "订单信息加载失败,请稍后重试。", submitError: "订单提交失败,请稍后重试。", addressRequired: "请选择或填写收货地址。", + submitConflict: "服务器拒绝了订单,请检查购物车后重试。", + }, + pay: { + title: "支付订单组", + orderSummary: "本次支付包含的订单", + methodsTitle: "支付方式", + total: "合计", + payNow: "立即支付", + refresh: "刷新支付状态", + pending: "支付处理中,服务器确认后页面会自动更新。", + attemptInProgress: "已有支付尝试正在处理中,请等待完成。", + attemptUnknown: "支付服务商状态未知,请刷新后再试。", + redirecting: "正在跳转到安全支付页面…", + redirectFallback: "如果没有自动跳转,请点击此处继续支付。", + conflict: "服务器拒绝了本次支付尝试,支付方式已刷新。", + attemptFailed: "支付尝试失败,可以发起新的支付尝试。", + loadFailed: "支付信息加载失败,请重试。", + methodsFailed: "支付方式加载失败,请刷新重试。", + noMethods: "当前支付没有可用的支付方式。", + appliedTitle: "支付完成", + appliedNote: "该支付已应用到以下订单。", + closedTitle: "支付已取消", + closedNote: "该支付订单组已关闭,不能继续收款。", + cancel: "取消支付订单组", + cancelGroupConfirm: "取消该支付将同时取消全部未付款订单:{orders}。确定继续吗?", + cancelRefused: "服务器拒绝了取消操作:{message}", + retry: "重试支付", + noPaymentId: "未选择支付订单,请从待付款订单继续。", + successTitle: "支付结果", + successPending: "已收到支付,正在等待服务器将其应用到订单。", + successApplied: "支付已确认", + successClosed: "支付在应用前已取消。", + cancelTitle: "支付返回", + cancelPending: "支付仍在处理中,请刷新查看服务器状态。", + cancelApplied: "该支付已完成。", + cancelClosed: "该支付订单组已取消。", }, } as const; diff --git a/apps/mobile/pages/aftersales.vue b/apps/mobile/pages/aftersales.vue index 105e94b..ff9b650 100644 --- a/apps/mobile/pages/aftersales.vue +++ b/apps/mobile/pages/aftersales.vue @@ -35,7 +35,7 @@ const filteredRows = computed(() => function statusTone(status: AftersaleStatus): "green" | "blue" | "red" | "orange" { if (status === "refunded") return "green"; if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue"; - if (["rejected", "cancelled"].includes(status)) return "red"; + if (["rejected", "cancelled", "refund_failed"].includes(status)) return "red"; return "orange"; } @@ -97,7 +97,7 @@ async function runAction(key: string, action: () => Promise): Promise $api.cancelAftersale(detail.value!.id)); } @@ -258,11 +258,37 @@ onMounted(() => void (selectedId.value ? loadDetail() : loadList())); + +

{{ t("aftersale.refundProgressTitle") }}

+

{{ t("aftersale.refundOriginalMethod") }}

+
+
+
{{ t("aftersale.refundMethodLabel") }}
+
+ {{ detail.refund_operation.method === "wallet" ? t("aftersale.refundMethodWallet") : t("aftersale.refundMethodStripe") }} +
+
+
+
{{ t("common.status") }}
+
{{ t(`aftersale.refundStatus_${detail.refund_operation.status}`) }}
+
+
+
{{ t("common.total") }}
+
+
+
+ +

+ {{ t("aftersale.refundRetryMerchant") }} +

+
void (selectedId.value ? loadDetail() : loadList()));

+

{{ t("aftersale.returnTrackingTitle") }}

diff --git a/apps/mobile/pages/checkout.vue b/apps/mobile/pages/checkout.vue index 1aa2e94..7d9a511 100644 --- a/apps/mobile/pages/checkout.vue +++ b/apps/mobile/pages/checkout.vue @@ -4,10 +4,19 @@ import type { Address, AddressBookEntry, CartItem, + CheckoutResult, Coupon, LocalizedText, ShippingQuote, } from "@vmall/shared"; +import { + checkoutIdempotencyRecord, + clearCheckoutIdempotency, + newIdempotencyKey, + rememberPaymentId, + stableStringify, + storeCheckoutIdempotency, +} from "~/utils/payment"; type CheckoutGroup = { shopId: string; @@ -235,19 +244,38 @@ async function submitOrder(): Promise { for (const [shopId, couponId] of Object.entries(selectedCoupon)) { if (couponId) couponByShop[shopId] = couponId; } - const orders = await $api.checkout(address, sourceCurrency.value, couponByShop); - const first = orders[0]; - if (!first) { + const couponSelection = Object.keys(couponByShop).length > 0 ? couponByShop : undefined; + const payloadHash = stableStringify({ + shipping_address: address, + currency: sourceCurrency.value, + coupon_by_shop: couponSelection ?? {}, + group_buy: null, + lines: items.value.map((item) => ({ sku_id: item.sku_id, qty: item.qty })), + }); + const stored = checkoutIdempotencyRecord(); + const idempotencyKey = stored?.hash === payloadHash ? stored.key : newIdempotencyKey(); + storeCheckoutIdempotency({ key: idempotencyKey, hash: payloadHash }); + const result: CheckoutResult = await $api.checkout( + address, + sourceCurrency.value, + couponSelection, + undefined, + idempotencyKey, + ); + if (!result.payment_id || result.orders.length === 0) { error.value = t("checkout.submitError"); return; } - await router.push(`/orders/${first.id}`); + clearCheckoutIdempotency(); + rememberPaymentId(result.payment_id); + await router.push(`/orders/pay?payment_id=${encodeURIComponent(result.payment_id)}`); } catch (err) { - // A conflict (stock, coupon) carries a message worth showing; then the - // preview must converge back to server truth. - error.value = err instanceof ApiError && err.status === 409 ? err.message : t("checkout.submitError"); if (err instanceof ApiError && err.status === 409) { + error.value = `${t("checkout.submitConflict")} ${err.message}`.trim(); + clearCheckoutIdempotency(); await loadCart(); + } else { + error.value = t("checkout.submitError"); } } finally { submitting.value = false; diff --git a/apps/mobile/pages/orders/[id].vue b/apps/mobile/pages/orders/[id].vue index 1008594..b47bc9a 100644 --- a/apps/mobile/pages/orders/[id].vue +++ b/apps/mobile/pages/orders/[id].vue @@ -1,18 +1,21 @@ + + diff --git a/apps/mobile/pages/orders/pay/index.vue b/apps/mobile/pages/orders/pay/index.vue new file mode 100644 index 0000000..604aee7 --- /dev/null +++ b/apps/mobile/pages/orders/pay/index.vue @@ -0,0 +1,257 @@ + + + diff --git a/apps/mobile/pages/orders/pay/success.vue b/apps/mobile/pages/orders/pay/success.vue new file mode 100644 index 0000000..a0f6af9 --- /dev/null +++ b/apps/mobile/pages/orders/pay/success.vue @@ -0,0 +1,112 @@ + + + diff --git a/apps/mobile/pages/wallet.vue b/apps/mobile/pages/wallet.vue index 1d5d19b..8c6a046 100644 --- a/apps/mobile/pages/wallet.vue +++ b/apps/mobile/pages/wallet.vue @@ -254,7 +254,7 @@ onMounted(() => void load());
- +

{{ t("wallet.rechargeTitle") }}

{{ t("wallet.rechargeDemoBadge") }} @@ -275,6 +275,7 @@ onMounted(() => void load()); +

{{ t("wallet.rechargeUnavailableNote") }}

{{ t("wallet.withdrawTitle") }}

diff --git a/apps/mobile/utils/payment.ts b/apps/mobile/utils/payment.ts new file mode 100644 index 0000000..274bb9f --- /dev/null +++ b/apps/mobile/utils/payment.ts @@ -0,0 +1,94 @@ +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; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(",")}}`; +} diff --git a/apps/shop-admin/locales-extra.ts b/apps/shop-admin/locales-extra.ts index d341654..107cf37 100644 --- a/apps/shop-admin/locales-extra.ts +++ b/apps/shop-admin/locales-extra.ts @@ -321,6 +321,8 @@ export const enExtra = { rejected: "Application rejected.", receiptConfirmed: "Return receipt confirmed.", refunded: "Refund completed.", + refundSubmitted: "Refund submitted to the original payment method; awaiting confirmation.", + refundRetried: "Refund re-submitted; awaiting original-method confirmation.", staleAction: "The after-sale state changed while you were acting; the latest state is shown below.", reopened: "Reopened", @@ -340,8 +342,34 @@ export const enExtra = { buyer_shipping: "Return in transit", merchant_confirmed: "Return received", refunded: "Refunded", + refund_pending: "Refund pending", + refund_failed: "Refund failed", cancelled: "Cancelled", }, + refundSection: "Original-method refund", + refundState: { + pending: "Submitted — awaiting confirmation", + unknown: "Outcome unknown", + failed: "Failed", + succeeded: "Completed", + }, + refundMethod: "Refund method", + method: { + wallet: "Wallet balance", + stripe_crypto: "Crypto (returns to the original wallet)", + }, + refundSubmittedAt: "Submitted at", + refundUpdatedAt: "Last updated", + refundReference: "Provider reference", + refundError: "Failure detail", + refundPendingNote: + "The refund was submitted to the original payment method and is awaiting confirmation. No replacement submission is offered while it is unresolved.", + refundUnknownNote: + "The provider has not confirmed this refund's outcome. It remains unresolved and cannot be re-submitted.", + refundFailedNote: + "This refund failed definitively. It can only be re-submitted through an authorized retry.", + refundSucceededNote: "The refund completed and returned to the original payment method.", + refundRetry: "Retry refund", }, review: { title: "Product reviews", @@ -679,6 +707,8 @@ export const zhExtra = { rejected: "已拒绝申请。", receiptConfirmed: "已确认收货。", refunded: "退款已完成。", + refundSubmitted: "退款已提交至原支付方式,等待确认。", + refundRetried: "退款已重新提交,等待原路确认。", staleAction: "售后状态已发生变化,下方为最新状态。", reopened: "已重新申请", role: { @@ -697,8 +727,31 @@ export const zhExtra = { buyer_shipping: "买家退货中", merchant_confirmed: "商家已收货", refunded: "已退款", + refund_pending: "退款处理中", + refund_failed: "退款失败", cancelled: "已取消", }, + refundSection: "原路退款", + refundState: { + pending: "已提交,等待确认", + unknown: "结果未知", + failed: "退款失败", + succeeded: "退款完成", + }, + refundMethod: "退款方式", + method: { + wallet: "站内余额", + stripe_crypto: "加密货币(退回原钱包)", + }, + refundSubmittedAt: "提交时间", + refundUpdatedAt: "更新时间", + refundReference: "渠道参考号", + refundError: "失败详情", + refundPendingNote: "退款已提交至原支付方式,等待渠道确认;未决期间不提供重复发起。", + refundUnknownNote: "渠道尚未确认本次退款结果,状态未决,无法重新发起。", + refundFailedNote: "本次退款已确定失败,仅可在获得授权后重新发起。", + refundSucceededNote: "退款已完成,资金按原支付方式退回。", + refundRetry: "重新发起退款", }, review: { title: "商品评价", diff --git a/apps/shop-admin/pages/aftersales/[id].vue b/apps/shop-admin/pages/aftersales/[id].vue index ed0d36c..64d5798 100644 --- a/apps/shop-admin/pages/aftersales/[id].vue +++ b/apps/shop-admin/pages/aftersales/[id].vue @@ -5,6 +5,7 @@ import type { AftersaleMessage, AftersaleStatus, LocalizedText, + RefundOperation, } from "@vmall/shared"; definePageMeta({ middleware: "auth" }); @@ -23,9 +24,11 @@ const messageEn = ref(""); const messageZh = ref(""); const messageEvidence = ref(""); -function statusClass(value: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" { +function statusClass( + value: ShopAftersaleStatus, +): "green" | "red" | "orange" | "blue" | "gray" { if (value === "refunded") return "green"; - if (value === "rejected") return "red"; + if (value === "rejected" || value === "refund_failed") return "red"; if (value === "cancelled") return "gray"; if (value === "pending") return "orange"; return "blue"; @@ -35,6 +38,17 @@ function roleClass(value: AftersaleMessage["author_role"]): "green" | "blue" | " return value === "merchant" ? "blue" : value === "platform" ? "gray" : "green"; } +/** Shared status union plus the asynchronous original-method refund states. */ +type ShopAftersaleStatus = AftersaleStatus | "refund_pending" | "refund_failed"; + +function refundStateTone( + value: RefundOperation["status"], +): "green" | "red" | "orange" | "blue" { + if (value === "succeeded") return "green"; + if (value === "failed") return "red"; + return value === "unknown" ? "orange" : "blue"; +} + function formatDate(value: string): string { return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US"); } @@ -85,36 +99,60 @@ async function load(): Promise { const canDecide = computed(() => detail.value?.status === "pending"); const canConfirmReceipt = computed(() => detail.value?.status === "buyer_shipping"); +const refundOperation = computed( + () => detail.value?.refund_operation ?? null, +); const canRefund = computed(() => { const current = detail.value; if (!current) return false; + // A submitted refund is never re-submittable while pending or unknown. + if (current.status === "refund_pending" || current.status === "refund_failed") return false; + const operation = refundOperation.value; + if (operation && operation.status !== "succeeded" && operation.status !== "failed") { + return false; + } return ( current.status === "merchant_confirmed" || (current.status === "approved" && current.kind === "refund_only") ); }); -const hasActions = computed(() => canDecide.value || canConfirmReceipt.value || canRefund.value); +/** Retry exists only when the server authorizes it after a definitive failure. */ +const canRetryRefund = computed(() => { + const operation = refundOperation.value; + return operation?.status === "failed" && operation.retry_allowed === true; +}); +const hasActions = computed( + () => + canDecide.value || canConfirmReceipt.value || canRefund.value || canRetryRefund.value, +); -type ShopAction = "approve" | "reject" | "confirmReceipt" | "refund"; +type ShopAction = "approve" | "reject" | "confirmReceipt" | "refund" | "retryRefund"; const doneKey: Record = { approve: "aftersale.approved", reject: "aftersale.rejected", confirmReceipt: "aftersale.receiptConfirmed", - refund: "aftersale.refunded", + refund: "aftersale.refundSubmitted", + retryRefund: "aftersale.refundRetried", }; async function run(action: ShopAction): Promise { busy.value = true; error.value = ""; notice.value = ""; + let done = translate(doneKey[action]); try { if (action === "approve") await $api.shop.approveAftersale(aftersaleId.value); else if (action === "reject") await $api.shop.rejectAftersale(aftersaleId.value); else if (action === "confirmReceipt") await $api.shop.confirmAftersaleReceipt(aftersaleId.value); - else await $api.shop.refundAftersale(aftersaleId.value); - notice.value = translate(doneKey[action]); + else { + // Authoritative state: a wallet refund may complete immediately, an + // external original-method refund stays pending until verified success. + const result = await $api.shop.refundAftersale(aftersaleId.value); + if (result.status === "refunded") done = translate("aftersale.refunded"); + } + notice.value = done; } catch (err: unknown) { showError(err); } finally { @@ -256,6 +294,50 @@ onMounted(() => {
+ +

{{ $t("aftersale.refundSection") }}

+
+ {{ + $t(`aftersale.refundState.${refundOperation.status}`) + }} + {{ $t(`aftersale.method.${refundOperation.method}`) }} +
+
+
+ {{ $t("aftersale.amount") }} + {{ fmt(refundOperation.amount_minor, refundOperation.currency) }} +
+
+ {{ $t("aftersale.refundSubmittedAt") }} + {{ formatDate(refundOperation.created_at) }} +
+
+ {{ $t("aftersale.refundUpdatedAt") }} + {{ formatDate(refundOperation.updated_at) }} +
+
+ {{ $t("aftersale.refundReference") }} + {{ refundOperation.reference }} +
+
+

+ {{ $t("aftersale.refundError") }}: + {{ refundOperation.error }} +

+

+ {{ $t("aftersale.refundPendingNote") }} +

+

+ {{ $t("aftersale.refundUnknownNote") }} +

+

+ {{ $t("aftersale.refundFailedNote") }} +

+

{{ $t("aftersale.refundSucceededNote") }}

+
+

{{ $t("common.actions") }}

@@ -277,6 +359,13 @@ onMounted(() => { {{ $t("aftersale.refund") }} + {{ $t("aftersale.refundRetry") }}
diff --git a/apps/shop-admin/pages/aftersales/index.vue b/apps/shop-admin/pages/aftersales/index.vue index b5b7cae..26e60d2 100644 --- a/apps/shop-admin/pages/aftersales/index.vue +++ b/apps/shop-admin/pages/aftersales/index.vue @@ -3,17 +3,22 @@ import type { Aftersale, AftersaleStatus } from "@vmall/shared"; definePageMeta({ middleware: "auth" }); +/** Shared status union plus the asynchronous original-method refund states. */ +type ShopAftersaleStatus = AftersaleStatus | "refund_pending" | "refund_failed"; + const { $api } = useNuxtApp(); const { locale, t: translate } = useI18n(); const { load: loadMoney, fmt } = useMoney(); const aftersales = ref([]); -const status = ref<"" | AftersaleStatus>(""); +const status = ref<"" | ShopAftersaleStatus>(""); const loading = ref(true); const error = ref(""); -function statusClass(value: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" { +function statusClass( + value: ShopAftersaleStatus, +): "green" | "red" | "orange" | "blue" | "gray" { if (value === "refunded") return "green"; - if (value === "rejected") return "red"; + if (value === "rejected" || value === "refund_failed") return "red"; if (value === "cancelled") return "gray"; if (value === "pending") return "orange"; return "blue"; @@ -63,6 +68,8 @@ onMounted(() => { + + diff --git a/docs/TBD-payments.md b/docs/TBD-payments.md new file mode 100644 index 0000000..f0e18ee --- /dev/null +++ b/docs/TBD-payments.md @@ -0,0 +1,34 @@ +# TBD — 支付上线前事项(add-configurable-payments 归档遗留) + +归档时间:2026-10-15。以下条目是 `add-configurable-payments` 中依赖外部资源或 +额外覆盖、在归档时如实保留未完成的事项。**任何 sandbox/live 部署前必须全部关闭。** + +## 外部前置(阻塞 sandbox/live 激活) + +- [ ] **Stripe 账户尽调(原 task 1.1)**:复核 Stripe 官方文档,记录 pinned API + 版本、crypto-only Checkout 参数、paid/refund 事件、交易限额与商户资格;确认 + hosted crypto 权限与 sandbox/testnet 钱包可用性(有效 API key 不等于已获批)。 +- [ ] **真实 sandbox 全链路(原 task 10.2)**:用合格 Stripe sandbox 账户 + + testnet 资金完成 hosted crypto 支付,验证签名回调入账 USD receipt/application, + 并执行一次部分 original-wallet 退款,留存外部证据。 +- [ ] **Worker 重启恢复 + 渠道轮换演练(原 task 10.3)**:实际重启 worker 并 + 在有未完成操作时禁用渠道/轮换密钥,确认回调与 original-method 退款继续、 + unknown 状态不会重复收款、补偿只恢复未支付资源一次。 +- [ ] **mobile 端浏览器走查(原 task 10.4 残余)**:mobile checkout/return + reload、pending/failure/refund UX;伪造成功参数、过期方法、不安全取消均不能 + 伪造完成态。 + +## 覆盖缺口(不阻塞归档,建议补齐) + +- [ ] **Stripe 协议行为用例(原 task 5.6)**:未支付完成会话、延迟结算、 + 团购席位丢失、多笔真实收据、补偿成功重放、金额/币种不匹配隔离、 + crypto 精度无损的集成测试。现有覆盖:伪造签名拒绝 + (`forged_stripe_webhook_is_rejected_without_payment_effects`)。 + +## 已验证基线(归档时证据) + +- `cargo test -p vmall-api` 两轮全绿(payments 11/11、wallet 7/7、aftersales 10/10 等)。 +- 四端构建全绿:@vmall/mall、@vmall/mobile、@vmall/admin、@vmall/shop-admin。 +- demo 环境浏览器走查:mall 加购→结算→钱包支付成功(VM260928000005,$315.96)、 + 余额不足 400、demo 充值、订单 Paid、`order_paid` 消息;admin 渠道与操作列表/ + 详情渲染正确。 diff --git a/docs/code_index/order.md b/docs/code_index/order.md index 5337ac4..1eb5553 100644 --- a/docs/code_index/order.md +++ b/docs/code_index/order.md @@ -1,34 +1,51 @@ # Index: Order & Freight Domain design: `docs/domains/orders.md`. Specs: `openspec/specs/order/`, -`shipment/`, `shipping/`. +`shipment/`, `shipping/`, `payment/` (change `add-configurable-payments`). ## Backend (`apps/api`) | File | Holds | |---|---| -| `src/modules/order/service.rs` | checkout transaction, pay/cancel, shipping quote | +| `src/modules/order/service.rs` | checkout transaction (payment membership + header-hash idempotency), cancel, shipping quote | | `src/modules/order/repo.rs` | `ORDER_COLS`/`ORDER_ITEM_COLS`, insert_order/insert_item, locks, status transitions | | `src/modules/order/handlers.rs` | `/orders*` customer routes, `/shop/orders*`, `/admin/orders` | | `src/modules/order/dto.rs` | OrderView, AddressBody, ShippingQuoteView | +| `src/modules/payment/service.rs` | payment resolution, eligible methods, attempts, wallet collection, group cancellation | +| `src/modules/payment/repo.rs` | payments/memberships/checkout_requests/attempts/receipts/refunds/commands/events rows | +| `src/modules/payment/adapters.rs` | wallet + Stripe crypto-only Checkout adapter boundary, capabilities | +| `src/modules/payment/worker.rs` | durable command lease/execute/recovery, supervised worker | +| `src/modules/payment/webhooks.rs` | raw-body Stripe signature/timestamp verification, dedup inbox | +| `src/modules/payment/refunds.rs` | original-method refund budgets, wallet/Stripe refund submission | +| `src/modules/payment/admin.rs` | channel CRUD/revisions/audit/verify, operations list/detail, reconcile | +| `src/modules/payment/handlers.rs` | `/payments*` owner routes, `/admin/payments*`, `/payments/webhooks/stripe/{id}` | +| `src/modules/payment/dto.rs` | payment DTOs, text enums, config shapes | | `src/modules/fulfillment/` | shipments: create (company-validated), ship, confirm-delivered | | `src/modules/freight/service.rs` | template CRUD, region rules, `shop_fee()` calculation, company dictionary | | `src/modules/freight/handlers.rs` | `/shop/freight-templates*`, `/shipping/companies` | | `migrations/0004_orders.sql` | orders/order_items/shipments base schema | | `migrations/0017_freight_templates.sql` | freight tables, `orders.shipping_fee_minor`, item snapshots, `skus.weight_grams` | -| `tests/orders.rs`, `tests/freight.rs` | integration coverage | +| `migrations/0024_payments.sql` | payments domain, legacy-demo provenance, aftersale refund states | +| `migrations/0025_checkout_request_header_hash.sql` | cart-independent header hash for checkout idempotency | +| `tests/orders.rs`, `tests/freight.rs`, `tests/payments.rs` | integration coverage | ## Shared contract (`packages/shared/src`) `types.ts`: `Order`, `OrderItem`, `Shipment`, `FreightTemplate*`, -`ShippingCompany`, `ShippingQuote`. `api.ts`: `checkout`, `quoteShipping`, -`listShippingCompanies`, shop freight CRUD, `createShipment`. +`ShippingCompany`, `ShippingQuote`, `Payment`, `CheckoutResult`, +`PaymentAttemptResult`, `EligibleMethod`, `NextAction`, `PaymentChannel*`, +`PaymentOperation*`, `RefundOperation`. `api.ts`: `checkout`, +`resolvePayment`, `getPayment`, `listPaymentMethods`, `createPaymentAttempt`, +`cancelPayment`, `quoteShipping`, `listShippingCompanies`, shop freight CRUD, +`createShipment`, admin channel/operations/reconcile methods. ## Frontends - Mall: `pages/checkout/{index,pay,success}.vue`, `pages/user/orders/`, `pages/user/addresses.vue` +- Mobile: `pages/checkout/`, `pages/orders/` (configured payment + return handling) - Shop-admin: `pages/orders/`, `pages/shipments.vue`, `pages/freight-templates.vue`, `components/ProductForm.vue` (freight template + SKU weight) -- Admin: `pages/orders.vue` (read-only) +- Admin: `pages/orders.vue` (read-only), `pages/payment-channels.vue`, + `pages/payments.vue` (operations list/detail/reconcile) diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/.openspec.yaml b/openspec/changes/archive/2026-10-15-add-configurable-payments/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/design.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/design.md new file mode 100644 index 0000000..20e0a7d --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/design.md @@ -0,0 +1,148 @@ +## Context + +The existing `order::service::pay` locks one order, claims a group seat, marks it paid, and emits a message, but collects no funds. The mall's payment radio selection is not submitted, and its per-order loop permits partial multi-shop completion. Aftersale completion always credits a wallet. Account primitives already support guarded debits and immutable entries; settlement already consumes fiat-denominated order/refund snapshots. + +This change is a coordinated contract cutover, not a compatibility layer. It covers platform collection and refunds, not external merchant payouts or permission to operate a regulated marketplace. + +## Goals / Non-Goals + +**Goals:** +- One server-owned payment unit for a checkout's shop orders, with all-or-none local application. +- Real implementations of wallet and Stripe-hosted stablecoin collection, sharing lifecycle and refund contracts. +- Safe recovery across external calls, crashes, repeated requests/events, cancellation, and configuration changes. +- Admin-configured availability with protected credentials, environment isolation, and operational recovery. + +**Non-Goals:** +Self-custody/private keys, chain indexing, native BTC/ETH, crypto-priced products, split tender, subscriptions, automatic FX, card/WeChat/Alipay integrations, arbitrary uploaded adapters, external merchant payouts, and unrelated type-check remediation. Do not add placeholder adapters for these methods. A general abandoned-cart/order-expiration scheduler is outside this change; provider-attempt expiry and payment-safe cancellation are included. + +## Decisions + +### 1. Separate payment, attempt, receipt, and order allocation + +Use `modules/payment/` with handlers → service → repo and concrete adapters behind a small provider boundary; configuration stays in that bounded context. Do not introduce generic repository traits or a new service deployment. + +Persist: +- `payments`: owner, immutable fiat currency/total, environment, application state (`unapplied`, `applied`, `closed`), and timestamps. +- `checkout_requests`: owner-scoped checkout idempotency keys and request hashes with their durable payment id, so an uncertain checkout response or uncleared Redis cart replays the original orders instead of creating a duplicate inventory allocation. +- `payment_orders`: immutable order membership and amount, unique `order_id`, foreign keys to both records. All orders in a checkout belong to one payment; a subsequent attempt reuses that payment. An order-detail pay action resolves its existing payment rather than silently making a second group. +- `payment_attempts`: payment, channel revision, request hash/idempotency key, provider operation key/reference, collection state (`created`, `pending`, `unknown`, `settled`, `failed`, `expired`), and next action. At most one unresolved attempt per payment, enforced by a partial unique index. Settled receipts with unresolved application/refund also block replacement attempts. +- `payment_receipts`: immutable actual collections, unique provider-account/environment/transaction identity, amount/currency, provider evidence and optional crypto metadata. Different successful collections remain different rows even when they concern the same payment. +- `payment_refunds`: original receipt, optional allocation/aftersale, amount/currency, idempotency key/reference, purpose (`aftersale` or `compensation`), and `pending`, `unknown`, `succeeded`, or `failed` state. Definitively failed operations retain history; a permitted replacement has a new operation identity and must not overlap an unresolved one. +- `payment_commands` and `payment_events`: durable provider work and verified inbox, with lease/recovery fields, due times, processed markers and bounded error details. +- `payment_channels`, immutable `payment_channel_revisions`, and append-only configuration audit. Revisions hold secret references, never plaintext credentials. + +Checked integer arithmetic establishes `payment.total = SUM(payment_orders.amount)` and each membership amount equals its order's snapshotted total. One applied receipt covers the entire fixed set. Provider fees do not change the shopper's order totals or existing merchant commission policy. Reject monetary values that cannot be represented exactly by the existing JSON-number contract; asset quantities use strings. + +Alternative rejected: one provider charge per shop from a frontend loop. That cannot provide checkout-wide atomic application and causes avoidable fees and recovery ambiguity. + +### 2. Centralize atomic local application + +Every operation that can pay/cancel/refund an order uses the same lock order: payment, member orders sorted by UUID, relevant group rows sorted by UUID, then account rows sorted by UUID. Cancellation/compensation additionally locks all affected SKU, flash-sale item and coupon rows sorted by UUID before any restoration. Integrate existing services with caller-owned transactions rather than nested transactions or independent commits. A per-payment row lock serializes payment, cancellation, and refund-budget changes. Reject a computed payment total of zero before writing orders, inventory, coupons, or payment membership; this change does not add a no-charge free-order path. + +For wallet: lock and revalidate owner, channel, all order states and group eligibility; debit the available account once; write its ledger entry and settled receipt; claim all group seats; mark all member orders paid; record allocations/application and messages; commit together. Insufficient balance or any group failure rolls everything back. Wallet is eligible only when the order currency equals the base-currency wallet; there is no implicit conversion. Use the account module, never an absolute balance write. + +For external receipts: record evidence durably first. In a separate transaction, validate provider account/environment, reference, amount and currency; lock the payment and every member order; apply the receipt, group seats, all order transitions and messages atomically. This gives at-most-once local business effects, not an impossible cross-system ACID transaction. + +A valid payment that can no longer be applied (cancelled order, lost group capacity, closed payment) is recorded as received-but-unapplied and schedules a full original-method compensating refund. An additional genuine collection is also retained and compensated. No subset of the checkout becomes paid. For group rejection, roll back all seat claims before scheduling compensation. Exact amount/currency mismatches are quarantined, never fulfilled or auto-credited; operators resolve them against authoritative provider records. + +When full compensation for a never-applied payment succeeds, atomically close that payment, cancel its still-pending member orders and restore their stock/activity inventory/coupons once through the shared cancellation path. Never release resources while collection/refund outcome is unknown. Compensation of an extra receipt for an already-applied payment leaves its legitimate paid orders and fulfillment untouched. This distinction prevents indefinite stock retention without cancelling a valid purchase. + +Group seat timing stays payment-time, preserving the archived rule that unpaid shoppers do not occupy seats. Temporary reservations were considered but rejected because they alter group semantics and still cannot eliminate late external transfers. This choice requires explicit compensation and a visible refund-pending state. + +### 3. External operations use durable commands, not open SQL transactions + +Persist an attempt and create-session command in one transaction. A worker claims due rows with recoverable leases, releases SQL locks, calls Stripe with a stable operation key, then persists the response. A provider timeout produces `unknown`, not failure. Cancellation, method switching and replacement charges remain blocked until reconciliation establishes a definitive safe outcome. + +The worker runs under the API process's supervised lifecycle, supports multiple API instances through database leases, and resumes work after restart. No Redis queue or new broker is necessary. Shutdown stops new claims; expired leases are reclaimable. Poll due work on a bounded interval; back off failed external calls and surface persistent unknowns for admin reconciliation without inventing a terminal result. + +Stripe API-v1 idempotency keys can be pruned after at least 24 hours. Therefore durable local identity is permanent, but automatic create/refund retries use a conservative window shorter than 24 hours. After that window, inspect known provider objects and recorded notifications or perform provider lookup by the persisted attempt metadata; never blindly reissue an ambiguous operation. If evidence cannot establish absence, leave the operation unknown and block new collection. Administrative reconcile requests fetch evidence; they do not assert paid or refunded. + +Verify webhook signatures over the unmodified body, including timestamp tolerance. Resolve credentials by provider account, environment and revision; require provider account/livemode/reference/amount checks. Persist the verified event before returning 2xx; database failure returns non-2xx. Deduplicate by provider account, environment and event id. Process events through the same receipt/application service as reconciliation. Notifications may arrive before the create response or out of order; signed attempt metadata and provider lookups allow attachment, and stale events cannot regress settled/succeeded records. Browser success/cancel URLs never prove collection. + +### 4. Implement Stripe crypto as hosted acceptance with fiat settlement + +Use Stripe Checkout Sessions in payment mode, scoped explicitly to the `crypto` method so a VMall crypto channel cannot silently offer cards. Persist the Checkout Session and PaymentIntent references and VMall payment/attempt metadata. Pin a supported Stripe API version in implementation and record the adapter contract version in channel revisions. Fulfill only when authoritative provider state establishes successful paid collection, not simply `checkout.session.completed` or a redirect; support asynchronous success/failure notifications and retrieval-based reconciliation. + +The channel is eligible only for USD orders, an approved account, and the lower of the configured limit and Stripe's documented USD 10,000 per-transaction ceiling. USD is the shopper-presentment and VMall receipt currency; Stripe settles to the platform Stripe balance in that account's local currency. Activation must verify the account's supported settlement currency and record it as provider-settlement metadata rather than claim USD balance settlement universally. No auto-FX or hardcoded stablecoin peg is introduced. Merchant statements continue to use existing fiat accounting. Approval for VMall's marketplace business model is an external activation prerequisite, not implied by an API key. + +Stripe hosts wallet connection, token/network selection, pricing, confirmation and original-wallet refunds. VMall does not pretend to control chain confirmation counts, networks, token lists, quote duration, or underpayment tolerances that the hosted API does not expose. The admin shows provider-managed choices as informational; it cannot save unsupported restrictions. Attempt expiry follows the returned Checkout Session contract. A provider-expired unpaid session can close its attempt, but late actual collection still follows receipt/compensation handling. + +When exposed by Stripe, persist asset identity (network and contract/native marker), integer base-unit amount as a decimal string, provider quote/reference/expiry, and transaction references. Missing chain details remain absent; never derive or fabricate them from fiat totals. `currencies` remains an ISO fiat registry. No crypto values pass through `formatMoney` or TS `number` arithmetic. + +Sources checked during proposal creation: +- https://docs.stripe.com/payments/stablecoin-payments — stablecoin support, business eligibility, USD presentment, limits, full/partial original-wallet refunds. +- https://docs.stripe.com/payments/accept-stablecoin-payments.md?payment-ui=checkout — hosted flow, account approval, USD settlement and testnet testing. This guide currently states US states except New York; eligibility must be rechecked during activation rather than inferred from a broader overview/private preview. +- https://docs.stripe.com/api/idempotent_requests — stable request keys and API-v1 retention behavior. +- https://docs.stripe.com/checkout/fulfillment — authoritative payment-state fulfillment guidance. + +### 5. Refunds reserve budgets and finish only on actual success + +A merchant/admin refund request validates the existing aftersale prerequisites, locks the payment and allocation, reserves refundable budget counting both pending/unknown and succeeded operations, creates one refund and command, and moves the application to `refund_pending`. Buyers cannot cancel after funds have been submitted for refund. Refund-only starts from approved; return-refund starts from merchant-confirmed; arbitration uses the same service. + +Wallet refunds execute the account credit, ledger entry, order total increment, aftersale transition and notification in one transaction and can return immediately completed. Stripe refunds use the original successful PaymentIntent/charge and fiat amount; Stripe returns stablecoins to the original wallet. VMall never credits an external payment's refund to the internal wallet. Only verified refund success increments `refund_total_minor`, completes the aftersale and emits `refund_completed`; pending/unknown remains visible without a false success. A definitive failure moves to `refund_failed`, releases its reserved budget and permits an explicit guarded retry; unknown operations never release budget or accept replacement requests. + +The aggregate refund budget includes compensation and aftersales across all allocations. Compensation for an unapplied receipt does not increment any order's aftersale totals. Existing settlement snapshots remain immutable; post-snapshot refunds retain existing accounting semantics and are visible in payment operations. Automated settlement adjustments are outside this collection change and must not be represented as external payouts. + +### 6. API and shared frontend contract + +All paths below have the `/api` prefix. Export request/response types only from `@vmall/shared`. + +- `POST /orders/checkout` requires an owner-scoped `Idempotency-Key`. Its server-side request hash includes address, currency, coupons, group intent and the cart snapshot. Replaying the same key/payload returns the original `{ orders, payment_id }`; reusing the key with different input returns 409. Checkout returns `{ orders, payment_id }`; creates payment membership within the checkout transaction. Migrate all consumers and fixtures together. A computed zero payment total is rejected before order/resource writes. +- `POST /payments` with owned pending `order_ids` resolves their existing exact payment membership idempotently, or creates a singleton for an ungrouped legacy pending order. It cannot regroup existing memberships or accept browser amounts. +- `GET /payments/{id}` returns authoritative orders/allocations, attempt summary, refund/exception status and safe next action. +- `GET /payments/{id}/methods` returns only eligible enabled channels and safe descriptors. No secrets or provider-internal configuration. +- `POST /payments/{id}/attempts` takes channel id, `return_surface: "mall" | "mobile"`, and `Idempotency-Key`; same owner/key/payload returns the same operation, different payload is 409. The server maps the typed surface to an allowlisted deployment origin and persists it on the attempt. Amount and currency derive from the locked payment. Returns wallet completion or 202 with pending/unknown/redirect next action. +- `POST /payments/{id}/cancel` cancels the entire unpaid payment group only when every attempt is conclusively non-collecting; otherwise 409 plus authoritative state. It restores all members' SKU/activity stock and coupons once and invokes existing opener-group cancellation. Existing single-order cancel delegates to this same group-safe behavior and returns the requested order; explain affected siblings in UI. +- `POST /payments/webhooks/stripe/{channel_id}` authenticates through Stripe signature, not JWT; do not require a customer's bearer token. +- Admin-only catalog/channel CRUD, immutable revisions and audit under `/admin/payments/channels`; credential reference updates require a new revision and optimistic revision precondition. `POST .../{id}/verify` performs a read-only provider connection/account check. No successful probe substitutes for Stripe approval. +- Admin-only paginated payment/refund/exception listing/details and `POST /admin/payments/{id}/reconcile`. Reconcile is a durable evidence-fetch command; repeated requests do not initiate collection. +- Existing shop/admin aftersale refund endpoints return the authoritative aftersale plus refund-operation state; do not assert synchronous external completion. + +Remove `/orders/{id}/pay`, `payOrder`, its mock implementation and every caller; do not keep deprecated aliases. Buyer reads are own-only with cross-owner 404. Shop users see only their order allocation/refund context through existing own-shop endpoints, never another shop's breakdown or processor credentials. Config/reconciliation endpoints require platform_admin. + +Use typed next-action discriminants (`none`, `redirect`, `wait`) for these actual adapters. Do not implement unused QR/manual-address variants. Return URLs are constructed from deployment-configured mall/mobile origin allowlists and payment ids, not arbitrary client URLs. All success pages re-fetch payment status and remain pending until confirmed; use a bounded polling interval while visible, stop on unmount/terminal status, and allow explicit refresh. Payment URLs contain stable ids so auth redirects and browser reloads recover without in-memory checkout arrays. + +Mall fixed-data mode implements deterministic wallet success, insufficient funds, pending external and refund states without contacting Stripe. Payment and order/cart mock/live selection must be coherent: enforce transaction-domain grouping or reject mixed live/fixed selections at startup rather than send fixture order ids to real collection. Mobile remains live-only. All four apps rebuild after shared changes, including merchant aftersale and admin operations pages; use existing shared UI primitives, en/zh copy, and loading/error/409 conventions. + +### 7. Admin channels are versioned, constrained configuration + +Ship only `wallet` and `stripe_crypto` adapters. Admin edits bilingual label, display order, enabled status, supported fiat currencies and integer amount limits within adapter capabilities. Deployment payment environment is immutable (`demo`, `sandbox`, `live`); admin cannot toggle it to bypass deployment isolation. Provider account and secret references belong to the same environment. Wallet eligibility is limited to base currency; Stripe crypto to USD. Enabling requires valid configuration and a successful provider/account capability check; no admin control overrides upstream availability. + +Use secret references resolved by a server-side secret resolver with deployment-allowlisted identifiers (for example environment variable references under a payment-specific prefix). Admin never receives key values and cannot reference arbitrary host variables. Missing secrets fail closed. No plaintext credential columns, key material in audit, arbitrary provider base URLs, or logged authorization/webhook bodies. The compiled Stripe adapter fixes API hosts. Rotate via a new revision while retaining old references/signing secrets for outstanding events, refunds and historical reconciliation. Disabling affects only creation; it does not strand old work. Channel deletion is archival when referenced. Audit actor/time/revision and redacted changes; optimistic concurrency returns 409 for stale edits. + +### 8. Environment isolation and clean legacy cutover + +Add immutable environment tags to payment records and validate Stripe livemode/account consistency. Demo recharge is allowed only in `demo`; `sandbox` and `live` refuse it server-side. Sandbox uses its own database, provider account/secrets and testnet assets; seed any wallet smoke funds only through explicit non-live fixtures/ledger operations. + +Historical status-only paid orders are marked `legacy_demo`, not backfilled with fictional captures. Demo legacy aftersales retain their existing wallet-credit behavior and are clearly labeled; they cannot use Stripe refunds. Existing pending orders get singleton payment membership on demand because no durable original checkout grouping exists. The migration does not invent historic multi-shop associations. + +Live mode requires a clean financially isolated database: startup/activation refuses legacy-demo paid history, demo recharge/monetary seed entries or non-live payment records. Never convert existing demo balances into spendable live money by toggling configuration. Demo/sandbox/live databases are separate deployments; this change supplies no financial-data promotion tool. Existing historical settlement statements remain immutable demo records and are not evidence of external payouts. + +## Risks / Trade-offs + +- External payment cannot roll back with SQL → durable evidence, atomic local application and original-method compensation; never promise distributed ACID. +- Processor approval or sandbox assets unavailable → keep the channel disabled, record the exact external prerequisite, and do not claim crypto verification from fixtures. Provider smoke is an acceptance gate. +- Group capacity changes during checkout → full compensation instead of partial fulfillment; no silent reservation-policy change. +- Credentials rotated/disabled during processing → immutable revision references and retained old signing-secret references. +- A processor operation outlives its idempotency window → reconcile by evidence, never blindly replay; remain unknown when absence cannot be established. +- Original-method refund fails or remains unknown → show the unresolved operation and preserve its budget appropriately; no internal-wallet fallback. +- Settlement snapshots precede later refunds → keep existing immutable-statement semantics visible; do not claim automated post-settlement netting or bank payout support. +- Changing checkout and refund DTOs breaks consumers → one coordinated API/shared/four-app cutover, not aliases. + +## Migration Plan + +1. During implementation, inspect the current migration sequence and append the next free migration; do not assume the previously observed `0024` is still free. Create payment/configuration/command/event/refund tables and constraints, legacy provenance, and aftersale states without altering old migration files. +2. Quiesce API writes during coordinated deployment of backend, shared client and all four apps. Create no enabled external channels by default; retain an explicitly labeled demo wallet only in demo deployments. Do not seed live funds. +3. Start supervised workers, verify replay/lease recovery, register environment-specific webhooks, and validate account capability and secret references. Enable sandbox crypto only after the real hosted flow and refund smoke pass. +4. Live activation is separate and requires a supported approved merchant account, matching USD configuration, registered webhook, clean live database and operations sign-off. Collection support alone does not certify external merchant payouts or regulatory readiness. +5. Rollback means disable new attempts and continue the compatible worker/webhook/refund release for already-started work. Do not revert to status-only payment or downgrade database schema while financial work exists. Restore an older release only if it understands all in-flight payment records; otherwise forward-fix. Keep migrations append-only. + +## Open Questions + +No product-scope decision blocks artifact completion: use hosted stablecoins with fiat settlement, Stripe crypto plus wallet, payment-time group claims with compensation, and platform-owned collection. Deployment prerequisites remain external and unverified: Stripe merchant eligibility/approval for this marketplace, actual API/signing-secret references, provider API-version selection, and access to a test wallet/testnet assets. Resolve and record these before provider acceptance tests or live activation; they are not permission to substitute mock processing. + +## Verification and Acceptance + +Preserve regression coverage for wallet concurrency, multi-shop rollback, idempotency payload conflicts, group-seat races, duplicate and out-of-order events, mismatched account/environment/amount, orphan create responses, expired provider idempotency windows, durable restart, cancellation races, and refund-budget oversubscription. Exercise provider protocol with an isolated HTTP fixture for deterministic integration failures, but do not mistake that for provider acceptance. + +Run the actual API against local Postgres/Redis; prove a multi-shop wallet debit and original-wallet refund from ledger rows and order state. With eligible sandbox credentials, complete the real Stripe-hosted crypto flow using testnet funds, receive its signed callback, reload each storefront's return URL, and complete a partial original-wallet provider refund. Observe worker restart recovery and admin disable/rotation while an attempt exists. Browser-check mall, mobile, admin configuration/operations and merchant aftersale status surfaces; confirm no redirect/query parameter can manufacture success. Run `cargo test -p vmall-api` twice, all four filtered frontend builds, strict change validation and finally strict all-spec validation. Update affected README/domain/code-index documents and remove throwaway smoke scaffolding after proof. diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/proposal.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/proposal.md new file mode 100644 index 0000000..fd4aaab --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/proposal.md @@ -0,0 +1,37 @@ +## Why + +Payment currently changes order status without collecting funds, ignores the storefront's selected method, and processes multi-shop payments sequentially. Configurable wallet and crypto payments need one auditable lifecycle with atomic local effects and explicit handling of external settlement. + +## What Changes + +- Add checkout-wide payments, per-order allocations, idempotent attempts, durable provider commands/events, reconciliation, and original-method refunds. +- Implement atomic wallet payment and Stripe-hosted stablecoin Checkout with USD presentment and provider-local settlement; require an eligible, approved Stripe account. +- Add versioned admin channel configuration, secret references, environment isolation, and payment/refund operations. +- **BREAKING**: replace status-only `payOrder` and its endpoint; migrate every caller and both storefronts to shared payment contracts. +- Handle external receipts after cancellation, expiry, or lost group capacity by tracked compensating refunds, never partial order application. + +## Capabilities + +### New Capabilities + +- `payments`: Collection, allocations, adapters, recovery, crypto processing, refunds, and legacy cutover. +- `payment-configuration`: Admin-managed channels, credentials, eligibility, revisions, and audit. + +### Modified Capabilities + +- `order`: Payment-backed transitions and payment-safe cancellation. +- `group-buying`: External receipt compensation when a seat cannot be claimed. +- `aftersale`: Asynchronous original-method refund completion. +- `wallet`: Environment-gated demo recharge. +- `frontend-mall`: Configured methods and reload-safe payment outcomes. +- `frontend-mobile`: Equivalent mobile payment lifecycle. +- `frontend-admin`: Channel management and payment/refund operations. +- `frontend-shop-admin`: Pending and completed original-method refund results. + +## Impact + +Rust API, append-only migrations, account/order/aftersale integrations, `@vmall/shared`, fixed-data adapter, all four Nuxt apps, worker lifecycle, and domain/code-index documentation. Requires Stripe credentials, webhook forwarding, and an approved merchant account for provider smoke verification. + +## Non-goals + +Self-custody, native BTC/ETH transfers, crypto-denominated product prices, split tender, subscriptions, arbitrary adapter uploads, automatic FX, external merchant payouts, and unrelated frontend type-check cleanup. diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/aftersale/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/aftersale/spec.md new file mode 100644 index 0000000..7475879 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/aftersale/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Guarded aftersale state machine +Aftersales SHALL use guarded status transitions: pending to approved or rejected; refund_only from approved into the payment-backed refund path; return_refund from approved to buyer_shipping to merchant_confirmed into that same path. External submission SHALL transition the eligible application to refund_pending, then refunded only after verified success, or refund_failed after definitive failure. An unknown operation SHALL remain unresolved and SHALL NOT allow replacement submission. An explicit retry from refund_failed SHALL reserve budget again and create a new operation only when its predecessor definitively failed. A customer MAY cancel a non-terminal application before refund submission, but SHALL NOT cancel refund_pending or refund_failed. A rejected application MAY reopen to pending once. Every transition SHALL guard the expected state and reject stale or illegal actions with 409. Refund-pending and refund-failed applications SHALL remain active for the one-active-application constraint. + +#### Scenario: merchant approves a return +- **WHEN** the owning shop approves a pending return_refund application +- **THEN** its status becomes approved and the customer can submit return tracking + +#### Scenario: merchant confirms returned goods +- **WHEN** the owning shop confirms buyer-shipped returned goods and requests a refund +- **THEN** the service records merchant_confirmed and either completes an atomic wallet refund or shows refund_pending for external processing + +#### Scenario: buyer cancels before completion +- **WHEN** a customer cancels an eligible application before any refund submission +- **THEN** its status becomes cancelled and later merchant transitions return 409 + +#### Scenario: buyer cannot cancel submitted refund +- **WHEN** a customer attempts cancellation after a refund was submitted +- **THEN** the API returns 409 and preserves the refund operation and its reserved budget + +#### Scenario: rejected appeal is limited +- **WHEN** a customer reopens a rejected application for the first time +- **THEN** it returns to pending and a second reopen attempt returns 409 + +### Requirement: Ledger-backed refund completion +Refund completion SHALL use the original payment method and a guarded, receipt/allocation-bounded refund operation. Wallet completion SHALL atomically credit the customer's available account with an immutable aftersale-referenced entry, increment integer order refund_total_minor and complete the aftersale. External submission SHALL NOT credit the internal wallet or increment completed-refund totals. Only verified provider refund success SHALL atomically complete the aftersale, increment order refund_total_minor and create the idempotent refund_completed system-message event. Pending and unknown refunds SHALL reserve budget. Demo-only legacy status-paid orders SHALL use the explicitly labeled legacy wallet-credit path and SHALL NOT be refunded through a fabricated provider capture. Concurrent completion SHALL return the completed result without duplicate credits, totals or events. + +#### Scenario: completed refund credits the customer +- **WHEN** a valid wallet-funded aftersale completes +- **THEN** one credit and immutable ledger entry increase available balance by the requested amount and the order refund total increases by the same amount + +#### Scenario: external refund waits for success +- **WHEN** Stripe accepts the original-payment refund but has not confirmed success +- **THEN** the application remains refund_pending, its budget remains reserved and neither wallet balance nor completed order refund total changes + +#### Scenario: concurrent completion is idempotent +- **WHEN** two workers apply the same refund-success evidence +- **THEN** one completion increments the order total and emits the event and the other observes the completed result + +### Requirement: Optional platform arbitration +A platform administrator SHALL view applications across shops and MAY resolve an escalated application with rejection or the same original-method refund path as merchant processing. Arbitration SHALL enforce amount limits, receipt/allocation budgets, expected states and immutable audit/message records without bypassing them. External refund arbitration SHALL remain refund_pending until provider success, rather than declaring terminal refund at submission. + +#### Scenario: platform grants a disputed refund +- **WHEN** a platform administrator resolves an eligible dispute in the customer's favor +- **THEN** the application follows its original wallet or provider refund path and reaches refunded only on actual completion + +#### Scenario: platform rejects a dispute +- **WHEN** a platform administrator rejects an eligible escalated application +- **THEN** it becomes rejected and only the permitted one-time customer reopen can resume it diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-admin/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-admin/spec.md new file mode 100644 index 0000000..bb5522f --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-admin/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Payment channel management console +The platform console SHALL provide navigable channel management through the shared client: installed adapter capabilities, bilingual label, display order, enabled state, supported fiat currencies, integer limits, environment, provider account, allowlisted secret references, read-only connection verification and revision/audit history. It SHALL show missing approval/credentials and reject unsupported controls. Existing secret values SHALL never be displayed. Disabling SHALL explain that it stops new attempts but preserves existing callback/refund processing. Stale edits SHALL show 409 and retain user input while refreshing authoritative revision context. All controls SHALL use shared UI primitives and en/zh copy. + +#### Scenario: configure and activate crypto +- **WHEN** an administrator saves valid USD Stripe crypto configuration and successfully verifies the approved account +- **THEN** activation becomes available and the enabled channel appears to eligible storefront payments + +#### Scenario: credential reference is missing +- **WHEN** verification cannot resolve a configured allowlisted secret reference +- **THEN** the UI shows the unmet prerequisite without exposing a secret and does not claim the method is ready + +### Requirement: Payment and refund recovery console +The platform console SHALL provide paginated payment/refund/exception lists and details, showing allocation breakdown, receipt evidence, attempts, pending/unknown states and redacted audit history. Reconciliation SHALL request evidence fetching through the shared API; the UI SHALL never offer an unconditional mark-paid/refunded action. Received-but-unapplied and additional genuine collections SHALL show compensation progress. Disabling a channel SHALL not remove historical records from operations views. + +#### Scenario: recover provider timeout +- **WHEN** an administrator reconciles an unknown attempt +- **THEN** the console shows queued evidence lookup and later authoritative status without starting a second collection + +#### Scenario: seat rejection after collection +- **WHEN** an external receipt could not be applied to a group-bearing checkout +- **THEN** its receipt, unchanged order application and compensating refund remain inspectable + +## MODIFIED Requirements + +### Requirement: Platform aftersale monitoring and arbitration +The platform console SHALL provide a cross-shop aftersale list/detail with customer, shop, order-item, amounts, evidence, status, message history, completed totals and original-payment refund-operation context. Administrators SHALL resolve eligible disputes through the same guarded original-method refund or rejection path without editing balances or claiming synchronous external completion. Pending/unknown/failed refunds SHALL show their authoritative state and permit only server-authorized actions. + +#### Scenario: admin inspects a cross-shop application +- **WHEN** an administrator opens the aftersale workspace +- **THEN** cross-shop applications, authoritative amounts and original-method refund state are available with filters + +#### Scenario: admin grants terminal refund +- **WHEN** an administrator grants an eligible refund +- **THEN** wallet completion appears immediately if committed, while crypto remains refund_pending until verified provider success updates the total + +#### Scenario: admin rejects terminal dispute +- **WHEN** an administrator rejects an eligible dispute +- **THEN** the console shows rejected and only the permitted one-time customer reopen can resume it diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mall/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mall/spec.md new file mode 100644 index 0000000..e984233 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mall/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Configured recoverable payment experience +The mall SHALL discover eligible methods from the shared payment API, submit the selected channel and idempotency key, and recover the checkout-wide payment from a stable URL after reload or sign-in. It SHALL display authoritative all-order totals, pending/unknown state, receipt exceptions and original-method refund progress, and SHALL show success only after server-confirmed application. Provider redirects SHALL use safe backend-issued destinations. While unknown, the UI SHALL disable replacement payment and unsafe cancellation. Group cancellation SHALL explain that all unpaid member orders are affected. It SHALL not display unsupported balance/WeChat/Alipay/card choices as functional. Pending polling SHALL stop on unmount or terminal status and support explicit refresh. + +#### Scenario: reload after crypto redirect +- **WHEN** a signed-in buyer reloads the provider return page before collection is confirmed +- **THEN** the mall resolves the payment through the shared client and shows pending instead of inferring success from URL parameters + +#### Scenario: admin disables a method +- **WHEN** a cached channel becomes ineligible before the buyer submits +- **THEN** the mall shows the server refusal, refreshes method availability and does not silently switch methods + +#### Scenario: group rejection is refunded +- **WHEN** external funds arrive but a required group seat is unavailable +- **THEN** the mall shows received-but-unapplied and refund progress, not a paid or ordinary retry state + +## MODIFIED Requirements + +### Requirement: Mock transaction flow +The mall SHALL provide a store-grouped cart, address-selecting checkout preview, configured payment selection and authoritative payment result using the shared cart/order/payment contracts. Quantity changes, removals, checkout and payment state SHALL persist through the selected adapter for the signed-in shopper and survive reload. A payment SHALL cover all shop orders returned by its checkout; the browser SHALL NOT loop over independent per-order pay calls. Fixed-data mode SHALL provide deterministic wallet and pending external outcomes without a provider connection. Transaction-related cart/order/payment adapter selection SHALL remain coherent; unsupported mixed live/fixed combinations SHALL fail configuration rather than submit fixture orders to real collection. + +#### Scenario: complete mock purchase +- **WHEN** a buyer completes a fixed-data checkout and selects its configured wallet method +- **THEN** the adapter clears the cart, applies all member orders and returns a recoverable payment result shown on the success surface + +#### Scenario: cart survives a reload +- **WHEN** a signed-in shopper adds an item and reloads +- **THEN** the cart still holds that item priced and stocked through the selected adapter + +#### Scenario: prevent mixed financial adapters +- **WHEN** configuration mixes fixed order ids with live payment collection +- **THEN** startup/configuration refuses that combination rather than initiating a provider operation + +### Requirement: Buyer wallet surface +The mall SHALL render the buyer wallet through the shared selected adapter: available/frozen balances and currency, paginated entries, a withdrawal request form and a clearly labeled demo recharge form only when deployment capabilities permit it. Balances and entries SHALL reflect backend state after each action. The wallet SHALL retain its fixed-adapter fallback and established live-domain wiring. Sandbox/live mode SHALL not offer simulated recharge as a way to obtain spendable funds. + +#### Scenario: wallet page loads live state +- **WHEN** a signed-in buyer opens the wallet +- **THEN** balances and the first page of entries render through the shared wallet contract + +#### Scenario: demo recharge updates balance +- **WHEN** a buyer in an explicitly enabled demo environment submits recharge +- **THEN** available balance reflects the backend credit and the form is visibly simulated + +#### Scenario: withdrawal freezes visibly +- **WHEN** a buyer submits a withdrawal request +- **THEN** available balance decreases, frozen balance increases and the request appears pending + +#### Scenario: fixed adapter remains functional +- **WHEN** the wallet domain uses fixed data +- **THEN** the wallet behaves deterministically through the same shared methods with simulated capabilities clearly identified + +#### Scenario: live mode hides demo recharge +- **WHEN** the server capabilities identify sandbox or live mode +- **THEN** no actionable demo recharge form is presented + +### Requirement: Mall aftersale customer flow +The Mall SHALL use the shared selected API adapter to expose eligible per-item aftersale actions, list/detail, chronological messages, cancellation before refund submission, one-time rejected appeal and return tracking. Forms SHALL show localized reason/type, integer amount/remaining limit, evidence URLs, status and authoritative original-method refund progress. Submitted refunds SHALL not remain cancellable. Anonymous actions SHALL redirect to sign-in with the current route as return destination. + +#### Scenario: apply from an eligible order line +- **WHEN** a signed-in shopper submits an eligible paid or shipped order-item application within its window +- **THEN** the shared client creates the application and displays its persisted pending state + +#### Scenario: return shipping is recorded +- **WHEN** an approved return-refund shopper submits carrier and tracking data +- **THEN** buyer-shipping status and tracking persist after reload + +#### Scenario: shopper cancels an application +- **WHEN** a shopper cancels an eligible application before refund submission +- **THEN** the API and views show cancelled and no merchant completion action remains + +#### Scenario: shopper messages the merchant +- **WHEN** a shopper sends localized text and optional evidence URLs +- **THEN** it appears in the persisted chronological detail history + +#### Scenario: fixed adapter remains usable +- **WHEN** aftersales use fixed data +- **THEN** list, detail, messaging, pre-submission cancellation, tracking and refund progress remain deterministic through the same methods + +#### Scenario: crypto refund is still pending +- **WHEN** an external refund has been submitted but is unconfirmed +- **THEN** the buyer sees refund pending rather than a wallet credit or completed refund and cannot cancel it diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mobile/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mobile/spec.md new file mode 100644 index 0000000..bb5932b --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mobile/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Transaction and account surfaces on existing APIs +Cart, checkout and order list/detail SHALL use shared cart, checkout, payment, order and address contracts so state survives reload and matches the mall. Checkout SHALL offer saved addresses defaulting to default with inline manual fallback, and eligible owned coupons per shop. Order detail SHALL resolve the payment group for pay/cancel and explain group-wide effects, and use guarded shared methods for delivery confirmation. Coupon center SHALL list claimable coupons, claim them and show owned coupons. Payment-method discovery SHALL be server-configured rather than hardcoded; pending/unknown collection and original-method refund progress SHALL remain recoverable through stable routes, without treating provider return parameters as success. Unknown attempts SHALL prevent replacement payment and unsafe cancellation. + +#### Scenario: cart survives reload +- **WHEN** a signed-in shopper adds an item and reloads /cart +- **THEN** the backend cart line remains visible + +#### Scenario: claim from the coupon center +- **WHEN** a signed-in shopper claims a coupon with remaining stock +- **THEN** it appears through the shared contract without fixture imports + +#### Scenario: mobile crypto return survives reload +- **WHEN** a buyer returns from hosted Checkout and reloads before confirmation +- **THEN** the stable payment route loads server state and remains pending until verified application + +#### Scenario: one checkout pays multiple shops +- **WHEN** a buyer selects an eligible method for a multi-shop payment +- **THEN** mobile initiates one payment attempt and shows the authoritative all-order outcome rather than calling per-order pay in a loop + +## ADDED Requirements + +### Requirement: Mobile financial environment and refund visibility +Mobile SHALL read deployment financial capabilities through the shared contract, hide demo recharge outside demo mode, and show pending, unknown, failed and completed original-method refunds without claiming a wallet credit for crypto. Submitted refunds SHALL not offer cancellation. Payment/financial actions SHALL use localized copy, safe-area layouts, shared tokens and accessible loading/error/409 states. + +#### Scenario: sandbox wallet does not mint demo funds +- **WHEN** a mobile buyer opens wallet in sandbox mode +- **THEN** the balance/entries remain available but no actionable demo-recharge form appears + +#### Scenario: external refund pending +- **WHEN** the original crypto refund is submitted but unconfirmed +- **THEN** mobile shows pending, does not show completed money movement and offers no cancellation or duplicate submission diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-shop-admin/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-shop-admin/spec.md new file mode 100644 index 0000000..6e0201f --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-shop-admin/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Shop-scoped aftersale workspace +Shop-admin SHALL provide list/detail filtered to authenticated own_shop resources. Authorized shop users SHALL inspect order-item evidence/messages, approve/reject eligible applications, confirm returned goods and request the guarded original-method refund. The UI SHALL show remaining amount, completed totals and pending/unknown/failed/completed refund state, without assuming an external submission is completed or credits the customer's internal wallet. Server-authorized retry after definitive failure SHALL remain distinct from duplicate submission of an unknown operation. Payment context SHALL expose only this shop's allocation, not another shop's order breakdown or provider credentials. Stale-action errors SHALL use the shared contract's 409 recovery. + +#### Scenario: merchant approves a request +- **WHEN** an authorized shop user approves its pending application +- **THEN** the persisted status advances according to its aftersale type and remains visible to the buyer + +#### Scenario: merchant confirms return and refunds +- **WHEN** a shop user confirms returned goods and requests refund +- **THEN** the service records merchant confirmation and the UI shows atomic wallet completion or externally pending original-method refund, completing only on verified success + +#### Scenario: shop scope is enforced +- **WHEN** a shop user requests or mutates another shop's application or payment allocation +- **THEN** the API denies the operation without exposing cross-shop data or secrets + +#### Scenario: merchant messages buyer +- **WHEN** an authorized shop user appends localized text +- **THEN** it appears in the persisted chronological thread visible to that buyer + +#### Scenario: unknown refund cannot be submitted again +- **WHEN** the provider refund outcome is unknown +- **THEN** the workspace shows unresolved status and offers no replacement refund action diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/group-buying/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/group-buying/spec.md new file mode 100644 index 0000000..8176582 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/group-buying/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Group membership and lifecycle +Payment SHALL atomically claim a paid membership in an open unexpired group with all other effects of its checkout-wide payment. Unpaid attempts SHALL NOT occupy group seats. A group SHALL become successful exactly when paid member count reaches its required count; it SHALL become expired when its lifetime ends before success; it SHALL become cancelled when its opening pending-payment order is cancelled while paid member count is zero. Full, expired or cancelled groups SHALL reject wallet payment without collection or order-state changes. When external funds have already been collected, inability to claim any required seat SHALL roll back every local application effect, retain the receipt and request a full original-method compensating refund. The receipt SHALL remain visible until compensation is confirmed. + +#### Scenario: concurrent final seat +- **WHEN** two payments compete for one final paid group seat +- **THEN** exactly one applies and the group becomes successful; a losing wallet attempt collects nothing and a losing external receipt follows compensation + +#### Scenario: expired paid group +- **WHEN** an open group expires before reaching its required count +- **THEN** it becomes expired and its already-paid orders remain identifiable with their original payment references for the existing later refund flow + +#### Scenario: unpaid opener cancel closes an empty group +- **WHEN** the opening pending-payment order is safely cancelled while paid member count is zero +- **THEN** the group becomes cancelled, stock is restored once, and later collection cannot apply another order against that group + +#### Scenario: no partial multi-shop application +- **WHEN** one group-bearing member of a multi-shop payment loses eligibility before external settlement +- **THEN** no member becomes paid, no group seat remains claimed, and the entire receipt is retained for compensation diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/order/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/order/spec.md new file mode 100644 index 0000000..c2e9fd2 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/order/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Payment-backed checkout contract +Checkout SHALL create its payment and immutable per-shop order memberships in the same transaction as the existing stock, pricing, coupon and order writes, and SHALL return `{orders, payment_id}`. Payment detail SHALL expose the original complete group to the owning buyer across reloads. The status-only per-order pay endpoint SHALL be removed; payment initiation SHALL use the payment contract and the selected configured channel. A member's order-detail action SHALL resolve its payment, not independently charge a subset. + +#### Scenario: checkout returns recoverable payment +- **WHEN** a two-shop checkout succeeds and the browser reloads +- **THEN** its payment id resolves both orders and their server-snapshotted totals without an in-memory order-id list + +## MODIFIED Requirements + +### Requirement: Order lifecycle +Status transitions SHALL be pending_payment → paid → fulfilling → shipped → completed. An order SHALL become paid only through application of its payment's funded receipt, with all member orders applied atomically. Cancellation SHALL be allowed only from pending_payment when the payment has no unresolved or applied collection; cancelling one member SHALL cancel its entire unpaid payment group atomically and restore SKU stock, reserved flash-sale stock and redeemed coupons exactly once. Unknown provider outcomes SHALL block cancellation with 409. Pending-payment cancellation SHALL NOT change group paid-member counts. When a cancelled order opened a group with zero paid members, that group SHALL become cancelled. Payment SHALL atomically validate and claim any required open group seats. A wallet attempt against a full, expired or cancelled group SHALL return 409 without collection or order changes. External collection that cannot claim a seat SHALL retain its receipt, leave every member unpaid and schedule compensation rather than imply funds were rolled back. + +#### Scenario: group payment wins final seat +- **WHEN** funded payment claims the final available group seat +- **THEN** all member orders become paid together and the group becomes successful + +#### Scenario: cancel restores stock +- **WHEN** a customer cancels a pending-payment group whose attempts are conclusively non-collecting +- **THEN** all member orders become cancelled, their stock is restored once and no paid group membership is removed + +#### Scenario: unpaid opener cancel closes an empty group +- **WHEN** a cancelled payment contains an opening order whose group has zero paid members +- **THEN** that group becomes cancelled and later collection cannot apply an order against it + +#### Scenario: cancel restores coupon +- **WHEN** a safely cancelled payment includes a coupon-bearing order +- **THEN** the coupon is detached from that order and restored to claimed once + +#### Scenario: illegal transition rejected +- **WHEN** a customer tries to cancel an already paid order through the cancellation endpoint +- **THEN** the API returns 409 and no payment, stock or coupon state changes + +#### Scenario: timeout blocks cancellation +- **WHEN** an external attempt has an unknown collection outcome +- **THEN** cancelling any member returns 409 until authoritative reconciliation establishes a safe outcome diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payment-configuration/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payment-configuration/spec.md new file mode 100644 index 0000000..ddd3164 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payment-configuration/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Installed adapter channel configuration +Platform administrators SHALL manage versioned channels for the installed wallet and Stripe crypto adapters. Channels SHALL define bilingual label, display order, enabled state, supported fiat currencies, and positive integer minor-unit limits within adapter capabilities. Unknown adapter types, arbitrary executable integrations and arbitrary provider API hosts SHALL be rejected. Deployment environment SHALL be server-owned and immutable through the dashboard. Wallet SHALL support only the base wallet currency and Stripe crypto only USD; configuration SHALL NOT imply automatic FX or unsupported asset/network/finality controls. + +#### Scenario: configure crypto checkout +- **WHEN** an administrator saves a valid disabled Stripe crypto channel with USD limits and bilingual labels +- **THEN** the channel is persisted with its adapter capabilities and cannot be used until its activation prerequisites pass + +#### Scenario: unsupported controls +- **WHEN** an administrator attempts to enable JPY, an arbitrary token network restriction, or a custom provider API host for Stripe crypto +- **THEN** the API rejects the unsupported configuration rather than claiming it will be enforced by hosted Checkout + +### Requirement: Credential references and fail-closed activation +Credentials SHALL be resolved server-side through deployment-allowlisted payment secret references, never returned through any API or stored as plaintext channel/audit fields. Missing references, incorrect account/environment and unsupported merchant capability SHALL prevent activation. The API SHALL offer an admin-only read-only connection/account verification operation, but SHALL NOT equate valid credentials with provider business approval. Provider-managed asset/network selection SHALL be represented as provider-managed information, not editable promises. + +#### Scenario: secrets stay private +- **WHEN** an administrator or customer reads channel data or an audit entry +- **THEN** no API key, signing secret, authorization header or raw secret value is returned + +#### Scenario: unapproved provider account +- **WHEN** an administrator tries to activate crypto without established provider eligibility/capability +- **THEN** activation is refused with the unmet prerequisite and no enabled crypto method is advertised + +#### Scenario: arbitrary environment-secret reference +- **WHEN** an administrator references a host secret outside the payment allowlist +- **THEN** validation fails without resolving or revealing that secret + +### Requirement: Immutable channel revisions and audit +Configuration changes SHALL create immutable revisions with actor/time and redacted audit details. Stale revision edits SHALL return 409. Attempts and refunds SHALL retain their original revision and credential references. Disabled or archived channels SHALL stop new attempts without preventing existing notifications, reconciliation or refunds. Referenced channels SHALL be archived rather than deleted, and old signing references SHALL remain usable for relevant outstanding events after rotation. + +#### Scenario: disable during payment +- **WHEN** an administrator disables a channel after an attempt starts +- **THEN** new attempts are refused while its verified settlement notification and original-method refund still process + +#### Scenario: rotate credentials +- **WHEN** an administrator creates a new credential revision +- **THEN** new attempts use it and existing work remains associated with its prior revision + +#### Scenario: concurrent configuration edits +- **WHEN** two administrators save against the same previous revision +- **THEN** one update wins and the stale update returns 409 without overwriting it + +### Requirement: Server-authoritative method eligibility +Customer method discovery SHALL expose only safe descriptors of enabled, configured, environment-matching channels whose currency, amount and capability constraints cover the owned payment. Initiation SHALL revalidate eligibility transactionally and SHALL reject disabled channels even when a browser cached their prior availability. No unsupported WeChat, Alipay or card option SHALL be advertised as functional. + +#### Scenario: stale method selection +- **WHEN** a buyer starts a channel that was disabled after method discovery +- **THEN** initiation fails without a provider call and the storefront can refresh eligible methods + +### Requirement: Audited payment recovery operations +Only platform administrators SHALL inspect cross-shop payment/receipt/refund exceptions and request reconciliation. A reconciliation action SHALL durably fetch authoritative evidence and reuse the normal application path, never directly set paid/refunded, initiate another collection, edit a balance, or bypass amount/ownership guards. Lists SHALL be paginated and filterable by environment, status, provider reference and payment/order identity; customer and merchant data SHALL remain scoped in their respective endpoints. + +#### Scenario: reconcile unknown operation +- **WHEN** an administrator requests reconciliation of a timed-out attempt +- **THEN** an auditable evidence-fetch operation is queued without creating a replacement charge + +#### Scenario: merchant cannot configure processor +- **WHEN** a shop owner calls channel configuration or cross-shop recovery APIs +- **THEN** the request is denied and no credential or other shop's payment details are disclosed diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payments/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payments/spec.md new file mode 100644 index 0000000..94cd9ca --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payments/spec.md @@ -0,0 +1,129 @@ +## ADDED Requirements + +### Requirement: Server-owned checkout payment unit +Checkout SHALL require an owner-scoped idempotency key. The API SHALL persist one owned payment with immutable fiat currency, checked integer total, and per-order amount memberships for every new checkout, in the checkout transaction. Membership amounts SHALL equal the snapshotted order totals and sum to the payment total. Each order SHALL belong to at most one payment. A replay with the same key and request hash SHALL return the original orders and payment without another inventory allocation; a changed payload SHALL return 409. A computed payment total of zero SHALL be rejected before any order, inventory, coupon, or payment write. Resolving an existing order's payment SHALL return its original full membership rather than regroup it. The API SHALL never accept client-calculated collection amounts or silently convert currencies. + +#### Scenario: multi-shop payment total +- **WHEN** checkout creates two shop orders for 1000 and 2500 minor units in one currency +- **THEN** the payment contains both orders and an immutable total of 3500 minor units + +#### Scenario: cross-customer access +- **WHEN** a customer reads or initiates a payment owned by another customer +- **THEN** the API returns 404 without exposing amounts, orders, provider references, or actions + +#### Scenario: checkout replay is durable +- **WHEN** a customer retries checkout with the same idempotency key and unchanged request after an uncertain response +- **THEN** the API returns the original orders and payment without creating new orders or decrementing stock again + +#### Scenario: zero total is rejected +- **WHEN** server-side checkout calculation reaches a payment total of zero +- **THEN** the API returns 409 and creates no order, payment, coupon redemption, or inventory change + +### Requirement: Atomic wallet collection and order application +An enabled eligible wallet channel SHALL debit the buyer's available balance using guarded account primitives and commit one ledger entry, collection receipt, every member order's paid transition, all required group seats, and system messages in one database transaction. Wallet payment SHALL be eligible only for the base-currency wallet's currency. Failure of any member's preconditions SHALL roll back all these effects. Concurrent attempts SHALL never overdraw or apply an order twice. + +#### Scenario: wallet pays two shops +- **WHEN** a buyer with 5000 minor units pays the 3500-minor-unit multi-shop payment +- **THEN** available balance becomes 1500, one debit entry references the payment, and both orders become paid together + +#### Scenario: one group seat is unavailable +- **WHEN** any member order cannot claim its required group seat during wallet payment +- **THEN** no member order is paid, no seat is claimed, and no balance or ledger entry changes + +#### Scenario: insufficient balance +- **WHEN** concurrent wallet operations exceed the available balance +- **THEN** insufficient operations fail with 409 and no committed account becomes negative + +### Requirement: Idempotent attempts and unresolved-outcome exclusion +Starting a payment SHALL require an owner-scoped idempotency key, eligible channel, and typed return surface (`mall` or `mobile`). The API SHALL map the surface exclusively to a server-allowlisted storefront origin and persist it with the attempt. Repeating the same key and payload SHALL return the same operation; reusing it with different input SHALL return 409. At most one unresolved attempt SHALL exist for a payment, enforced by persistence constraints. A timeout SHALL produce an unknown outcome and SHALL NOT permit method switching, replacement collection, or cancellation until authoritative reconciliation establishes a safe outcome. An already applied payment SHALL never initiate another collection. + +#### Scenario: timeout followed by another method +- **WHEN** a provider create call times out and the buyer selects wallet payment +- **THEN** the API returns the unresolved attempt and 409 rather than initiating a second collection + +#### Scenario: duplicate initiation +- **WHEN** two requests use the same idempotency key and input concurrently +- **THEN** they resolve to one durable attempt and one provider operation identity + +### Requirement: Durable provider commands and verified events +External create, refund and reconciliation work SHALL be committed durably before execution and SHALL run outside SQL transactions. Workers SHALL recover expired leases after restart and SHALL share work safely across API instances. Stable provider idempotency keys SHALL be retained with their operation. An ambiguous operation beyond the provider's guaranteed idempotency retention SHALL be reconciled rather than blindly reissued. Webhooks SHALL be signature-verified against the raw body and expected account/environment, durably recorded before 2xx acknowledgement, and deduplicated by provider account, environment and event id. Reconciliation and notification processing SHALL use the same monotonic application path. + +#### Scenario: provider succeeds before the worker crashes +- **WHEN** the provider creates a checkout but the worker restarts before persisting the response +- **THEN** recovery uses the same operation identity or authoritative provider evidence and does not create an unrelated new charge + +#### Scenario: replay after provider retention +- **WHEN** a create operation remains ambiguous beyond the provider idempotency retention window +- **THEN** processing preserves unknown status and uses evidence lookup instead of replaying creation + +#### Scenario: forged or wrong-environment webhook +- **WHEN** an event has an invalid signature, stale signing timestamp, wrong account, or wrong livemode +- **THEN** it causes no order, balance, receipt application, or refund completion + +#### Scenario: database unavailable at receipt +- **WHEN** a valid notification cannot be persisted +- **THEN** the webhook returns non-2xx rather than acknowledging and losing the event + +### Requirement: Hosted stablecoin collection with fiat accounting +The Stripe crypto adapter SHALL create a hosted payment-mode Checkout Session restricted to crypto, using server-owned USD amounts and persistent payment/attempt metadata. It SHALL require eligible approved merchant configuration and enforce the lower of configured limits and the provider's USD 10000 transaction maximum. Provider-established successful paid collection SHALL be required before local application; redirects or session completion without paid confirmation SHALL NOT suffice. USD SHALL be the presentment and VMall receipt currency; provider-balance settlement SHALL use the Stripe account's supported local currency and SHALL be recorded as provider metadata when exposed, without changing order amounts or requiring that local currency to be USD. Stripe SHALL manage wallet connection, supported assets/networks, pricing and finality; VMall SHALL neither fabricate these details nor claim unsupported configuration controls. Provider-exposed asset quantities SHALL be exact integer-base-unit strings identified by network and contract/native asset, separate from ISO fiat currency amounts. + +#### Scenario: stablecoin purchase +- **WHEN** an eligible buyer pays a USD payment through the configured Stripe crypto channel and Stripe confirms paid collection +- **THEN** VMall records the USD receipt and atomically applies all member orders without holding crypto keys or changing their fiat totals + +#### Scenario: unsupported currency +- **WHEN** the payment currency is JPY +- **THEN** Stripe crypto is unavailable and direct initiation is rejected without automatic conversion or a provider call + +#### Scenario: browser claims success +- **WHEN** a buyer returns with a success query parameter before authoritative paid confirmation +- **THEN** the payment remains pending and no order becomes paid + +#### Scenario: exact crypto metadata +- **WHEN** provider evidence includes a base-unit amount greater than JavaScript's exact-integer range +- **THEN** the API preserves it as a string without floating-point arithmetic or loss of precision + +### Requirement: Captured receipts survive rejected application +Actual collection receipts SHALL be retained independently of order application and SHALL be unique by provider-account/environment/transaction identity. Application SHALL validate amount, currency and payment membership, lock all affected resources, and commit all business effects once. A valid receipt that cannot be applied because the payment is closed or a member became ineligible SHALL schedule a full original-method compensating refund without paying a subset of orders. Separate genuine additional collections SHALL be retained and compensated, not discarded as duplicate events. Mismatched receipts SHALL remain quarantined for authoritative reconciliation and SHALL NOT authorize fulfillment or wallet credits. + +#### Scenario: group fills before external settlement +- **WHEN** valid external collection arrives after another shopper took the last required group seat +- **THEN** all local order/seat application rolls back and a tracked full compensating refund is scheduled + +#### Scenario: duplicate events versus duplicate payments +- **WHEN** duplicate notifications describe one collection and another notification describes a second genuine collection +- **THEN** the first collection is applied at most once and the second is recorded separately for compensation + +#### Scenario: compensation releases only unapplied orders +- **WHEN** a full compensating refund succeeds for a payment that never applied +- **THEN** the payment closes, its still-pending member orders cancel and stock/activity inventory/coupons restore once; compensating an extra receipt on an already-applied payment instead leaves its valid orders unchanged + +#### Scenario: unexpected amount +- **WHEN** receipt amount or currency differs from the immutable payment +- **THEN** it is quarantined and no member order becomes paid + +### Requirement: Original-method refunds and reserved refund budget +Refunds SHALL reference the original receipt and use its method. Pending, unknown and succeeded refund amounts SHALL reserve budget against both the receipt and the affected allocation so concurrent operations cannot exceed captured funds. Wallet refunds SHALL commit their account credit, immutable entry and business completion atomically. Stripe refunds SHALL return funds through the provider to the original wallet, never credit the internal wallet as a fallback. External completion SHALL require verified provider refund success; only then SHALL aftersale totals/status/messages change. Definitive failure SHALL release its reservation and allow an explicit guarded new operation; an unknown operation SHALL retain its reservation and block replacement. Compensation for unapplied receipts SHALL NOT increase order aftersale totals. + +#### Scenario: concurrent partial refunds +- **WHEN** two requested refunds together exceed the unreserved captured allocation +- **THEN** at most the covered amount is reserved and the excess request fails without a provider call + +#### Scenario: pending crypto refund +- **WHEN** Stripe accepts a refund request but has not confirmed success +- **THEN** it remains pending, no internal wallet credit is written, and order refund totals remain unchanged + +#### Scenario: refund-success replay +- **WHEN** a succeeded refund notification is delivered repeatedly or concurrently with reconciliation +- **THEN** the order total, aftersale status and completion message change once + +### Requirement: Environment-isolated legacy cutover +Payment records SHALL carry immutable deployment environment. Demo, sandbox and live SHALL use separate financial databases and matching provider environments. Historical status-only paid orders SHALL be explicitly marked legacy-demo, never supplied fictional captured receipts. Demo legacy refunds SHALL retain the labeled historical wallet path; live provider refunds SHALL reject such orders. Existing ungrouped pending orders SHALL acquire singleton membership on demand. Live startup/activation SHALL reject legacy-demo paid history, demo monetary credits or non-live payment records. The removed status-only pay endpoint/client method SHALL have no compatibility alias. + +#### Scenario: historical demo purchase +- **WHEN** a historical paid order is migrated +- **THEN** it remains explicitly legacy-demo and cannot be presented as a funded live purchase + +#### Scenario: demo database cannot become live money +- **WHEN** live mode is configured against a database containing demo recharge or legacy-demo paid records +- **THEN** financial activation fails closed rather than making its balances spendable live diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/wallet/spec.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/wallet/spec.md new file mode 100644 index 0000000..65b7fb8 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/specs/wallet/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Demo wallet recharge +In a demo deployment an authenticated user SHALL recharge their wallet through the simulated flow that records a recharge row and credits the available monetary account with one signed ledger entry in one transaction, without invoking an external channel. The payload and UI SHALL clearly label the flow simulated/demo. Amounts SHALL be positive integer minor units in account currency without floating-point arithmetic. Sandbox and live deployments SHALL refuse this operation server-side and SHALL not advertise it as available. Turning off a UI control SHALL NOT be the security boundary; historical demo credits SHALL prevent live financial activation in that database. + +#### Scenario: demo recharge credits balance +- **WHEN** a signed-in buyer in a demo deployment recharges 5000 minor units +- **THEN** available balance increases by exactly 5000 and one wallet_recharge entry records the resulting balance + +#### Scenario: flow is labeled as demo +- **WHEN** a buyer opens the enabled recharge flow +- **THEN** it is visibly labeled simulated and contacts no external provider + +#### Scenario: live recharge cannot mint funds +- **WHEN** a signed-in buyer directly calls the demo-recharge endpoint in sandbox or live mode +- **THEN** the operation is refused without a recharge record, balance change or ledger entry diff --git a/openspec/changes/archive/2026-10-15-add-configurable-payments/tasks.md b/openspec/changes/archive/2026-10-15-add-configurable-payments/tasks.md new file mode 100644 index 0000000..c5a5fdf --- /dev/null +++ b/openspec/changes/archive/2026-10-15-add-configurable-payments/tasks.md @@ -0,0 +1,80 @@ +## 1. Contracts and deployment prerequisites + +- [ ] 1.1 Recheck the linked Stripe documentation and record the pinned API version, crypto-only Checkout parameters, paid-state events, refund events, transaction limits and merchant eligibility for the actual account. Confirm hosted crypto access and sandbox/test-wallet availability without claiming approval from a valid API key alone. +- [x] 1.2 Define the shared exported DTOs for checkout `{orders, payment_id}`, checkout idempotency, payment memberships, attempts, typed return surfaces, eligible methods, typed next actions, receipts, refund operations, channel revisions, capabilities and paginated operations. Keep fiat integer minor units and optional crypto base-unit strings separate. +- [x] 1.3 Define immutable deployment payment mode, payment-secret reference allowlist, permitted mall/mobile return origins and provider account/environment validation. Document separate demo/sandbox/live database requirements and refusal of demo monetary history in live mode. + +## 2. Persistent payment domain + +- [x] 2.1 Inspect existing migrations and append the next available migration for checkout request idempotency, payments, unique order memberships, attempts, receipts, refunds, commands, verified events, channels/revisions/audit and required aftersale states. Do not edit applied migrations or assume a migration number from this proposal. +- [x] 2.2 Add explicit legacy-demo provenance for historical status-only paid orders and preserve historical ledger/statement rows. Do not fabricate captures or checkout groupings; existing pending orders acquire singleton membership on demand. +- [x] 2.3 Implement typed rows, state transitions and repos under `modules/payment/`; enforce unique membership, operation identity, provider transaction/event identity, one unresolved attempt, immutable environment and checked amount invariants. +- [x] 2.4 Implement deterministic lock acquisition and reusable caller-owned transaction helpers for payment, sorted order/group/account rows, cancellation and refund budgets. Prove impossible transitions fail without partial writes. +- [x] 2.5 Add isolated migration/domain integration cases for uniqueness races, checked totals, ownership, environment mismatches and active/refund budget constraints using unique fixture ids. + +## 3. Payment resolution, wallet and checkout cutover + +- [x] 3.1 Create payment membership in the existing checkout transaction, persist owner-scoped checkout idempotency with request hashes, replay identical results without inventory changes, reject changed keys and zero payment totals before resource writes, and return the new shared checkout result. Implement own-only payment resolution/detail and exact-membership validation without accepting browser amounts. +- [x] 3.2 Implement eligible-method discovery and transactionally revalidated initiation with owner-scoped idempotency-key/payload binding and unresolved-attempt exclusion. +- [x] 3.3 Implement wallet collection using guarded account debit plus one ledger entry, receipt, all order paid transitions, group-seat claims and messages in one transaction. Reject non-base-currency wallet payments rather than convert them. +- [x] 3.4 Replace status-only order pay routing with payment initiation and migrate all backend consumers/fixtures; remove the old endpoint and exported client method without aliases. +- [x] 3.5 Route group-wide payment cancellation and existing member-order cancellation through the shared guarded service, blocking unknown/existing collection and restoring SKU/activity stock, coupons and opener-group effects once. Lock payment, sorted orders/groups, affected SKU/activity/coupon rows and accounts in the documented global order. +- [x] 3.6 Add behavioral regressions for multi-shop all-or-none wallet application, insufficient/competing debits, final-group-seat contention, duplicate initiation, payload-key conflict, cancellation races and cross-customer 404. + +## 4. Durable provider execution and recovery + +- [x] 4.1 Implement the minimal adapter boundary for checkout creation, authoritative status retrieval, raw-body notification verification and original-payment refund. Register only the implemented wallet and Stripe crypto methods; expose honest capability differences. +- [x] 4.2 Implement durable command enqueue/lease/finish/recovery and supervised workers attached to API startup/shutdown. Keep all external calls outside SQL transactions and support concurrent API processes without duplicate unrelated operations. +- [x] 4.3 Persist stable provider operation keys, bounded replay windows and unknown outcomes. Implement evidence-based recovery when a response is lost or provider idempotency retention has expired; block replacement collection when absence cannot be established. +- [x] 4.4 Implement signature/timestamp verification, account/environment/reference validation, durable inbox acknowledgement, event deduplication and monotonic processing shared with reconciliation. Support event-before-create-response and out-of-order delivery. +- [x] 4.5 Add deterministic isolated provider-protocol integration cases for crash after external success, lease recovery, signature/replay rejection, wrong account/livemode, persistence failure before acknowledgement, duplicate/out-of-order events and expired-idempotency-window safety. + +## 5. Stripe-hosted stablecoin collection + +- [x] 5.1 Implement real Stripe crypto-only Checkout creation with server-owned USD line amount, typed `mall`/`mobile` return-surface mapping to deployment-allowlisted origins, stable operation keys and persisted payment/attempt metadata plus Checkout/PaymentIntent references. +- [x] 5.2 Enforce USD eligibility and configured/provider amount ceilings, validate capability/account environment, and return typed redirect/wait actions. Preserve provider expiry and optional asset/network/quantity/transaction evidence without fabricating unavailable metadata. +- [x] 5.3 Reconcile verified provider paid states into one immutable receipt and atomic application to every member order. Never fulfill from browser parameters, a create response or an unpaid completed session. +- [x] 5.4 Quarantine reference/amount/currency mismatches. Record and compensate genuine additional receipts and valid collections rejected by cancelled/closed payments or lost group capacity, without partial order application. +- [x] 5.5 On successful full compensation for never-applied payments, close the payment and cancel/restore still-pending members once. Compensating an extra receipt for an applied payment must leave valid orders/fulfillment untouched. +- [ ] 5.6 Add deterministic behavior cases for unpaid-completed sessions, late settlement, lost group seats, multiple genuine receipts, compensation-success replay, mismatch quarantine, and absence of crypto precision loss. + +## 6. Original-method refunds and aftersales + +- [x] 6.1 Implement receipt/allocation refundable-budget reservation including pending/unknown/succeeded operations and compensation, with idempotent submission and separate explicit retry only after definitive failure. +- [x] 6.2 Implement atomic wallet refunds and real Stripe original-payment partial/full refund calls, preserving stable operation identity and original channel revision. Never substitute internal-wallet credits for external refunds. +- [x] 6.3 Integrate refund_pending/refund_failed and verified-success completion into merchant processing and platform arbitration; prohibit customer cancellation after submission and keep unresolved refund applications active for uniqueness. +- [x] 6.4 Apply verified refund success atomically to completed order totals, aftersale terminal state and idempotent system messages. Keep compensation for unapplied receipts out of order aftersale totals, and preserve explicitly demo-only historical refund behavior. +- [x] 6.5 Add regressions for concurrent partial refunds, receipt/allocation budget oversubscription, unknown-budget retention, definitive-failure retry, duplicate success evidence and no false wallet credit or premature total increment. + +## 7. Versioned configuration and admin APIs + +- [x] 7.1 Implement admin-only installed-adapter catalog, paginated channel CRUD, immutable revisions, optimistic edit preconditions, bilingual labels, ordering, currency/amount validation and archival of referenced channels. +- [x] 7.2 Implement allowlisted server-side secret-reference resolution, redacted audit and read-only provider connection/capability verification. Fail closed for missing secrets or unapproved/mismatched accounts; fix provider hosts in code. +- [x] 7.3 Implement current-configuration eligibility checks while retaining original revision/signing references for callbacks, reconciliation and refunds after disable/rotation. Reject unsupported hosted-provider policy controls. +- [x] 7.4 Implement admin payment/refund/exception list/detail and audited durable reconciliation requests. Do not add unconditional mark-paid/refunded, balance-edit or replacement-charge recovery actions. +- [x] 7.5 Gate demo recharge server-side by deployment mode and expose safe capability flags to wallet surfaces. Refuse live activation with non-live financial provenance. +- [x] 7.6 Add behavior/security cases for stale admin updates, disabled cached methods, old-revision event/refund processing, secret non-disclosure, arbitrary secret/URL rejection, role/tenant boundaries and demo-to-live activation refusal. + +## 8. Shared client and shopper surfaces + +- [x] 8.1 Implement shared live client methods and migrate every checkout/pay/cancel/refund caller and body/result type. Keep external data typed/validated without any or ignored errors. +- [x] 8.2 Implement deterministic fixed-data payment/refund capabilities and transitions for the mall adapter. Enforce coherent transaction-domain adapter selection so fixture ids cannot reach real collection; mobile remains live-only. +- [x] 8.3 Replace mall checkout/payment/success and buyer-order pay actions with configured methods, one payment attempt and stable payment URLs, recovering after reload/sign-in without in-memory order arrays. +- [x] 8.4 Implement equivalent mobile configured payment/return/order-detail handling, auth return paths and group-wide cancellation messaging, preserving safe-area and accessible interaction conventions. +- [x] 8.5 Show pending/unknown/compensation/refund states accurately in both storefronts, stop polling on unmount/terminal state, block unsafe replacement/cancellation and hide demo recharge when disallowed. Add en/zh copy in the appropriate app/shared ownership locations. + +## 9. Platform and merchant console surfaces + +- [x] 9.1 Add navigable admin channel editor/detail/verification/audit views with secret references only, adapter capability constraints, activation prerequisites, disable/rotation explanations and conflict-safe edits using existing shared UI components/tokens. +- [x] 9.2 Add admin paginated payment/refund/exception list/detail and evidence-reconciliation actions with allocation breakdown, pending/unknown/compensation states and no manual assertion of payment success. +- [x] 9.3 Update merchant and platform aftersale workspaces for original-method pending/unknown/failed/completed results and server-authorized retry, removing assumptions of immediate customer-wallet credit. Preserve own-shop isolation of payment context. + +## 10. End-to-end acceptance and documentation + +- [x] 10.1 Run the actual API with local Postgres/Redis and demonstrate multi-shop wallet debit, group-failure rollback and original-wallet refund, recording order states and exact ledger/receipt/refund evidence. Do not rely only on test assertions or compilation. +- [ ] 10.2 With an eligible Stripe sandbox account, complete the real hosted crypto flow using testnet funds, verify signed callback processing and USD receipt/application, then perform and confirm a partial original-wallet provider refund. Record external prerequisites and actual evidence; protocol fixtures do not satisfy this gate. +- [ ] 10.3 Exercise actual worker restart/recovery and admin channel disable/credential rotation with outstanding work. Confirm callbacks and original-method refunds continue, unknown states cannot double-charge and compensations restore only unpaid resources once. +- [ ] 10.4 Browser-verify mall/mobile checkout and return reloads, pending/failure/refund UX, admin channel/operations views and merchant refund progress. Confirm forged success parameters, stale methods and unsafe cancellation cannot manufacture completion. +- [x] 10.5 Run cargo test -p vmall-api twice and the filtered builds for @vmall/mall, @vmall/mobile, @vmall/admin and @vmall/shop-admin. Verify existing tests and contracts are migrated, with no status-only payment path retained. +- [x] 10.6 After smoke proof, update README and affected domain/development/shared/frontend guidance and code-index entries for added/removed files, document deployment secrets/webhooks/eligibility/rollback and demo/live limits, and remove throwaway verification scaffolding. +- [ ] 10.7 Validate this change strictly, archive only after every implementation and real-provider acceptance item is satisfied, and run openspec validate --all --strict after archive. Document any genuinely unavailable external prerequisite without marking its acceptance task complete. diff --git a/openspec/specs/aftersale/spec.md b/openspec/specs/aftersale/spec.md index 7dc123b..c34cae8 100644 --- a/openspec/specs/aftersale/spec.md +++ b/openspec/specs/aftersale/spec.md @@ -22,23 +22,27 @@ An authenticated customer SHALL apply for after-sale against one owned order ite - **THEN** the detail response returns the same validated URL list without storing binary image data ### Requirement: Guarded aftersale state machine -Aftersales SHALL use guarded status transitions: `pending` to `approved` or `rejected`; for `refund_only`, `approved` SHALL transition to `refunded` through the ledger-backed completion path; `return_refund` SHALL proceed from `approved` to `buyer_shipping` to `merchant_confirmed` to `refunded`. A customer MAY cancel any non-terminal pending state before refund completion. A rejected application MAY be reopened to `pending` at most once. Every transition SHALL condition its update on the expected previous status and return 409 for stale or illegal actions. +Aftersales SHALL use guarded status transitions: pending to approved or rejected; refund_only from approved into the payment-backed refund path; return_refund from approved to buyer_shipping to merchant_confirmed into that same path. External submission SHALL transition the eligible application to refund_pending, then refunded only after verified success, or refund_failed after definitive failure. An unknown operation SHALL remain unresolved and SHALL NOT allow replacement submission. An explicit retry from refund_failed SHALL reserve budget again and create a new operation only when its predecessor definitively failed. A customer MAY cancel a non-terminal application before refund submission, but SHALL NOT cancel refund_pending or refund_failed. A rejected application MAY reopen to pending once. Every transition SHALL guard the expected state and reject stale or illegal actions with 409. Refund-pending and refund-failed applications SHALL remain active for the one-active-application constraint. #### Scenario: merchant approves a return -- **WHEN** the owning shop approves a pending `return_refund` application -- **THEN** its status becomes `approved` and the customer can submit return tracking +- **WHEN** the owning shop approves a pending return_refund application +- **THEN** its status becomes approved and the customer can submit return tracking #### Scenario: merchant confirms returned goods -- **WHEN** the owning shop confirms receipt for a `buyer_shipping` application and completes the refund action -- **THEN** the service records the `merchant_confirmed` step and reaches `refunded` only through the guarded refund-completion transaction +- **WHEN** the owning shop confirms buyer-shipped returned goods and requests a refund +- **THEN** the service records merchant_confirmed and either completes an atomic wallet refund or shows refund_pending for external processing #### Scenario: buyer cancels before completion -- **WHEN** the customer cancels a still-pending aftersale before refund completion -- **THEN** the status becomes `cancelled` and a later merchant transition returns 409 +- **WHEN** a customer cancels an eligible application before any refund submission +- **THEN** its status becomes cancelled and later merchant transitions return 409 + +#### Scenario: buyer cannot cancel submitted refund +- **WHEN** a customer attempts cancellation after a refund was submitted +- **THEN** the API returns 409 and preserves the refund operation and its reserved budget #### Scenario: rejected appeal is limited - **WHEN** a customer reopens a rejected application for the first time -- **THEN** it returns to `pending`; a second reopen attempt returns 409 +- **THEN** it returns to pending and a second reopen attempt returns 409 ### Requirement: Unique active aftersale per order item The persistence layer SHALL permit at most one active aftersale for an order item at a time. Active statuses SHALL include every non-terminal application state, while `refunded`, `rejected`, and `cancelled` records remain historical. A reopened rejection SHALL reuse its record and count as the one active application. @@ -63,24 +67,28 @@ Customers and the owning shop's authorized users SHALL append messages to an aft - **THEN** the API returns 404 or 403 and reveals no message content ### Requirement: Ledger-backed refund completion -A refund completion SHALL run in one transaction with a guarded status update, a guarded increment of the order's integer `refund_total_minor`, and one append-only available-balance credit in `customer_accounts` using the order currency and aftersale reference. The `refund_completed` hook SHALL be emitted after commit. Retrying a completed action SHALL not create another ledger entry or increase the order total twice. +Refund completion SHALL use the original payment method and a guarded, receipt/allocation-bounded refund operation. Wallet completion SHALL atomically credit the customer's available account with an immutable aftersale-referenced entry, increment integer order refund_total_minor and complete the aftersale. External submission SHALL NOT credit the internal wallet or increment completed-refund totals. Only verified provider refund success SHALL atomically complete the aftersale, increment order refund_total_minor and create the idempotent refund_completed system-message event. Pending and unknown refunds SHALL reserve budget. Demo-only legacy status-paid orders SHALL use the explicitly labeled legacy wallet-credit path and SHALL NOT be refunded through a fabricated provider capture. Concurrent completion SHALL return the completed result without duplicate credits, totals or events. #### Scenario: completed refund credits the customer -- **WHEN** a valid aftersale reaches `refunded` -- **THEN** the customer's account balance and immutable ledger entry increase by exactly the requested minor-unit amount, and the order refund total increases by the same amount +- **WHEN** a valid wallet-funded aftersale completes +- **THEN** one credit and immutable ledger entry increase available balance by the requested amount and the order refund total increases by the same amount + +#### Scenario: external refund waits for success +- **WHEN** Stripe accepts the original-payment refund but has not confirmed success +- **THEN** the application remains refund_pending, its budget remains reserved and neither wallet balance nor completed order refund total changes #### Scenario: concurrent completion is idempotent -- **WHEN** two workers attempt to complete the same merchant-confirmed aftersale -- **THEN** one guarded transition performs the credit and the other returns the already-completed result without a duplicate entry +- **WHEN** two workers apply the same refund-success evidence +- **THEN** one completion increments the order total and emits the event and the other observes the completed result ### Requirement: Optional platform arbitration -A platform administrator SHALL view aftersale applications across shops and MAY resolve an escalated application with a terminal refund or rejection. Arbitration SHALL enforce the same ownership-independent guarded transitions, amount limits, order-total and ledger invariants, and immutable audit/message record as merchant processing. +A platform administrator SHALL view applications across shops and MAY resolve an escalated application with rejection or the same original-method refund path as merchant processing. Arbitration SHALL enforce amount limits, receipt/allocation budgets, expected states and immutable audit/message records without bypassing them. External refund arbitration SHALL remain refund_pending until provider success, rather than declaring terminal refund at submission. #### Scenario: platform grants a disputed refund - **WHEN** a platform administrator resolves an eligible dispute in the customer's favor -- **THEN** the application reaches `refunded` through the same ledger-backed completion path +- **THEN** the application follows its original wallet or provider refund path and reaches refunded only on actual completion #### Scenario: platform rejects a dispute -- **WHEN** a platform administrator rejects an escalated application -- **THEN** it reaches terminal `rejected` and cannot be refunded without the one permitted customer reopen +- **WHEN** a platform administrator rejects an eligible escalated application +- **THEN** it becomes rejected and only the permitted one-time customer reopen can resume it diff --git a/openspec/specs/frontend-admin/spec.md b/openspec/specs/frontend-admin/spec.md index 396ae6a..097892e 100644 --- a/openspec/specs/frontend-admin/spec.md +++ b/openspec/specs/frontend-admin/spec.md @@ -2,9 +2,7 @@ ## Purpose The platform console for managing users, shops and currencies. - ## Requirements - ### Requirement: Platform user and shop management Platform admins SHALL assign user roles (with shop scope), create shops, and suspend/activate shops. Suspended shops' products MUST NOT be purchasable (enforced by API, reflected in UI). @@ -75,19 +73,19 @@ Platform admins SHALL manage the ordered brand registry on a brands page reachab - **THEN** the page shows a success confirmation or keeps the edited rows with a failure message, respectively ### Requirement: Platform aftersale monitoring and arbitration -The platform admin console SHALL provide a read-only cross-shop aftersale list and detail view with customer, shop, order-item, amount, evidence, status, message history, and refund-total context. A platform administrator SHALL be able to resolve an escalated dispute with a terminal refund or rejection through the guarded service path, without directly editing balances or bypassing shop ownership checks. +The platform console SHALL provide a cross-shop aftersale list/detail with customer, shop, order-item, amounts, evidence, status, message history, completed totals and original-payment refund-operation context. Administrators SHALL resolve eligible disputes through the same guarded original-method refund or rejection path without editing balances or claiming synchronous external completion. Pending/unknown/failed refunds SHALL show their authoritative state and permit only server-authorized actions. #### Scenario: admin inspects a cross-shop application -- **WHEN** a platform administrator opens the aftersale workspace -- **THEN** applications from all shops are listed with filters and authoritative status and refund amounts +- **WHEN** an administrator opens the aftersale workspace +- **THEN** cross-shop applications, authoritative amounts and original-method refund state are available with filters #### Scenario: admin grants terminal refund -- **WHEN** a platform administrator resolves an eligible dispute in favor of the customer -- **THEN** the aftersale reaches refunded, the customer ledger is credited once, and the order refund total refreshes from the API +- **WHEN** an administrator grants an eligible refund +- **THEN** wallet completion appears immediately if committed, while crypto remains refund_pending until verified provider success updates the total #### Scenario: admin rejects terminal dispute -- **WHEN** a platform administrator rejects an escalated dispute -- **THEN** the aftersale reaches rejected and the console shows that only the permitted one-time customer reopen can resume it +- **WHEN** an administrator rejects an eligible dispute +- **THEN** the console shows rejected and only the permitted one-time customer reopen can resume it ### Requirement: Platform review moderation Platform admins SHALL review a paginated moderation list of all reviews with their product, customer, and shop context and SHALL hide or delete reviews through the shared API contract. Admin SHALL expose review moderation navigation beside existing platform operations. @@ -167,3 +165,26 @@ Platform admins SHALL manage member levels from a dedicated admin console page t #### Scenario: level management appears in admin navigation - **WHEN** an authenticated platform admin opens the admin console - **THEN** member-level management is reachable from the console nav + +### Requirement: Payment channel management console +The platform console SHALL provide navigable channel management through the shared client: installed adapter capabilities, bilingual label, display order, enabled state, supported fiat currencies, integer limits, environment, provider account, allowlisted secret references, read-only connection verification and revision/audit history. It SHALL show missing approval/credentials and reject unsupported controls. Existing secret values SHALL never be displayed. Disabling SHALL explain that it stops new attempts but preserves existing callback/refund processing. Stale edits SHALL show 409 and retain user input while refreshing authoritative revision context. All controls SHALL use shared UI primitives and en/zh copy. + +#### Scenario: configure and activate crypto +- **WHEN** an administrator saves valid USD Stripe crypto configuration and successfully verifies the approved account +- **THEN** activation becomes available and the enabled channel appears to eligible storefront payments + +#### Scenario: credential reference is missing +- **WHEN** verification cannot resolve a configured allowlisted secret reference +- **THEN** the UI shows the unmet prerequisite without exposing a secret and does not claim the method is ready + +### Requirement: Payment and refund recovery console +The platform console SHALL provide paginated payment/refund/exception lists and details, showing allocation breakdown, receipt evidence, attempts, pending/unknown states and redacted audit history. Reconciliation SHALL request evidence fetching through the shared API; the UI SHALL never offer an unconditional mark-paid/refunded action. Received-but-unapplied and additional genuine collections SHALL show compensation progress. Disabling a channel SHALL not remove historical records from operations views. + +#### Scenario: recover provider timeout +- **WHEN** an administrator reconciles an unknown attempt +- **THEN** the console shows queued evidence lookup and later authoritative status without starting a second collection + +#### Scenario: seat rejection after collection +- **WHEN** an external receipt could not be applied to a group-bearing checkout +- **THEN** its receipt, unchanged order application and compensating refund remain inspectable + diff --git a/openspec/specs/frontend-mall/spec.md b/openspec/specs/frontend-mall/spec.md index b8ab270..7d80fb8 100644 --- a/openspec/specs/frontend-mall/spec.md +++ b/openspec/specs/frontend-mall/spec.md @@ -2,9 +2,7 @@ ## Purpose The buyer-facing storefront: shell, home page, discovery, shopping and transaction flows, and the buyer center. - ## Requirements - ### Requirement: Localized storefront The mall SHALL render every UI string and all catalog/store/marketing mock content in en or zh from one switcher, defaulting to en. Switching locale SHALL update the desktop shell and current page without a full reload. @@ -95,15 +93,19 @@ The mall SHALL provide `/search` with breadcrumb, category, brand and sort contr - **THEN** the page shows the shop's own after-sale copy and no rating, review count or reviewer comment ### Requirement: Mock transaction flow -The mall SHALL provide a store-grouped cart, address-selecting checkout preview, payment selection and payment-success result, all reading and writing the live cart and order APIs. Quantity changes, removals, selection totals, checkout and payment SHALL be persisted by the backend for the signed-in shopper, so they survive a page reload. +The mall SHALL provide a store-grouped cart, address-selecting checkout preview, configured payment selection and authoritative payment result using the shared cart/order/payment contracts. Quantity changes, removals, checkout and payment state SHALL persist through the selected adapter for the signed-in shopper and survive reload. A payment SHALL cover all shop orders returned by its checkout; the browser SHALL NOT loop over independent per-order pay calls. Fixed-data mode SHALL provide deterministic wallet and pending external outcomes without a provider connection. Transaction-related cart/order/payment adapter selection SHALL remain coherent; unsupported mixed live/fixed combinations SHALL fail configuration rather than submit fixture orders to real collection. #### Scenario: complete mock purchase -- **WHEN** a shopper adds an in-stock SKU, checks out with a mock address and confirms a payment -- **THEN** the cart is cleared, the success page is shown and the new order appears in the user order list +- **WHEN** a buyer completes a fixed-data checkout and selects its configured wallet method +- **THEN** the adapter clears the cart, applies all member orders and returns a recoverable payment result shown on the success surface #### Scenario: cart survives a reload -- **WHEN** a signed-in shopper adds an item and then reloads the page -- **THEN** the cart still holds that item, priced and stocked from the catalog +- **WHEN** a signed-in shopper adds an item and reloads +- **THEN** the cart still holds that item priced and stocked through the selected adapter + +#### Scenario: prevent mixed financial adapters +- **WHEN** configuration mixes fixed order ids with live payment collection +- **THEN** startup/configuration refuses that combination rather than initiating a provider operation ### Requirement: Auth and buyer center The mall SHALL provide B2B2C mall-style login, register and forgot-password panels backed by the live auth API, so credentials, roles and tokens belong to the real user rather than a fixed demo account. Registration SHALL require a password of at least 8 characters, matching the API's rule, and the register panel SHALL NOT ask for a verification code because no endpoint issues one. Failures SHALL be reported distinctly: invalid credentials on sign-in, and an already-registered email on registration. A token restored from storage SHALL be validated against the auth API on load, and a rejected token SHALL clear the session and return the shopper to sign-in. `/user` SHALL render a two-column buyer center with dashboard, order list/detail, addresses, favorites, coupons and invoices. @@ -243,27 +245,31 @@ Mall pages SHALL style layout with Tailwind utilities and shared primitives. Mal - **THEN** brand red, the 1200px grid, and the 450px hero height come from the shared theme keys rather than `mall.css` ### Requirement: Mall aftersale customer flow -The Mall SHALL use the shared selected API adapter to expose an after-sale action for eligible order items, an aftersale list and detail view, chronological buyer/merchant messages, cancellation and one-time rejected appeal, and return-refund shipping tracking. The form SHALL show the localized reason/type, integer minor-unit amount and remaining limit, evidence URL inputs, current status, and authoritative refund result. Anonymous actions SHALL redirect to sign-in with the current route as return destination. +The Mall SHALL use the shared selected API adapter to expose eligible per-item aftersale actions, list/detail, chronological messages, cancellation before refund submission, one-time rejected appeal and return tracking. Forms SHALL show localized reason/type, integer amount/remaining limit, evidence URLs, status and authoritative original-method refund progress. Submitted refunds SHALL not remain cancellable. Anonymous actions SHALL redirect to sign-in with the current route as return destination. #### Scenario: apply from an eligible order line -- **WHEN** a signed-in shopper opens a paid or shipped order inside its after-sale window and submits a valid item application -- **THEN** the Mall creates the aftersale through the shared client and shows its pending status without fixture-only state +- **WHEN** a signed-in shopper submits an eligible paid or shipped order-item application within its window +- **THEN** the shared client creates the application and displays its persisted pending state #### Scenario: return shipping is recorded - **WHEN** an approved return-refund shopper submits carrier and tracking data -- **THEN** the detail view shows the buyer-shipping status and the persisted tracking information after reload +- **THEN** buyer-shipping status and tracking persist after reload #### Scenario: shopper cancels an application -- **WHEN** a shopper cancels a non-terminal pending aftersale -- **THEN** the API state and list/detail views show cancelled and merchant actions are no longer offered +- **WHEN** a shopper cancels an eligible application before refund submission +- **THEN** the API and views show cancelled and no merchant completion action remains #### Scenario: shopper messages the merchant -- **WHEN** a shopper sends a localized message with optional evidence URLs -- **THEN** it appears in chronological detail history and remains after a reload +- **WHEN** a shopper sends localized text and optional evidence URLs +- **THEN** it appears in the persisted chronological detail history #### Scenario: fixed adapter remains usable -- **WHEN** the aftersale domain is configured for fixed data -- **THEN** Mall list, detail, messages, cancellation, tracking, and refund-history flows behave deterministically through the same shared methods +- **WHEN** aftersales use fixed data +- **THEN** list, detail, messaging, pre-submission cancellation, tracking and refund progress remain deterministic through the same methods + +#### Scenario: crypto refund is still pending +- **WHEN** an external refund has been submitted but is unconfirmed +- **THEN** the buyer sees refund pending rather than a wallet credit or completed refund and cannot cancel it ### Requirement: Checkout shipping fee display Checkout SHALL show each shop group's delivery fee and the combined shipping total computed by the server, alongside the merchandise and order totals in the selected currency. Changing the selected shipping address SHALL refresh the fee breakdown before submission, and payment and order detail SHALL show the persisted `shipping_fee` from the order payload. Fees SHALL come only from the shared `@vmall/shared` contract; the fixed-data adapter SHALL implement the same quote surface as a fallback so checkout works without the backend. @@ -307,23 +313,27 @@ The buyer center SHALL expose a "pending review" entry counting completed order - **THEN** the review area, pending-review entry, and submission flows behave deterministically through the same shared client methods ### Requirement: Buyer wallet surface -The mall SHALL render a buyer-center wallet page driven by the selected API adapter through `@vmall/shared`: available and frozen balance with currency, paginated fund entries, a clearly labeled demo recharge form, and a withdrawal request form. Balances and entries SHALL reflect backend state after each action rather than local-only state. The wallet SHALL be a mall API domain with fixed-adapter fallback methods and `LIVE_PICKS` wiring following the established per-domain adapter pattern. +The mall SHALL render the buyer wallet through the shared selected adapter: available/frozen balances and currency, paginated entries, a withdrawal request form and a clearly labeled demo recharge form only when deployment capabilities permit it. Balances and entries SHALL reflect backend state after each action. The wallet SHALL retain its fixed-adapter fallback and established live-domain wiring. Sandbox/live mode SHALL not offer simulated recharge as a way to obtain spendable funds. #### Scenario: wallet page loads live state -- **WHEN** a signed-in buyer opens the wallet page -- **THEN** balances and the first page of fund entries render from the shared wallet contract +- **WHEN** a signed-in buyer opens the wallet +- **THEN** balances and the first page of entries render through the shared wallet contract #### Scenario: demo recharge updates balance -- **WHEN** the buyer submits a demo recharge -- **THEN** the visible available balance reflects the credit without a reload and the form is visibly marked simulated +- **WHEN** a buyer in an explicitly enabled demo environment submits recharge +- **THEN** available balance reflects the backend credit and the form is visibly simulated #### Scenario: withdrawal freezes visibly -- **WHEN** the buyer submits a withdrawal request -- **THEN** the visible summary shows available decreased and frozen increased by the requested amount, and the request appears in the withdrawal list as pending +- **WHEN** a buyer submits a withdrawal request +- **THEN** available balance decreases, frozen balance increases and the request appears pending #### Scenario: fixed adapter remains functional -- **WHEN** the wallet domain is configured to fixed data -- **THEN** the wallet page behaves deterministically through the same shared client methods +- **WHEN** the wallet domain uses fixed data +- **THEN** the wallet behaves deterministically through the same shared methods with simulated capabilities clearly identified + +#### Scenario: live mode hides demo recharge +- **WHEN** the server capabilities identify sandbox or live mode +- **THEN** no actionable demo recharge form is presented ### Requirement: Merchant onboarding multi-step form The mall SHALL expose a merchant onboarding page ("商家入驻") reachable from the top-bar entry, with a multi-step form covering entity kind (personal 个人 / enterprise 企业), kind-specific entity information, operating categories from the published category tree, contact details, and qualification materials as URL input fields with no file-upload controls. The form SHALL be fillable while signed out, but submission SHALL require registration or sign-in and return the applicant to the completed form to submit through the shared selected API adapter. A duplicate-application conflict SHALL be surfaced inline. All copy SHALL come from the mall locale source in en and zh. @@ -399,3 +409,19 @@ The mall shell SHALL show the authenticated customer's unread message count as a #### Scenario: badge clears after mark-all-read - **WHEN** a shopper marks all messages read and returns to the shell - **THEN** the badge shows no unread count + +### Requirement: Configured recoverable payment experience +The mall SHALL discover eligible methods from the shared payment API, submit the selected channel and idempotency key, and recover the checkout-wide payment from a stable URL after reload or sign-in. It SHALL display authoritative all-order totals, pending/unknown state, receipt exceptions and original-method refund progress, and SHALL show success only after server-confirmed application. Provider redirects SHALL use safe backend-issued destinations. While unknown, the UI SHALL disable replacement payment and unsafe cancellation. Group cancellation SHALL explain that all unpaid member orders are affected. It SHALL not display unsupported balance/WeChat/Alipay/card choices as functional. Pending polling SHALL stop on unmount or terminal status and support explicit refresh. + +#### Scenario: reload after crypto redirect +- **WHEN** a signed-in buyer reloads the provider return page before collection is confirmed +- **THEN** the mall resolves the payment through the shared client and shows pending instead of inferring success from URL parameters + +#### Scenario: admin disables a method +- **WHEN** a cached channel becomes ineligible before the buyer submits +- **THEN** the mall shows the server refusal, refreshes method availability and does not silently switch methods + +#### Scenario: group rejection is refunded +- **WHEN** external funds arrive but a required group seat is unavailable +- **THEN** the mall shows received-but-unapplied and refund progress, not a paid or ordinary retry state + diff --git a/openspec/specs/frontend-mobile/spec.md b/openspec/specs/frontend-mobile/spec.md index 4eec5a9..9796e73 100644 --- a/openspec/specs/frontend-mobile/spec.md +++ b/openspec/specs/frontend-mobile/spec.md @@ -59,15 +59,23 @@ The app SHALL persist the session exactly as the mall does — `AuthTokens.token - **THEN** `vmall.token` and `vmall.user` are cleared and the shopper is routed to `/login` ### Requirement: Transaction and account surfaces on existing APIs -Cart, checkout, order list and order detail SHALL read and write backend state exclusively through shared client cart, checkout, order and address methods, so carts and orders survive reloads and match the mall's data. Checkout SHALL offer the customer's saved addresses (defaulting to the default address) with an inline manual address form when none exist, and SHALL present eligible owned coupons per shop order. Order detail SHALL expose the appropriate status actions (pay, cancel, confirm delivery) through the shared client's guarded status transitions. Coupon center SHALL list claimable shop coupons, claim them, and show the customer's owned coupons through the shared client. +Cart, checkout and order list/detail SHALL use shared cart, checkout, payment, order and address contracts so state survives reload and matches the mall. Checkout SHALL offer saved addresses defaulting to default with inline manual fallback, and eligible owned coupons per shop. Order detail SHALL resolve the payment group for pay/cancel and explain group-wide effects, and use guarded shared methods for delivery confirmation. Coupon center SHALL list claimable coupons, claim them and show owned coupons. Payment-method discovery SHALL be server-configured rather than hardcoded; pending/unknown collection and original-method refund progress SHALL remain recoverable through stable routes, without treating provider return parameters as success. Unknown attempts SHALL prevent replacement payment and unsafe cancellation. #### Scenario: cart survives reload -- **WHEN** a signed-in shopper adds an item and reloads `/cart` -- **THEN** the line is still present from backend state +- **WHEN** a signed-in shopper adds an item and reloads /cart +- **THEN** the backend cart line remains visible #### Scenario: claim from the coupon center -- **WHEN** a signed-in shopper claims a coupon with remaining stock from `/coupon` -- **THEN** it appears in the owned-coupon list without any fixture import +- **WHEN** a signed-in shopper claims a coupon with remaining stock +- **THEN** it appears through the shared contract without fixture imports + +#### Scenario: mobile crypto return survives reload +- **WHEN** a buyer returns from hosted Checkout and reloads before confirmation +- **THEN** the stable payment route loads server state and remains pending until verified application + +#### Scenario: one checkout pays multiple shops +- **WHEN** a buyer selects an eligible method for a multi-shop payment +- **THEN** mobile initiates one payment attempt and shows the authoritative all-order outcome rather than calling per-order pay in a loop ### Requirement: Dependency-gated after-sale and wallet surfaces The after-sale entry SHALL expose the customer's after-sale requests through the `aftersale` shared-client API from `add-aftersale-refunds`, and the wallet page SHALL render the customer's balances and ledger entries through the `wallet` shared-client API from `add-wallet-settlement`. While either change is not yet archived, the corresponding surface SHALL render a localized "Coming soon" placeholder instead of broken or fixture-backed behavior, and SHALL be enabled without further design change once its dependency lands. @@ -87,3 +95,14 @@ The mall top bar's "Mobile" (手机端) entry SHALL resolve to the H5 app's home - **WHEN** a desktop shopper clicks the mall top bar's Mobile entry - **THEN** the `apps/mobile` home page opens instead of reloading the mall home +### Requirement: Mobile financial environment and refund visibility +Mobile SHALL read deployment financial capabilities through the shared contract, hide demo recharge outside demo mode, and show pending, unknown, failed and completed original-method refunds without claiming a wallet credit for crypto. Submitted refunds SHALL not offer cancellation. Payment/financial actions SHALL use localized copy, safe-area layouts, shared tokens and accessible loading/error/409 states. + +#### Scenario: sandbox wallet does not mint demo funds +- **WHEN** a mobile buyer opens wallet in sandbox mode +- **THEN** the balance/entries remain available but no actionable demo-recharge form appears + +#### Scenario: external refund pending +- **WHEN** the original crypto refund is submitted but unconfirmed +- **THEN** mobile shows pending, does not show completed money movement and offers no cancellation or duplicate submission + diff --git a/openspec/specs/frontend-shop-admin/spec.md b/openspec/specs/frontend-shop-admin/spec.md index 261e116..e316291 100644 --- a/openspec/specs/frontend-shop-admin/spec.md +++ b/openspec/specs/frontend-shop-admin/spec.md @@ -2,9 +2,7 @@ ## Purpose The merchant console for managing a shop's products and fulfilling its orders. - ## Requirements - ### Requirement: Merchant product management Shop users SHALL manage only their own shop's products: create/edit bilingual content, manage SKUs, publish/unpublish with immediate effect on the storefront. @@ -86,23 +84,27 @@ Shop users SHALL edit their own shop's profile from a shop-admin page reachable - **THEN** the page keeps the entered values and shows a failure message ### Requirement: Shop-scoped aftersale workspace -Shop-admin SHALL provide an aftersale list and detail workspace filtered to the authenticated shop's `own_shop` resources. Authorized shop users SHALL inspect order-item evidence and messages, approve or reject pending applications, and confirm returned goods with the guarded refund action. The UI SHALL show status transitions, remaining amount, ledger-backed refund result, and stale-action errors through the shared API contract. +Shop-admin SHALL provide list/detail filtered to authenticated own_shop resources. Authorized shop users SHALL inspect order-item evidence/messages, approve/reject eligible applications, confirm returned goods and request the guarded original-method refund. The UI SHALL show remaining amount, completed totals and pending/unknown/failed/completed refund state, without assuming an external submission is completed or credits the customer's internal wallet. Server-authorized retry after definitive failure SHALL remain distinct from duplicate submission of an unknown operation. Payment context SHALL expose only this shop's allocation, not another shop's order breakdown or provider credentials. Stale-action errors SHALL use the shared contract's 409 recovery. #### Scenario: merchant approves a request -- **WHEN** a shop user opens a pending application for an item belonging to their shop and approves it -- **THEN** the status advances according to the selected aftersale type and the customer can see the persisted result +- **WHEN** an authorized shop user approves its pending application +- **THEN** the persisted status advances according to its aftersale type and remains visible to the buyer #### Scenario: merchant confirms return and refunds -- **WHEN** a shop user confirms receipt of a buyer-shipped return -- **THEN** the service records merchant confirmation, credits the customer's account once, and displays the refunded status and order total +- **WHEN** a shop user confirms returned goods and requests refund +- **THEN** the service records merchant confirmation and the UI shows atomic wallet completion or externally pending original-method refund, completing only on verified success #### Scenario: shop scope is enforced -- **WHEN** a shop user requests or mutates an aftersale for another shop -- **THEN** the API denies the operation and the workspace exposes no cross-shop data +- **WHEN** a shop user requests or mutates another shop's application or payment allocation +- **THEN** the API denies the operation without exposing cross-shop data or secrets #### Scenario: merchant messages buyer -- **WHEN** an authorized shop user appends a localized message -- **THEN** the message appears in the same chronological aftersale thread visible to the buyer +- **WHEN** an authorized shop user appends localized text +- **THEN** it appears in the persisted chronological thread visible to that buyer + +#### Scenario: unknown refund cannot be submitted again +- **WHEN** the provider refund outcome is unknown +- **THEN** the workspace shows unresolved status and offers no replacement refund action ### Requirement: Merchant freight template management Shop-admin SHALL provide a freight template management page for the signed-in shop user's own shop only, listing the shop's templates with their default flag, always-free toggle, pricing method, minor-unit fees, integer unit sizes, free-shipping threshold, and region rules. Create, edit, default-toggle, region-rule editing, and delete SHALL go through the shared `@vmall/shared` contract, and making a template default SHALL reflect the server's single-default outcome. The page SHALL use `@vmall/ui` primitives and shared tokens. @@ -154,3 +156,4 @@ The shop owner SHALL view the shop account summary (available and frozen balance #### Scenario: reviewed request reflects outcome - **WHEN** the platform rejects the shop owner's pending withdrawal - **THEN** the shop-account summary shows the amount returned to available balance and the request shows as rejected + diff --git a/openspec/specs/group-buying/spec.md b/openspec/specs/group-buying/spec.md index 170d2a5..3e4dfee 100644 --- a/openspec/specs/group-buying/spec.md +++ b/openspec/specs/group-buying/spec.md @@ -2,9 +2,7 @@ ## Purpose Let a shop run timed group-buying on one of its SKUs: a shopper opens a group or joins an open one, and the group succeeds once enough members have paid. A seat is claimed at payment rather than checkout, so unpaid orders never occupy one; a group expires when its lifetime ends, and cancelling the unpaid opener closes a still-empty group. Group pricing is resolved and snapshotted server-side, and it is mutually exclusive with flash-sale pricing and coupons on the same shop order. - ## Requirements - ### Requirement: Shop group-buying activities Shop users SHALL manage only their timed group-buying activities, each bound to one owned SKU with localized display content, fixed minor-unit group price, required paid-member count of at least two, group lifetime, and a per-order activity quantity of 1. The service SHALL reject a SKU that already has an overlapping active flash-sale item. @@ -24,16 +22,21 @@ Customers SHALL discover active group-buying activities and open groups. A custo - **THEN** the resulting pending-payment order identifies that group and snapshots the group price ### Requirement: Group membership and lifecycle -Payment SHALL atomically claim a paid membership in an open unexpired group. A group SHALL become successful exactly when paid member count reaches its required count; it SHALL become expired when its lifetime ends before success; it SHALL become cancelled when its opening pending-payment order is cancelled while paid member count is zero. A full, expired, or cancelled group SHALL reject payment without changing order state. +Payment SHALL atomically claim a paid membership in an open unexpired group with all other effects of its checkout-wide payment. Unpaid attempts SHALL NOT occupy group seats. A group SHALL become successful exactly when paid member count reaches its required count; it SHALL become expired when its lifetime ends before success; it SHALL become cancelled when its opening pending-payment order is cancelled while paid member count is zero. Full, expired or cancelled groups SHALL reject wallet payment without collection or order-state changes. When external funds have already been collected, inability to claim any required seat SHALL roll back every local application effect, retain the receipt and request a full original-method compensating refund. The receipt SHALL remain visible until compensation is confirmed. #### Scenario: concurrent final seat -- **WHEN** two pending group orders attempt payment for one final seat concurrently -- **THEN** exactly one payment succeeds and the group becomes successful +- **WHEN** two payments compete for one final paid group seat +- **THEN** exactly one applies and the group becomes successful; a losing wallet attempt collects nothing and a losing external receipt follows compensation #### Scenario: expired paid group - **WHEN** an open group expires before reaching its required count -- **THEN** it is marked expired and its paid orders remain identifiable for a later refund flow +- **THEN** it becomes expired and its already-paid orders remain identifiable with their original payment references for the existing later refund flow #### Scenario: unpaid opener cancel closes an empty group -- **WHEN** the opening pending-payment order is cancelled and the group still has zero paid members -- **THEN** the group becomes cancelled, SKU stock is restored, and later join or payment for that group returns 409 +- **WHEN** the opening pending-payment order is safely cancelled while paid member count is zero +- **THEN** the group becomes cancelled, stock is restored once, and later collection cannot apply another order against that group + +#### Scenario: no partial multi-shop application +- **WHEN** one group-bearing member of a multi-shop payment loses eligibility before external settlement +- **THEN** no member becomes paid, no group seat remains claimed, and the entire receipt is retained for compensation + diff --git a/openspec/specs/order/spec.md b/openspec/specs/order/spec.md index 09da93c..09ae228 100644 --- a/openspec/specs/order/spec.md +++ b/openspec/specs/order/spec.md @@ -35,27 +35,31 @@ Checkout splitting a cart into per-shop orders, plus order lifecycle and ownersh - **THEN** the order records that coupon and its server-calculated discount ### Requirement: Order lifecycle -Status transitions SHALL be: pending_payment → paid → fulfilling → shipped → completed; cancellable only from pending_payment, which MUST restore SKU stock, any reserved flash-sale activity stock consumed by the order, and a redeemed coupon to claimed status when the order has one. Pending-payment cancellation SHALL NOT change group paid-member counts. When the cancelled order opened the group and paid member count is zero, the group SHALL become cancelled. Payment of an order with group-buying intent SHALL also validate and atomically claim an open group seat; full, expired, or cancelled groups SHALL return 409 and leave the order pending_payment. +Status transitions SHALL be pending_payment → paid → fulfilling → shipped → completed. An order SHALL become paid only through application of its payment's funded receipt, with all member orders applied atomically. Cancellation SHALL be allowed only from pending_payment when the payment has no unresolved or applied collection; cancelling one member SHALL cancel its entire unpaid payment group atomically and restore SKU stock, reserved flash-sale stock and redeemed coupons exactly once. Unknown provider outcomes SHALL block cancellation with 409. Pending-payment cancellation SHALL NOT change group paid-member counts. When a cancelled order opened a group with zero paid members, that group SHALL become cancelled. Payment SHALL atomically validate and claim any required open group seats. A wallet attempt against a full, expired or cancelled group SHALL return 409 without collection or order changes. External collection that cannot claim a seat SHALL retain its receipt, leave every member unpaid and schedule compensation rather than imply funds were rolled back. #### Scenario: group payment wins final seat -- **WHEN** payment fills the final available group seat -- **THEN** the payment succeeds and the group state becomes successful +- **WHEN** funded payment claims the final available group seat +- **THEN** all member orders become paid together and the group becomes successful #### Scenario: cancel restores stock -- **WHEN** a customer cancels a pending_payment group-intent order -- **THEN** SKU stock increases by the ordered qty, status is cancelled, and no paid group membership is removed +- **WHEN** a customer cancels a pending-payment group whose attempts are conclusively non-collecting +- **THEN** all member orders become cancelled, their stock is restored once and no paid group membership is removed #### Scenario: unpaid opener cancel closes an empty group -- **WHEN** the opening pending_payment order is cancelled and the group has zero paid members -- **THEN** the group status becomes cancelled and a later payment against that group returns 409 +- **WHEN** a cancelled payment contains an opening order whose group has zero paid members +- **THEN** that group becomes cancelled and later collection cannot apply an order against it #### Scenario: cancel restores coupon -- **WHEN** a customer cancels a pending_payment order with a redeemed coupon -- **THEN** the coupon is detached from the order and becomes claimed +- **WHEN** a safely cancelled payment includes a coupon-bearing order +- **THEN** the coupon is detached from that order and restored to claimed once #### Scenario: illegal transition rejected -- **WHEN** cancelling a paid order via the customer endpoint -- **THEN** the API returns 409 +- **WHEN** a customer tries to cancel an already paid order through the cancellation endpoint +- **THEN** the API returns 409 and no payment, stock or coupon state changes + +#### Scenario: timeout blocks cancellation +- **WHEN** an external attempt has an unknown collection outcome +- **THEN** cancelling any member returns 409 until authoritative reconciliation establishes a safe outcome ### Requirement: Order ownership Customers SHALL see only their own orders; shop roles only their shop's orders; platform_admin sees all. @@ -108,3 +112,10 @@ Each order SHALL persist its shipping fee in i64 minor units alongside its other - **WHEN** a customer or shop user opens an order created before a template's fees changed - **THEN** the order detail shows the shipping fee and item pricing methods snapshotted at checkout +### Requirement: Payment-backed checkout contract +Checkout SHALL create its payment and immutable per-shop order memberships in the same transaction as the existing stock, pricing, coupon and order writes, and SHALL return `{orders, payment_id}`. Payment detail SHALL expose the original complete group to the owning buyer across reloads. The status-only per-order pay endpoint SHALL be removed; payment initiation SHALL use the payment contract and the selected configured channel. A member's order-detail action SHALL resolve its payment, not independently charge a subset. + +#### Scenario: checkout returns recoverable payment +- **WHEN** a two-shop checkout succeeds and the browser reloads +- **THEN** its payment id resolves both orders and their server-snapshotted totals without an in-memory order-id list + diff --git a/openspec/specs/payment-configuration/spec.md b/openspec/specs/payment-configuration/spec.md new file mode 100644 index 0000000..85ee376 --- /dev/null +++ b/openspec/specs/payment-configuration/spec.md @@ -0,0 +1,64 @@ +# payment-configuration Specification + +## Purpose +TBD - created by archiving change add-configurable-payments. Update Purpose after archive. +## Requirements +### Requirement: Installed adapter channel configuration +Platform administrators SHALL manage versioned channels for the installed wallet and Stripe crypto adapters. Channels SHALL define bilingual label, display order, enabled state, supported fiat currencies, and positive integer minor-unit limits within adapter capabilities. Unknown adapter types, arbitrary executable integrations and arbitrary provider API hosts SHALL be rejected. Deployment environment SHALL be server-owned and immutable through the dashboard. Wallet SHALL support only the base wallet currency and Stripe crypto only USD; configuration SHALL NOT imply automatic FX or unsupported asset/network/finality controls. + +#### Scenario: configure crypto checkout +- **WHEN** an administrator saves a valid disabled Stripe crypto channel with USD limits and bilingual labels +- **THEN** the channel is persisted with its adapter capabilities and cannot be used until its activation prerequisites pass + +#### Scenario: unsupported controls +- **WHEN** an administrator attempts to enable JPY, an arbitrary token network restriction, or a custom provider API host for Stripe crypto +- **THEN** the API rejects the unsupported configuration rather than claiming it will be enforced by hosted Checkout + +### Requirement: Credential references and fail-closed activation +Credentials SHALL be resolved server-side through deployment-allowlisted payment secret references, never returned through any API or stored as plaintext channel/audit fields. Missing references, incorrect account/environment and unsupported merchant capability SHALL prevent activation. The API SHALL offer an admin-only read-only connection/account verification operation, but SHALL NOT equate valid credentials with provider business approval. Provider-managed asset/network selection SHALL be represented as provider-managed information, not editable promises. + +#### Scenario: secrets stay private +- **WHEN** an administrator or customer reads channel data or an audit entry +- **THEN** no API key, signing secret, authorization header or raw secret value is returned + +#### Scenario: unapproved provider account +- **WHEN** an administrator tries to activate crypto without established provider eligibility/capability +- **THEN** activation is refused with the unmet prerequisite and no enabled crypto method is advertised + +#### Scenario: arbitrary environment-secret reference +- **WHEN** an administrator references a host secret outside the payment allowlist +- **THEN** validation fails without resolving or revealing that secret + +### Requirement: Immutable channel revisions and audit +Configuration changes SHALL create immutable revisions with actor/time and redacted audit details. Stale revision edits SHALL return 409. Attempts and refunds SHALL retain their original revision and credential references. Disabled or archived channels SHALL stop new attempts without preventing existing notifications, reconciliation or refunds. Referenced channels SHALL be archived rather than deleted, and old signing references SHALL remain usable for relevant outstanding events after rotation. + +#### Scenario: disable during payment +- **WHEN** an administrator disables a channel after an attempt starts +- **THEN** new attempts are refused while its verified settlement notification and original-method refund still process + +#### Scenario: rotate credentials +- **WHEN** an administrator creates a new credential revision +- **THEN** new attempts use it and existing work remains associated with its prior revision + +#### Scenario: concurrent configuration edits +- **WHEN** two administrators save against the same previous revision +- **THEN** one update wins and the stale update returns 409 without overwriting it + +### Requirement: Server-authoritative method eligibility +Customer method discovery SHALL expose only safe descriptors of enabled, configured, environment-matching channels whose currency, amount and capability constraints cover the owned payment. Initiation SHALL revalidate eligibility transactionally and SHALL reject disabled channels even when a browser cached their prior availability. No unsupported WeChat, Alipay or card option SHALL be advertised as functional. + +#### Scenario: stale method selection +- **WHEN** a buyer starts a channel that was disabled after method discovery +- **THEN** initiation fails without a provider call and the storefront can refresh eligible methods + +### Requirement: Audited payment recovery operations +Only platform administrators SHALL inspect cross-shop payment/receipt/refund exceptions and request reconciliation. A reconciliation action SHALL durably fetch authoritative evidence and reuse the normal application path, never directly set paid/refunded, initiate another collection, edit a balance, or bypass amount/ownership guards. Lists SHALL be paginated and filterable by environment, status, provider reference and payment/order identity; customer and merchant data SHALL remain scoped in their respective endpoints. + +#### Scenario: reconcile unknown operation +- **WHEN** an administrator requests reconciliation of a timed-out attempt +- **THEN** an auditable evidence-fetch operation is queued without creating a replacement charge + +#### Scenario: merchant cannot configure processor +- **WHEN** a shop owner calls channel configuration or cross-shop recovery APIs +- **THEN** the request is denied and no credential or other shop's payment details are disclosed + diff --git a/openspec/specs/payments/spec.md b/openspec/specs/payments/spec.md new file mode 100644 index 0000000..5d4b4eb --- /dev/null +++ b/openspec/specs/payments/spec.md @@ -0,0 +1,133 @@ +# payments Specification + +## Purpose +TBD - created by archiving change add-configurable-payments. Update Purpose after archive. +## Requirements +### Requirement: Server-owned checkout payment unit +Checkout SHALL require an owner-scoped idempotency key. The API SHALL persist one owned payment with immutable fiat currency, checked integer total, and per-order amount memberships for every new checkout, in the checkout transaction. Membership amounts SHALL equal the snapshotted order totals and sum to the payment total. Each order SHALL belong to at most one payment. A replay with the same key and request hash SHALL return the original orders and payment without another inventory allocation; a changed payload SHALL return 409. A computed payment total of zero SHALL be rejected before any order, inventory, coupon, or payment write. Resolving an existing order's payment SHALL return its original full membership rather than regroup it. The API SHALL never accept client-calculated collection amounts or silently convert currencies. + +#### Scenario: multi-shop payment total +- **WHEN** checkout creates two shop orders for 1000 and 2500 minor units in one currency +- **THEN** the payment contains both orders and an immutable total of 3500 minor units + +#### Scenario: cross-customer access +- **WHEN** a customer reads or initiates a payment owned by another customer +- **THEN** the API returns 404 without exposing amounts, orders, provider references, or actions + +#### Scenario: checkout replay is durable +- **WHEN** a customer retries checkout with the same idempotency key and unchanged request after an uncertain response +- **THEN** the API returns the original orders and payment without creating new orders or decrementing stock again + +#### Scenario: zero total is rejected +- **WHEN** server-side checkout calculation reaches a payment total of zero +- **THEN** the API returns 409 and creates no order, payment, coupon redemption, or inventory change + +### Requirement: Atomic wallet collection and order application +An enabled eligible wallet channel SHALL debit the buyer's available balance using guarded account primitives and commit one ledger entry, collection receipt, every member order's paid transition, all required group seats, and system messages in one database transaction. Wallet payment SHALL be eligible only for the base-currency wallet's currency. Failure of any member's preconditions SHALL roll back all these effects. Concurrent attempts SHALL never overdraw or apply an order twice. + +#### Scenario: wallet pays two shops +- **WHEN** a buyer with 5000 minor units pays the 3500-minor-unit multi-shop payment +- **THEN** available balance becomes 1500, one debit entry references the payment, and both orders become paid together + +#### Scenario: one group seat is unavailable +- **WHEN** any member order cannot claim its required group seat during wallet payment +- **THEN** no member order is paid, no seat is claimed, and no balance or ledger entry changes + +#### Scenario: insufficient balance +- **WHEN** concurrent wallet operations exceed the available balance +- **THEN** insufficient operations fail with 409 and no committed account becomes negative + +### Requirement: Idempotent attempts and unresolved-outcome exclusion +Starting a payment SHALL require an owner-scoped idempotency key, eligible channel, and typed return surface (`mall` or `mobile`). The API SHALL map the surface exclusively to a server-allowlisted storefront origin and persist it with the attempt. Repeating the same key and payload SHALL return the same operation; reusing it with different input SHALL return 409. At most one unresolved attempt SHALL exist for a payment, enforced by persistence constraints. A timeout SHALL produce an unknown outcome and SHALL NOT permit method switching, replacement collection, or cancellation until authoritative reconciliation establishes a safe outcome. An already applied payment SHALL never initiate another collection. + +#### Scenario: timeout followed by another method +- **WHEN** a provider create call times out and the buyer selects wallet payment +- **THEN** the API returns the unresolved attempt and 409 rather than initiating a second collection + +#### Scenario: duplicate initiation +- **WHEN** two requests use the same idempotency key and input concurrently +- **THEN** they resolve to one durable attempt and one provider operation identity + +### Requirement: Durable provider commands and verified events +External create, refund and reconciliation work SHALL be committed durably before execution and SHALL run outside SQL transactions. Workers SHALL recover expired leases after restart and SHALL share work safely across API instances. Stable provider idempotency keys SHALL be retained with their operation. An ambiguous operation beyond the provider's guaranteed idempotency retention SHALL be reconciled rather than blindly reissued. Webhooks SHALL be signature-verified against the raw body and expected account/environment, durably recorded before 2xx acknowledgement, and deduplicated by provider account, environment and event id. Reconciliation and notification processing SHALL use the same monotonic application path. + +#### Scenario: provider succeeds before the worker crashes +- **WHEN** the provider creates a checkout but the worker restarts before persisting the response +- **THEN** recovery uses the same operation identity or authoritative provider evidence and does not create an unrelated new charge + +#### Scenario: replay after provider retention +- **WHEN** a create operation remains ambiguous beyond the provider idempotency retention window +- **THEN** processing preserves unknown status and uses evidence lookup instead of replaying creation + +#### Scenario: forged or wrong-environment webhook +- **WHEN** an event has an invalid signature, stale signing timestamp, wrong account, or wrong livemode +- **THEN** it causes no order, balance, receipt application, or refund completion + +#### Scenario: database unavailable at receipt +- **WHEN** a valid notification cannot be persisted +- **THEN** the webhook returns non-2xx rather than acknowledging and losing the event + +### Requirement: Hosted stablecoin collection with fiat accounting +The Stripe crypto adapter SHALL create a hosted payment-mode Checkout Session restricted to crypto, using server-owned USD amounts and persistent payment/attempt metadata. It SHALL require eligible approved merchant configuration and enforce the lower of configured limits and the provider's USD 10000 transaction maximum. Provider-established successful paid collection SHALL be required before local application; redirects or session completion without paid confirmation SHALL NOT suffice. USD SHALL be the presentment and VMall receipt currency; provider-balance settlement SHALL use the Stripe account's supported local currency and SHALL be recorded as provider metadata when exposed, without changing order amounts or requiring that local currency to be USD. Stripe SHALL manage wallet connection, supported assets/networks, pricing and finality; VMall SHALL neither fabricate these details nor claim unsupported configuration controls. Provider-exposed asset quantities SHALL be exact integer-base-unit strings identified by network and contract/native asset, separate from ISO fiat currency amounts. + +#### Scenario: stablecoin purchase +- **WHEN** an eligible buyer pays a USD payment through the configured Stripe crypto channel and Stripe confirms paid collection +- **THEN** VMall records the USD receipt and atomically applies all member orders without holding crypto keys or changing their fiat totals + +#### Scenario: unsupported currency +- **WHEN** the payment currency is JPY +- **THEN** Stripe crypto is unavailable and direct initiation is rejected without automatic conversion or a provider call + +#### Scenario: browser claims success +- **WHEN** a buyer returns with a success query parameter before authoritative paid confirmation +- **THEN** the payment remains pending and no order becomes paid + +#### Scenario: exact crypto metadata +- **WHEN** provider evidence includes a base-unit amount greater than JavaScript's exact-integer range +- **THEN** the API preserves it as a string without floating-point arithmetic or loss of precision + +### Requirement: Captured receipts survive rejected application +Actual collection receipts SHALL be retained independently of order application and SHALL be unique by provider-account/environment/transaction identity. Application SHALL validate amount, currency and payment membership, lock all affected resources, and commit all business effects once. A valid receipt that cannot be applied because the payment is closed or a member became ineligible SHALL schedule a full original-method compensating refund without paying a subset of orders. Separate genuine additional collections SHALL be retained and compensated, not discarded as duplicate events. Mismatched receipts SHALL remain quarantined for authoritative reconciliation and SHALL NOT authorize fulfillment or wallet credits. + +#### Scenario: group fills before external settlement +- **WHEN** valid external collection arrives after another shopper took the last required group seat +- **THEN** all local order/seat application rolls back and a tracked full compensating refund is scheduled + +#### Scenario: duplicate events versus duplicate payments +- **WHEN** duplicate notifications describe one collection and another notification describes a second genuine collection +- **THEN** the first collection is applied at most once and the second is recorded separately for compensation + +#### Scenario: compensation releases only unapplied orders +- **WHEN** a full compensating refund succeeds for a payment that never applied +- **THEN** the payment closes, its still-pending member orders cancel and stock/activity inventory/coupons restore once; compensating an extra receipt on an already-applied payment instead leaves its valid orders unchanged + +#### Scenario: unexpected amount +- **WHEN** receipt amount or currency differs from the immutable payment +- **THEN** it is quarantined and no member order becomes paid + +### Requirement: Original-method refunds and reserved refund budget +Refunds SHALL reference the original receipt and use its method. Pending, unknown and succeeded refund amounts SHALL reserve budget against both the receipt and the affected allocation so concurrent operations cannot exceed captured funds. Wallet refunds SHALL commit their account credit, immutable entry and business completion atomically. Stripe refunds SHALL return funds through the provider to the original wallet, never credit the internal wallet as a fallback. External completion SHALL require verified provider refund success; only then SHALL aftersale totals/status/messages change. Definitive failure SHALL release its reservation and allow an explicit guarded new operation; an unknown operation SHALL retain its reservation and block replacement. Compensation for unapplied receipts SHALL NOT increase order aftersale totals. + +#### Scenario: concurrent partial refunds +- **WHEN** two requested refunds together exceed the unreserved captured allocation +- **THEN** at most the covered amount is reserved and the excess request fails without a provider call + +#### Scenario: pending crypto refund +- **WHEN** Stripe accepts a refund request but has not confirmed success +- **THEN** it remains pending, no internal wallet credit is written, and order refund totals remain unchanged + +#### Scenario: refund-success replay +- **WHEN** a succeeded refund notification is delivered repeatedly or concurrently with reconciliation +- **THEN** the order total, aftersale status and completion message change once + +### Requirement: Environment-isolated legacy cutover +Payment records SHALL carry immutable deployment environment. Demo, sandbox and live SHALL use separate financial databases and matching provider environments. Historical status-only paid orders SHALL be explicitly marked legacy-demo, never supplied fictional captured receipts. Demo legacy refunds SHALL retain the labeled historical wallet path; live provider refunds SHALL reject such orders. Existing ungrouped pending orders SHALL acquire singleton membership on demand. Live startup/activation SHALL reject legacy-demo paid history, demo monetary credits or non-live payment records. The removed status-only pay endpoint/client method SHALL have no compatibility alias. + +#### Scenario: historical demo purchase +- **WHEN** a historical paid order is migrated +- **THEN** it remains explicitly legacy-demo and cannot be presented as a funded live purchase + +#### Scenario: demo database cannot become live money +- **WHEN** live mode is configured against a database containing demo recharge or legacy-demo paid records +- **THEN** financial activation fails closed rather than making its balances spendable live + diff --git a/openspec/specs/wallet/spec.md b/openspec/specs/wallet/spec.md index 5a3283e..af48568 100644 --- a/openspec/specs/wallet/spec.md +++ b/openspec/specs/wallet/spec.md @@ -8,19 +8,21 @@ reject returns it to available), and a paginated view of the caller's own fund entries. Balances only move through the account module's credit/debit/freeze/release primitives in the same transaction as the business record, and every movement pairs with an append-only ledger entry. - ## Requirements - ### Requirement: Demo wallet recharge -An authenticated user SHALL recharge their wallet through a simulated demo flow that records a wallet recharge row and credits the available monetary account with one signed ledger entry in one transaction, without invoking any external payment channel. The API payload and the wallet UI SHALL clearly label the recharge as simulated/demo. Amounts SHALL be positive integer minor units in the account's currency, and monetary arithmetic SHALL NOT use floating point. +In a demo deployment an authenticated user SHALL recharge their wallet through the simulated flow that records a recharge row and credits the available monetary account with one signed ledger entry in one transaction, without invoking an external channel. The payload and UI SHALL clearly label the flow simulated/demo. Amounts SHALL be positive integer minor units in account currency without floating-point arithmetic. Sandbox and live deployments SHALL refuse this operation server-side and SHALL not advertise it as available. Turning off a UI control SHALL NOT be the security boundary; historical demo credits SHALL prevent live financial activation in that database. #### Scenario: demo recharge credits balance -- **WHEN** a signed-in buyer submits a demo recharge of 5000 minor units -- **THEN** the available balance increases by exactly 5000 minor units and exactly one ledger entry with reason `wallet_recharge` records the resulting balance +- **WHEN** a signed-in buyer in a demo deployment recharges 5000 minor units +- **THEN** available balance increases by exactly 5000 and one wallet_recharge entry records the resulting balance #### Scenario: flow is labeled as demo -- **WHEN** a buyer opens the recharge flow -- **THEN** it is visibly marked as a simulated recharge and no external payment provider is contacted +- **WHEN** a buyer opens the enabled recharge flow +- **THEN** it is visibly labeled simulated and contacts no external provider + +#### Scenario: live recharge cannot mint funds +- **WHEN** a signed-in buyer directly calls the demo-recharge endpoint in sandbox or live mode +- **THEN** the operation is refused without a recharge record, balance change or ledger entry ### Requirement: Guarded withdrawal freeze An authenticated user SHALL apply to withdraw a positive amount in their account currency. The application SHALL atomically move the amount from available to frozen balance through guarded conditional updates that succeed only when available balance covers the amount, and each balance change SHALL be paired with a ledger entry in the same transaction. When available balance is insufficient the request SHALL fail without changing any balance, and concurrent applications SHALL never overdraw or produce a negative balance. @@ -58,3 +60,4 @@ An authenticated user SHALL page through their own monetary account entries newe #### Scenario: entries are user-isolated - **WHEN** one user requests fund entries - **THEN** no entry belonging to another user is ever returned + diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index fc18a5f..604dea6 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -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( path: string, body?: unknown, query?: Query, + idempotencyKey?: string, ): Promise { const url = new URL(opts.baseUrl + path); if (query) { @@ -197,6 +216,7 @@ async function request( const headers: Record = { "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; updateCartItem(skuId: string, qty: number): Promise; removeCartItem(skuId: string): Promise; + /** + * 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, - /** Optional group-buying intent for one activity SKU at quantity 1. */ - groupBuy?: GroupBuyIntent, - ): Promise; + couponByShop: Record | undefined, + groupBuy: GroupBuyIntent | undefined, + idempotencyKey: string, + ): Promise; listMyOrders(page?: number): Promise>; getOrder(id: string): Promise; cancelOrder(id: string): Promise; - payOrder(id: string): Promise; + /** Resolves the existing payment of owned pending orders, or creates a singleton. */ + resolvePayment(orderIds: string[]): Promise; + getPayment(id: string): Promise; + /** Eligible enabled channels for this payment; never secrets or provider config. */ + listPaymentMethods(id: string): Promise; + /** + * 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; + /** Cancels the whole unpaid payment group; 409 with authoritative state otherwise. */ + cancelPayment(id: string): Promise; confirmDelivered(shipmentId: string): Promise; listMyShipments(): Promise; requestInvoice( @@ -372,7 +412,8 @@ export interface ApiClient { approveAftersale(id: string): Promise; rejectAftersale(id: string): Promise; confirmAftersaleReceipt(id: string): Promise; - refundAftersale(id: string): Promise; + /** Submits (or retries after definitive failure) the original-method refund. */ + refundAftersale(id: string): Promise; addAftersaleMessage(id: string, body: AftersaleMessageBody): Promise; listFreightTemplates(): Promise; createFreightTemplate(body: FreightTemplateInput): Promise; @@ -405,7 +446,8 @@ export interface ApiClient { ): Promise; listAftersales(status?: AftersaleStatus): Promise; getAftersale(id: string): Promise; - arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise; + /** Resolves an escalated application via the shared refund path. */ + arbitrateAftersale(id: string, outcome: AftersaleArbitration): Promise; listReviews(page?: number): Promise>; hideReview(id: string): Promise; deleteReview(id: string): Promise; @@ -439,12 +481,40 @@ export interface ApiClient { updateMemberLevel(id: string, body: MemberLevelInput): Promise; /** Rejected with a 409 when any customer currently holds the level. */ deleteMemberLevel(id: string): Promise; + /** Adapter catalog with capabilities and external activation prerequisites. */ + listPaymentAdapters(): Promise; + listPaymentChannels(q?: PaymentChannelQuery): Promise>; + getPaymentChannel(id: string): Promise; + /** Returns the detail superset, including secret references and readiness. */ + createPaymentChannel(body: PaymentChannelCreateBody): Promise; + /** 409 when `expected_revision` no longer matches the channel revision. */ + updatePaymentChannel( + id: string, + body: PaymentChannelUpdateBody, + ): Promise; + /** Archival when the channel is referenced; never destructive removal. */ + deletePaymentChannel(id: string): Promise; + listPaymentChannelRevisions(id: string): Promise; + listPaymentChannelAudit(id: string): Promise; + /** Read-only provider connection/account check; implies no approval. */ + verifyPaymentChannel(id: string): Promise; + /** Paginated payment/refund/exception operations. */ + listPaymentOperations(q?: PaymentOperationQuery): Promise>; + /** Recovery detail: authoritative payment plus redacted channel audit history. */ + getPayment(id: string): Promise; + /** Durable evidence-fetch command; a repeat never initiates collection. */ + reconcilePayment(id: string): Promise; }; } export function createApi(opts: ApiClientOptions): ApiClient { - const r = (m: string, p: string, b?: unknown, q?: Query): Promise => - request(opts, m, p, b, q); + const r = ( + m: string, + p: string, + b?: unknown, + q?: Query, + idempotencyKey?: string, + ): Promise => request(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`), }, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2715c83..1466343 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; + 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. */