feat(M05-D): 完成收款配置与幂等分账
This commit is contained in:
@@ -14,6 +14,7 @@ QIPAI_SESSION_TTL_SECONDS=604800
|
|||||||
QIPAI_WECHAT_APP_SECRETS={}
|
QIPAI_WECHAT_APP_SECRETS={}
|
||||||
QIPAI_TEST_PAYMENT_ENABLED=false
|
QIPAI_TEST_PAYMENT_ENABLED=false
|
||||||
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
||||||
|
QIPAI_PROFIT_SHARE_MOCK_ENABLED=false
|
||||||
QIPAI_THIRD_PARTY_CREDENTIALS={}
|
QIPAI_THIRD_PARTY_CREDENTIALS={}
|
||||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||||
QIPAI_MQTT_USERNAME=
|
QIPAI_MQTT_USERNAME=
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const configSchema = z.object({
|
|||||||
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
||||||
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||||
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
||||||
|
QIPAI_PROFIT_SHARE_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||||
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
|
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
|
||||||
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
||||||
QIPAI_MQTT_USERNAME: z.string().default(''),
|
QIPAI_MQTT_USERNAME: z.string().default(''),
|
||||||
@@ -59,7 +60,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
|||||||
payment: {
|
payment: {
|
||||||
testAdapterEnabled: parsed.NODE_ENV !== 'production'
|
testAdapterEnabled: parsed.NODE_ENV !== 'production'
|
||||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||||
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
|
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS,
|
||||||
|
profitShareMockEnabled: parsed.NODE_ENV !== 'production'
|
||||||
|
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true'
|
||||||
},
|
},
|
||||||
thirdParty: {
|
thirdParty: {
|
||||||
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062014_m04d_order_shares.up.sql',
|
'database/migrations/2026062014_m04d_order_shares.up.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.up.sql',
|
'database/migrations/2026062015_m05a_payment_domain.up.sql',
|
||||||
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
|
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
|
||||||
'database/migrations/2026062217_m05c_third_party.up.sql'
|
'database/migrations/2026062217_m05c_third_party.up.sql',
|
||||||
|
'database/migrations/2026062218_m05d_profit_sharing.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -56,9 +57,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062014_m04d_order_shares.verify.sql',
|
'database/migrations/2026062014_m04d_order_shares.verify.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
|
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
|
||||||
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
|
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
|
||||||
'database/migrations/2026062217_m05c_third_party.verify.sql'
|
'database/migrations/2026062217_m05c_third_party.verify.sql',
|
||||||
|
'database/migrations/2026062218_m05d_profit_sharing.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
|
||||||
'database/migrations/2026062217_m05c_third_party.down.sql',
|
'database/migrations/2026062217_m05c_third_party.down.sql',
|
||||||
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
||||||
@@ -208,7 +211,8 @@ export async function executeMigrationPlan(
|
|||||||
1, 8, 3, 1,
|
1, 8, 3, 1,
|
||||||
5, 8, 4, 1,
|
5, 8, 4, 1,
|
||||||
1, 5, 3, 1,
|
1, 5, 3, 1,
|
||||||
5, 6, 1
|
5, 6, 1,
|
||||||
|
3, 7, 5, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import {
|
||||||
|
WechatPayClient, WechatPayError, type WechatPayCredential
|
||||||
|
} from './wechat-pay-client.js';
|
||||||
|
|
||||||
|
interface PaymentRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
storeId: string;
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
providerPaymentId: string | null;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
interface AccountRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
storeId: string | null;
|
||||||
|
merchantId: string;
|
||||||
|
credentialRef: string;
|
||||||
|
authorizationStatus: string;
|
||||||
|
profitSharingEnabled: number;
|
||||||
|
}
|
||||||
|
interface PolicyRow extends RowDataPacket {
|
||||||
|
receiverId: string;
|
||||||
|
receiverType: string;
|
||||||
|
receiverMasked: string;
|
||||||
|
receiverCredentialRef: string;
|
||||||
|
receiverAuthorizationStatus: string;
|
||||||
|
percentageBps: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
interface ShareRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
shareNo: string;
|
||||||
|
status: string;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProfitSharingError extends Error {
|
||||||
|
constructor(public readonly code: string, message = code) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProfitSharingService {
|
||||||
|
constructor(
|
||||||
|
private readonly pool: MySqlPool,
|
||||||
|
private readonly client: WechatPayClient,
|
||||||
|
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||||
|
private readonly mockEnabled: boolean
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async saveCollectionAccount(input: {
|
||||||
|
tenantId: string;
|
||||||
|
platformAppId: string;
|
||||||
|
actorId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
storeId: string | null;
|
||||||
|
merchantId: string;
|
||||||
|
credentialRef: string;
|
||||||
|
authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
|
||||||
|
profitSharingEnabled: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
assertTenantManager(input.access);
|
||||||
|
if (!/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) {
|
||||||
|
throw new ProfitSharingError('COLLECTION_CREDENTIAL_REF_INVALID');
|
||||||
|
}
|
||||||
|
if (input.profitSharingEnabled && input.authorizationStatus !== 'AUTHORIZED') {
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED');
|
||||||
|
}
|
||||||
|
await this.assertStore(input.tenantId, input.storeId);
|
||||||
|
const scopeKey = `app:${input.platformAppId}:store:${input.storeId ?? 'ALL'}`;
|
||||||
|
const [existing] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_collection_accounts
|
||||||
|
WHERE tenant_id = ? AND provider = 'WECHAT' AND scope_key = ? LIMIT 1`,
|
||||||
|
[input.tenantId, scopeKey]
|
||||||
|
);
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_collection_accounts
|
||||||
|
SET merchant_id = ?, credential_ref = ?, authorization_status = ?,
|
||||||
|
profit_sharing_enabled = ?, enabled = ?
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[input.merchantId, input.credentialRef, input.authorizationStatus,
|
||||||
|
input.profitSharingEnabled, input.enabled, input.tenantId, existing[0].id]
|
||||||
|
);
|
||||||
|
return { accountId: String(existing[0].id), created: false };
|
||||||
|
}
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_collection_accounts
|
||||||
|
(tenant_id, platform_app_id, store_id, provider, merchant_id,
|
||||||
|
scope_key, credential_ref, authorization_status,
|
||||||
|
profit_sharing_enabled, enabled)
|
||||||
|
VALUES (?, ?, ?, 'WECHAT', ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[input.tenantId, input.platformAppId, input.storeId, input.merchantId,
|
||||||
|
scopeKey, input.credentialRef, input.authorizationStatus,
|
||||||
|
input.profitSharingEnabled, input.enabled]
|
||||||
|
);
|
||||||
|
return { accountId: String(result.insertId), created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveReceiver(input: {
|
||||||
|
tenantId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
collectionAccountId: string;
|
||||||
|
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
|
||||||
|
receiverAccount: string;
|
||||||
|
receiverCredentialRef: string;
|
||||||
|
relationType: string;
|
||||||
|
name: string;
|
||||||
|
authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
assertTenantManager(input.access);
|
||||||
|
if (!/^receiver:[A-Z0-9_:-]+$/.test(input.receiverCredentialRef)) {
|
||||||
|
throw new ProfitSharingError('PROFIT_RECEIVER_REF_INVALID');
|
||||||
|
}
|
||||||
|
const account = await this.loadAccount(
|
||||||
|
input.tenantId, input.collectionAccountId, false
|
||||||
|
);
|
||||||
|
if (!account) throw new ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND');
|
||||||
|
const hash = hashValue(input.receiverAccount);
|
||||||
|
const [existing] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_profit_share_receivers
|
||||||
|
WHERE tenant_id = ? AND collection_account_id = ? AND receiver_hash = ? LIMIT 1`,
|
||||||
|
[input.tenantId, input.collectionAccountId, hash]
|
||||||
|
);
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_profit_share_receivers
|
||||||
|
SET receiver_type = ?, receiver_masked = ?, receiver_credential_ref = ?,
|
||||||
|
relation_type = ?, name = ?, authorization_status = ?, enabled = ?
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[input.receiverType, maskValue(input.receiverAccount),
|
||||||
|
input.receiverCredentialRef, input.relationType, input.name,
|
||||||
|
input.authorizationStatus, input.enabled, input.tenantId, existing[0].id]
|
||||||
|
);
|
||||||
|
return { receiverId: String(existing[0].id), created: false };
|
||||||
|
}
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_profit_share_receivers
|
||||||
|
(tenant_id, collection_account_id, receiver_type, receiver_hash,
|
||||||
|
receiver_masked, receiver_credential_ref, relation_type, name,
|
||||||
|
enabled, authorization_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[input.tenantId, input.collectionAccountId, input.receiverType, hash,
|
||||||
|
maskValue(input.receiverAccount), input.receiverCredentialRef,
|
||||||
|
input.relationType, input.name, input.enabled, input.authorizationStatus]
|
||||||
|
);
|
||||||
|
return { receiverId: String(result.insertId), created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePolicy(input: {
|
||||||
|
tenantId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
collectionAccountId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
receiverId: string;
|
||||||
|
percentageBps: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
assertTenantManager(input.access);
|
||||||
|
if (input.percentageBps <= 0 || input.percentageBps > 10000) {
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARE_PERCENTAGE_INVALID');
|
||||||
|
}
|
||||||
|
await this.assertStore(input.tenantId, input.storeId);
|
||||||
|
const [receiverRows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT 1 FROM qipai_profit_share_receivers
|
||||||
|
WHERE tenant_id = ? AND id = ? AND collection_account_id = ?`,
|
||||||
|
[input.tenantId, input.receiverId, input.collectionAccountId]
|
||||||
|
);
|
||||||
|
if (!receiverRows[0]) throw new ProfitSharingError('PROFIT_RECEIVER_NOT_FOUND');
|
||||||
|
const [sumRows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT COALESCE(SUM(percentage_bps), 0) AS totalBps
|
||||||
|
FROM qipai_profit_share_policies
|
||||||
|
WHERE tenant_id = ? AND collection_account_id = ?
|
||||||
|
AND store_id <=> ? AND receiver_id <> ? AND enabled = 1`,
|
||||||
|
[input.tenantId, input.collectionAccountId, input.storeId, input.receiverId]
|
||||||
|
);
|
||||||
|
if (Number(sumRows[0].totalBps) + (input.enabled ? input.percentageBps : 0) > 10000) {
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARE_TOTAL_EXCEEDED');
|
||||||
|
}
|
||||||
|
const scopeKey = input.storeId ? `store:${input.storeId}` : 'store:ALL';
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT INTO qipai_profit_share_policies
|
||||||
|
(tenant_id, collection_account_id, store_id, receiver_id, scope_key,
|
||||||
|
percentage_bps, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE percentage_bps = VALUES(percentage_bps),
|
||||||
|
enabled = VALUES(enabled)`,
|
||||||
|
[input.tenantId, input.collectionAccountId, input.storeId,
|
||||||
|
input.receiverId, scopeKey, input.percentageBps, input.enabled]
|
||||||
|
);
|
||||||
|
return { saved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(input: {
|
||||||
|
tenantId: string;
|
||||||
|
actorId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
paymentId: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
mode: 'API' | 'MOCK';
|
||||||
|
}) {
|
||||||
|
assertTenantManager(input.access);
|
||||||
|
const [existing] = await this.pool.execute<ShareRow[]>(
|
||||||
|
`SELECT id, share_no AS shareNo, status, amount_cents AS amountCents
|
||||||
|
FROM qipai_profit_shares
|
||||||
|
WHERE tenant_id = ? AND batch_request_id = ? ORDER BY id`,
|
||||||
|
[input.tenantId, input.clientRequestId]
|
||||||
|
);
|
||||||
|
if (existing[0]) return { shares: existing.map(normalizeShare), idempotent: true };
|
||||||
|
const payment = await this.loadPayment(input.tenantId, input.paymentId);
|
||||||
|
if (payment.status !== 'SUCCEEDED' || payment.provider !== 'WECHAT'
|
||||||
|
|| !payment.providerPaymentId) {
|
||||||
|
throw new ProfitSharingError('PAYMENT_NOT_SHAREABLE');
|
||||||
|
}
|
||||||
|
const account = await this.resolveAccount(input.tenantId, payment.storeId);
|
||||||
|
if (!account.profitSharingEnabled || account.authorizationStatus !== 'AUTHORIZED') {
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED');
|
||||||
|
}
|
||||||
|
const policies = await this.loadPolicies(
|
||||||
|
input.tenantId, account.id, payment.storeId
|
||||||
|
);
|
||||||
|
if (policies.length === 0) throw new ProfitSharingError('PROFIT_SHARE_POLICY_NOT_FOUND');
|
||||||
|
if (policies.some((policy) => policy.receiverAuthorizationStatus !== 'AUTHORIZED')) {
|
||||||
|
throw new ProfitSharingError('PROFIT_RECEIVER_NOT_AUTHORIZED');
|
||||||
|
}
|
||||||
|
if (input.mode === 'MOCK' && !this.mockEnabled) {
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARE_MOCK_DISABLED');
|
||||||
|
}
|
||||||
|
const shares = policies.map((policy, index) => ({
|
||||||
|
policy,
|
||||||
|
shareNo: `SHR${Date.now()}${index}${randomBytes(3).toString('hex').toUpperCase()}`,
|
||||||
|
amountCents: Math.floor(
|
||||||
|
Number(payment.amountCents) * Number(policy.percentageBps) / 10000
|
||||||
|
)
|
||||||
|
})).filter((share) => share.amountCents > 0);
|
||||||
|
if (shares.length === 0) throw new ProfitSharingError('PROFIT_SHARE_AMOUNT_ZERO');
|
||||||
|
const credential = this.resolveCredential(account.credentialRef);
|
||||||
|
let response: Record<string, unknown>;
|
||||||
|
let status: string;
|
||||||
|
if (input.mode === 'MOCK') {
|
||||||
|
response = { adapter: 'mock', state: 'FINISHED' };
|
||||||
|
status = 'SUCCEEDED';
|
||||||
|
} else {
|
||||||
|
if (!credential) throw new ProfitSharingError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||||
|
if (credential.merchantId !== account.merchantId) {
|
||||||
|
throw new ProfitSharingError('COLLECTION_MERCHANT_MISMATCH');
|
||||||
|
}
|
||||||
|
const receivers = shares.map((share) => {
|
||||||
|
const receiver = credential.profitShareReceivers?.[
|
||||||
|
share.policy.receiverCredentialRef
|
||||||
|
];
|
||||||
|
if (!receiver) throw new ProfitSharingError('PROFIT_RECEIVER_CREDENTIAL_MISSING');
|
||||||
|
return {
|
||||||
|
type: share.policy.receiverType,
|
||||||
|
account: receiver,
|
||||||
|
amountCents: share.amountCents,
|
||||||
|
description: `订单分账-${share.policy.name}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
response = await this.client.createProfitSharing(credential, {
|
||||||
|
transactionId: payment.providerPaymentId,
|
||||||
|
outOrderNo: input.clientRequestId,
|
||||||
|
receivers,
|
||||||
|
finish: true
|
||||||
|
});
|
||||||
|
status = mapShareState(response.state);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordShares(input, payment, account, shares, 'MANUAL_REVIEW', {
|
||||||
|
errorCode: error instanceof WechatPayError ? error.code : 'PROFIT_SHARE_API_FAILED'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recorded = await this.recordShares(
|
||||||
|
input, payment, account, shares, status, response
|
||||||
|
);
|
||||||
|
return { shares: recorded, idempotent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(input: {
|
||||||
|
tenantId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
storeId?: string;
|
||||||
|
}) {
|
||||||
|
assertTenantManager(input.access);
|
||||||
|
const [accounts] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
|
||||||
|
provider, merchant_id AS merchantId,
|
||||||
|
authorization_status AS authorizationStatus,
|
||||||
|
profit_sharing_enabled AS profitSharingEnabled, enabled
|
||||||
|
FROM qipai_collection_accounts
|
||||||
|
WHERE tenant_id = ? ${input.storeId ? 'AND store_id = ?' : ''}
|
||||||
|
ORDER BY id`,
|
||||||
|
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
|
||||||
|
);
|
||||||
|
const [shares] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT ps.id, ps.payment_id AS paymentId, ps.order_id AS orderId,
|
||||||
|
p.store_id AS storeId, ps.share_no AS shareNo,
|
||||||
|
ps.receiver_type AS receiverType, ps.receiver_ref AS receiverMasked,
|
||||||
|
ps.percentage_bps AS percentageBps, ps.amount_cents AS amountCents,
|
||||||
|
ps.status, ps.failure_code AS failureCode, ps.created_at AS createdAt
|
||||||
|
FROM qipai_profit_shares ps
|
||||||
|
INNER JOIN qipai_payments p
|
||||||
|
ON p.tenant_id = ps.tenant_id AND p.id = ps.payment_id
|
||||||
|
WHERE ps.tenant_id = ? ${input.storeId ? 'AND p.store_id = ?' : ''}
|
||||||
|
ORDER BY ps.id DESC LIMIT 100`,
|
||||||
|
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
|
||||||
|
);
|
||||||
|
const [receivers] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT r.id, r.collection_account_id AS collectionAccountId,
|
||||||
|
r.receiver_type AS receiverType, r.receiver_masked AS receiverMasked,
|
||||||
|
r.relation_type AS relationType, r.name,
|
||||||
|
r.authorization_status AS authorizationStatus, r.enabled
|
||||||
|
FROM qipai_profit_share_receivers r
|
||||||
|
INNER JOIN qipai_collection_accounts a
|
||||||
|
ON a.tenant_id = r.tenant_id AND a.id = r.collection_account_id
|
||||||
|
WHERE r.tenant_id = ? ${input.storeId ? 'AND a.store_id = ?' : ''}
|
||||||
|
ORDER BY r.id`,
|
||||||
|
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
|
||||||
|
);
|
||||||
|
const [policies] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT p.id, p.collection_account_id AS collectionAccountId,
|
||||||
|
p.store_id AS storeId, p.receiver_id AS receiverId,
|
||||||
|
p.percentage_bps AS percentageBps, p.enabled
|
||||||
|
FROM qipai_profit_share_policies p
|
||||||
|
INNER JOIN qipai_collection_accounts a
|
||||||
|
ON a.tenant_id = p.tenant_id AND a.id = p.collection_account_id
|
||||||
|
WHERE p.tenant_id = ? ${input.storeId ? 'AND (p.store_id = ? OR p.store_id IS NULL)' : ''}
|
||||||
|
ORDER BY p.id`,
|
||||||
|
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
|
||||||
|
);
|
||||||
|
return { accounts, receivers, policies, shares };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordShares(
|
||||||
|
input: {
|
||||||
|
tenantId: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
},
|
||||||
|
payment: PaymentRow,
|
||||||
|
account: AccountRow,
|
||||||
|
shares: Array<{
|
||||||
|
policy: PolicyRow;
|
||||||
|
shareNo: string;
|
||||||
|
amountCents: number;
|
||||||
|
}>,
|
||||||
|
status: string,
|
||||||
|
response: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const result = [];
|
||||||
|
for (const share of shares) {
|
||||||
|
const [insert] = await connection.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_profit_shares
|
||||||
|
(tenant_id, payment_id, order_id, collection_account_id, receiver_id,
|
||||||
|
share_no, client_request_id, batch_request_id,
|
||||||
|
receiver_type, receiver_ref, percentage_bps,
|
||||||
|
amount_cents, status, failure_code,
|
||||||
|
provider_share_id, raw_response, completed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON),
|
||||||
|
IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))`,
|
||||||
|
[input.tenantId, payment.id, payment.orderId, account.id,
|
||||||
|
share.policy.receiverId, share.shareNo,
|
||||||
|
`${input.clientRequestId}:${share.policy.receiverId}`,
|
||||||
|
input.clientRequestId,
|
||||||
|
share.policy.receiverType, share.policy.receiverMasked,
|
||||||
|
share.policy.percentageBps, share.amountCents, status,
|
||||||
|
status === 'MANUAL_REVIEW' ? stringValue(response.errorCode) : '',
|
||||||
|
stringValue(response.order_id), JSON.stringify(sanitizeResponse(response)), status]
|
||||||
|
);
|
||||||
|
result.push({
|
||||||
|
shareId: String(insert.insertId),
|
||||||
|
shareNo: share.shareNo,
|
||||||
|
amountCents: share.amountCents,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPayment(tenantId: string, paymentId: string) {
|
||||||
|
const [rows] = await this.pool.execute<PaymentRow[]>(
|
||||||
|
`SELECT id, order_id AS orderId, store_id AS storeId, status, provider,
|
||||||
|
provider_payment_id AS providerPaymentId, amount_cents AS amountCents
|
||||||
|
FROM qipai_payments
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[tenantId, paymentId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new ProfitSharingError('PAYMENT_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAccount(tenantId: string, storeId: string) {
|
||||||
|
const [rows] = await this.pool.execute<AccountRow[]>(
|
||||||
|
`SELECT id, store_id AS storeId, merchant_id AS merchantId,
|
||||||
|
credential_ref AS credentialRef,
|
||||||
|
authorization_status AS authorizationStatus,
|
||||||
|
profit_sharing_enabled AS profitSharingEnabled
|
||||||
|
FROM qipai_collection_accounts
|
||||||
|
WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1
|
||||||
|
AND (store_id IS NULL OR store_id = ?)
|
||||||
|
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
|
||||||
|
[tenantId, storeId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAccount(tenantId: string, accountId: string, enabledOnly: boolean) {
|
||||||
|
const [rows] = await this.pool.execute<AccountRow[]>(
|
||||||
|
`SELECT id, store_id AS storeId, merchant_id AS merchantId,
|
||||||
|
credential_ref AS credentialRef,
|
||||||
|
authorization_status AS authorizationStatus,
|
||||||
|
profit_sharing_enabled AS profitSharingEnabled
|
||||||
|
FROM qipai_collection_accounts
|
||||||
|
WHERE tenant_id = ? AND id = ? ${enabledOnly ? 'AND enabled = 1' : ''} LIMIT 1`,
|
||||||
|
[tenantId, accountId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPolicies(tenantId: string, accountId: string, storeId: string) {
|
||||||
|
const [rows] = await this.pool.execute<PolicyRow[]>(
|
||||||
|
`SELECT p.receiver_id AS receiverId, r.receiver_type AS receiverType,
|
||||||
|
r.receiver_masked AS receiverMasked,
|
||||||
|
r.receiver_credential_ref AS receiverCredentialRef,
|
||||||
|
r.authorization_status AS receiverAuthorizationStatus,
|
||||||
|
p.percentage_bps AS percentageBps, r.name
|
||||||
|
FROM qipai_profit_share_policies p
|
||||||
|
INNER JOIN qipai_profit_share_receivers r
|
||||||
|
ON r.tenant_id = p.tenant_id AND r.id = p.receiver_id
|
||||||
|
WHERE p.tenant_id = ? AND p.collection_account_id = ?
|
||||||
|
AND p.enabled = 1 AND r.enabled = 1
|
||||||
|
AND (p.store_id IS NULL OR p.store_id = ?)
|
||||||
|
AND (p.store_id = ? OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM qipai_profit_share_policies sp
|
||||||
|
WHERE sp.tenant_id = p.tenant_id
|
||||||
|
AND sp.collection_account_id = p.collection_account_id
|
||||||
|
AND sp.receiver_id = p.receiver_id
|
||||||
|
AND sp.store_id = ? AND sp.enabled = 1
|
||||||
|
))
|
||||||
|
ORDER BY (p.store_id IS NOT NULL) DESC, p.id`,
|
||||||
|
[tenantId, accountId, storeId, storeId, storeId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCredential(reference: string) {
|
||||||
|
return this.credentials.get(reference)
|
||||||
|
?? this.credentials.get(reference.replace(/^env:/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStore(tenantId: string, storeId: string | null) {
|
||||||
|
if (!storeId) return;
|
||||||
|
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT 1 FROM qipai_stores
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||||
|
[tenantId, storeId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new ProfitSharingError('STORE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
const connection = await this.pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const result = await work(connection);
|
||||||
|
await connection.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTenantManager(access: AccessProfile) {
|
||||||
|
if (access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN')) return;
|
||||||
|
throw new ProfitSharingError('PROFIT_SHARE_FORBIDDEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashValue(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(value: string) {
|
||||||
|
if (value.length <= 4) return '*'.repeat(value.length);
|
||||||
|
return `${value.slice(0, 2)}${'*'.repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShare(row: ShareRow) {
|
||||||
|
return {
|
||||||
|
shareId: String(row.id),
|
||||||
|
shareNo: row.shareNo,
|
||||||
|
amountCents: Number(row.amountCents),
|
||||||
|
status: row.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapShareState(value: unknown) {
|
||||||
|
if (value === 'FINISHED') return 'SUCCEEDED';
|
||||||
|
if (value === 'PROCESSING') return 'PROCESSING';
|
||||||
|
return 'MANUAL_REVIEW';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeResponse(value: Record<string, unknown>) {
|
||||||
|
const copy = { ...value };
|
||||||
|
delete copy.receivers;
|
||||||
|
delete copy.account;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface WechatPayCredential {
|
|||||||
privateKeyPem: string;
|
privateKeyPem: string;
|
||||||
apiV3Key: string;
|
apiV3Key: string;
|
||||||
platformCertificates: Record<string, string>;
|
platformCertificates: Record<string, string>;
|
||||||
|
profitShareReceivers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatPayTransport {
|
export interface WechatPayTransport {
|
||||||
@@ -144,6 +145,34 @@ export class WechatPayClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createProfitSharing(
|
||||||
|
credential: WechatPayCredential,
|
||||||
|
input: {
|
||||||
|
transactionId: string;
|
||||||
|
outOrderNo: string;
|
||||||
|
receivers: Array<{
|
||||||
|
type: string;
|
||||||
|
account: string;
|
||||||
|
amountCents: number;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
finish: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.apiRequest(credential, 'POST', '/v3/profitsharing/orders', {
|
||||||
|
appid: credential.appId,
|
||||||
|
transaction_id: input.transactionId,
|
||||||
|
out_order_no: input.outOrderNo,
|
||||||
|
receivers: input.receivers.map((receiver) => ({
|
||||||
|
type: receiver.type,
|
||||||
|
account: receiver.account,
|
||||||
|
amount: receiver.amountCents,
|
||||||
|
description: receiver.description.slice(0, 80)
|
||||||
|
})),
|
||||||
|
unfreeze_unsplit: input.finish
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
verifyAndDecrypt(
|
verifyAndDecrypt(
|
||||||
credential: WechatPayCredential,
|
credential: WechatPayCredential,
|
||||||
headers: WechatNotificationHeaders,
|
headers: WechatNotificationHeaders,
|
||||||
@@ -238,7 +267,19 @@ export function parseWechatPayCredentials(value: string) {
|
|||||||
}
|
}
|
||||||
return [serial, certificate];
|
return [serial, certificate];
|
||||||
})
|
})
|
||||||
)
|
),
|
||||||
|
profitShareReceivers: item.profitShareReceivers
|
||||||
|
&& typeof item.profitShareReceivers === 'object'
|
||||||
|
&& !Array.isArray(item.profitShareReceivers)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(item.profitShareReceivers as Record<string, unknown>)
|
||||||
|
.map(([reference, account]) => {
|
||||||
|
if (typeof account !== 'string' || account.length === 0) {
|
||||||
|
throw new WechatPayError('WECHAT_PROFIT_RECEIVER_INVALID');
|
||||||
|
}
|
||||||
|
return [reference, account];
|
||||||
|
})
|
||||||
|
) : {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return credentials;
|
return credentials;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
} from '../payments/payment-repository.js';
|
} from '../payments/payment-repository.js';
|
||||||
import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
|
import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
|
||||||
import { WechatPayError } from '../payments/wechat-pay-client.js';
|
import { WechatPayError } from '../payments/wechat-pay-client.js';
|
||||||
|
import {
|
||||||
|
ProfitSharingError, type ProfitSharingService
|
||||||
|
} from '../payments/profit-sharing-service.js';
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
|
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
@@ -33,12 +36,46 @@ const billSchema = z.object({
|
|||||||
billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
|
billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
|
||||||
}).strict();
|
}).strict();
|
||||||
|
const collectionAccountSchema = z.object({
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
|
||||||
|
merchantId: z.string().min(6).max(64),
|
||||||
|
credentialRef: z.string().min(5).max(255),
|
||||||
|
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
|
||||||
|
profitSharingEnabled: z.boolean(),
|
||||||
|
enabled: z.boolean().default(true)
|
||||||
|
}).strict();
|
||||||
|
const receiverSchema = z.object({
|
||||||
|
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
receiverType: z.enum(['MERCHANT_ID', 'PERSONAL_OPENID']),
|
||||||
|
receiverAccount: z.string().min(4).max(128),
|
||||||
|
receiverCredentialRef: z.string().min(10).max(255),
|
||||||
|
relationType: z.string().min(2).max(32),
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
|
||||||
|
enabled: z.boolean().default(true)
|
||||||
|
}).strict();
|
||||||
|
const policySchema = z.object({
|
||||||
|
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
|
||||||
|
receiverId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
percentageBps: z.number().int().min(1).max(10000),
|
||||||
|
enabled: z.boolean().default(true)
|
||||||
|
}).strict();
|
||||||
|
const executeShareSchema = z.object({
|
||||||
|
paymentId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
clientRequestId: z.string().min(8).max(96),
|
||||||
|
mode: z.enum(['API', 'MOCK'])
|
||||||
|
}).strict();
|
||||||
|
const shareListQuery = z.object({
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
|
});
|
||||||
|
|
||||||
export interface PaymentRouteOptions {
|
export interface PaymentRouteOptions {
|
||||||
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
|
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
|
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
|
||||||
wechat?: WechatPaymentService;
|
wechat?: WechatPaymentService;
|
||||||
|
profitSharing?: ProfitSharingService;
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
testAdapterEnabled: boolean;
|
testAdapterEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -184,6 +221,91 @@ export async function registerPaymentRoutes(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.profitSharing) {
|
||||||
|
app.put('/admin-api/pay/collection-account', async (request, reply) => {
|
||||||
|
const auth = await authenticateAdmin(request.headers.authorization, options);
|
||||||
|
const body = collectionAccountSchema.safeParse(request.body);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.profitSharing!.saveCollectionAccount({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
platformAppId: auth.platformAppId,
|
||||||
|
actorId: auth.userId,
|
||||||
|
access: auth.access,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/pay/profit-share-receiver', async (request, reply) => {
|
||||||
|
const auth = await authenticateAdmin(request.headers.authorization, options);
|
||||||
|
const body = receiverSchema.safeParse(request.body);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.profitSharing!.saveReceiver({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
access: auth.access,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/pay/profit-share-policy', async (request, reply) => {
|
||||||
|
const auth = await authenticateAdmin(request.headers.authorization, options);
|
||||||
|
const body = policySchema.safeParse(request.body);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.profitSharing!.savePolicy({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
access: auth.access,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/pay/profit-shares', async (request, reply) => {
|
||||||
|
const auth = await authenticateAdmin(request.headers.authorization, options);
|
||||||
|
const body = executeShareSchema.safeParse(request.body);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.profitSharing!.execute({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
actorId: auth.userId,
|
||||||
|
access: auth.access,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/pay/profit-shares', async (request, reply) => {
|
||||||
|
const auth = await authenticateAdmin(request.headers.authorization, options);
|
||||||
|
const query = shareListQuery.safeParse(request.query);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.profitSharing!.list({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
access: auth.access,
|
||||||
|
storeId: query.data.storeId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authenticate(
|
async function authenticate(
|
||||||
@@ -208,14 +330,16 @@ async function authenticateAdmin(
|
|||||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||||
if (!access.capabilities.includes('tenant.manage')
|
if (!access.capabilities.includes('tenant.manage')
|
||||||
&& !access.roles.includes('PLATFORM_ADMIN')) return null;
|
&& !access.roles.includes('PLATFORM_ADMIN')) return null;
|
||||||
return auth;
|
return { ...auth, access };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
try {
|
try {
|
||||||
return await work();
|
return await work();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof PaymentError) && !(error instanceof WechatPayError)) throw error;
|
if (!(error instanceof PaymentError)
|
||||||
|
&& !(error instanceof WechatPayError)
|
||||||
|
&& !(error instanceof ProfitSharingError)) throw error;
|
||||||
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
|
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
|
||||||
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
|
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
|
||||||
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
|
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
|
||||||
} from './payments/wechat-pay-client.js';
|
} from './payments/wechat-pay-client.js';
|
||||||
import { WechatPaymentService } from './payments/wechat-payment-service.js';
|
import { WechatPaymentService } from './payments/wechat-payment-service.js';
|
||||||
|
import { ProfitSharingService } from './payments/profit-sharing-service.js';
|
||||||
import {
|
import {
|
||||||
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
|
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
|
||||||
} from './third-party/third-party-client.js';
|
} from './third-party/third-party-client.js';
|
||||||
@@ -33,6 +34,7 @@ const accessControl = new RbacRepository(pool);
|
|||||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||||
const paymentRepository = new PaymentRepository(pool);
|
const paymentRepository = new PaymentRepository(pool);
|
||||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||||
|
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||||
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
@@ -104,9 +106,15 @@ const app = await buildApp({
|
|||||||
wechat: new WechatPaymentService(
|
wechat: new WechatPaymentService(
|
||||||
pool,
|
pool,
|
||||||
paymentRepository,
|
paymentRepository,
|
||||||
new WechatPayClient(new FetchWechatPayTransport()),
|
wechatPayClient,
|
||||||
wechatCredentials
|
wechatCredentials
|
||||||
),
|
),
|
||||||
|
profitSharing: new ProfitSharingService(
|
||||||
|
pool,
|
||||||
|
wechatPayClient,
|
||||||
|
wechatCredentials,
|
||||||
|
config.payment.profitShareMockEnabled
|
||||||
|
),
|
||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_r
|
|||||||
const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql');
|
const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql');
|
||||||
const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql');
|
const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql');
|
||||||
const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql');
|
const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql');
|
||||||
|
const profitSharingUpSql = read('database/migrations/2026062218_m05d_profit_sharing.up.sql');
|
||||||
|
const profitSharingDownSql = read('database/migrations/2026062218_m05d_profit_sharing.down.sql');
|
||||||
|
const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_sharing.verify.sql');
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -267,5 +270,20 @@ assert.match(thirdPartyUpSql, /voucher_hash CHAR\(64\)/);
|
|||||||
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/);
|
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/);
|
||||||
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/);
|
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/);
|
||||||
assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i);
|
assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i);
|
||||||
|
for (const table of [
|
||||||
|
'qipai_collection_accounts', 'qipai_profit_share_receivers',
|
||||||
|
'qipai_profit_share_policies'
|
||||||
|
]) {
|
||||||
|
assert.match(profitSharingUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||||
|
assert.match(profitSharingDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||||
|
assert.match(profitSharingVerifySql, new RegExp(`'${table}'`));
|
||||||
|
}
|
||||||
|
assert.match(profitSharingUpSql, /receiver_hash CHAR\(64\)/);
|
||||||
|
assert.match(profitSharingUpSql, /percentage_bps SMALLINT UNSIGNED/);
|
||||||
|
assert.match(profitSharingUpSql, /uq_qipai_profit_share_payment_receiver/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
profitSharingUpSql,
|
||||||
|
/\bprivate_key\b|\bapi_v3_key\b|\breceiver_account\b/i
|
||||||
|
);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M05-C migration contracts are present.');
|
console.log('PASS: M01-B through M05-D migration contracts are present.');
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
|
|||||||
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
|
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
|
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/);
|
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
PaymentError, PaymentRepository
|
PaymentError, PaymentRepository
|
||||||
} from '../dist/payments/payment-repository.js';
|
} from '../dist/payments/payment-repository.js';
|
||||||
|
import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js';
|
||||||
|
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
|
||||||
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
|
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
|
||||||
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +46,7 @@ const expectedTables = [
|
|||||||
'qipai_async_tasks',
|
'qipai_async_tasks',
|
||||||
'qipai_audit_logs',
|
'qipai_audit_logs',
|
||||||
'qipai_auth_sessions',
|
'qipai_auth_sessions',
|
||||||
|
'qipai_collection_accounts',
|
||||||
'qipai_devices',
|
'qipai_devices',
|
||||||
'qipai_direct_bookings',
|
'qipai_direct_bookings',
|
||||||
'qipai_group_redemptions',
|
'qipai_group_redemptions',
|
||||||
@@ -65,6 +68,8 @@ const expectedTables = [
|
|||||||
'qipai_payments',
|
'qipai_payments',
|
||||||
'qipai_permissions',
|
'qipai_permissions',
|
||||||
'qipai_platform_apps',
|
'qipai_platform_apps',
|
||||||
|
'qipai_profit_share_policies',
|
||||||
|
'qipai_profit_share_receivers',
|
||||||
'qipai_profit_shares',
|
'qipai_profit_shares',
|
||||||
'qipai_reconciliation_runs',
|
'qipai_reconciliation_runs',
|
||||||
'qipai_refunds',
|
'qipai_refunds',
|
||||||
@@ -110,12 +115,12 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217']
|
'2026062015', '2026062216', '2026062217', '2026062218']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1340,6 +1345,175 @@ async function assertThirdPartyDomain(pool, context) {
|
|||||||
assert.equal(unmapped.status, 'PENDING_MAPPING');
|
assert.equal(unmapped.status, 'PENDING_MAPPING');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertProfitSharingDomain(pool, context) {
|
||||||
|
const [adminRows] = await pool.query(
|
||||||
|
`SELECT u.id FROM qipai_users u
|
||||||
|
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||||
|
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
const [paymentRows] = await pool.query(
|
||||||
|
`SELECT p.id, p.order_id AS orderId, p.store_id AS storeId,
|
||||||
|
p.amount_cents AS amountCents
|
||||||
|
FROM qipai_payments p
|
||||||
|
WHERE p.tenant_id = ? AND p.provider = 'GROUP_BUY'
|
||||||
|
AND p.status = 'SUCCEEDED' ORDER BY p.id DESC LIMIT 1`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
const adminId = String(adminRows[0].id);
|
||||||
|
const payment = paymentRows[0];
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO qipai_payments
|
||||||
|
(tenant_id, platform_app_id, order_id, store_id, payment_no,
|
||||||
|
channel, provider, client_request_id, status, amount_cents,
|
||||||
|
provider_payment_id, paid_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'M05D-WECHAT-PAYMENT', 'WECHAT', 'WECHAT',
|
||||||
|
'm05d-wechat-payment', 'SUCCEEDED', ?, 'wx-m05d-transaction',
|
||||||
|
UTC_TIMESTAMP(3))`,
|
||||||
|
[context.tenantId, context.platformAppId, payment.orderId,
|
||||||
|
payment.storeId, payment.amountCents]
|
||||||
|
);
|
||||||
|
const [wechatPaymentRows] = await pool.query(
|
||||||
|
`SELECT id FROM qipai_payments
|
||||||
|
WHERE tenant_id = ? AND client_request_id = 'm05d-wechat-payment'`,
|
||||||
|
[context.tenantId]
|
||||||
|
);
|
||||||
|
const access = {
|
||||||
|
roles: ['TENANT_ADMIN'],
|
||||||
|
capabilities: ['tenant.manage'],
|
||||||
|
storeIds: []
|
||||||
|
};
|
||||||
|
const service = new ProfitSharingService(
|
||||||
|
pool,
|
||||||
|
new WechatPayClient({
|
||||||
|
async request() {
|
||||||
|
throw new Error('M05-D MySQL test uses the explicit mock adapter only.');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new Map(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.saveCollectionAccount({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
platformAppId: context.platformAppId,
|
||||||
|
actorId: adminId,
|
||||||
|
access,
|
||||||
|
storeId: String(payment.storeId),
|
||||||
|
merchantId: '1900000109',
|
||||||
|
credentialRef: 'env:WX_M05D',
|
||||||
|
authorizationStatus: 'PENDING',
|
||||||
|
profitSharingEnabled: true,
|
||||||
|
enabled: true
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'PROFIT_SHARING_NOT_AUTHORIZED'
|
||||||
|
);
|
||||||
|
const account = await service.saveCollectionAccount({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
platformAppId: context.platformAppId,
|
||||||
|
actorId: adminId,
|
||||||
|
access,
|
||||||
|
storeId: String(payment.storeId),
|
||||||
|
merchantId: '1900000109',
|
||||||
|
credentialRef: 'env:WX_M05D',
|
||||||
|
authorizationStatus: 'AUTHORIZED',
|
||||||
|
profitSharingEnabled: true,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
const receiverA = await service.saveReceiver({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
access,
|
||||||
|
collectionAccountId: account.accountId,
|
||||||
|
receiverType: 'MERCHANT_ID',
|
||||||
|
receiverAccount: 'receiver-private-account-a',
|
||||||
|
receiverCredentialRef: 'receiver:STORE_A',
|
||||||
|
relationType: 'PARTNER',
|
||||||
|
name: 'M05D Receiver A',
|
||||||
|
authorizationStatus: 'AUTHORIZED',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
const receiverB = await service.saveReceiver({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
access,
|
||||||
|
collectionAccountId: account.accountId,
|
||||||
|
receiverType: 'MERCHANT_ID',
|
||||||
|
receiverAccount: 'receiver-private-account-b',
|
||||||
|
receiverCredentialRef: 'receiver:STORE_B',
|
||||||
|
relationType: 'PARTNER',
|
||||||
|
name: 'M05D Receiver B',
|
||||||
|
authorizationStatus: 'AUTHORIZED',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
await service.savePolicy({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
access,
|
||||||
|
collectionAccountId: account.accountId,
|
||||||
|
storeId: String(payment.storeId),
|
||||||
|
receiverId: receiverA.receiverId,
|
||||||
|
percentageBps: 3000,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
await service.savePolicy({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
access,
|
||||||
|
collectionAccountId: account.accountId,
|
||||||
|
storeId: String(payment.storeId),
|
||||||
|
receiverId: receiverB.receiverId,
|
||||||
|
percentageBps: 2000,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.savePolicy({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
access,
|
||||||
|
collectionAccountId: account.accountId,
|
||||||
|
storeId: String(payment.storeId),
|
||||||
|
receiverId: receiverB.receiverId,
|
||||||
|
percentageBps: 8000,
|
||||||
|
enabled: true
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'PROFIT_SHARE_TOTAL_EXCEEDED'
|
||||||
|
);
|
||||||
|
const result = await service.execute({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
access,
|
||||||
|
paymentId: String(wechatPaymentRows[0].id),
|
||||||
|
clientRequestId: 'm05d-profit-share-request',
|
||||||
|
mode: 'MOCK'
|
||||||
|
});
|
||||||
|
assert.equal(result.shares.length, 2);
|
||||||
|
assert.equal(result.shares.every((share) => share.status === 'SUCCEEDED'), true);
|
||||||
|
const duplicate = await service.execute({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
actorId: adminId,
|
||||||
|
access,
|
||||||
|
paymentId: String(wechatPaymentRows[0].id),
|
||||||
|
clientRequestId: 'm05d-profit-share-request',
|
||||||
|
mode: 'MOCK'
|
||||||
|
});
|
||||||
|
assert.equal(duplicate.idempotent, true);
|
||||||
|
assert.equal(duplicate.shares.length, 2);
|
||||||
|
const [shareRows] = await pool.query(
|
||||||
|
`SELECT ps.status, ps.percentage_bps AS percentageBps,
|
||||||
|
ps.amount_cents AS amountCents, ps.receiver_ref AS receiverMasked,
|
||||||
|
r.receiver_hash AS receiverHash, r.receiver_credential_ref AS receiverRef
|
||||||
|
FROM qipai_profit_shares ps
|
||||||
|
INNER JOIN qipai_profit_share_receivers r
|
||||||
|
ON r.tenant_id = ps.tenant_id AND r.id = ps.receiver_id
|
||||||
|
WHERE ps.tenant_id = ? AND ps.batch_request_id = ?
|
||||||
|
ORDER BY ps.id`,
|
||||||
|
[context.tenantId, 'm05d-profit-share-request']
|
||||||
|
);
|
||||||
|
assert.equal(shareRows.length, 2);
|
||||||
|
assert.deepEqual(shareRows.map((row) => row.percentageBps), [3000, 2000]);
|
||||||
|
assert.equal(shareRows.every((row) => row.status === 'SUCCEEDED'), true);
|
||||||
|
assert.equal(shareRows.every((row) => /^[a-f0-9]{64}$/.test(row.receiverHash)), true);
|
||||||
|
assert.equal(shareRows.some((row) => row.receiverMasked.includes('private')), false);
|
||||||
|
assert.equal(shareRows.every((row) => row.receiverRef.startsWith('receiver:')), true);
|
||||||
|
}
|
||||||
|
|
||||||
async function assertContentManagement(pool, context) {
|
async function assertContentManagement(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -1444,7 +1618,8 @@ try {
|
|||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
{ version: '2026062015', name: 'm05a_payment_domain' },
|
||||||
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
||||||
{ version: '2026062217', name: 'm05c_third_party' }
|
{ version: '2026062217', name: 'm05c_third_party' },
|
||||||
|
{ version: '2026062218', name: 'm05d_profit_sharing' }
|
||||||
]);
|
]);
|
||||||
await assertTaskDurability(pool);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -1460,13 +1635,14 @@ try {
|
|||||||
await assertOrderShares(pool, loginContext);
|
await assertOrderShares(pool, loginContext);
|
||||||
await assertPaymentDomain(pool, loginContext);
|
await assertPaymentDomain(pool, loginContext);
|
||||||
await assertThirdPartyDomain(pool, loginContext);
|
await assertThirdPartyDomain(pool, loginContext);
|
||||||
|
await assertProfitSharingDomain(pool, loginContext);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: down removed all M01-B through M05-C tables.');
|
console.log('PASS: down removed all M01-B through M05-D tables.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.up);
|
await executeMigrationPlan(pool, plans.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -1488,7 +1664,8 @@ try {
|
|||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
{ version: '2026062015', name: 'm05a_payment_domain' },
|
||||||
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
||||||
{ version: '2026062217', name: 'm05c_third_party' }
|
{ version: '2026062217', name: 'm05c_third_party' },
|
||||||
|
{ version: '2026062218', name: 'm05d_profit_sharing' }
|
||||||
]);
|
]);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -1576,6 +1753,11 @@ try {
|
|||||||
'third-party booking webhook idempotency',
|
'third-party booking webhook idempotency',
|
||||||
'mapped booking claim creates a paid order',
|
'mapped booking claim creates a paid order',
|
||||||
'unmapped booking enters manual queue'
|
'unmapped booking enters manual queue'
|
||||||
|
,
|
||||||
|
'collection account authorization gate',
|
||||||
|
'profit-share percentage total validation',
|
||||||
|
'receiver hash and masked storage',
|
||||||
|
'payment and receiver idempotent profit sharing'
|
||||||
]
|
]
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||||
|
|
||||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M05-C migration roundtrip in a temporary database."
|
echo "INFO: MySQL ${mysql_version}; running M01-B through M05-D migration roundtrip in a temporary database."
|
||||||
npm --prefix backend run test:mysql:migration
|
npm --prefix backend run test:mysql:migration
|
||||||
echo "PASS: M01-B through M05-C live MySQL migration roundtrip completed."
|
echo "PASS: M01-B through M05-D live MySQL migration roundtrip completed."
|
||||||
|
|||||||
Reference in New Issue
Block a user