feat(M05-D): 完成收款配置与幂等分账
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
|
||||
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
QIPAI_JWT_SECRET: 'production-profit-share-secret-long-enough',
|
||||
QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true'
|
||||
}).payment.profitShareMockEnabled, false);
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true'
|
||||
}).payment.profitShareMockEnabled, true);
|
||||
|
||||
const keys = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
||||
});
|
||||
let apiRequest;
|
||||
const client = new WechatPayClient({
|
||||
async request(input) {
|
||||
apiRequest = input;
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ order_id: 'wx-share-order', state: 'FINISHED' })
|
||||
};
|
||||
}
|
||||
});
|
||||
const shareResult = await client.createProfitSharing({
|
||||
appId: 'wx-test',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'serial-test',
|
||||
privateKeyPem: keys.privateKey,
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: {},
|
||||
profitShareReceivers: {}
|
||||
}, {
|
||||
transactionId: 'wx-transaction-001',
|
||||
outOrderNo: 'share-request-001',
|
||||
receivers: [{
|
||||
type: 'MERCHANT_ID',
|
||||
account: 'receiver-account-private',
|
||||
amountCents: 1200,
|
||||
description: 'sanitized share'
|
||||
}],
|
||||
finish: true
|
||||
});
|
||||
assert.equal(shareResult.state, 'FINISHED');
|
||||
assert.match(apiRequest.url, /\/v3\/profitsharing\/orders$/);
|
||||
assert.match(apiRequest.body, /"amount":1200/);
|
||||
assert.equal(apiRequest.headers.Authorization.includes(keys.privateKey), false);
|
||||
|
||||
const secret = 'profit-sharing-route-secret-32-characters';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let executeInput;
|
||||
const app = await buildApp({
|
||||
payment: {
|
||||
jwtSecret: secret,
|
||||
testAdapterEnabled: false,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'ADMIN', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile() {
|
||||
return {
|
||||
roles: ['TENANT_ADMIN'],
|
||||
capabilities: ['tenant.manage'],
|
||||
storeIds: []
|
||||
};
|
||||
}
|
||||
},
|
||||
repository: {
|
||||
async createPayment() { return {}; },
|
||||
async processTestCallback() { return {}; }
|
||||
},
|
||||
profitSharing: {
|
||||
async saveCollectionAccount() {
|
||||
return { accountId: '41', created: true };
|
||||
},
|
||||
async saveReceiver() {
|
||||
return { receiverId: '42', created: true };
|
||||
},
|
||||
async savePolicy() {
|
||||
return { saved: true };
|
||||
},
|
||||
async execute(input) {
|
||||
executeInput = input;
|
||||
return {
|
||||
shares: [{ shareId: '43', amountCents: 1200, status: 'SUCCEEDED' }],
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async list() {
|
||||
return { accounts: [], shares: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const executed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/pay/profit-shares',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
paymentId: '31',
|
||||
clientRequestId: 'share-request-001',
|
||||
mode: 'MOCK'
|
||||
}
|
||||
});
|
||||
assert.equal(executed.statusCode, 200);
|
||||
assert.equal(executeInput.tenantId, '7');
|
||||
assert.equal(executeInput.access.capabilities[0], 'tenant.manage');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M05-D Wechat profit-sharing request, production mock gate and admin routes.');
|
||||
@@ -0,0 +1,16 @@
|
||||
DELETE FROM qipai_schema_migrations WHERE version = '2026062218';
|
||||
ALTER TABLE qipai_profit_shares
|
||||
DROP INDEX uq_qipai_profit_share_payment_receiver,
|
||||
DROP INDEX uq_qipai_profit_share_request,
|
||||
DROP FOREIGN KEY fk_qipai_profit_share_receiver,
|
||||
DROP FOREIGN KEY fk_qipai_profit_share_account,
|
||||
DROP COLUMN raw_response,
|
||||
DROP COLUMN failure_code,
|
||||
DROP COLUMN percentage_bps,
|
||||
DROP COLUMN batch_request_id,
|
||||
DROP COLUMN client_request_id,
|
||||
DROP COLUMN receiver_id,
|
||||
DROP COLUMN collection_account_id;
|
||||
DROP TABLE IF EXISTS qipai_profit_share_policies;
|
||||
DROP TABLE IF EXISTS qipai_profit_share_receivers;
|
||||
DROP TABLE IF EXISTS qipai_collection_accounts;
|
||||
@@ -0,0 +1,99 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_collection_accounts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
platform_app_id BIGINT UNSIGNED NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT',
|
||||
scope_key VARCHAR(255) NOT NULL,
|
||||
merchant_id VARCHAR(64) NOT NULL,
|
||||
credential_ref VARCHAR(255) NOT NULL,
|
||||
authorization_status VARCHAR(32) NOT NULL DEFAULT 'UNAUTHORIZED',
|
||||
profit_sharing_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_collection_account_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_collection_account_app
|
||||
FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id),
|
||||
CONSTRAINT fk_qipai_collection_account_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
UNIQUE KEY uq_qipai_collection_account_scope
|
||||
(tenant_id, provider, scope_key),
|
||||
KEY idx_qipai_collection_account_resolution
|
||||
(tenant_id, provider, enabled, store_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_profit_share_receivers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
collection_account_id BIGINT UNSIGNED NOT NULL,
|
||||
receiver_type VARCHAR(32) NOT NULL,
|
||||
receiver_hash CHAR(64) NOT NULL,
|
||||
receiver_masked VARCHAR(128) NOT NULL,
|
||||
receiver_credential_ref VARCHAR(255) NOT NULL,
|
||||
relation_type VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
authorization_status VARCHAR(32) NOT NULL DEFAULT 'UNAUTHORIZED',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_profit_receiver_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_profit_receiver_account
|
||||
FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id),
|
||||
UNIQUE KEY uq_qipai_profit_receiver_hash
|
||||
(tenant_id, collection_account_id, receiver_hash),
|
||||
KEY idx_qipai_profit_receiver_enabled
|
||||
(tenant_id, collection_account_id, enabled, authorization_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_profit_share_policies (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
collection_account_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
receiver_id BIGINT UNSIGNED NOT NULL,
|
||||
scope_key VARCHAR(255) NOT NULL,
|
||||
percentage_bps SMALLINT UNSIGNED NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_profit_policy_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_profit_policy_account
|
||||
FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id),
|
||||
CONSTRAINT fk_qipai_profit_policy_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_profit_policy_receiver
|
||||
FOREIGN KEY (receiver_id) REFERENCES qipai_profit_share_receivers(id),
|
||||
UNIQUE KEY uq_qipai_profit_policy_receiver
|
||||
(tenant_id, collection_account_id, scope_key, receiver_id),
|
||||
KEY idx_qipai_profit_policy_resolution
|
||||
(tenant_id, collection_account_id, store_id, enabled),
|
||||
CONSTRAINT chk_qipai_profit_policy_bps
|
||||
CHECK (percentage_bps > 0 AND percentage_bps <= 10000)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE qipai_profit_shares
|
||||
ADD COLUMN collection_account_id BIGINT UNSIGNED NULL AFTER order_id,
|
||||
ADD COLUMN receiver_id BIGINT UNSIGNED NULL AFTER collection_account_id,
|
||||
ADD COLUMN client_request_id VARCHAR(128) NULL AFTER share_no,
|
||||
ADD COLUMN batch_request_id VARCHAR(128) NULL AFTER client_request_id,
|
||||
ADD COLUMN percentage_bps SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER receiver_ref,
|
||||
ADD COLUMN failure_code VARCHAR(64) NOT NULL DEFAULT '' AFTER status,
|
||||
ADD COLUMN raw_response JSON NULL AFTER provider_share_id,
|
||||
ADD CONSTRAINT fk_qipai_profit_share_account
|
||||
FOREIGN KEY (collection_account_id) REFERENCES qipai_collection_accounts(id),
|
||||
ADD CONSTRAINT fk_qipai_profit_share_receiver
|
||||
FOREIGN KEY (receiver_id) REFERENCES qipai_profit_share_receivers(id),
|
||||
ADD UNIQUE KEY uq_qipai_profit_share_request (tenant_id, client_request_id),
|
||||
ADD KEY idx_qipai_profit_share_batch (tenant_id, batch_request_id),
|
||||
ADD UNIQUE KEY uq_qipai_profit_share_payment_receiver
|
||||
(tenant_id, payment_id, receiver_id);
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062218', 'm05d_profit_sharing');
|
||||
@@ -0,0 +1,29 @@
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name IN (
|
||||
'qipai_collection_accounts', 'qipai_profit_share_receivers',
|
||||
'qipai_profit_share_policies'
|
||||
) ORDER BY table_name;
|
||||
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'qipai_profit_shares'
|
||||
AND column_name IN (
|
||||
'collection_account_id', 'receiver_id', 'client_request_id', 'batch_request_id',
|
||||
'percentage_bps', 'failure_code', 'raw_response'
|
||||
) ORDER BY column_name;
|
||||
|
||||
SELECT table_name, index_name FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'qipai_collection_accounts'
|
||||
AND index_name = 'uq_qipai_collection_account_scope')
|
||||
OR (table_name = 'qipai_profit_share_receivers'
|
||||
AND index_name = 'uq_qipai_profit_receiver_hash')
|
||||
OR (table_name = 'qipai_profit_share_policies'
|
||||
AND index_name = 'uq_qipai_profit_policy_receiver')
|
||||
OR (table_name = 'qipai_profit_shares'
|
||||
AND index_name IN (
|
||||
'uq_qipai_profit_share_request',
|
||||
'uq_qipai_profit_share_payment_receiver'
|
||||
)))
|
||||
GROUP BY table_name, index_name ORDER BY table_name, index_name;
|
||||
|
||||
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062218';
|
||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M05-C migration roundtrip in a temporary database."
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M05-D migration roundtrip in a temporary database."
|
||||
npm --prefix backend run test:mysql:migration
|
||||
echo "PASS: M01-B through M05-C live MySQL migration roundtrip completed."
|
||||
echo "PASS: M01-B through M05-D live MySQL migration roundtrip completed."
|
||||
|
||||
Reference in New Issue
Block a user