feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
47
backend/migrations/090_payment_orders.sql
Normal file
47
backend/migrations/090_payment_orders.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
user_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
user_notes TEXT,
|
||||
amount DECIMAL(20,2) NOT NULL,
|
||||
pay_amount DECIMAL(20,2) NOT NULL,
|
||||
fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0,
|
||||
recharge_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
payment_type VARCHAR(30) NOT NULL DEFAULT '',
|
||||
payment_trade_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
pay_url TEXT,
|
||||
qr_code TEXT,
|
||||
qr_code_img TEXT,
|
||||
order_type VARCHAR(20) NOT NULL DEFAULT 'balance',
|
||||
plan_id BIGINT,
|
||||
subscription_group_id BIGINT,
|
||||
subscription_days INT,
|
||||
provider_instance_id VARCHAR(64),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
|
||||
refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0,
|
||||
refund_reason TEXT,
|
||||
refund_at TIMESTAMPTZ,
|
||||
force_refund BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
refund_requested_at TIMESTAMPTZ,
|
||||
refund_request_reason TEXT,
|
||||
refund_requested_by VARCHAR(20),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
paid_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
failed_reason TEXT,
|
||||
client_ip VARCHAR(50) NOT NULL DEFAULT '',
|
||||
src_host VARCHAR(255) NOT NULL DEFAULT '',
|
||||
src_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type);
|
||||
9
backend/migrations/091_payment_audit_logs.sql
Normal file
9
backend/migrations/091_payment_audit_logs.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS payment_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
operator VARCHAR(100) NOT NULL DEFAULT 'system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id);
|
||||
4
backend/migrations/092_removed_payment_channels.sql
Normal file
4
backend/migrations/092_removed_payment_channels.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration 092: payment_channels table was removed before release.
|
||||
-- This file is a no-op placeholder to maintain migration numbering continuity.
|
||||
-- The payment system now uses the existing channels table (migration 081).
|
||||
SELECT 1;
|
||||
18
backend/migrations/093_subscription_plans.sql
Normal file
18
backend/migrations/093_subscription_plans.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS subscription_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_id BIGINT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price DECIMAL(20,2) NOT NULL,
|
||||
original_price DECIMAL(20,2),
|
||||
validity_days INT NOT NULL DEFAULT 30,
|
||||
validity_unit VARCHAR(10) NOT NULL DEFAULT 'day',
|
||||
features TEXT NOT NULL DEFAULT '',
|
||||
product_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
for_sale BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale);
|
||||
15
backend/migrations/094_payment_provider_instances.sql
Normal file
15
backend/migrations/094_payment_provider_instances.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS payment_provider_instances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider_key VARCHAR(30) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
config TEXT NOT NULL,
|
||||
supported_types VARCHAR(200) NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
limits TEXT NOT NULL DEFAULT '',
|
||||
refund_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled);
|
||||
@@ -0,0 +1,70 @@
|
||||
-- 096_migrate_purchase_subscription_to_custom_menu.sql
|
||||
--
|
||||
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
|
||||
-- After migration, purchase_subscription_enabled is set to "false" and
|
||||
-- purchase_subscription_url is cleared.
|
||||
--
|
||||
-- Idempotent: skips if custom_menu_items already contains
|
||||
-- "migrated_purchase_subscription".
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_enabled text;
|
||||
v_url text;
|
||||
v_raw text;
|
||||
v_items jsonb;
|
||||
v_new_item jsonb;
|
||||
BEGIN
|
||||
-- Read legacy settings
|
||||
SELECT value INTO v_enabled
|
||||
FROM settings WHERE key = 'purchase_subscription_enabled';
|
||||
SELECT value INTO v_url
|
||||
FROM settings WHERE key = 'purchase_subscription_url';
|
||||
|
||||
-- Skip if not enabled or URL is empty
|
||||
IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Read current custom_menu_items
|
||||
SELECT value INTO v_raw
|
||||
FROM settings WHERE key = 'custom_menu_items';
|
||||
|
||||
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
|
||||
v_items := '[]'::jsonb;
|
||||
ELSE
|
||||
v_items := v_raw::jsonb;
|
||||
END IF;
|
||||
|
||||
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(v_items) elem
|
||||
WHERE elem ->> 'id' = 'migrated_purchase_subscription'
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Build the new menu item
|
||||
v_new_item := jsonb_build_object(
|
||||
'id', 'migrated_purchase_subscription',
|
||||
'label', 'Purchase',
|
||||
'icon_svg', '',
|
||||
'url', TRIM(v_url),
|
||||
'visibility', 'user',
|
||||
'sort_order', 100
|
||||
);
|
||||
|
||||
-- Append to array
|
||||
v_items := v_items || jsonb_build_array(v_new_item);
|
||||
|
||||
-- Upsert custom_menu_items
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('custom_menu_items', v_items::text)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
|
||||
-- Clear legacy settings
|
||||
UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled';
|
||||
UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url';
|
||||
|
||||
RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url;
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 098_remove_easypay_from_enabled_payment_types.sql
|
||||
--
|
||||
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
|
||||
-- "easypay" is a provider key, not a payment type. Valid payment types
|
||||
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
|
||||
--
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
UPDATE settings
|
||||
SET value = array_to_string(
|
||||
array_remove(
|
||||
string_to_array(value, ','),
|
||||
'easypay'
|
||||
), ','
|
||||
)
|
||||
WHERE key = 'ENABLED_PAYMENT_TYPES'
|
||||
AND value LIKE '%easypay%';
|
||||
16
backend/migrations/099_add_payment_mode.sql
Normal file
16
backend/migrations/099_add_payment_mode.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Add payment_mode field to payment_provider_instances
|
||||
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
|
||||
ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT '';
|
||||
|
||||
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
|
||||
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
|
||||
UPDATE payment_provider_instances
|
||||
SET payment_mode = 'redirect',
|
||||
supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(
|
||||
supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', ''))
|
||||
WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%';
|
||||
|
||||
-- EasyPay instances without 'easypay' in supported_types → api mode
|
||||
UPDATE payment_provider_instances
|
||||
SET payment_mode = 'api'
|
||||
WHERE provider_key = 'easypay' AND payment_mode = '';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 100_add_out_trade_no_to_payment_orders.sql
|
||||
-- Adds out_trade_no column for external order ID used with payment providers.
|
||||
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
|
||||
|
||||
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no);
|
||||
Reference in New Issue
Block a user