From 1680d734bff5b28943932da339c3c13db50623a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 11:49:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(M05-D):=20=E5=AE=8C=E6=88=90=E6=94=B6?= =?UTF-8?q?=E6=AC=BE=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=B9=82=E7=AD=89=E5=88=86?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 1 + backend/package.json | 2 +- backend/src/config.ts | 5 +- backend/src/db/migration-runner.ts | 10 +- .../src/payments/profit-sharing-service.ts | 527 ++++++++++++++++++ backend/src/payments/wechat-pay-client.ts | 43 +- backend/src/routes/payments.ts | 128 ++++- backend/src/server.ts | 10 +- backend/tests/migration-contract.test.mjs | 20 +- backend/tests/migration-runner.test.mjs | 3 +- .../tests/mysql-migration-roundtrip.test.mjs | 192 ++++++- backend/tests/profit-sharing.test.mjs | 132 +++++ .../2026062218_m05d_profit_sharing.down.sql | 16 + .../2026062218_m05d_profit_sharing.up.sql | 99 ++++ .../2026062218_m05d_profit_sharing.verify.sql | 29 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 16 files changed, 1203 insertions(+), 18 deletions(-) create mode 100644 backend/src/payments/profit-sharing-service.ts create mode 100644 backend/tests/profit-sharing.test.mjs create mode 100644 database/migrations/2026062218_m05d_profit_sharing.down.sql create mode 100644 database/migrations/2026062218_m05d_profit_sharing.up.sql create mode 100644 database/migrations/2026062218_m05d_profit_sharing.verify.sql diff --git a/backend/.env.example b/backend/.env.example index 653de07..991364c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800 QIPAI_WECHAT_APP_SECRETS={} QIPAI_TEST_PAYMENT_ENABLED=false QIPAI_WECHAT_PAY_CREDENTIALS={} +QIPAI_PROFIT_SHARE_MOCK_ENABLED=false QIPAI_THIRD_PARTY_CREDENTIALS={} QIPAI_MQTT_URL=mqtt://101.42.38.246:1883 QIPAI_MQTT_USERNAME= diff --git a/backend/package.json b/backend/package.json index 69cf0da..617f2c6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/config.ts b/backend/src/config.ts index 4efb2c6..e23fc91 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -18,6 +18,7 @@ const configSchema = z.object({ QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'), QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'), QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'), + QIPAI_PROFIT_SHARE_MOCK_ENABLED: z.enum(['true', 'false']).default('false'), QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'), QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'), QIPAI_MQTT_USERNAME: z.string().default(''), @@ -59,7 +60,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) { payment: { testAdapterEnabled: parsed.NODE_ENV !== 'production' && parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true', - wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS + wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS, + profitShareMockEnabled: parsed.NODE_ENV !== 'production' + && parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true' }, thirdParty: { credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 2466f35..dfa33b5 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -37,7 +37,8 @@ const migrationFiles: Record = { 'database/migrations/2026062014_m04d_order_shares.up.sql', 'database/migrations/2026062015_m05a_payment_domain.up.sql', 'database/migrations/2026062216_m05b_wechat_refunds.up.sql', - 'database/migrations/2026062217_m05c_third_party.up.sql' + 'database/migrations/2026062217_m05c_third_party.up.sql', + 'database/migrations/2026062218_m05d_profit_sharing.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -56,9 +57,11 @@ const migrationFiles: Record = { 'database/migrations/2026062014_m04d_order_shares.verify.sql', 'database/migrations/2026062015_m05a_payment_domain.verify.sql', 'database/migrations/2026062216_m05b_wechat_refunds.verify.sql', - 'database/migrations/2026062217_m05c_third_party.verify.sql' + 'database/migrations/2026062217_m05c_third_party.verify.sql', + 'database/migrations/2026062218_m05d_profit_sharing.verify.sql' ], down: [ + 'database/migrations/2026062218_m05d_profit_sharing.down.sql', 'database/migrations/2026062217_m05c_third_party.down.sql', 'database/migrations/2026062216_m05b_wechat_refunds.down.sql', 'database/migrations/2026062015_m05a_payment_domain.down.sql', @@ -208,7 +211,8 @@ export async function executeMigrationPlan( 1, 8, 3, 1, 5, 8, 4, 1, 1, 5, 3, 1, - 5, 6, 1 + 5, 6, 1, + 3, 7, 5, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/payments/profit-sharing-service.ts b/backend/src/payments/profit-sharing-service.ts new file mode 100644 index 0000000..12a5cd1 --- /dev/null +++ b/backend/src/payments/profit-sharing-service.ts @@ -0,0 +1,527 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import type { MySqlPool } from '../db/mysql.js'; +import { + WechatPayClient, WechatPayError, type WechatPayCredential +} from './wechat-pay-client.js'; + +interface PaymentRow extends RowDataPacket { + id: string; + orderId: string; + storeId: string; + status: string; + provider: string; + providerPaymentId: string | null; + amountCents: number; +} +interface AccountRow extends RowDataPacket { + id: string; + storeId: string | null; + merchantId: string; + credentialRef: string; + authorizationStatus: string; + profitSharingEnabled: number; +} +interface PolicyRow extends RowDataPacket { + receiverId: string; + receiverType: string; + receiverMasked: string; + receiverCredentialRef: string; + receiverAuthorizationStatus: string; + percentageBps: number; + name: string; +} +interface ShareRow extends RowDataPacket { + id: string; + shareNo: string; + status: string; + amountCents: number; +} + +export class ProfitSharingError extends Error { + constructor(public readonly code: string, message = code) { + super(message); + } +} + +export class ProfitSharingService { + constructor( + private readonly pool: MySqlPool, + private readonly client: WechatPayClient, + private readonly credentials: ReadonlyMap, + private readonly mockEnabled: boolean + ) {} + + async saveCollectionAccount(input: { + tenantId: string; + platformAppId: string; + actorId: string; + access: AccessProfile; + storeId: string | null; + merchantId: string; + credentialRef: string; + authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED'; + profitSharingEnabled: boolean; + enabled: boolean; + }) { + assertTenantManager(input.access); + if (!/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) { + throw new ProfitSharingError('COLLECTION_CREDENTIAL_REF_INVALID'); + } + if (input.profitSharingEnabled && input.authorizationStatus !== 'AUTHORIZED') { + throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED'); + } + await this.assertStore(input.tenantId, input.storeId); + const scopeKey = `app:${input.platformAppId}:store:${input.storeId ?? 'ALL'}`; + const [existing] = await this.pool.execute( + `SELECT id FROM qipai_collection_accounts + WHERE tenant_id = ? AND provider = 'WECHAT' AND scope_key = ? LIMIT 1`, + [input.tenantId, scopeKey] + ); + if (existing[0]) { + await this.pool.execute( + `UPDATE qipai_collection_accounts + SET merchant_id = ?, credential_ref = ?, authorization_status = ?, + profit_sharing_enabled = ?, enabled = ? + WHERE tenant_id = ? AND id = ?`, + [input.merchantId, input.credentialRef, input.authorizationStatus, + input.profitSharingEnabled, input.enabled, input.tenantId, existing[0].id] + ); + return { accountId: String(existing[0].id), created: false }; + } + const [result] = await this.pool.execute( + `INSERT INTO qipai_collection_accounts + (tenant_id, platform_app_id, store_id, provider, merchant_id, + scope_key, credential_ref, authorization_status, + profit_sharing_enabled, enabled) + VALUES (?, ?, ?, 'WECHAT', ?, ?, ?, ?, ?, ?)`, + [input.tenantId, input.platformAppId, input.storeId, input.merchantId, + scopeKey, input.credentialRef, input.authorizationStatus, + input.profitSharingEnabled, input.enabled] + ); + return { accountId: String(result.insertId), created: true }; + } + + async saveReceiver(input: { + tenantId: string; + access: AccessProfile; + collectionAccountId: string; + receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID'; + receiverAccount: string; + receiverCredentialRef: string; + relationType: string; + name: string; + authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED'; + enabled: boolean; + }) { + assertTenantManager(input.access); + if (!/^receiver:[A-Z0-9_:-]+$/.test(input.receiverCredentialRef)) { + throw new ProfitSharingError('PROFIT_RECEIVER_REF_INVALID'); + } + const account = await this.loadAccount( + input.tenantId, input.collectionAccountId, false + ); + if (!account) throw new ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND'); + const hash = hashValue(input.receiverAccount); + const [existing] = await this.pool.execute( + `SELECT id FROM qipai_profit_share_receivers + WHERE tenant_id = ? AND collection_account_id = ? AND receiver_hash = ? LIMIT 1`, + [input.tenantId, input.collectionAccountId, hash] + ); + if (existing[0]) { + await this.pool.execute( + `UPDATE qipai_profit_share_receivers + SET receiver_type = ?, receiver_masked = ?, receiver_credential_ref = ?, + relation_type = ?, name = ?, authorization_status = ?, enabled = ? + WHERE tenant_id = ? AND id = ?`, + [input.receiverType, maskValue(input.receiverAccount), + input.receiverCredentialRef, input.relationType, input.name, + input.authorizationStatus, input.enabled, input.tenantId, existing[0].id] + ); + return { receiverId: String(existing[0].id), created: false }; + } + const [result] = await this.pool.execute( + `INSERT INTO qipai_profit_share_receivers + (tenant_id, collection_account_id, receiver_type, receiver_hash, + receiver_masked, receiver_credential_ref, relation_type, name, + enabled, authorization_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [input.tenantId, input.collectionAccountId, input.receiverType, hash, + maskValue(input.receiverAccount), input.receiverCredentialRef, + input.relationType, input.name, input.enabled, input.authorizationStatus] + ); + return { receiverId: String(result.insertId), created: true }; + } + + async savePolicy(input: { + tenantId: string; + access: AccessProfile; + collectionAccountId: string; + storeId: string | null; + receiverId: string; + percentageBps: number; + enabled: boolean; + }) { + assertTenantManager(input.access); + if (input.percentageBps <= 0 || input.percentageBps > 10000) { + throw new ProfitSharingError('PROFIT_SHARE_PERCENTAGE_INVALID'); + } + await this.assertStore(input.tenantId, input.storeId); + const [receiverRows] = await this.pool.execute( + `SELECT 1 FROM qipai_profit_share_receivers + WHERE tenant_id = ? AND id = ? AND collection_account_id = ?`, + [input.tenantId, input.receiverId, input.collectionAccountId] + ); + if (!receiverRows[0]) throw new ProfitSharingError('PROFIT_RECEIVER_NOT_FOUND'); + const [sumRows] = await this.pool.execute( + `SELECT COALESCE(SUM(percentage_bps), 0) AS totalBps + FROM qipai_profit_share_policies + WHERE tenant_id = ? AND collection_account_id = ? + AND store_id <=> ? AND receiver_id <> ? AND enabled = 1`, + [input.tenantId, input.collectionAccountId, input.storeId, input.receiverId] + ); + if (Number(sumRows[0].totalBps) + (input.enabled ? input.percentageBps : 0) > 10000) { + throw new ProfitSharingError('PROFIT_SHARE_TOTAL_EXCEEDED'); + } + const scopeKey = input.storeId ? `store:${input.storeId}` : 'store:ALL'; + await this.pool.execute( + `INSERT INTO qipai_profit_share_policies + (tenant_id, collection_account_id, store_id, receiver_id, scope_key, + percentage_bps, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE percentage_bps = VALUES(percentage_bps), + enabled = VALUES(enabled)`, + [input.tenantId, input.collectionAccountId, input.storeId, + input.receiverId, scopeKey, input.percentageBps, input.enabled] + ); + return { saved: true }; + } + + async execute(input: { + tenantId: string; + actorId: string; + access: AccessProfile; + paymentId: string; + clientRequestId: string; + mode: 'API' | 'MOCK'; + }) { + assertTenantManager(input.access); + const [existing] = await this.pool.execute( + `SELECT id, share_no AS shareNo, status, amount_cents AS amountCents + FROM qipai_profit_shares + WHERE tenant_id = ? AND batch_request_id = ? ORDER BY id`, + [input.tenantId, input.clientRequestId] + ); + if (existing[0]) return { shares: existing.map(normalizeShare), idempotent: true }; + const payment = await this.loadPayment(input.tenantId, input.paymentId); + if (payment.status !== 'SUCCEEDED' || payment.provider !== 'WECHAT' + || !payment.providerPaymentId) { + throw new ProfitSharingError('PAYMENT_NOT_SHAREABLE'); + } + const account = await this.resolveAccount(input.tenantId, payment.storeId); + if (!account.profitSharingEnabled || account.authorizationStatus !== 'AUTHORIZED') { + throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED'); + } + const policies = await this.loadPolicies( + input.tenantId, account.id, payment.storeId + ); + if (policies.length === 0) throw new ProfitSharingError('PROFIT_SHARE_POLICY_NOT_FOUND'); + if (policies.some((policy) => policy.receiverAuthorizationStatus !== 'AUTHORIZED')) { + throw new ProfitSharingError('PROFIT_RECEIVER_NOT_AUTHORIZED'); + } + if (input.mode === 'MOCK' && !this.mockEnabled) { + throw new ProfitSharingError('PROFIT_SHARE_MOCK_DISABLED'); + } + const shares = policies.map((policy, index) => ({ + policy, + shareNo: `SHR${Date.now()}${index}${randomBytes(3).toString('hex').toUpperCase()}`, + amountCents: Math.floor( + Number(payment.amountCents) * Number(policy.percentageBps) / 10000 + ) + })).filter((share) => share.amountCents > 0); + if (shares.length === 0) throw new ProfitSharingError('PROFIT_SHARE_AMOUNT_ZERO'); + const credential = this.resolveCredential(account.credentialRef); + let response: Record; + let status: string; + if (input.mode === 'MOCK') { + response = { adapter: 'mock', state: 'FINISHED' }; + status = 'SUCCEEDED'; + } else { + if (!credential) throw new ProfitSharingError('WECHAT_CREDENTIAL_NOT_CONFIGURED'); + if (credential.merchantId !== account.merchantId) { + throw new ProfitSharingError('COLLECTION_MERCHANT_MISMATCH'); + } + const receivers = shares.map((share) => { + const receiver = credential.profitShareReceivers?.[ + share.policy.receiverCredentialRef + ]; + if (!receiver) throw new ProfitSharingError('PROFIT_RECEIVER_CREDENTIAL_MISSING'); + return { + type: share.policy.receiverType, + account: receiver, + amountCents: share.amountCents, + description: `订单分账-${share.policy.name}` + }; + }); + try { + response = await this.client.createProfitSharing(credential, { + transactionId: payment.providerPaymentId, + outOrderNo: input.clientRequestId, + receivers, + finish: true + }); + status = mapShareState(response.state); + } catch (error) { + await this.recordShares(input, payment, account, shares, 'MANUAL_REVIEW', { + errorCode: error instanceof WechatPayError ? error.code : 'PROFIT_SHARE_API_FAILED' + }); + throw error; + } + } + const recorded = await this.recordShares( + input, payment, account, shares, status, response + ); + return { shares: recorded, idempotent: false }; + } + + async list(input: { + tenantId: string; + access: AccessProfile; + storeId?: string; + }) { + assertTenantManager(input.access); + const [accounts] = await this.pool.execute( + `SELECT id, platform_app_id AS platformAppId, store_id AS storeId, + provider, merchant_id AS merchantId, + authorization_status AS authorizationStatus, + profit_sharing_enabled AS profitSharingEnabled, enabled + FROM qipai_collection_accounts + WHERE tenant_id = ? ${input.storeId ? 'AND store_id = ?' : ''} + ORDER BY id`, + input.storeId ? [input.tenantId, input.storeId] : [input.tenantId] + ); + const [shares] = await this.pool.execute( + `SELECT ps.id, ps.payment_id AS paymentId, ps.order_id AS orderId, + p.store_id AS storeId, ps.share_no AS shareNo, + ps.receiver_type AS receiverType, ps.receiver_ref AS receiverMasked, + ps.percentage_bps AS percentageBps, ps.amount_cents AS amountCents, + ps.status, ps.failure_code AS failureCode, ps.created_at AS createdAt + FROM qipai_profit_shares ps + INNER JOIN qipai_payments p + ON p.tenant_id = ps.tenant_id AND p.id = ps.payment_id + WHERE ps.tenant_id = ? ${input.storeId ? 'AND p.store_id = ?' : ''} + ORDER BY ps.id DESC LIMIT 100`, + input.storeId ? [input.tenantId, input.storeId] : [input.tenantId] + ); + const [receivers] = await this.pool.execute( + `SELECT r.id, r.collection_account_id AS collectionAccountId, + r.receiver_type AS receiverType, r.receiver_masked AS receiverMasked, + r.relation_type AS relationType, r.name, + r.authorization_status AS authorizationStatus, r.enabled + FROM qipai_profit_share_receivers r + INNER JOIN qipai_collection_accounts a + ON a.tenant_id = r.tenant_id AND a.id = r.collection_account_id + WHERE r.tenant_id = ? ${input.storeId ? 'AND a.store_id = ?' : ''} + ORDER BY r.id`, + input.storeId ? [input.tenantId, input.storeId] : [input.tenantId] + ); + const [policies] = await this.pool.execute( + `SELECT p.id, p.collection_account_id AS collectionAccountId, + p.store_id AS storeId, p.receiver_id AS receiverId, + p.percentage_bps AS percentageBps, p.enabled + FROM qipai_profit_share_policies p + INNER JOIN qipai_collection_accounts a + ON a.tenant_id = p.tenant_id AND a.id = p.collection_account_id + WHERE p.tenant_id = ? ${input.storeId ? 'AND (p.store_id = ? OR p.store_id IS NULL)' : ''} + ORDER BY p.id`, + input.storeId ? [input.tenantId, input.storeId] : [input.tenantId] + ); + return { accounts, receivers, policies, shares }; + } + + private async recordShares( + input: { + tenantId: string; + clientRequestId: string; + }, + payment: PaymentRow, + account: AccountRow, + shares: Array<{ + policy: PolicyRow; + shareNo: string; + amountCents: number; + }>, + status: string, + response: Record + ) { + return this.transaction(async (connection) => { + const result = []; + for (const share of shares) { + const [insert] = await connection.execute( + `INSERT INTO qipai_profit_shares + (tenant_id, payment_id, order_id, collection_account_id, receiver_id, + share_no, client_request_id, batch_request_id, + receiver_type, receiver_ref, percentage_bps, + amount_cents, status, failure_code, + provider_share_id, raw_response, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), + IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))`, + [input.tenantId, payment.id, payment.orderId, account.id, + share.policy.receiverId, share.shareNo, + `${input.clientRequestId}:${share.policy.receiverId}`, + input.clientRequestId, + share.policy.receiverType, share.policy.receiverMasked, + share.policy.percentageBps, share.amountCents, status, + status === 'MANUAL_REVIEW' ? stringValue(response.errorCode) : '', + stringValue(response.order_id), JSON.stringify(sanitizeResponse(response)), status] + ); + result.push({ + shareId: String(insert.insertId), + shareNo: share.shareNo, + amountCents: share.amountCents, + status + }); + } + return result; + }); + } + + private async loadPayment(tenantId: string, paymentId: string) { + const [rows] = await this.pool.execute( + `SELECT id, order_id AS orderId, store_id AS storeId, status, provider, + provider_payment_id AS providerPaymentId, amount_cents AS amountCents + FROM qipai_payments + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`, + [tenantId, paymentId] + ); + if (!rows[0]) throw new ProfitSharingError('PAYMENT_NOT_FOUND'); + return rows[0]; + } + + private async resolveAccount(tenantId: string, storeId: string) { + const [rows] = await this.pool.execute( + `SELECT id, store_id AS storeId, merchant_id AS merchantId, + credential_ref AS credentialRef, + authorization_status AS authorizationStatus, + profit_sharing_enabled AS profitSharingEnabled + FROM qipai_collection_accounts + WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1 + AND (store_id IS NULL OR store_id = ?) + ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`, + [tenantId, storeId] + ); + if (!rows[0]) throw new ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND'); + return rows[0]; + } + + private async loadAccount(tenantId: string, accountId: string, enabledOnly: boolean) { + const [rows] = await this.pool.execute( + `SELECT id, store_id AS storeId, merchant_id AS merchantId, + credential_ref AS credentialRef, + authorization_status AS authorizationStatus, + profit_sharing_enabled AS profitSharingEnabled + FROM qipai_collection_accounts + WHERE tenant_id = ? AND id = ? ${enabledOnly ? 'AND enabled = 1' : ''} LIMIT 1`, + [tenantId, accountId] + ); + return rows[0] ?? null; + } + + private async loadPolicies(tenantId: string, accountId: string, storeId: string) { + const [rows] = await this.pool.execute( + `SELECT p.receiver_id AS receiverId, r.receiver_type AS receiverType, + r.receiver_masked AS receiverMasked, + r.receiver_credential_ref AS receiverCredentialRef, + r.authorization_status AS receiverAuthorizationStatus, + p.percentage_bps AS percentageBps, r.name + FROM qipai_profit_share_policies p + INNER JOIN qipai_profit_share_receivers r + ON r.tenant_id = p.tenant_id AND r.id = p.receiver_id + WHERE p.tenant_id = ? AND p.collection_account_id = ? + AND p.enabled = 1 AND r.enabled = 1 + AND (p.store_id IS NULL OR p.store_id = ?) + AND (p.store_id = ? OR NOT EXISTS ( + SELECT 1 FROM qipai_profit_share_policies sp + WHERE sp.tenant_id = p.tenant_id + AND sp.collection_account_id = p.collection_account_id + AND sp.receiver_id = p.receiver_id + AND sp.store_id = ? AND sp.enabled = 1 + )) + ORDER BY (p.store_id IS NOT NULL) DESC, p.id`, + [tenantId, accountId, storeId, storeId, storeId] + ); + return rows; + } + + private resolveCredential(reference: string) { + return this.credentials.get(reference) + ?? this.credentials.get(reference.replace(/^env:/, '')); + } + + private async assertStore(tenantId: string, storeId: string | null) { + if (!storeId) return; + const [rows] = await this.pool.execute( + `SELECT 1 FROM qipai_stores + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, + [tenantId, storeId] + ); + if (!rows[0]) throw new ProfitSharingError('STORE_NOT_FOUND'); + } + + private async transaction(work: (connection: PoolConnection) => Promise) { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } +} + +function assertTenantManager(access: AccessProfile) { + if (access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN')) return; + throw new ProfitSharingError('PROFIT_SHARE_FORBIDDEN'); +} + +function hashValue(value: string) { + return createHash('sha256').update(value).digest('hex'); +} + +function maskValue(value: string) { + if (value.length <= 4) return '*'.repeat(value.length); + return `${value.slice(0, 2)}${'*'.repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`; +} + +function normalizeShare(row: ShareRow) { + return { + shareId: String(row.id), + shareNo: row.shareNo, + amountCents: Number(row.amountCents), + status: row.status + }; +} + +function mapShareState(value: unknown) { + if (value === 'FINISHED') return 'SUCCEEDED'; + if (value === 'PROCESSING') return 'PROCESSING'; + return 'MANUAL_REVIEW'; +} + +function stringValue(value: unknown) { + return typeof value === 'string' ? value : ''; +} + +function sanitizeResponse(value: Record) { + const copy = { ...value }; + delete copy.receivers; + delete copy.account; + return copy; +} diff --git a/backend/src/payments/wechat-pay-client.ts b/backend/src/payments/wechat-pay-client.ts index 4538cdc..f88a567 100644 --- a/backend/src/payments/wechat-pay-client.ts +++ b/backend/src/payments/wechat-pay-client.ts @@ -9,6 +9,7 @@ export interface WechatPayCredential { privateKeyPem: string; apiV3Key: string; platformCertificates: Record; + profitShareReceivers?: Record; } export interface WechatPayTransport { @@ -144,6 +145,34 @@ export class WechatPayClient { ); } + async createProfitSharing( + credential: WechatPayCredential, + input: { + transactionId: string; + outOrderNo: string; + receivers: Array<{ + type: string; + account: string; + amountCents: number; + description: string; + }>; + finish: boolean; + } + ) { + return this.apiRequest(credential, 'POST', '/v3/profitsharing/orders', { + appid: credential.appId, + transaction_id: input.transactionId, + out_order_no: input.outOrderNo, + receivers: input.receivers.map((receiver) => ({ + type: receiver.type, + account: receiver.account, + amount: receiver.amountCents, + description: receiver.description.slice(0, 80) + })), + unfreeze_unsplit: input.finish + }); + } + verifyAndDecrypt( credential: WechatPayCredential, headers: WechatNotificationHeaders, @@ -238,7 +267,19 @@ export function parseWechatPayCredentials(value: string) { } return [serial, certificate]; }) - ) + ), + profitShareReceivers: item.profitShareReceivers + && typeof item.profitShareReceivers === 'object' + && !Array.isArray(item.profitShareReceivers) + ? Object.fromEntries( + Object.entries(item.profitShareReceivers as Record) + .map(([reference, account]) => { + if (typeof account !== 'string' || account.length === 0) { + throw new WechatPayError('WECHAT_PROFIT_RECEIVER_INVALID'); + } + return [reference, account]; + }) + ) : {} }); } return credentials; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index b594670..bb6fbe8 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -11,6 +11,9 @@ import { } from '../payments/payment-repository.js'; import type { WechatPaymentService } from '../payments/wechat-payment-service.js'; import { WechatPayError } from '../payments/wechat-pay-client.js'; +import { + ProfitSharingError, type ProfitSharingService +} from '../payments/profit-sharing-service.js'; const createSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/), @@ -33,12 +36,46 @@ const billSchema = z.object({ billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), billType: z.enum(['ALL', 'SUCCESS', 'REFUND']) }).strict(); +const collectionAccountSchema = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null), + merchantId: z.string().min(6).max(64), + credentialRef: z.string().min(5).max(255), + authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']), + profitSharingEnabled: z.boolean(), + enabled: z.boolean().default(true) +}).strict(); +const receiverSchema = z.object({ + collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/), + receiverType: z.enum(['MERCHANT_ID', 'PERSONAL_OPENID']), + receiverAccount: z.string().min(4).max(128), + receiverCredentialRef: z.string().min(10).max(255), + relationType: z.string().min(2).max(32), + name: z.string().min(1).max(128), + authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']), + enabled: z.boolean().default(true) +}).strict(); +const policySchema = z.object({ + collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/), + storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null), + receiverId: z.string().regex(/^[1-9]\d{0,19}$/), + percentageBps: z.number().int().min(1).max(10000), + enabled: z.boolean().default(true) +}).strict(); +const executeShareSchema = z.object({ + paymentId: z.string().regex(/^[1-9]\d{0,19}$/), + clientRequestId: z.string().min(8).max(96), + mode: z.enum(['API', 'MOCK']) +}).strict(); +const shareListQuery = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional() +}); export interface PaymentRouteOptions { repository: Pick; authRepository: Pick; accessControl?: Pick; wechat?: WechatPaymentService; + profitSharing?: ProfitSharingService; jwtSecret: string; testAdapterEnabled: boolean; } @@ -184,6 +221,91 @@ export async function registerPaymentRoutes( })); }); } + + if (options.profitSharing) { + app.put('/admin-api/pay/collection-account', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = collectionAccountSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.profitSharing!.saveCollectionAccount({ + tenantId: auth.tenantId, + platformAppId: auth.platformAppId, + actorId: auth.userId, + access: auth.access, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.put('/admin-api/pay/profit-share-receiver', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = receiverSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.profitSharing!.saveReceiver({ + tenantId: auth.tenantId, + access: auth.access, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.put('/admin-api/pay/profit-share-policy', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = policySchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.profitSharing!.savePolicy({ + tenantId: auth.tenantId, + access: auth.access, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/admin-api/pay/profit-shares', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = executeShareSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.profitSharing!.execute({ + tenantId: auth.tenantId, + actorId: auth.userId, + access: auth.access, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.get('/admin-api/pay/profit-shares', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const query = shareListQuery.safeParse(request.query); + if (!auth) return unauthorized(reply, request.traceId); + if (!query.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.profitSharing!.list({ + tenantId: auth.tenantId, + access: auth.access, + storeId: query.data.storeId + }), + traceId: request.traceId + })); + }); + } } async function authenticate( @@ -208,14 +330,16 @@ async function authenticateAdmin( const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); if (!access.capabilities.includes('tenant.manage') && !access.roles.includes('PLATFORM_ADMIN')) return null; - return auth; + return { ...auth, access }; } async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { try { return await work(); } catch (error) { - if (!(error instanceof PaymentError) && !(error instanceof WechatPayError)) throw error; + if (!(error instanceof PaymentError) + && !(error instanceof WechatPayError) + && !(error instanceof ProfitSharingError)) throw error; const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND' ? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409 : error.code.includes('FORBIDDEN') ? 403 : 400; diff --git a/backend/src/server.ts b/backend/src/server.ts index 159885f..9be16b4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -21,6 +21,7 @@ import { FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient } from './payments/wechat-pay-client.js'; import { WechatPaymentService } from './payments/wechat-payment-service.js'; +import { ProfitSharingService } from './payments/profit-sharing-service.js'; import { FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient } from './third-party/third-party-client.js'; @@ -33,6 +34,7 @@ const accessControl = new RbacRepository(pool); const orderManagementRepository = new OrderManagementRepository(pool); const paymentRepository = new PaymentRepository(pool); const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); +const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport()); const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson); const app = await buildApp({ config, @@ -104,9 +106,15 @@ const app = await buildApp({ wechat: new WechatPaymentService( pool, paymentRepository, - new WechatPayClient(new FetchWechatPayTransport()), + wechatPayClient, wechatCredentials ), + profitSharing: new ProfitSharingService( + pool, + wechatPayClient, + wechatCredentials, + config.payment.profitShareMockEnabled + ), authRepository, accessControl, jwtSecret: config.auth.jwtSecret, diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index fe0c0ff..82251c6 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -60,6 +60,9 @@ const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_r const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql'); const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql'); const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql'); +const profitSharingUpSql = read('database/migrations/2026062218_m05d_profit_sharing.up.sql'); +const profitSharingDownSql = read('database/migrations/2026062218_m05d_profit_sharing.down.sql'); +const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_sharing.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -267,5 +270,20 @@ assert.match(thirdPartyUpSql, /voucher_hash CHAR\(64\)/); assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/); assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/); assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i); +for (const table of [ + 'qipai_collection_accounts', 'qipai_profit_share_receivers', + 'qipai_profit_share_policies' +]) { + assert.match(profitSharingUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(profitSharingDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(profitSharingVerifySql, new RegExp(`'${table}'`)); +} +assert.match(profitSharingUpSql, /receiver_hash CHAR\(64\)/); +assert.match(profitSharingUpSql, /percentage_bps SMALLINT UNSIGNED/); +assert.match(profitSharingUpSql, /uq_qipai_profit_share_payment_receiver/); +assert.doesNotMatch( + profitSharingUpSql, + /\bprivate_key\b|\bapi_v3_key\b|\breceiver_account\b/i +); -console.log('PASS: M01-B through M05-C migration contracts are present.'); +console.log('PASS: M01-B through M05-D migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 9542704..44ef812 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -28,7 +28,8 @@ assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/); assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/); assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/); assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/); -assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/); +assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/); +assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index c1714cb..4035785 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -31,6 +31,8 @@ import { import { PaymentError, PaymentRepository } from '../dist/payments/payment-repository.js'; +import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js'; +import { WechatPayClient } from '../dist/payments/wechat-pay-client.js'; import { ThirdPartyClient } from '../dist/third-party/third-party-client.js'; import { ThirdPartyService } from '../dist/third-party/third-party-service.js'; import { @@ -44,6 +46,7 @@ const expectedTables = [ 'qipai_async_tasks', 'qipai_audit_logs', 'qipai_auth_sessions', + 'qipai_collection_accounts', 'qipai_devices', 'qipai_direct_bookings', 'qipai_group_redemptions', @@ -65,6 +68,8 @@ const expectedTables = [ 'qipai_payments', 'qipai_permissions', 'qipai_platform_apps', + 'qipai_profit_share_policies', + 'qipai_profit_share_receivers', 'qipai_profit_shares', 'qipai_reconciliation_runs', 'qipai_refunds', @@ -110,12 +115,12 @@ async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations - WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY version`, ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014', - '2026062015', '2026062216', '2026062217'] + '2026062015', '2026062216', '2026062217', '2026062218'] ); return rows; } @@ -1340,6 +1345,175 @@ async function assertThirdPartyDomain(pool, context) { assert.equal(unmapped.status, 'PENDING_MAPPING'); } +async function assertProfitSharingDomain(pool, context) { + const [adminRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id + INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, + [context.tenantId] + ); + const [paymentRows] = await pool.query( + `SELECT p.id, p.order_id AS orderId, p.store_id AS storeId, + p.amount_cents AS amountCents + FROM qipai_payments p + WHERE p.tenant_id = ? AND p.provider = 'GROUP_BUY' + AND p.status = 'SUCCEEDED' ORDER BY p.id DESC LIMIT 1`, + [context.tenantId] + ); + const adminId = String(adminRows[0].id); + const payment = paymentRows[0]; + await pool.query( + `INSERT INTO qipai_payments + (tenant_id, platform_app_id, order_id, store_id, payment_no, + channel, provider, client_request_id, status, amount_cents, + provider_payment_id, paid_at) + VALUES (?, ?, ?, ?, 'M05D-WECHAT-PAYMENT', 'WECHAT', 'WECHAT', + 'm05d-wechat-payment', 'SUCCEEDED', ?, 'wx-m05d-transaction', + UTC_TIMESTAMP(3))`, + [context.tenantId, context.platformAppId, payment.orderId, + payment.storeId, payment.amountCents] + ); + const [wechatPaymentRows] = await pool.query( + `SELECT id FROM qipai_payments + WHERE tenant_id = ? AND client_request_id = 'm05d-wechat-payment'`, + [context.tenantId] + ); + const access = { + roles: ['TENANT_ADMIN'], + capabilities: ['tenant.manage'], + storeIds: [] + }; + const service = new ProfitSharingService( + pool, + new WechatPayClient({ + async request() { + throw new Error('M05-D MySQL test uses the explicit mock adapter only.'); + } + }), + new Map(), + true + ); + await assert.rejects( + () => service.saveCollectionAccount({ + tenantId: context.tenantId, + platformAppId: context.platformAppId, + actorId: adminId, + access, + storeId: String(payment.storeId), + merchantId: '1900000109', + credentialRef: 'env:WX_M05D', + authorizationStatus: 'PENDING', + profitSharingEnabled: true, + enabled: true + }), + (error) => error.code === 'PROFIT_SHARING_NOT_AUTHORIZED' + ); + const account = await service.saveCollectionAccount({ + tenantId: context.tenantId, + platformAppId: context.platformAppId, + actorId: adminId, + access, + storeId: String(payment.storeId), + merchantId: '1900000109', + credentialRef: 'env:WX_M05D', + authorizationStatus: 'AUTHORIZED', + profitSharingEnabled: true, + enabled: true + }); + const receiverA = await service.saveReceiver({ + tenantId: context.tenantId, + access, + collectionAccountId: account.accountId, + receiverType: 'MERCHANT_ID', + receiverAccount: 'receiver-private-account-a', + receiverCredentialRef: 'receiver:STORE_A', + relationType: 'PARTNER', + name: 'M05D Receiver A', + authorizationStatus: 'AUTHORIZED', + enabled: true + }); + const receiverB = await service.saveReceiver({ + tenantId: context.tenantId, + access, + collectionAccountId: account.accountId, + receiverType: 'MERCHANT_ID', + receiverAccount: 'receiver-private-account-b', + receiverCredentialRef: 'receiver:STORE_B', + relationType: 'PARTNER', + name: 'M05D Receiver B', + authorizationStatus: 'AUTHORIZED', + enabled: true + }); + await service.savePolicy({ + tenantId: context.tenantId, + access, + collectionAccountId: account.accountId, + storeId: String(payment.storeId), + receiverId: receiverA.receiverId, + percentageBps: 3000, + enabled: true + }); + await service.savePolicy({ + tenantId: context.tenantId, + access, + collectionAccountId: account.accountId, + storeId: String(payment.storeId), + receiverId: receiverB.receiverId, + percentageBps: 2000, + enabled: true + }); + await assert.rejects( + () => service.savePolicy({ + tenantId: context.tenantId, + access, + collectionAccountId: account.accountId, + storeId: String(payment.storeId), + receiverId: receiverB.receiverId, + percentageBps: 8000, + enabled: true + }), + (error) => error.code === 'PROFIT_SHARE_TOTAL_EXCEEDED' + ); + const result = await service.execute({ + tenantId: context.tenantId, + actorId: adminId, + access, + paymentId: String(wechatPaymentRows[0].id), + clientRequestId: 'm05d-profit-share-request', + mode: 'MOCK' + }); + assert.equal(result.shares.length, 2); + assert.equal(result.shares.every((share) => share.status === 'SUCCEEDED'), true); + const duplicate = await service.execute({ + tenantId: context.tenantId, + actorId: adminId, + access, + paymentId: String(wechatPaymentRows[0].id), + clientRequestId: 'm05d-profit-share-request', + mode: 'MOCK' + }); + assert.equal(duplicate.idempotent, true); + assert.equal(duplicate.shares.length, 2); + const [shareRows] = await pool.query( + `SELECT ps.status, ps.percentage_bps AS percentageBps, + ps.amount_cents AS amountCents, ps.receiver_ref AS receiverMasked, + r.receiver_hash AS receiverHash, r.receiver_credential_ref AS receiverRef + FROM qipai_profit_shares ps + INNER JOIN qipai_profit_share_receivers r + ON r.tenant_id = ps.tenant_id AND r.id = ps.receiver_id + WHERE ps.tenant_id = ? AND ps.batch_request_id = ? + ORDER BY ps.id`, + [context.tenantId, 'm05d-profit-share-request'] + ); + assert.equal(shareRows.length, 2); + assert.deepEqual(shareRows.map((row) => row.percentageBps), [3000, 2000]); + assert.equal(shareRows.every((row) => row.status === 'SUCCEEDED'), true); + assert.equal(shareRows.every((row) => /^[a-f0-9]{64}$/.test(row.receiverHash)), true); + assert.equal(shareRows.some((row) => row.receiverMasked.includes('private')), false); + assert.equal(shareRows.every((row) => row.receiverRef.startsWith('receiver:')), true); +} + async function assertContentManagement(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u @@ -1444,7 +1618,8 @@ try { { version: '2026062014', name: 'm04d_order_shares' }, { version: '2026062015', name: 'm05a_payment_domain' }, { version: '2026062216', name: 'm05b_wechat_refunds' }, - { version: '2026062217', name: 'm05c_third_party' } + { version: '2026062217', name: 'm05c_third_party' }, + { version: '2026062218', name: 'm05d_profit_sharing' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -1460,13 +1635,14 @@ try { await assertOrderShares(pool, loginContext); await assertPaymentDomain(pool, loginContext); await assertThirdPartyDomain(pool, loginContext); + await assertProfitSharingDomain(pool, loginContext); await assertLegacyCompatibility(pool); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); await assertLegacyCompatibility(pool); - console.log('PASS: down removed all M01-B through M05-C tables.'); + console.log('PASS: down removed all M01-B through M05-D tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -1488,7 +1664,8 @@ try { { version: '2026062014', name: 'm04d_order_shares' }, { version: '2026062015', name: 'm05a_payment_domain' }, { version: '2026062216', name: 'm05b_wechat_refunds' }, - { version: '2026062217', name: 'm05c_third_party' } + { version: '2026062217', name: 'm05c_third_party' }, + { version: '2026062218', name: 'm05d_profit_sharing' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -1576,6 +1753,11 @@ try { 'third-party booking webhook idempotency', 'mapped booking claim creates a paid order', 'unmapped booking enters manual queue' + , + 'collection account authorization gate', + 'profit-share percentage total validation', + 'receiver hash and masked storage', + 'payment and receiver idempotent profit sharing' ] }, null, 2)); } finally { diff --git a/backend/tests/profit-sharing.test.mjs b/backend/tests/profit-sharing.test.mjs new file mode 100644 index 0000000..c51eda0 --- /dev/null +++ b/backend/tests/profit-sharing.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { buildApp } from '../dist/app.js'; +import { loadConfig } from '../dist/config.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; +import { WechatPayClient } from '../dist/payments/wechat-pay-client.js'; + +assert.equal(loadConfig({ + NODE_ENV: 'production', + QIPAI_JWT_SECRET: 'production-profit-share-secret-long-enough', + QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true' +}).payment.profitShareMockEnabled, false); +assert.equal(loadConfig({ + NODE_ENV: 'test', + QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true' +}).payment.profitShareMockEnabled, true); + +const keys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } +}); +let apiRequest; +const client = new WechatPayClient({ + async request(input) { + apiRequest = input; + return { + status: 200, + headers: {}, + body: JSON.stringify({ order_id: 'wx-share-order', state: 'FINISHED' }) + }; + } +}); +const shareResult = await client.createProfitSharing({ + appId: 'wx-test', + merchantId: '1900000109', + serialNo: 'serial-test', + privateKeyPem: keys.privateKey, + apiV3Key: '0123456789abcdef0123456789abcdef', + platformCertificates: {}, + profitShareReceivers: {} +}, { + transactionId: 'wx-transaction-001', + outOrderNo: 'share-request-001', + receivers: [{ + type: 'MERCHANT_ID', + account: 'receiver-account-private', + amountCents: 1200, + description: 'sanitized share' + }], + finish: true +}); +assert.equal(shareResult.state, 'FINISHED'); +assert.match(apiRequest.url, /\/v3\/profitsharing\/orders$/); +assert.match(apiRequest.body, /"amount":1200/); +assert.equal(apiRequest.headers.Authorization.includes(keys.privateKey), false); + +const secret = 'profit-sharing-route-secret-32-characters'; +const token = signAccessToken({ + sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); +let executeInput; +const app = await buildApp({ + payment: { + jwtSecret: secret, + testAdapterEnabled: false, + authRepository: { + async validateSession() { + return { + id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tenantId: '7', platformAppId: '9', + expiresAt: new Date(Date.now() + 60000), + user: { + id: '21', tenantId: '7', userType: 'ADMIN', status: 'ACTIVE', + roleVersion: 1, nickname: '', avatarUrl: '', phone: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { + roles: ['TENANT_ADMIN'], + capabilities: ['tenant.manage'], + storeIds: [] + }; + } + }, + repository: { + async createPayment() { return {}; }, + async processTestCallback() { return {}; } + }, + profitSharing: { + async saveCollectionAccount() { + return { accountId: '41', created: true }; + }, + async saveReceiver() { + return { receiverId: '42', created: true }; + }, + async savePolicy() { + return { saved: true }; + }, + async execute(input) { + executeInput = input; + return { + shares: [{ shareId: '43', amountCents: 1200, status: 'SUCCEEDED' }], + idempotent: false + }; + }, + async list() { + return { accounts: [], shares: [] }; + } + } + } +}); +const executed = await app.inject({ + method: 'POST', + url: '/admin-api/pay/profit-shares', + headers: { authorization: `Bearer ${token}` }, + payload: { + paymentId: '31', + clientRequestId: 'share-request-001', + mode: 'MOCK' + } +}); +assert.equal(executed.statusCode, 200); +assert.equal(executeInput.tenantId, '7'); +assert.equal(executeInput.access.capabilities[0], 'tenant.manage'); + +await app.close(); +console.log('PASS: M05-D Wechat profit-sharing request, production mock gate and admin routes.'); diff --git a/database/migrations/2026062218_m05d_profit_sharing.down.sql b/database/migrations/2026062218_m05d_profit_sharing.down.sql new file mode 100644 index 0000000..aefbccb --- /dev/null +++ b/database/migrations/2026062218_m05d_profit_sharing.down.sql @@ -0,0 +1,16 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062218'; +ALTER TABLE qipai_profit_shares + DROP INDEX uq_qipai_profit_share_payment_receiver, + DROP INDEX uq_qipai_profit_share_request, + DROP FOREIGN KEY fk_qipai_profit_share_receiver, + DROP FOREIGN KEY fk_qipai_profit_share_account, + DROP COLUMN raw_response, + DROP COLUMN failure_code, + DROP COLUMN percentage_bps, + DROP COLUMN batch_request_id, + DROP COLUMN client_request_id, + DROP COLUMN receiver_id, + DROP COLUMN collection_account_id; +DROP TABLE IF EXISTS qipai_profit_share_policies; +DROP TABLE IF EXISTS qipai_profit_share_receivers; +DROP TABLE IF EXISTS qipai_collection_accounts; diff --git a/database/migrations/2026062218_m05d_profit_sharing.up.sql b/database/migrations/2026062218_m05d_profit_sharing.up.sql new file mode 100644 index 0000000..0b7d0eb --- /dev/null +++ b/database/migrations/2026062218_m05d_profit_sharing.up.sql @@ -0,0 +1,99 @@ +CREATE TABLE IF NOT EXISTS qipai_collection_accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + platform_app_id BIGINT UNSIGNED NULL, + store_id BIGINT UNSIGNED NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT', + scope_key VARCHAR(255) NOT NULL, + merchant_id VARCHAR(64) NOT NULL, + credential_ref VARCHAR(255) NOT NULL, + authorization_status VARCHAR(32) NOT NULL DEFAULT 'UNAUTHORIZED', + profit_sharing_enabled TINYINT(1) NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_collection_account_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_collection_account_app + FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id), + CONSTRAINT fk_qipai_collection_account_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_collection_account_scope + (tenant_id, provider, scope_key), + KEY idx_qipai_collection_account_resolution + (tenant_id, provider, enabled, store_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_profit_share_receivers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + collection_account_id BIGINT UNSIGNED NOT NULL, + receiver_type VARCHAR(32) NOT NULL, + receiver_hash CHAR(64) NOT NULL, + receiver_masked VARCHAR(128) NOT NULL, + receiver_credential_ref VARCHAR(255) NOT NULL, + relation_type VARCHAR(32) NOT NULL, + name VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + authorization_status VARCHAR(32) NOT NULL DEFAULT 'UNAUTHORIZED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_profit_receiver_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_profit_receiver_account + FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id), + UNIQUE KEY uq_qipai_profit_receiver_hash + (tenant_id, collection_account_id, receiver_hash), + KEY idx_qipai_profit_receiver_enabled + (tenant_id, collection_account_id, enabled, authorization_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_profit_share_policies ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + collection_account_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NULL, + receiver_id BIGINT UNSIGNED NOT NULL, + scope_key VARCHAR(255) NOT NULL, + percentage_bps SMALLINT UNSIGNED NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_profit_policy_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_profit_policy_account + FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id), + CONSTRAINT fk_qipai_profit_policy_store + FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_profit_policy_receiver + FOREIGN KEY (receiver_id) REFERENCES qipai_profit_share_receivers(id), + UNIQUE KEY uq_qipai_profit_policy_receiver + (tenant_id, collection_account_id, scope_key, receiver_id), + KEY idx_qipai_profit_policy_resolution + (tenant_id, collection_account_id, store_id, enabled), + CONSTRAINT chk_qipai_profit_policy_bps + CHECK (percentage_bps > 0 AND percentage_bps <= 10000) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE qipai_profit_shares + ADD COLUMN collection_account_id BIGINT UNSIGNED NULL AFTER order_id, + ADD COLUMN receiver_id BIGINT UNSIGNED NULL AFTER collection_account_id, + ADD COLUMN client_request_id VARCHAR(128) NULL AFTER share_no, + ADD COLUMN batch_request_id VARCHAR(128) NULL AFTER client_request_id, + ADD COLUMN percentage_bps SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER receiver_ref, + ADD COLUMN failure_code VARCHAR(64) NOT NULL DEFAULT '' AFTER status, + ADD COLUMN raw_response JSON NULL AFTER provider_share_id, + ADD CONSTRAINT fk_qipai_profit_share_account + FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id), + ADD CONSTRAINT fk_qipai_profit_share_receiver + FOREIGN KEY (receiver_id) REFERENCES qipai_profit_share_receivers(id), + ADD UNIQUE KEY uq_qipai_profit_share_request (tenant_id, client_request_id), + ADD KEY idx_qipai_profit_share_batch (tenant_id, batch_request_id), + ADD UNIQUE KEY uq_qipai_profit_share_payment_receiver + (tenant_id, payment_id, receiver_id); + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062218', 'm05d_profit_sharing'); diff --git a/database/migrations/2026062218_m05d_profit_sharing.verify.sql b/database/migrations/2026062218_m05d_profit_sharing.verify.sql new file mode 100644 index 0000000..cfce830 --- /dev/null +++ b/database/migrations/2026062218_m05d_profit_sharing.verify.sql @@ -0,0 +1,29 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() AND table_name IN ( + 'qipai_collection_accounts', 'qipai_profit_share_receivers', + 'qipai_profit_share_policies' +) ORDER BY table_name; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_profit_shares' + AND column_name IN ( + 'collection_account_id', 'receiver_id', 'client_request_id', 'batch_request_id', + 'percentage_bps', 'failure_code', 'raw_response' + ) ORDER BY column_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_collection_accounts' + AND index_name = 'uq_qipai_collection_account_scope') + OR (table_name = 'qipai_profit_share_receivers' + AND index_name = 'uq_qipai_profit_receiver_hash') + OR (table_name = 'qipai_profit_share_policies' + AND index_name = 'uq_qipai_profit_policy_receiver') + OR (table_name = 'qipai_profit_shares' + AND index_name IN ( + 'uq_qipai_profit_share_request', + 'uq_qipai_profit_share_payment_receiver' + ))) +GROUP BY table_name, index_name ORDER BY table_name, index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062218'; diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index f1cadff..4cf3108 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B through M05-C migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M05-D migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M05-C live MySQL migration roundtrip completed." +echo "PASS: M01-B through M05-D live MySQL migration roundtrip completed."