Unverified Commit 97f14b7a authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1572 from touwaeriol/feat/payment-system-v2

feat(payment): add complete payment system with multi-provider support
parents 1ef3782d 6793503e
package service
import (
"context"
"encoding/json"
"log/slog"
"math"
"sort"
"strconv"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
)
// --- Dashboard & Analytics ---
func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*DashboardStats, error) {
if days <= 0 {
days = 30
}
now := time.Now()
since := now.AddDate(0, 0, -days)
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
paidStatuses := []string{OrderStatusCompleted, OrderStatusPaid, OrderStatusRecharging}
orders, err := s.entClient.PaymentOrder.Query().
Where(
paymentorder.StatusIn(paidStatuses...),
paymentorder.PaidAtGTE(since),
).
All(ctx)
if err != nil {
return nil, err
}
st := &DashboardStats{}
computeBasicStats(st, orders, todayStart)
st.PendingOrders, err = s.entClient.PaymentOrder.Query().
Where(paymentorder.StatusEQ(OrderStatusPending)).
Count(ctx)
if err != nil {
return nil, err
}
st.DailySeries = buildDailySeries(orders, since, days)
st.PaymentMethods = buildMethodDistribution(orders)
st.TopUsers = buildTopUsers(orders)
return st, nil
}
func computeBasicStats(st *DashboardStats, orders []*dbent.PaymentOrder, todayStart time.Time) {
var totalAmount, todayAmount float64
var todayCount int
for _, o := range orders {
totalAmount += o.PayAmount
if o.PaidAt != nil && !o.PaidAt.Before(todayStart) {
todayAmount += o.PayAmount
todayCount++
}
}
st.TotalAmount = math.Round(totalAmount*100) / 100
st.TodayAmount = math.Round(todayAmount*100) / 100
st.TotalCount = len(orders)
st.TodayCount = todayCount
if st.TotalCount > 0 {
st.AvgAmount = math.Round(totalAmount/float64(st.TotalCount)*100) / 100
}
}
func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) []DailyStats {
dailyMap := make(map[string]*DailyStats)
for _, o := range orders {
if o.PaidAt == nil {
continue
}
date := o.PaidAt.Format("2006-01-02")
ds, ok := dailyMap[date]
if !ok {
ds = &DailyStats{Date: date}
dailyMap[date] = ds
}
ds.Amount += o.PayAmount
ds.Count++
}
series := make([]DailyStats, 0, days)
for i := 0; i < days; i++ {
date := since.AddDate(0, 0, i+1).Format("2006-01-02")
if ds, ok := dailyMap[date]; ok {
ds.Amount = math.Round(ds.Amount*100) / 100
series = append(series, *ds)
} else {
series = append(series, DailyStats{Date: date})
}
}
return series
}
func buildMethodDistribution(orders []*dbent.PaymentOrder) []PaymentMethodStat {
methodMap := make(map[string]*PaymentMethodStat)
for _, o := range orders {
ms, ok := methodMap[o.PaymentType]
if !ok {
ms = &PaymentMethodStat{Type: o.PaymentType}
methodMap[o.PaymentType] = ms
}
ms.Amount += o.PayAmount
ms.Count++
}
methods := make([]PaymentMethodStat, 0, len(methodMap))
for _, ms := range methodMap {
ms.Amount = math.Round(ms.Amount*100) / 100
methods = append(methods, *ms)
}
return methods
}
func buildTopUsers(orders []*dbent.PaymentOrder) []TopUserStat {
userMap := make(map[int64]*TopUserStat)
for _, o := range orders {
us, ok := userMap[o.UserID]
if !ok {
us = &TopUserStat{UserID: o.UserID, Email: o.UserEmail}
userMap[o.UserID] = us
}
us.Amount += o.PayAmount
}
userList := make([]*TopUserStat, 0, len(userMap))
for _, us := range userMap {
us.Amount = math.Round(us.Amount*100) / 100
userList = append(userList, us)
}
sort.Slice(userList, func(i, j int) bool {
return userList[i].Amount > userList[j].Amount
})
limit := topUsersLimit
if len(userList) < limit {
limit = len(userList)
}
result := make([]TopUserStat, 0, limit)
for i := 0; i < limit; i++ {
result = append(result, *userList[i])
}
return result
}
// --- Audit Logs ---
func (s *PaymentService) writeAuditLog(ctx context.Context, oid int64, action, op string, detail map[string]any) {
dj, _ := json.Marshal(detail)
_, err := s.entClient.PaymentAuditLog.Create().SetOrderID(strconv.FormatInt(oid, 10)).SetAction(action).SetDetail(string(dj)).SetOperator(op).Save(ctx)
if err != nil {
slog.Error("audit log failed", "orderID", oid, "action", action, "error", err)
}
}
func (s *PaymentService) GetOrderAuditLogs(ctx context.Context, oid int64) ([]*dbent.PaymentAuditLog, error) {
return s.entClient.PaymentAuditLog.Query().Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10))).Order(paymentauditlog.ByCreatedAt()).All(ctx)
}
...@@ -170,6 +170,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -170,6 +170,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyBackendModeEnabled, SettingKeyBackendModeEnabled,
SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName, SettingKeyOIDCConnectProviderName,
SettingPaymentEnabled,
} }
settings, err := s.settingRepo.GetMultiple(ctx, keys) settings, err := s.settingRepo.GetMultiple(ctx, keys)
...@@ -236,6 +237,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -236,6 +237,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled, OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName, OIDCOAuthProviderName: oidcProviderName,
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
}, nil }, nil
} }
...@@ -287,6 +289,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -287,6 +289,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
BackendModeEnabled bool `json:"backend_mode_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
}{ }{
RegistrationEnabled: settings.RegistrationEnabled, RegistrationEnabled: settings.RegistrationEnabled,
...@@ -316,6 +319,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -316,6 +319,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
BackendModeEnabled: settings.BackendModeEnabled, BackendModeEnabled: settings.BackendModeEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName, OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
PaymentEnabled: settings.PaymentEnabled,
Version: s.version, Version: s.version,
}, nil }, nil
} }
......
...@@ -143,6 +143,7 @@ type PublicSettings struct { ...@@ -143,6 +143,7 @@ type PublicSettings struct {
BackendModeEnabled bool BackendModeEnabled bool
OIDCOAuthEnabled bool OIDCOAuthEnabled bool
OIDCOAuthProviderName string OIDCOAuthProviderName string
PaymentEnabled bool
Version string Version string
} }
......
...@@ -5,7 +5,9 @@ import ( ...@@ -5,7 +5,9 @@ import (
"database/sql" "database/sql"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/google/wire" "github.com/google/wire"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
...@@ -460,4 +462,20 @@ var ProviderSet = wire.NewSet( ...@@ -460,4 +462,20 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService, NewGroupCapacityService,
NewChannelService, NewChannelService,
NewModelPricingResolver, NewModelPricingResolver,
ProvidePaymentConfigService,
NewPaymentService,
ProvidePaymentOrderExpiryService,
) )
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, key payment.EncryptionKey) *PaymentConfigService {
return NewPaymentConfigService(entClient, settingRepo, []byte(key))
}
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService) *PaymentOrderExpiryService {
svc := NewPaymentOrderExpiryService(paymentSvc, 60*time.Second)
svc.Start()
return svc
}
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);
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);
-- 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;
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);
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);
-- 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 $$;
-- 097_fix_migrated_purchase_menu_label_icon.sql
--
-- Fixes the custom menu item created by migration 096: updates the label
-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
-- to a credit-card SVG matching the sidebar CreditCardIcon.
--
-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
DO $$
DECLARE
v_raw text;
v_items jsonb;
v_idx int;
v_icon text;
v_elem jsonb;
v_i int := 0;
BEGIN
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
RETURN;
END IF;
v_items := v_raw::jsonb;
-- Find the index of the migrated item by iterating the array
v_idx := NULL;
FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP
IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN
v_idx := v_i;
EXIT;
END IF;
v_i := v_i + 1;
END LOOP;
IF v_idx IS NULL THEN
RETURN; -- item not found, nothing to fix
END IF;
-- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
v_icon := '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"/></svg>';
-- Update label and icon_svg
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb);
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon));
UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items';
RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG';
END $$;
-- 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%';
-- 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 = '';
-- 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);
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@lobehub/icons": "^4.0.2", "@lobehub/icons": "^4.0.2",
"@stripe/stripe-js": "^9.0.1",
"@tanstack/vue-virtual": "^3.13.23", "@tanstack/vue-virtual": "^3.13.23",
"@vueuse/core": "^10.7.0", "@vueuse/core": "^10.7.0",
"axios": "^1.15.0", "axios": "^1.15.0",
......
...@@ -11,6 +11,9 @@ importers: ...@@ -11,6 +11,9 @@ importers:
'@lobehub/icons': '@lobehub/icons':
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@stripe/stripe-js':
specifier: ^9.0.1
version: 9.0.1
'@tanstack/vue-virtual': '@tanstack/vue-virtual':
specifier: ^3.13.23 specifier: ^3.13.23
version: 3.13.23(vue@3.5.26(typescript@5.6.3)) version: 3.13.23(vue@3.5.26(typescript@5.6.3))
...@@ -1395,6 +1398,10 @@ packages: ...@@ -1395,6 +1398,10 @@ packages:
peerDependencies: peerDependencies:
react: '>= 16.3.0' react: '>= 16.3.0'
'@stripe/stripe-js@9.0.1':
resolution: {integrity: sha512-un0URSosrW7wNr7xZ5iI2mC9mdeXZ3KERoVlA2RdmeLXYxHUPXq0yHzir2n/MtyXXEdSaELtz4WXGS6dzPEeKA==}
engines: {node: '>=12.16'}
'@tanstack/virtual-core@3.13.23': '@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
...@@ -5867,6 +5874,8 @@ snapshots: ...@@ -5867,6 +5874,8 @@ snapshots:
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
'@stripe/stripe-js@9.0.1': {}
'@tanstack/virtual-core@3.13.23': {} '@tanstack/virtual-core@3.13.23': {}
'@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))': '@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))':
......
...@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests' ...@@ -26,6 +26,7 @@ import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup' import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile' import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels' import channelsAPI from './channels'
import adminPaymentAPI from './payment'
/** /**
* Unified admin API object for convenient access * Unified admin API object for convenient access
...@@ -53,7 +54,8 @@ export const adminAPI = { ...@@ -53,7 +54,8 @@ export const adminAPI = {
scheduledTests: scheduledTestsAPI, scheduledTests: scheduledTestsAPI,
backup: backupAPI, backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI, tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI channels: channelsAPI,
payment: adminPaymentAPI
} }
export { export {
...@@ -79,7 +81,8 @@ export { ...@@ -79,7 +81,8 @@ export {
scheduledTestsAPI, scheduledTestsAPI,
backupAPI, backupAPI,
tlsFingerprintProfileAPI, tlsFingerprintProfileAPI,
channelsAPI channelsAPI,
adminPaymentAPI
} }
export default adminAPI export default adminAPI
......
/**
* Admin Payment API endpoints
* Handles payment management operations for administrators
*/
import { apiClient } from '../client'
import type {
DashboardStats,
PaymentOrder,
PaymentChannel,
SubscriptionPlan,
ProviderInstance
} from '@/types/payment'
import type { BasePaginationResponse } from '@/types'
/** Admin-facing payment config returned by GET /admin/payment/config */
export interface AdminPaymentConfig {
enabled: boolean
min_amount: number
max_amount: number
daily_limit: number
order_timeout_minutes: number
max_pending_orders: number
enabled_payment_types: string[]
balance_disabled: boolean
load_balance_strategy: string
product_name_prefix: string
product_name_suffix: string
help_image_url: string
help_text: string
}
/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */
export interface UpdatePaymentConfigRequest {
enabled?: boolean
min_amount?: number
max_amount?: number
daily_limit?: number
order_timeout_minutes?: number
max_pending_orders?: number
enabled_payment_types?: string[]
balance_disabled?: boolean
load_balance_strategy?: string
product_name_prefix?: string
product_name_suffix?: string
help_image_url?: string
help_text?: string
}
export const adminPaymentAPI = {
// ==================== Config ====================
/** Get payment configuration (admin view) */
getConfig() {
return apiClient.get<AdminPaymentConfig>('/admin/payment/config')
},
/** Update payment configuration */
updateConfig(data: UpdatePaymentConfigRequest) {
return apiClient.put('/admin/payment/config', data)
},
// ==================== Dashboard ====================
/** Get payment dashboard statistics */
getDashboard(days?: number) {
return apiClient.get<DashboardStats>('/admin/payment/dashboard', {
params: days ? { days } : undefined
})
},
// ==================== Orders ====================
/** Get all orders (paginated, with filters) */
getOrders(params?: {
page?: number
page_size?: number
status?: string
payment_type?: string
user_id?: number
keyword?: string
start_date?: string
end_date?: string
order_type?: string
}) {
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/admin/payment/orders', { params })
},
/** Get a specific order by ID */
getOrder(id: number) {
return apiClient.get<PaymentOrder>(`/admin/payment/orders/${id}`)
},
/** Cancel an order (admin) */
cancelOrder(id: number) {
return apiClient.post(`/admin/payment/orders/${id}/cancel`)
},
/** Retry recharge for a failed order */
retryRecharge(id: number) {
return apiClient.post(`/admin/payment/orders/${id}/retry`)
},
/** Process a refund */
refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) {
return apiClient.post(`/admin/payment/orders/${id}/refund`, data)
},
// ==================== Channels ====================
/** Get all payment channels */
getChannels() {
return apiClient.get<PaymentChannel[]>('/admin/payment/channels')
},
/** Create a payment channel */
createChannel(data: Partial<PaymentChannel>) {
return apiClient.post<PaymentChannel>('/admin/payment/channels', data)
},
/** Update a payment channel */
updateChannel(id: number, data: Partial<PaymentChannel>) {
return apiClient.put<PaymentChannel>(`/admin/payment/channels/${id}`, data)
},
/** Delete a payment channel */
deleteChannel(id: number) {
return apiClient.delete(`/admin/payment/channels/${id}`)
},
// ==================== Subscription Plans ====================
/** Get all subscription plans */
getPlans() {
return apiClient.get<SubscriptionPlan[]>('/admin/payment/plans')
},
/** Create a subscription plan */
createPlan(data: Record<string, unknown>) {
return apiClient.post<SubscriptionPlan>('/admin/payment/plans', data)
},
/** Update a subscription plan */
updatePlan(id: number, data: Record<string, unknown>) {
return apiClient.put<SubscriptionPlan>(`/admin/payment/plans/${id}`, data)
},
/** Delete a subscription plan */
deletePlan(id: number) {
return apiClient.delete(`/admin/payment/plans/${id}`)
},
// ==================== Provider Instances ====================
/** Get all provider instances */
getProviders() {
return apiClient.get<ProviderInstance[]>('/admin/payment/providers')
},
/** Create a provider instance */
createProvider(data: Partial<ProviderInstance>) {
return apiClient.post<ProviderInstance>('/admin/payment/providers', data)
},
/** Update a provider instance */
updateProvider(id: number, data: Partial<ProviderInstance>) {
return apiClient.put<ProviderInstance>(`/admin/payment/providers/${id}`, data)
},
/** Delete a provider instance */
deleteProvider(id: number) {
return apiClient.delete(`/admin/payment/providers/${id}`)
}
}
export default adminPaymentAPI
...@@ -38,8 +38,6 @@ export interface SystemSettings { ...@@ -38,8 +38,6 @@ export interface SystemSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
table_default_page_size: number table_default_page_size: number
table_page_size_options: number[] table_page_size_options: number[]
backend_mode_enabled: boolean backend_mode_enabled: boolean
...@@ -116,6 +114,26 @@ export interface SystemSettings { ...@@ -116,6 +114,26 @@ export interface SystemSettings {
enable_fingerprint_unification: boolean enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean enable_metadata_passthrough: boolean
enable_cch_signing: boolean enable_cch_signing: boolean
// Payment configuration
payment_enabled: boolean
payment_min_amount: number
payment_max_amount: number
payment_daily_limit: number
payment_order_timeout_minutes: number
payment_max_pending_orders: number
payment_enabled_types: string[]
payment_balance_disabled: boolean
payment_load_balance_strategy: string
payment_product_name_prefix: string
payment_product_name_suffix: string
payment_help_image_url: string
payment_help_text: string
payment_cancel_rate_limit_enabled: boolean
payment_cancel_rate_limit_max: number
payment_cancel_rate_limit_window: number
payment_cancel_rate_limit_unit: string
payment_cancel_rate_limit_window_mode: string
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
...@@ -138,8 +156,6 @@ export interface UpdateSettingsRequest { ...@@ -138,8 +156,6 @@ export interface UpdateSettingsRequest {
doc_url?: string doc_url?: string
home_content?: string home_content?: string
hide_ccs_import_button?: boolean hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
table_default_page_size?: number table_default_page_size?: number
table_page_size_options?: number[] table_page_size_options?: number[]
backend_mode_enabled?: boolean backend_mode_enabled?: boolean
...@@ -198,6 +214,25 @@ export interface UpdateSettingsRequest { ...@@ -198,6 +214,25 @@ export interface UpdateSettingsRequest {
enable_fingerprint_unification?: boolean enable_fingerprint_unification?: boolean
enable_metadata_passthrough?: boolean enable_metadata_passthrough?: boolean
enable_cch_signing?: boolean enable_cch_signing?: boolean
// Payment configuration
payment_enabled?: boolean
payment_min_amount?: number
payment_max_amount?: number
payment_daily_limit?: number
payment_order_timeout_minutes?: number
payment_max_pending_orders?: number
payment_enabled_types?: string[]
payment_balance_disabled?: boolean
payment_load_balance_strategy?: string
payment_product_name_prefix?: string
payment_product_name_suffix?: string
payment_help_image_url?: string
payment_help_text?: string
payment_cancel_rate_limit_enabled?: boolean
payment_cancel_rate_limit_max?: number
payment_cancel_rate_limit_window?: number
payment_cancel_rate_limit_unit?: string
payment_cancel_rate_limit_window_mode?: string
} }
/** /**
......
...@@ -92,10 +92,13 @@ apiClient.interceptors.response.use( ...@@ -92,10 +92,13 @@ apiClient.interceptors.response.use(
response.data = apiResponse.data response.data = apiResponse.data
} else { } else {
// API error // API error
const resp = apiResponse as Record<string, unknown>
return Promise.reject({ return Promise.reject({
status: response.status, status: response.status,
code: apiResponse.code, code: apiResponse.code,
message: apiResponse.message || 'Unknown error' message: apiResponse.message || 'Unknown error',
reason: resp.reason,
metadata: resp.metadata,
}) })
} }
} }
...@@ -268,7 +271,9 @@ apiClient.interceptors.response.use( ...@@ -268,7 +271,9 @@ apiClient.interceptors.response.use(
status, status,
code: apiData.code, code: apiData.code,
error: apiData.error, error: apiData.error,
message: apiData.message || apiData.detail || error.message message: apiData.message || apiData.detail || error.message,
reason: apiData.reason,
metadata: apiData.metadata,
}) })
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment