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_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=
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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
+7 -3
View File
@@ -37,7 +37,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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<MigrationDirection, readonly string[]> = {
'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(
@@ -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;
apiV3Key: string;
platformCertificates: Record<string, string>;
profitShareReceivers?: Record<string, string>;
}
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<string, unknown>)
.map(([reference, account]) => {
if (typeof account !== 'string' || account.length === 0) {
throw new WechatPayError('WECHAT_PROFIT_RECEIVER_INVALID');
}
return [reference, account];
})
) : {}
});
}
return credentials;
+126 -2
View File
@@ -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<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
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<unknown>) {
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;
+9 -1
View File
@@ -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,
+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 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.');
+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, /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);
@@ -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 {
+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.');