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
+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,