From a7cd392bd47ea5967156e2c02ef3956e51fa499a Mon Sep 17 00:00:00 2001
From: Chengdong Zhang
Date: Thu, 15 Oct 2026 16:14:28 +0800
Subject: [PATCH] =?UTF-8?q?feat(payments):=20configurable=20checkout=20pay?=
=?UTF-8?q?ments=20=E2=80=94=20wallet=20+=20Stripe=20crypto,=20original-me?=
=?UTF-8?q?thod=20refunds=20(add-configurable-payments)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- payment domain: payments/memberships/attempts/receipts/refunds/commands/events, legacy-demo provenance (0024), checkout header-hash idempotency (0025)
- wallet collection all-or-none across shop orders; group-wide cancellation; checkout cutover, status-only payOrder removed
- Stripe crypto-only Checkout adapter, raw-body HMAC webhooks, durable command worker with leases
- original-method refunds with receipt/allocation budgets; refund_pending/refund_failed aftersale states
- admin channel CRUD/revisions/audit/verify + operations list/detail/reconcile; secret-ref allowlist (VMALL_PAYMENT_*), env-fixed demo/sandbox/live
- mall/mobile checkout pay surfaces, admin console pages; shared contract updated
- pre-launch leftovers recorded in docs/TBD-payments.md
cargo test -p vmall-api green twice; four frontend builds green; browser smoke: mall wallet checkout paid, admin channels/operations verified
---
Cargo.lock | 387 ++---
README.md | 9 +
apps/admin/app.vue | 2 +
apps/admin/composables/useRedactedAudit.ts | 28 +
apps/admin/locales-extra.ts | 404 ++++-
apps/admin/pages/aftersales.vue | 105 +-
apps/admin/pages/payment-channels.vue | 955 ++++++++++++
apps/admin/pages/payments.vue | 714 +++++++++
apps/api/Cargo.toml | 6 +-
apps/api/migrations/0024_payments.sql | 312 ++++
.../0025_checkout_request_header_hash.sql | 10 +
apps/api/src/config.rs | 141 ++
apps/api/src/main.rs | 18 +-
apps/api/src/models.rs | 8 +
apps/api/src/modules/aftersale/handlers.rs | 4 +-
apps/api/src/modules/aftersale/service.rs | 138 +-
apps/api/src/modules/group_buying/dto.rs | 2 +-
apps/api/src/modules/mod.rs | 2 +
apps/api/src/modules/order/dto.rs | 2 +-
apps/api/src/modules/order/handlers.rs | 18 +-
apps/api/src/modules/order/mod.rs | 4 +-
apps/api/src/modules/order/repo.rs | 51 +-
apps/api/src/modules/order/service.rs | 198 ++-
apps/api/src/modules/payment/adapters.rs | 640 ++++++++
apps/api/src/modules/payment/admin.rs | 927 +++++++++++
apps/api/src/modules/payment/dto.rs | 555 +++++++
apps/api/src/modules/payment/handlers.rs | 262 ++++
apps/api/src/modules/payment/mod.rs | 33 +
apps/api/src/modules/payment/refunds.rs | 573 +++++++
apps/api/src/modules/payment/repo.rs | 1355 +++++++++++++++++
apps/api/src/modules/payment/service.rs | 1214 +++++++++++++++
apps/api/src/modules/payment/webhooks.rs | 811 ++++++++++
apps/api/src/modules/payment/worker.rs | 282 ++++
apps/api/src/modules/wallet/dto.rs | 6 +-
apps/api/src/modules/wallet/service.rs | 14 +-
apps/api/src/state.rs | 6 +-
apps/api/tests/aftersales.rs | 10 +-
apps/api/tests/common/mod.rs | 94 +-
apps/api/tests/coupons.rs | 7 +-
apps/api/tests/flash_sales.rs | 23 +-
apps/api/tests/group_buying.rs | 80 +-
apps/api/tests/messaging.rs | 3 +-
apps/api/tests/order_service.rs | 61 +-
apps/api/tests/orders.rs | 1 +
apps/api/tests/payments.rs | 746 +++++++++
apps/mall/locales/checkout.ts | 8 +-
apps/mall/locales/user.ts | 12 +-
apps/mall/locales/wallet.ts | 4 +-
apps/mall/mock/api.ts | 510 ++++++-
apps/mall/nuxt.config.ts | 1 +
apps/mall/pages/checkout/index.vue | 22 +-
apps/mall/pages/checkout/pay.vue | 234 ++-
apps/mall/pages/checkout/success.vue | 106 +-
apps/mall/pages/user/aftersales/[id].vue | 9 +
apps/mall/pages/user/aftersales/index.vue | 2 +-
apps/mall/pages/user/orders/[id].vue | 27 +-
apps/mall/pages/user/orders/index.vue | 4 +-
apps/mall/pages/user/wallet.vue | 19 +-
apps/mall/plugins/api.ts | 21 +-
apps/mobile/locales-extra.ts | 108 +-
apps/mobile/pages/aftersales.vue | 33 +-
apps/mobile/pages/checkout.vue | 42 +-
apps/mobile/pages/orders/[id].vue | 53 +-
apps/mobile/pages/orders/index.vue | 54 +-
apps/mobile/pages/orders/pay/cancel.vue | 152 ++
apps/mobile/pages/orders/pay/index.vue | 257 ++++
apps/mobile/pages/orders/pay/success.vue | 112 ++
apps/mobile/pages/wallet.vue | 3 +-
apps/mobile/utils/payment.ts | 94 ++
apps/shop-admin/locales-extra.ts | 53 +
apps/shop-admin/pages/aftersales/[id].vue | 103 +-
apps/shop-admin/pages/aftersales/index.vue | 13 +-
docs/TBD-payments.md | 34 +
docs/code_index/order.md | 29 +-
.../.openspec.yaml | 2 +
.../design.md | 148 ++
.../proposal.md | 37 +
.../specs/aftersale/spec.md | 50 +
.../specs/frontend-admin/spec.md | 40 +
.../specs/frontend-mall/spec.md | 83 +
.../specs/frontend-mobile/spec.md | 33 +
.../specs/frontend-shop-admin/spec.md | 24 +
.../specs/group-buying/spec.md | 20 +
.../specs/order/spec.md | 37 +
.../specs/payment-configuration/spec.md | 60 +
.../specs/payments/spec.md | 129 ++
.../specs/wallet/spec.md | 16 +
.../tasks.md | 80 +
openspec/specs/aftersale/spec.md | 42 +-
openspec/specs/frontend-admin/spec.md | 39 +-
openspec/specs/frontend-mall/spec.md | 78 +-
openspec/specs/frontend-mobile/spec.md | 29 +-
openspec/specs/frontend-shop-admin/spec.md | 25 +-
openspec/specs/group-buying/spec.md | 19 +-
openspec/specs/order/spec.md | 33 +-
openspec/specs/payment-configuration/spec.md | 64 +
openspec/specs/payments/spec.md | 133 ++
openspec/specs/wallet/spec.md | 17 +-
packages/shared/src/api.ts | 130 +-
packages/shared/src/types.ts | 391 ++++-
100 files changed, 14251 insertions(+), 848 deletions(-)
create mode 100644 apps/admin/composables/useRedactedAudit.ts
create mode 100644 apps/admin/pages/payment-channels.vue
create mode 100644 apps/admin/pages/payments.vue
create mode 100644 apps/api/migrations/0024_payments.sql
create mode 100644 apps/api/migrations/0025_checkout_request_header_hash.sql
create mode 100644 apps/api/src/modules/payment/adapters.rs
create mode 100644 apps/api/src/modules/payment/admin.rs
create mode 100644 apps/api/src/modules/payment/dto.rs
create mode 100644 apps/api/src/modules/payment/handlers.rs
create mode 100644 apps/api/src/modules/payment/mod.rs
create mode 100644 apps/api/src/modules/payment/refunds.rs
create mode 100644 apps/api/src/modules/payment/repo.rs
create mode 100644 apps/api/src/modules/payment/service.rs
create mode 100644 apps/api/src/modules/payment/webhooks.rs
create mode 100644 apps/api/src/modules/payment/worker.rs
create mode 100644 apps/api/tests/payments.rs
create mode 100644 apps/mobile/pages/orders/pay/cancel.vue
create mode 100644 apps/mobile/pages/orders/pay/index.vue
create mode 100644 apps/mobile/pages/orders/pay/success.vue
create mode 100644 apps/mobile/utils/payment.ts
create mode 100644 docs/TBD-payments.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/.openspec.yaml
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/design.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/proposal.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/aftersale/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-admin/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mall/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-mobile/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/frontend-shop-admin/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/group-buying/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/order/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payment-configuration/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/payments/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/specs/wallet/spec.md
create mode 100644 openspec/changes/archive/2026-10-15-add-configurable-payments/tasks.md
create mode 100644 openspec/specs/payment-configuration/spec.md
create mode 100644 openspec/specs/payments/spec.md
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.aftersaleOutcomeRefundPending") }}
+
+ {{ $t("admin.aftersaleRefundOpHint") }}
+
+
+
+ {{ $t("admin.aftersaleOutcomeRefundFailed") }}
+
+
+ {{ $t("admin.aftersaleRetryRefund") }}
+
+
+ {{ $t("admin.aftersaleRetryNotAllowed") }}
+
+
{{ $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 @@
+
+
+
+
+
+
+ {{ createOpen ? $t("common.cancel") : $t("admin.payChannelNew") }}
+
+
+
+
+ {{ $t("admin.payChannelAdapters") }}
+ {{ $t("admin.payChannelEnvOwned") }}
+
+
+
+
{{ localizedName(adapter.label) }}
+ {{ adapter.kind }}
+ {{ adapter.environment }}
+
+
+
+
- {{ $t("admin.payChannelCurrencies") }}
+ - {{ adapter.capabilities.currencies.join(", ") }}
+
+
+
- {{ $t("admin.payChannelLimits") }}
+ -
+ {{ adapter.capabilities.min_amount_minor }} —
+ {{
+ adapter.capabilities.max_amount_minor === null
+ ? $t("admin.payChannelMaxNone")
+ : adapter.capabilities.max_amount_minor
+ }}
+
+
+
+
- {{ $t("admin.payChannelSecretSlots") }}
+ - {{ adapter.secret_slots.join(", ") || "—" }}
+
+
+
- {{ $t("admin.payChannelProviderManaged") }}
+
- {{ adapter.capabilities.provider_managed.join(", ") }}
+
{{ $t("admin.payChannelProviderManagedHint") }}
+
+
+
- {{ $t("admin.payChannelPrerequisites") }}
+
-
+
+ —
+
+
+
+
+
+
+
+
+ {{ $t("admin.payChannelNew") }}
+ {{ createError }}
+
+
+
+ {{ $t("admin.payChannelListTitle") }}
+ {{ createNotice }}
+ {{ $t("common.loading") }}
+ {{ listError }}
+ {{ $t("common.empty") }}
+
+
+
+
+
+ | {{ $t("admin.payChannelLabel") }} |
+ {{ $t("admin.payChannelAdapter") }} |
+ {{ $t("admin.payChannelOrder") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payChannelCurrencies") }} |
+ {{ $t("admin.payChannelLimits") }} |
+ {{ $t("admin.payChannelProviderAccount") }} |
+ {{ $t("admin.payChannelEnvironment") }} |
+ {{ $t("admin.payChannelRevision") }} |
+ {{ $t("common.actions") }} |
+
+
+
+
+
+ |
+ {{ row.label.en || "—" }}
+ {{ row.label.zh || "—" }}
+ |
+ {{ adapterLabel(row.adapter) }} |
+ {{ row.display_order }} |
+
+ {{ $t("admin.payChannelArchived") }}
+ {{ $t("admin.payChannelEnabled") }}
+ {{ $t("admin.payChannelDisabled") }}
+ |
+ {{ row.supported_currencies.join(", ") }} |
+
+ {{ row.min_amount_minor }} —
+ {{
+ row.max_amount_minor === null
+ ? $t("admin.payChannelMaxNone")
+ : row.max_amount_minor
+ }}
+ |
+
+ {{
+ row.provider_account_id
+ }}
+ —
+ |
+ {{ row.environment }} |
+ {{ row.revision }} |
+
+
+ {{
+ openId === row.id ? $t("admin.aftersaleClose") : $t("admin.payChannelView")
+ }}
+
+ |
+
+
+
+
+ {{ $t("common.loading") }}
+
+ {{ detailError }}
+
+
+
+
+ {{
+ detail.enabled
+ ? $t("admin.payChannelEnabled")
+ : $t("admin.payChannelDisabled")
+ }}
+
+ {{ $t("admin.payChannelRevision") }} {{ detail.revision }}
+
+
+
+
+ {{ $t("admin.payChannelId") }}{{ shortId(detail.id) }}
+
+
+ {{ $t("admin.payChannelAdapter") }}{{ adapterLabel(detail.adapter) }}
+
+
+ {{ $t("admin.payChannelLabelEn") }}{{ detail.label.en || "—" }}
+
+
+ {{ $t("admin.payChannelLabelZh") }}{{ detail.label.zh || "—" }}
+
+
+ {{ $t("admin.payChannelOrder") }}{{ detail.display_order }}
+
+
+ {{ $t("admin.payChannelCurrencies") }}{{ detail.supported_currencies.join(", ") }}
+
+
+ {{ $t("admin.payChannelMinAmount") }}{{ detail.min_amount_minor }}
+
+
+ {{ $t("admin.payChannelMaxAmount") }}
+ {{
+ detail.max_amount_minor === null
+ ? $t("admin.payChannelMaxNone")
+ : detail.max_amount_minor
+ }}
+
+
+
+ {{ $t("admin.payChannelProviderAccount") }}{{
+ detail.provider_account_id
+ }}—
+
+
+ {{ $t("admin.payChannelEnvironment") }}{{ detail.environment }}
+
+
+
+
+
+ {{ $t("admin.payChannelSecretSlots") }}
+
+ {{ $t("admin.payChannelSecretRefHint") }}
+
+ -
+
{{ slot.slot }}
+
+ {{
+ slot.configured
+ ? $t("admin.payChannelSecretConfigured")
+ : $t("admin.payChannelSecretMissing")
+ }}
+
+ {{
+ slot.reference
+ }}
+
+
+
+
+ {{ $t("admin.payChannelUnmet") }}:
+
+ {{ detail.unmet_prerequisites.join(", ") }}
+
+
+
+
+
+ {{ $t("admin.payChannelVerify") }}
+
+ {{ $t("admin.payChannelVerifyHint") }}
+
+
+ {{ verifying ? $t("admin.payChannelVerifying") : $t("admin.payChannelVerify") }}
+
+
+
+ {{
+ verification.ok
+ ? $t("admin.payChannelVerifyOk")
+ : $t("admin.payChannelVerifyFail")
+ }}
+
+
+ {{ $t("admin.payChannelCheckedAt") }}
+ {{ formatDate(verification.checked_at) }}
+
+
+
+
+
+ {{ $t("admin.payChannelProviderAccount") }}:
+ {{ verification.provider_account }}
+
+
+ {{ $t("admin.payChannelUnmet") }}:
+ {{ verification.unmet_prerequisites.join(", ") }}
+
+ {{ verification.detail }}
+
+
+
+
+
+ {{ $t("admin.payChannelLifecycle") }}
+
+
+ - {{ $t("admin.payChannelDisableHint") }}
+ - {{ $t("admin.payChannelRotateHint") }}
+ - {{ $t("admin.payChannelArchiveHint") }}
+
+
+
+
+
+
+
+
+
+ | {{ $t("admin.payChannelRevision") }} |
+ {{ $t("admin.payChannelAdapterContract") }} |
+ {{ $t("admin.created") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payChannelOrder") }} |
+ {{ $t("admin.payChannelCurrencies") }} |
+ {{ $t("admin.payChannelLimits") }} |
+ {{ $t("admin.payChannelProviderAccount") }} |
+ {{ $t("admin.payChannelSecretKeyRef") }} |
+ {{ $t("admin.payChannelWebhookSecretRef") }} |
+
+
+
+
+ | {{ revision.revision }} |
+
+ {{ revision.adapter_contract_version }}
+ |
+ {{ formatDate(revision.created_at) }} |
+
+
+ {{
+ revision.enabled
+ ? $t("admin.payChannelEnabled")
+ : $t("admin.payChannelDisabled")
+ }}
+
+ |
+ {{ revision.display_order }} |
+ {{ revision.supported_currencies.join(", ") }} |
+
+ {{ revision.min_amount_minor }} —
+ {{
+ revision.max_amount_minor === null
+ ? $t("admin.payChannelMaxNone")
+ : revision.max_amount_minor
+ }}
+ |
+
+ {{
+ revision.provider_account_id
+ }}
+ —
+ |
+
+ {{
+ revision.secret_key_ref
+ }}
+ —
+ |
+
+ {{
+ revision.webhook_secret_ref
+ }}
+ —
+ |
+
+
+
+
+
+ {{ $t("admin.payChannelNoAudit") }}
+
+
+
+
+
+
+
+
+ | {{ $t("admin.created") }} |
+ {{ $t("admin.payChannelAuditAction") }} |
+ {{ $t("admin.payChannelRevision") }} |
+ {{ $t("admin.payChannelAuditActor") }} |
+ {{ $t("admin.payChannelAuditChanges") }} |
+
+
+
+
+ | {{ formatDate(entry.created_at) }} |
+ {{ entry.action }} |
+ {{ entry.revision }} |
+
+ {{
+ shortId(entry.actor)
+ }}
+ |
+
+
+ -
+ {{ String(key) }}:
+ {{ changeText(String(key), change) }}
+
+
+ |
+
+
+
+
+
+ {{ $t("admin.payChannelNoAudit") }}
+
+
+
+
+ {{ editNotice }}
+
+
+
+ {{ $t("admin.payChannelEdit") }}
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ $t("common.prev") }}
+
+ {{ $t("admin.pageOf", { page, total: totalPages }) }}
+
+ {{ $t("common.next") }}
+
+
+
+
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 @@
+
+
+
+
+
+ {{
+ $t("admin.pageOf", { page, total: totalPages })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("admin.refresh") }}
+
+
+
+
+ {{ $t("common.loading") }}
+ {{ listError }}
+ {{ $t("common.empty") }}
+
+
+
+
+
+ | {{ $t("admin.payOpsId") }} |
+ {{ $t("admin.payOpsFilterKind") }} |
+ {{ $t("admin.payOpsFilterPaymentId") }} |
+ {{ $t("admin.payOpsFilterEnvironment") }} |
+ {{ $t("admin.payOpsAmount") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payOpsContext") }} |
+ {{ $t("admin.aftersaleUpdated") }} |
+ {{ $t("common.actions") }} |
+
+
+
+
+
+
+ {{
+ shortId(row.id)
+ }}
+ |
+
+ {{ kindLabel(row.kind) }}
+ |
+
+ {{
+ shortId(row.payment_id)
+ }}
+ |
+ {{ row.environment }} |
+ {{ money(row.amount_minor, row.currency) }} |
+
+ {{ rowStatusText(row) }}
+ |
+
+
+
+ {{ attemptLabel(row.attempt_status) }}
+
+ —
+
+
+ {{ $t(`admin.payOpsPurposes.${row.purpose}`) }}
+ {{
+ $t("admin.payOpsRetryAllowed")
+ }}
+
+
+ {{
+ $t(`admin.payOpsExceptionKinds.${row.exception_kind}`)
+ }}
+
+ |
+ {{ formatDate(row.updated_at) }} |
+
+
+ {{
+ openPaymentId === row.payment_id
+ ? $t("admin.aftersaleClose")
+ : $t("admin.payOpsView")
+ }}
+
+ |
+
+
+
+
+ {{ $t("common.loading") }}
+
+ {{ detailError }}
+
+
+
+ {{ notice }}
+
+
+
+
+ {{ applicationLabel(detail.status) }}
+
+ {{ detail.environment }}
+
+
+
+
+ {{ $t("admin.payOpsId") }}{{ detail.id }}
+
+
+ {{ $t("admin.payOpsTotal") }}{{
+ money(detail.total_minor, detail.currency)
+ }}
+
+
+ {{ $t("admin.payOpsNextAction") }}{{
+ $t(`admin.payOpsNextActions.${detail.next_action.type}`)
+ }}
+
+
+
+ {{ $t("admin.payOpsCompensationNote") }}
+
+
+
+
+
+
+
+
+ | {{ $t("order.orderNo") }} |
+ {{ $t("admin.payOpsShop") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payOpsAmount") }} |
+ {{ $t("admin.payOpsRefundedAmount") }} |
+
+
+
+
+ | {{ order.order_no }} |
+
+ {{
+ shortId(order.shop_id)
+ }}
+ |
+ {{ $t(`order.status.${order.status}`) }} |
+ {{ money(order.amount_minor, detail.currency) }} |
+ {{ money(order.refund_total_minor, detail.currency) }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ $t("admin.payOpsAttemptId") }} |
+ {{ $t("admin.payOpsChannel") }} |
+ {{ $t("admin.payChannelAdapter") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payOpsNextAction") }} |
+
+
+
+
+
+ {{
+ shortId(attempt.id)
+ }}
+ |
+
+ {{ shortId(attempt.channel_id) }}
+ |
+ {{ attempt.adapter }} |
+
+ {{
+ attemptLabel(attempt.status)
+ }}
+ |
+
+ {{ $t(`admin.payOpsNextActions.${attempt.next_action.type}`) }}
+ |
+
+
+
+
+
+ {{ $t("common.empty") }}
+
+
+
+
+
+
+
+
+ | {{ $t("admin.payOpsReceiptId") }} |
+ {{ $t("admin.payChannelAdapter") }} |
+ {{ $t("admin.payOpsAmount") }} |
+ {{ $t("admin.payOpsProviderRef") }} |
+ {{ $t("admin.payOpsCrypto") }} |
+ {{ $t("admin.payOpsCollectedAt") }} |
+
+
+
+
+
+ {{
+ shortId(receipt.id)
+ }}
+ |
+ {{ receipt.adapter }} |
+ {{ money(receipt.amount_minor, receipt.currency) }} |
+
+ {{
+ receipt.reference
+ }}
+ —
+ |
+
+
+
+ {{ receipt.crypto.network || "—" }} ·
+ {{ receipt.crypto.symbol || "—" }}
+
+
+ {{ $t("admin.payOpsCryptoBaseUnits") }}
+ {{ receipt.crypto.base_units }}
+
+
+ {{ $t("admin.payOpsCryptoContract") }}
+ {{ receipt.crypto.contract_address }}
+
+
+ {{ $t("admin.payOpsCryptoTx") }}
+ {{ receipt.crypto.transaction_reference }}
+
+
+ {{ $t("admin.payOpsCryptoQuote") }}
+ {{ receipt.crypto.quote_reference }}
+
+ · {{ formatDate(receipt.crypto.quote_expires_at) }}
+
+
+
+ —
+ |
+ {{ formatDate(receipt.collected_at) }} |
+
+
+
+
+
+ {{ $t("common.empty") }}
+
+
+
+
+
+
+
+
+ | {{ $t("admin.payOpsRefundId") }} |
+ {{ $t("admin.payOpsPurpose") }} |
+ {{ $t("admin.payOpsAmount") }} |
+ {{ $t("common.status") }} |
+ {{ $t("admin.payOpsMethod") }} |
+ {{ $t("admin.payOpsProviderRef") }} |
+ {{ $t("admin.payOpsError") }} |
+ {{ $t("admin.aftersaleUpdated") }} |
+
+
+
+
+
+ {{
+ shortId(refund.id)
+ }}
+ |
+ {{ $t(`admin.payOpsPurposes.${refund.purpose}`) }} |
+ {{ money(refund.amount_minor, refund.currency) }} |
+
+ {{
+ refundLabel(refund.status)
+ }}
+ {{
+ $t("admin.payOpsRetryAllowed")
+ }}
+ |
+ {{ refund.method }} |
+
+ {{
+ refund.reference
+ }}
+ —
+ |
+ {{ refund.error || "—" }} |
+ {{ formatDate(refund.updated_at) }} |
+
+
+
+
+
+ {{ $t("common.empty") }}
+
+
+
+
+
+
+
+
+ | {{ $t("admin.payOpsException") }} |
+ {{ $t("admin.payOpsExceptionKind") }} |
+ {{ $t("admin.payOpsExceptionDetail") }} |
+ {{ $t("admin.created") }} |
+
+
+
+
+
+ {{ shortId(exception.id) }}
+ |
+
+ {{
+ $t(`admin.payOpsExceptionKinds.${exception.kind}`)
+ }}
+ |
+
+ {{
+ typeof exception.detail === "string"
+ ? exception.detail
+ : exception.detail
+ ? localizedText(exception.detail, locale)
+ : "—"
+ }}
+ |
+ {{ formatDate(exception.created_at) }} |
+
+
+
+
+
+ {{ $t("common.empty") }}
+
+
+
+ {{ $t("admin.payOpsAuditHint") }}
+
+
+
+
+
+ | {{ $t("admin.created") }} |
+ {{ $t("admin.payOpsChannel") }} |
+ {{ $t("admin.payOpsAuditAction") }} |
+ {{ $t("admin.payChannelRevision") }} |
+ {{ $t("admin.payOpsAuditActor") }} |
+ {{ $t("admin.payChannelAuditChanges") }} |
+
+
+
+
+ | {{ formatDate(entry.created_at) }} |
+
+ {{
+ shortId(entry.channel_id)
+ }}
+ |
+ {{ entry.action }} |
+ {{ entry.revision }} |
+
+ {{
+ shortId(entry.actor)
+ }}
+ |
+
+
+ -
+ {{ String(key) }}:
+ {{ changeText(String(key), change) }}
+
+
+ |
+
+
+
+
+
+ {{ $t("admin.payChannelNoAudit") }}
+
+
+
+
+ {{ $t("admin.payOpsReconcileHint") }}
+
+
+ {{ $t("admin.payOpsReconcile") }}
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ $t("common.prev") }}
+
+ {{ page }} / {{ totalPages }}
+
+ {{ $t("common.next") }}
+
+
+
+
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