feat(M05-D): 完成收款配置与幂等分账

This commit is contained in:
Codex
2026-06-22 11:49:37 +08:00
parent 52edf2482e
commit 1680d734bf
16 changed files with 1203 additions and 18 deletions
+1
View File
@@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800
QIPAI_WECHAT_APP_SECRETS={} QIPAI_WECHAT_APP_SECRETS={}
QIPAI_TEST_PAYMENT_ENABLED=false QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_WECHAT_PAY_CREDENTIALS={} QIPAI_WECHAT_PAY_CREDENTIALS={}
QIPAI_PROFIT_SHARE_MOCK_ENABLED=false
QIPAI_THIRD_PARTY_CREDENTIALS={} QIPAI_THIRD_PARTY_CREDENTIALS={}
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883 QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_USERNAME= QIPAI_MQTT_USERNAME=
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "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", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+4 -1
View File
@@ -18,6 +18,7 @@ const configSchema = z.object({
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'), QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'), QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'), 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_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'), QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''), QIPAI_MQTT_USERNAME: z.string().default(''),
@@ -59,7 +60,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
payment: { payment: {
testAdapterEnabled: parsed.NODE_ENV !== 'production' testAdapterEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true', && 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: { thirdParty: {
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
+7 -3
View File
@@ -37,7 +37,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062014_m04d_order_shares.up.sql', 'database/migrations/2026062014_m04d_order_shares.up.sql',
'database/migrations/2026062015_m05a_payment_domain.up.sql', 'database/migrations/2026062015_m05a_payment_domain.up.sql',
'database/migrations/2026062216_m05b_wechat_refunds.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -56,9 +57,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062014_m04d_order_shares.verify.sql', 'database/migrations/2026062014_m04d_order_shares.verify.sql',
'database/migrations/2026062015_m05a_payment_domain.verify.sql', 'database/migrations/2026062015_m05a_payment_domain.verify.sql',
'database/migrations/2026062216_m05b_wechat_refunds.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: [ down: [
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
'database/migrations/2026062217_m05c_third_party.down.sql', 'database/migrations/2026062217_m05c_third_party.down.sql',
'database/migrations/2026062216_m05b_wechat_refunds.down.sql', 'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
'database/migrations/2026062015_m05a_payment_domain.down.sql', 'database/migrations/2026062015_m05a_payment_domain.down.sql',
@@ -208,7 +211,8 @@ export async function executeMigrationPlan(
1, 8, 3, 1, 1, 8, 3, 1,
5, 8, 4, 1, 5, 8, 4, 1,
1, 5, 3, 1, 1, 5, 3, 1,
5, 6, 1 5, 6, 1,
3, 7, 5, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
@@ -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<string, WechatPayCredential>,
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<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<ShareRow[]>(
`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<string, unknown>;
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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<string, unknown>
) {
return this.transaction(async (connection) => {
const result = [];
for (const share of shares) {
const [insert] = await connection.execute<ResultSetHeader>(
`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<PaymentRow[]>(
`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<AccountRow[]>(
`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<AccountRow[]>(
`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<PolicyRow[]>(
`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<RowDataPacket[]>(
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
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<string, unknown>) {
const copy = { ...value };
delete copy.receivers;
delete copy.account;
return copy;
}
+42 -1
View File
@@ -9,6 +9,7 @@ export interface WechatPayCredential {
privateKeyPem: string; privateKeyPem: string;
apiV3Key: string; apiV3Key: string;
platformCertificates: Record<string, string>; platformCertificates: Record<string, string>;
profitShareReceivers?: Record<string, string>;
} }
export interface WechatPayTransport { 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( verifyAndDecrypt(
credential: WechatPayCredential, credential: WechatPayCredential,
headers: WechatNotificationHeaders, headers: WechatNotificationHeaders,
@@ -238,7 +267,19 @@ export function parseWechatPayCredentials(value: string) {
} }
return [serial, certificate]; return [serial, certificate];
}) })
) ),
profitShareReceivers: item.profitShareReceivers
&& typeof item.profitShareReceivers === 'object'
&& !Array.isArray(item.profitShareReceivers)
? Object.fromEntries(
Object.entries(item.profitShareReceivers as Record<string, unknown>)
.map(([reference, account]) => {
if (typeof account !== 'string' || account.length === 0) {
throw new WechatPayError('WECHAT_PROFIT_RECEIVER_INVALID');
}
return [reference, account];
})
) : {}
}); });
} }
return credentials; return credentials;
+126 -2
View File
@@ -11,6 +11,9 @@ import {
} from '../payments/payment-repository.js'; } from '../payments/payment-repository.js';
import type { WechatPaymentService } from '../payments/wechat-payment-service.js'; import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js'; import { WechatPayError } from '../payments/wechat-pay-client.js';
import {
ProfitSharingError, type ProfitSharingService
} from '../payments/profit-sharing-service.js';
const createSchema = z.object({ const createSchema = z.object({
orderId: z.string().regex(/^[1-9]\d{0,19}$/), 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}$/), billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
billType: z.enum(['ALL', 'SUCCESS', 'REFUND']) billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
}).strict(); }).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 { export interface PaymentRouteOptions {
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>; repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>; accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
wechat?: WechatPaymentService; wechat?: WechatPaymentService;
profitSharing?: ProfitSharingService;
jwtSecret: string; jwtSecret: string;
testAdapterEnabled: boolean; 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( async function authenticate(
@@ -208,14 +330,16 @@ async function authenticateAdmin(
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage') if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) return null; && !access.roles.includes('PLATFORM_ADMIN')) return null;
return auth; return { ...auth, access };
} }
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) { async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { try {
return await work(); return await work();
} catch (error) { } 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' const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409 ? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400; : error.code.includes('FORBIDDEN') ? 403 : 400;
+9 -1
View File
@@ -21,6 +21,7 @@ import {
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
} from './payments/wechat-pay-client.js'; } from './payments/wechat-pay-client.js';
import { WechatPaymentService } from './payments/wechat-payment-service.js'; import { WechatPaymentService } from './payments/wechat-payment-service.js';
import { ProfitSharingService } from './payments/profit-sharing-service.js';
import { import {
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
} from './third-party/third-party-client.js'; } from './third-party/third-party-client.js';
@@ -33,6 +34,7 @@ const accessControl = new RbacRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool); const orderManagementRepository = new OrderManagementRepository(pool);
const paymentRepository = new PaymentRepository(pool); const paymentRepository = new PaymentRepository(pool);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson); const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
const app = await buildApp({ const app = await buildApp({
config, config,
@@ -104,9 +106,15 @@ const app = await buildApp({
wechat: new WechatPaymentService( wechat: new WechatPaymentService(
pool, pool,
paymentRepository, paymentRepository,
new WechatPayClient(new FetchWechatPayTransport()), wechatPayClient,
wechatCredentials wechatCredentials
), ),
profitSharing: new ProfitSharingService(
pool,
wechatPayClient,
wechatCredentials,
config.payment.profitShareMockEnabled
),
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret, jwtSecret: config.auth.jwtSecret,
+19 -1
View File
@@ -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 thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql');
const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql'); const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql');
const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.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 = [ const coreTables = [
'qipai_schema_migrations', '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_group_redemption_voucher/);
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/); assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/);
assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i); 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.');
+2 -1
View File
@@ -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, /2026062014_m04d_order_shares\.up\.sql/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -31,6 +31,8 @@ import {
import { import {
PaymentError, PaymentRepository PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js'; } 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 { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js'; import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import { import {
@@ -44,6 +46,7 @@ const expectedTables = [
'qipai_async_tasks', 'qipai_async_tasks',
'qipai_audit_logs', 'qipai_audit_logs',
'qipai_auth_sessions', 'qipai_auth_sessions',
'qipai_collection_accounts',
'qipai_devices', 'qipai_devices',
'qipai_direct_bookings', 'qipai_direct_bookings',
'qipai_group_redemptions', 'qipai_group_redemptions',
@@ -65,6 +68,8 @@ const expectedTables = [
'qipai_payments', 'qipai_payments',
'qipai_permissions', 'qipai_permissions',
'qipai_platform_apps', 'qipai_platform_apps',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares', 'qipai_profit_shares',
'qipai_reconciliation_runs', 'qipai_reconciliation_runs',
'qipai_refunds', 'qipai_refunds',
@@ -110,12 +115,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217'] '2026062015', '2026062216', '2026062217', '2026062218']
); );
return rows; return rows;
} }
@@ -1340,6 +1345,175 @@ async function assertThirdPartyDomain(pool, context) {
assert.equal(unmapped.status, 'PENDING_MAPPING'); 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) { async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query( const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u `SELECT u.id FROM qipai_users u
@@ -1444,7 +1618,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' }, { version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }, { version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1460,13 +1635,14 @@ try {
await assertOrderShares(pool, loginContext); await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext); await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext); await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(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.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -1488,7 +1664,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' }, { version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }, { version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -1576,6 +1753,11 @@ try {
'third-party booking webhook idempotency', 'third-party booking webhook idempotency',
'mapped booking claim creates a paid order', 'mapped booking claim creates a paid order',
'unmapped booking enters manual queue' '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)); }, null, 2));
} finally { } finally {
+132
View File
@@ -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.');
@@ -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;
@@ -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');
@@ -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';
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 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 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."