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

cargo test -p vmall-api green twice; four frontend builds green; browser smoke: mall wallet checkout paid, admin channels/operations verified
2026-10-15 16:14:28 +08:00

313 lines
14 KiB
SQL

-- 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;