feat(M05-D): 完成收款配置与幂等分账
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user