feat(M05-C): 完成团购验券与第三方直订
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_THIRD_PARTY_CREDENTIALS={}
|
||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||
QIPAI_MQTT_USERNAME=
|
||||
QIPAI_MQTT_PASSWORD=
|
||||
|
||||
@@ -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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
} from './routes/order-management.js';
|
||||
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
|
||||
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
|
||||
import {
|
||||
registerThirdPartyRoutes, type ThirdPartyRouteOptions
|
||||
} from './routes/third-party.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -53,6 +56,7 @@ export interface BuildAppOptions {
|
||||
orderManagement?: OrderManagementRouteOptions;
|
||||
orderShare?: OrderShareRouteOptions;
|
||||
payment?: PaymentRouteOptions;
|
||||
thirdParty?: ThirdPartyRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -133,6 +137,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.payment) {
|
||||
await registerPaymentRoutes(app, options.payment);
|
||||
}
|
||||
if (options.thirdParty) {
|
||||
await registerThirdPartyRoutes(app, options.thirdParty);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -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_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(''),
|
||||
QIPAI_MQTT_PASSWORD: z.string().default('')
|
||||
@@ -60,6 +61,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
|
||||
},
|
||||
thirdParty: {
|
||||
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
||||
},
|
||||
mqtt: {
|
||||
url: parsed.QIPAI_MQTT_URL,
|
||||
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
|
||||
|
||||
@@ -36,7 +36,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
|
||||
'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/2026062216_m05b_wechat_refunds.up.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -54,9 +55,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
|
||||
'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/2026062216_m05b_wechat_refunds.verify.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062217_m05c_third_party.down.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
||||
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
||||
@@ -204,7 +207,8 @@ export async function executeMigrationPlan(
|
||||
2, 2, 1, 2, 1,
|
||||
1, 8, 3, 1,
|
||||
5, 8, 4, 1,
|
||||
1, 5, 3, 1
|
||||
1, 5, 3, 1,
|
||||
5, 6, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import {
|
||||
ThirdPartyError, type ThirdPartyProvider
|
||||
} from '../third-party/third-party-client.js';
|
||||
import type { ThirdPartyService } from '../third-party/third-party-service.js';
|
||||
|
||||
const providerSchema = z.enum(['MEITUAN', 'DIANPING', 'DOUYIN', 'KUAISHOU']);
|
||||
const redeemSchema = z.object({
|
||||
provider: providerSchema,
|
||||
voucherCode: z.string().min(4).max(128),
|
||||
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
clientRequestId: z.string().min(8).max(128)
|
||||
}).strict();
|
||||
const manualSchema = redeemSchema.extend({
|
||||
amountCents: z.number().int().positive(),
|
||||
note: z.string().min(1).max(512)
|
||||
}).strict();
|
||||
const notifyParams = z.object({
|
||||
tenantId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
provider: providerSchema
|
||||
});
|
||||
const bookingSchema = z.object({
|
||||
eventId: z.string().min(4).max(128),
|
||||
externalBookingNo: z.string().min(4).max(128),
|
||||
externalStoreRef: z.string().min(1).max(128),
|
||||
externalRoomRef: z.string().min(1).max(128),
|
||||
customerRef: z.string().max(255).default(''),
|
||||
startsAt: z.coerce.date(),
|
||||
endsAt: z.coerce.date(),
|
||||
amountCents: z.number().int().nonnegative()
|
||||
}).strict().refine((value) => value.endsAt > value.startsAt);
|
||||
const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const recordsQuery = z.object({
|
||||
provider: providerSchema.optional(),
|
||||
status: z.string().min(1).max(32).optional()
|
||||
});
|
||||
const configSchema = z.object({
|
||||
provider: providerSchema,
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
|
||||
mode: z.enum(['MANUAL', 'MOCK', 'API']),
|
||||
enabled: z.boolean().default(true),
|
||||
credentialRef: z.string().max(255).default(''),
|
||||
settings: z.record(z.unknown()).default({})
|
||||
}).strict();
|
||||
const mappingSchema = z.object({
|
||||
provider: providerSchema,
|
||||
resourceType: z.enum(['STORE', 'ROOM']),
|
||||
externalRef: z.string().min(1).max(128),
|
||||
localResourceId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||
}).strict();
|
||||
|
||||
export interface ThirdPartyRouteOptions {
|
||||
service: ThirdPartyService;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: {
|
||||
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
|
||||
};
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerThirdPartyRoutes(
|
||||
app: FastifyInstance,
|
||||
options: ThirdPartyRouteOptions
|
||||
) {
|
||||
app.post('/app-api/group-vouchers/redeem', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, false);
|
||||
const body = redeemSchema.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.service.redeemVoucher({
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = manualSchema.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.service.redeemVoucherManually({
|
||||
tenantId: auth.tenantId,
|
||||
actorId: auth.userId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
|
||||
async (request, reply) => {
|
||||
const params = notifyParams.safeParse(request.params);
|
||||
const body = bookingSchema.safeParse(request.body);
|
||||
const signature = request.headers['x-third-party-signature'];
|
||||
if (!params.success || !body.success || typeof signature !== 'string') {
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.receiveDirectBooking({
|
||||
tenantId: params.data.tenantId,
|
||||
provider: params.data.provider,
|
||||
signature,
|
||||
rawBody: JSON.stringify(request.body),
|
||||
payload: request.body as Record<string, unknown>,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/app-api/third-party/bookings/:bookingId/claim', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, false);
|
||||
const params = bookingParams.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.claimDirectBooking({
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId,
|
||||
bookingId: params.data.bookingId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/third-party/records', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const query = recordsQuery.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.service.listRecords({
|
||||
tenantId: auth.tenantId,
|
||||
access: auth.access!,
|
||||
provider: query.data.provider as ThirdPartyProvider | undefined,
|
||||
status: query.data.status
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/third-party/config', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = configSchema.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.service.saveConfig({
|
||||
tenantId: auth.tenantId,
|
||||
actorId: auth.userId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/third-party/mappings', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = mappingSchema.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.service.saveMapping({
|
||||
tenantId: auth.tenantId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
authorization: string | undefined,
|
||||
options: ThirdPartyRouteOptions,
|
||||
withAccess: boolean
|
||||
) {
|
||||
const result = await authenticateAccessToken(
|
||||
authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!result) return null;
|
||||
const tenantId = result.session.tenantId;
|
||||
const userId = result.session.user.id;
|
||||
return {
|
||||
tenantId,
|
||||
userId,
|
||||
access: withAccess
|
||||
? await options.accessControl.getAccessProfile(tenantId, userId)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ThirdPartyError)) throw error;
|
||||
const status = error.code.includes('NOT_FOUND') ? 404
|
||||
: error.code.includes('CONFLICT') || error.code.includes('ALREADY') ? 409
|
||||
: error.code.includes('FORBIDDEN') ? 403
|
||||
: error.code.includes('SIGNATURE') ? 401 : 400;
|
||||
return reply.status(status).send({
|
||||
code: error.code,
|
||||
message: 'The third-party request is not available.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
message: 'Authentication required.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_THIRD_PARTY_REQUEST',
|
||||
message: 'The third-party request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
|
||||
} from './payments/wechat-pay-client.js';
|
||||
import { WechatPaymentService } from './payments/wechat-payment-service.js';
|
||||
import {
|
||||
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
|
||||
} from './third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from './third-party/third-party-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -29,6 +33,7 @@ const accessControl = new RbacRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const paymentRepository = new PaymentRepository(pool);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||
const app = await buildApp({
|
||||
config,
|
||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||
@@ -106,6 +111,17 @@ const app = await buildApp({
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||
},
|
||||
thirdParty: {
|
||||
service: new ThirdPartyService(
|
||||
pool,
|
||||
new PricingRepository(pool),
|
||||
new ThirdPartyClient(new FetchThirdPartyTransport()),
|
||||
thirdPartyCredentials
|
||||
),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
|
||||
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
|
||||
|
||||
export interface ThirdPartyCredential {
|
||||
webhookSecret?: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface ThirdPartyTransport {
|
||||
request(input: {
|
||||
url: string;
|
||||
method: 'POST';
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}): Promise<{ status: number; body: string }>;
|
||||
}
|
||||
|
||||
export interface VoucherRedeemResult {
|
||||
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
|
||||
amountCents: number;
|
||||
externalProductId?: string;
|
||||
failureCode?: string;
|
||||
response?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ThirdPartyError extends Error {
|
||||
constructor(public readonly code: string, message = code) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchThirdPartyTransport implements ThirdPartyTransport {
|
||||
async request(input: {
|
||||
url: string;
|
||||
method: 'POST';
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}) {
|
||||
const response = await fetch(input.url, {
|
||||
method: input.method,
|
||||
headers: input.headers,
|
||||
body: input.body
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}
|
||||
}
|
||||
|
||||
export class ThirdPartyClient {
|
||||
constructor(private readonly transport: ThirdPartyTransport) {}
|
||||
|
||||
verifyWebhook(secret: string, rawBody: string, signature: string) {
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const left = Buffer.from(expected, 'utf8');
|
||||
const right = Buffer.from(signature.toLowerCase(), 'utf8');
|
||||
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_SIGNATURE_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
async redeemVoucher(input: {
|
||||
mode: ThirdPartyMode;
|
||||
provider: ThirdPartyProvider;
|
||||
voucherCode: string;
|
||||
orderNo: string;
|
||||
expectedAmountCents: number;
|
||||
settings: Record<string, unknown>;
|
||||
credential?: ThirdPartyCredential;
|
||||
}): Promise<VoucherRedeemResult> {
|
||||
if (input.mode === 'MANUAL') {
|
||||
throw new ThirdPartyError('THIRD_PARTY_MANUAL_ONLY');
|
||||
}
|
||||
if (input.mode === 'MOCK') {
|
||||
if (input.voucherCode.startsWith('FAIL-')) {
|
||||
return {
|
||||
status: 'FAILED',
|
||||
amountCents: 0,
|
||||
failureCode: 'MOCK_VOUCHER_REJECTED',
|
||||
response: { adapter: 'mock', accepted: false }
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'SUCCEEDED',
|
||||
amountCents: input.expectedAmountCents,
|
||||
externalProductId: 'mock-product',
|
||||
response: { adapter: 'mock', accepted: true }
|
||||
};
|
||||
}
|
||||
const endpoint = input.settings.redeemEndpoint;
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_ENDPOINT_INVALID');
|
||||
}
|
||||
if (!input.credential?.apiToken) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_NOT_CONFIGURED');
|
||||
}
|
||||
const response = await this.transport.request({
|
||||
url: endpoint,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.credential.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'qipai-backend/0.1'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
voucherCode: input.voucherCode,
|
||||
orderNo: input.orderNo
|
||||
})
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return {
|
||||
status: 'PENDING',
|
||||
amountCents: 0,
|
||||
failureCode: `THIRD_PARTY_HTTP_${response.status}`,
|
||||
response: { httpStatus: response.status }
|
||||
};
|
||||
}
|
||||
const payload = parseObject(response.body);
|
||||
const accepted = payload.accepted === true;
|
||||
const amountCents = Number(payload.amountCents);
|
||||
return {
|
||||
status: accepted ? 'SUCCEEDED' : 'FAILED',
|
||||
amountCents: Number.isSafeInteger(amountCents) && amountCents >= 0
|
||||
? amountCents : 0,
|
||||
externalProductId: typeof payload.productId === 'string' ? payload.productId : '',
|
||||
failureCode: accepted ? '' : stringValue(payload.failureCode, 'VOUCHER_REJECTED'),
|
||||
response: sanitizeResponse(payload)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseThirdPartyCredentials(value: string) {
|
||||
const raw = parseObject(value);
|
||||
const result = new Map<string, ThirdPartyCredential>();
|
||||
for (const [key, item] of Object.entries(raw)) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_INVALID');
|
||||
}
|
||||
const credential = item as Record<string, unknown>;
|
||||
result.set(key, {
|
||||
webhookSecret: optionalString(credential.webhookSecret),
|
||||
apiToken: optionalString(credential.apiToken)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseObject(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('object required');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ThirdPartyError('THIRD_PARTY_JSON_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function sanitizeResponse(value: Record<string, unknown>) {
|
||||
const copy = { ...value };
|
||||
delete copy.voucherCode;
|
||||
delete copy.token;
|
||||
delete copy.secret;
|
||||
return copy;
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
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 type { PricingRepository } from '../orders/pricing-repository.js';
|
||||
import {
|
||||
ThirdPartyClient, ThirdPartyError, type ThirdPartyCredential,
|
||||
type ThirdPartyMode, type ThirdPartyProvider
|
||||
} from './third-party-client.js';
|
||||
|
||||
interface OrderRow extends RowDataPacket {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
storeId: string;
|
||||
status: string;
|
||||
totalAmountCents: number;
|
||||
paidAmountCents: number;
|
||||
}
|
||||
interface ConfigRow extends RowDataPacket {
|
||||
id: string;
|
||||
mode: ThirdPartyMode;
|
||||
credentialRef: string;
|
||||
settings: string | Record<string, unknown>;
|
||||
}
|
||||
interface RedemptionRow extends RowDataPacket {
|
||||
id: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
voucherMasked: string;
|
||||
}
|
||||
interface BookingRow extends RowDataPacket {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
provider: ThirdPartyProvider;
|
||||
storeId: string | null;
|
||||
roomId: string | null;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
amountCents: number;
|
||||
status: string;
|
||||
orderId: string | null;
|
||||
}
|
||||
|
||||
export class ThirdPartyService {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly pricing: PricingRepository,
|
||||
private readonly client: ThirdPartyClient,
|
||||
private readonly credentials: ReadonlyMap<string, ThirdPartyCredential>
|
||||
) {}
|
||||
|
||||
async redeemVoucher(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
provider: ThirdPartyProvider;
|
||||
voucherCode: string;
|
||||
orderId: string;
|
||||
clientRequestId: string;
|
||||
}) {
|
||||
const existing = await this.findRedemptionByRequest(
|
||||
input.tenantId, input.clientRequestId, input.orderId
|
||||
);
|
||||
if (existing) return { ...existing, idempotent: true };
|
||||
const order = await this.loadOwnedOrder(input.tenantId, input.userId, input.orderId);
|
||||
const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider);
|
||||
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
|
||||
if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED');
|
||||
const result = await this.client.redeemVoucher({
|
||||
mode: config.mode,
|
||||
provider: input.provider,
|
||||
voucherCode: input.voucherCode,
|
||||
orderNo: order.orderNo,
|
||||
expectedAmountCents,
|
||||
settings: config.settings,
|
||||
credential: this.resolveCredential(config.credentialRef)
|
||||
});
|
||||
return this.recordRedemption({
|
||||
...input,
|
||||
actorId: input.userId,
|
||||
mode: config.mode,
|
||||
amountCents: result.amountCents,
|
||||
expectedAmountCents,
|
||||
externalProductId: result.externalProductId ?? '',
|
||||
status: result.status,
|
||||
failureCode: result.failureCode ?? '',
|
||||
response: result.response ?? {}
|
||||
});
|
||||
}
|
||||
|
||||
async redeemVoucherManually(input: {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
access: AccessProfile;
|
||||
provider: ThirdPartyProvider;
|
||||
voucherCode: string;
|
||||
orderId: string;
|
||||
amountCents: number;
|
||||
clientRequestId: string;
|
||||
note: string;
|
||||
}) {
|
||||
const existing = await this.findRedemptionByRequest(
|
||||
input.tenantId, input.clientRequestId, input.orderId
|
||||
);
|
||||
if (existing) return { ...existing, idempotent: true };
|
||||
const order = await this.loadOrder(input.tenantId, input.orderId);
|
||||
assertStoreAccess(input.access, order.storeId);
|
||||
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
|
||||
if (input.amountCents !== expectedAmountCents) {
|
||||
throw new ThirdPartyError('VOUCHER_AMOUNT_INVALID');
|
||||
}
|
||||
return this.recordRedemption({
|
||||
...input,
|
||||
mode: 'MANUAL',
|
||||
expectedAmountCents,
|
||||
externalProductId: '',
|
||||
status: 'SUCCEEDED',
|
||||
failureCode: '',
|
||||
response: { manual: true, note: input.note.slice(0, 200) }
|
||||
});
|
||||
}
|
||||
|
||||
async receiveDirectBooking(input: {
|
||||
tenantId: string;
|
||||
provider: ThirdPartyProvider;
|
||||
signature: string;
|
||||
rawBody: string;
|
||||
eventId: string;
|
||||
externalBookingNo: string;
|
||||
externalStoreRef: string;
|
||||
externalRoomRef: string;
|
||||
customerRef: string;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
amountCents: number;
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
const config = await this.resolveConfig(input.tenantId, null, input.provider);
|
||||
const credential = this.resolveCredential(config.credentialRef);
|
||||
if (!credential?.webhookSecret) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_WEBHOOK_NOT_CONFIGURED');
|
||||
}
|
||||
this.client.verifyWebhook(credential.webhookSecret, input.rawBody, input.signature);
|
||||
const [existing] = await this.pool.execute<BookingRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
|
||||
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
|
||||
amount_cents AS amountCents, status, order_id AS orderId
|
||||
FROM qipai_direct_bookings
|
||||
WHERE tenant_id = ? AND provider = ? AND event_id = ? LIMIT 1`,
|
||||
[input.tenantId, input.provider, input.eventId]
|
||||
);
|
||||
if (existing[0]) return { ...normalizeBooking(existing[0]), idempotent: true };
|
||||
const mapping = await this.resolveMappings(
|
||||
input.tenantId, input.provider, input.externalStoreRef, input.externalRoomRef
|
||||
);
|
||||
const status = mapping.storeId && mapping.roomId ? 'READY_TO_CLAIM' : 'PENDING_MAPPING';
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_direct_bookings
|
||||
(tenant_id, provider, event_id, external_booking_no,
|
||||
external_store_ref, external_room_ref, store_id, room_id,
|
||||
customer_ref_hash, starts_at, ends_at, amount_cents, status, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
|
||||
[input.tenantId, input.provider, input.eventId, input.externalBookingNo,
|
||||
input.externalStoreRef, input.externalRoomRef, mapping.storeId, mapping.roomId,
|
||||
hashValue(input.customerRef), input.startsAt, input.endsAt, input.amountCents,
|
||||
status, JSON.stringify(sanitizePayload(input.payload))]
|
||||
);
|
||||
return {
|
||||
bookingId: String(result.insertId),
|
||||
status,
|
||||
mapped: status === 'READY_TO_CLAIM',
|
||||
idempotent: false
|
||||
};
|
||||
}
|
||||
|
||||
async claimDirectBooking(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
bookingId: string;
|
||||
}) {
|
||||
const [rows] = await this.pool.execute<BookingRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
|
||||
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
|
||||
amount_cents AS amountCents, status, order_id AS orderId
|
||||
FROM qipai_direct_bookings
|
||||
WHERE tenant_id = ? AND id = ? LIMIT 1`,
|
||||
[input.tenantId, input.bookingId]
|
||||
);
|
||||
const booking = rows[0];
|
||||
if (!booking) throw new ThirdPartyError('DIRECT_BOOKING_NOT_FOUND');
|
||||
if (booking.status === 'CLAIMED') {
|
||||
return { bookingId: input.bookingId, orderId: String(booking.orderId), idempotent: true };
|
||||
}
|
||||
if (booking.status !== 'READY_TO_CLAIM' || !booking.roomId) {
|
||||
throw new ThirdPartyError('DIRECT_BOOKING_NOT_READY');
|
||||
}
|
||||
const quote = await this.pricing.quote({
|
||||
tenantId: input.tenantId,
|
||||
roomId: String(booking.roomId),
|
||||
startAt: booking.startsAt,
|
||||
endAt: booking.endsAt,
|
||||
pricingMode: 'HOURLY'
|
||||
});
|
||||
if (Number(booking.amountCents) > quote.totalCents) {
|
||||
throw new ThirdPartyError('DIRECT_BOOKING_AMOUNT_MISMATCH');
|
||||
}
|
||||
const order = await this.pricing.reserve({
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
roomId: String(booking.roomId),
|
||||
startAt: booking.startsAt,
|
||||
endAt: booking.endsAt,
|
||||
pricingMode: 'HOURLY',
|
||||
adjustment: { discountCents: quote.totalCents - Number(booking.amountCents) }
|
||||
});
|
||||
try {
|
||||
await this.transaction(async (connection) => {
|
||||
const [claim] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_direct_bookings
|
||||
SET status = 'CLAIMED', order_id = ?, claimed_user_id = ?,
|
||||
claimed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'READY_TO_CLAIM'`,
|
||||
[order.orderId, input.userId, input.tenantId, input.bookingId]
|
||||
);
|
||||
if (claim.affectedRows !== 1) throw new ThirdPartyError('DIRECT_BOOKING_CLAIM_CONFLICT');
|
||||
await this.insertSuccessfulPayment(connection, {
|
||||
tenantId: input.tenantId,
|
||||
orderId: order.orderId,
|
||||
storeId: order.storeId,
|
||||
amountCents: order.quote.totalCents,
|
||||
providerReference: `BOOKING-${input.bookingId}`
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await this.releaseClaimOrder(input.tenantId, order.orderId);
|
||||
throw error;
|
||||
}
|
||||
return { bookingId: input.bookingId, orderId: order.orderId, idempotent: false };
|
||||
}
|
||||
|
||||
async listRecords(input: {
|
||||
tenantId: string;
|
||||
access: AccessProfile;
|
||||
provider?: ThirdPartyProvider;
|
||||
status?: string;
|
||||
}) {
|
||||
const storeFilter = input.access.capabilities.includes('tenant.manage')
|
||||
|| input.access.roles.includes('PLATFORM_ADMIN')
|
||||
? null : input.access.storeIds;
|
||||
if (storeFilter && storeFilter.length === 0) {
|
||||
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
const params: string[] = [input.tenantId];
|
||||
const filters = ['tenant_id = ?'];
|
||||
if (input.provider) {
|
||||
filters.push('provider = ?');
|
||||
params.push(input.provider);
|
||||
}
|
||||
if (input.status) {
|
||||
filters.push('status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (storeFilter) {
|
||||
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
|
||||
params.push(...storeFilter);
|
||||
}
|
||||
const [bookings] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, provider, external_booking_no AS externalBookingNo,
|
||||
store_id AS storeId, room_id AS roomId, starts_at AS startsAt,
|
||||
ends_at AS endsAt, amount_cents AS amountCents, status,
|
||||
order_id AS orderId, failure_code AS failureCode, created_at AS createdAt
|
||||
FROM qipai_direct_bookings
|
||||
WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
|
||||
params
|
||||
);
|
||||
const [redemptions] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT r.id, v.provider, v.voucher_masked AS voucherMasked,
|
||||
r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode,
|
||||
r.status, r.failure_code AS failureCode, r.created_at AS createdAt
|
||||
FROM qipai_group_redemptions r
|
||||
INNER JOIN qipai_group_vouchers v
|
||||
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
|
||||
WHERE r.tenant_id = ?
|
||||
${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''}
|
||||
ORDER BY r.id DESC LIMIT 100`,
|
||||
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
|
||||
);
|
||||
return { bookings, redemptions };
|
||||
}
|
||||
|
||||
async saveConfig(input: {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
access: AccessProfile;
|
||||
provider: ThirdPartyProvider;
|
||||
storeId: string | null;
|
||||
mode: ThirdPartyMode;
|
||||
enabled: boolean;
|
||||
credentialRef: string;
|
||||
settings: Record<string, unknown>;
|
||||
}) {
|
||||
if (!input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
|
||||
}
|
||||
if (input.credentialRef && !/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_REF_INVALID');
|
||||
}
|
||||
if (input.storeId) {
|
||||
const [stores] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.storeId]
|
||||
);
|
||||
if (!stores[0]) throw new ThirdPartyError('STORE_NOT_FOUND');
|
||||
}
|
||||
const [existing] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_third_party_configs
|
||||
WHERE tenant_id = ? AND provider = ? AND store_id <=> ? LIMIT 1`,
|
||||
[input.tenantId, input.provider, input.storeId]
|
||||
);
|
||||
if (existing[0]) {
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_third_party_configs
|
||||
SET mode = ?, enabled = ?, credential_ref = ?, settings = CAST(? AS JSON)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.mode, input.enabled, input.credentialRef, JSON.stringify(input.settings),
|
||||
input.tenantId, existing[0].id]
|
||||
);
|
||||
return { configId: String(existing[0].id), created: false };
|
||||
}
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_third_party_configs
|
||||
(tenant_id, store_id, provider, mode, enabled, credential_ref, settings)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
|
||||
[input.tenantId, input.storeId, input.provider, input.mode, input.enabled,
|
||||
input.credentialRef, JSON.stringify(input.settings)]
|
||||
);
|
||||
return { configId: String(result.insertId), created: true };
|
||||
}
|
||||
|
||||
async saveMapping(input: {
|
||||
tenantId: string;
|
||||
access: AccessProfile;
|
||||
provider: ThirdPartyProvider;
|
||||
resourceType: 'STORE' | 'ROOM';
|
||||
externalRef: string;
|
||||
localResourceId: string;
|
||||
}) {
|
||||
if (!input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
|
||||
}
|
||||
const table = input.resourceType === 'STORE' ? 'qipai_stores' : 'qipai_rooms';
|
||||
const [resources] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM ${table}
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.localResourceId]
|
||||
);
|
||||
if (!resources[0]) throw new ThirdPartyError(`${input.resourceType}_NOT_FOUND`);
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_third_party_mappings
|
||||
(tenant_id, provider, resource_type, external_ref, local_resource_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE local_resource_id = VALUES(local_resource_id)`,
|
||||
[input.tenantId, input.provider, input.resourceType,
|
||||
input.externalRef, input.localResourceId]
|
||||
);
|
||||
return { mapped: true };
|
||||
}
|
||||
|
||||
private async recordRedemption(input: {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
provider: ThirdPartyProvider;
|
||||
voucherCode: string;
|
||||
orderId: string;
|
||||
clientRequestId: string;
|
||||
mode: ThirdPartyMode;
|
||||
amountCents: number;
|
||||
expectedAmountCents: number;
|
||||
externalProductId: string;
|
||||
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
|
||||
failureCode: string;
|
||||
response: Record<string, unknown>;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(input.tenantId, input.orderId, connection, true);
|
||||
const [existing] = await connection.execute<RedemptionRow[]>(
|
||||
`SELECT r.id, r.order_id AS orderId, r.status,
|
||||
v.voucher_masked AS voucherMasked
|
||||
FROM qipai_group_redemptions r
|
||||
INNER JOIN qipai_group_vouchers v
|
||||
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
|
||||
WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`,
|
||||
[input.tenantId, input.clientRequestId]
|
||||
);
|
||||
if (existing[0]) {
|
||||
if (String(existing[0].orderId) !== input.orderId) {
|
||||
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
|
||||
}
|
||||
return { ...existing[0], idempotent: true };
|
||||
}
|
||||
const voucherHash = hashValue(input.voucherCode);
|
||||
const [voucherResult] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_group_vouchers
|
||||
(tenant_id, provider, voucher_hash, voucher_masked,
|
||||
external_product_id, status, amount_cents, redeemed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||
[input.tenantId, input.provider, voucherHash, maskVoucher(input.voucherCode),
|
||||
input.externalProductId, input.status === 'SUCCEEDED' ? 'REDEEMED' : input.status,
|
||||
input.amountCents, input.status]
|
||||
);
|
||||
const voucherId = String(voucherResult.insertId);
|
||||
const [used] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT order_id AS orderId FROM qipai_group_redemptions
|
||||
WHERE tenant_id = ? AND voucher_id = ? LIMIT 1`,
|
||||
[input.tenantId, voucherId]
|
||||
);
|
||||
if (used[0]) throw new ThirdPartyError('VOUCHER_ALREADY_REDEEMED');
|
||||
const [redemption] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_group_redemptions
|
||||
(tenant_id, voucher_id, order_id, store_id, actor_id,
|
||||
redemption_mode, client_request_id, status, failure_code,
|
||||
provider_response, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON),
|
||||
IF(? IN ('SUCCEEDED', 'FAILED'), UTC_TIMESTAMP(3), NULL))`,
|
||||
[input.tenantId, voucherId, input.orderId, order.storeId, input.actorId,
|
||||
input.mode, input.clientRequestId, input.status, input.failureCode,
|
||||
JSON.stringify(input.response), input.status]
|
||||
);
|
||||
if (input.status === 'SUCCEEDED') {
|
||||
if (input.amountCents !== input.expectedAmountCents) {
|
||||
throw new ThirdPartyError('VOUCHER_AMOUNT_MISMATCH');
|
||||
}
|
||||
await this.insertSuccessfulPayment(connection, {
|
||||
tenantId: input.tenantId,
|
||||
orderId: input.orderId,
|
||||
storeId: order.storeId,
|
||||
amountCents: input.amountCents,
|
||||
providerReference: `VOUCHER-${voucherId}`
|
||||
});
|
||||
}
|
||||
return {
|
||||
redemptionId: String(redemption.insertId),
|
||||
voucherMasked: maskVoucher(input.voucherCode),
|
||||
status: input.status,
|
||||
failureCode: input.failureCode,
|
||||
idempotent: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async findRedemptionByRequest(
|
||||
tenantId: string,
|
||||
clientRequestId: string,
|
||||
orderId: string
|
||||
) {
|
||||
const [rows] = await this.pool.execute<RedemptionRow[]>(
|
||||
`SELECT r.id, r.order_id AS orderId, r.status,
|
||||
v.voucher_masked AS voucherMasked
|
||||
FROM qipai_group_redemptions r
|
||||
INNER JOIN qipai_group_vouchers v
|
||||
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
|
||||
WHERE r.tenant_id = ? AND r.client_request_id = ? LIMIT 1`,
|
||||
[tenantId, clientRequestId]
|
||||
);
|
||||
if (!rows[0]) return null;
|
||||
if (String(rows[0].orderId) !== orderId) {
|
||||
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async insertSuccessfulPayment(
|
||||
connection: PoolConnection,
|
||||
input: {
|
||||
tenantId: string;
|
||||
orderId: string;
|
||||
storeId: string;
|
||||
amountCents: number;
|
||||
providerReference: string;
|
||||
}
|
||||
) {
|
||||
const paymentNo = `GRP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
|
||||
const [payment] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_payments
|
||||
(tenant_id, order_id, store_id, payment_no, channel, provider,
|
||||
client_request_id, status, amount_cents, provider_payment_id, paid_at)
|
||||
VALUES (?, ?, ?, ?, 'GROUP_BUY', 'GROUP_BUY', ?, 'SUCCEEDED', ?, ?,
|
||||
UTC_TIMESTAMP(3))`,
|
||||
[input.tenantId, input.orderId, input.storeId, paymentNo,
|
||||
input.providerReference, input.amountCents, input.providerReference]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET paid_amount_cents = paid_amount_cents + ?,
|
||||
status = IF(paid_amount_cents + ? >= total_amount_cents, 'PAID', status),
|
||||
status_version = IF(paid_amount_cents + ? >= total_amount_cents,
|
||||
status_version + 1, status_version),
|
||||
status_updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.amountCents, input.amountCents, input.amountCents,
|
||||
input.tenantId, input.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
|
||||
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
|
||||
[input.tenantId, input.orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
|
||||
NULL, 'PAYMENT', 'Verified group-buy payment', ?,
|
||||
JSON_OBJECT('paymentId', ?, 'providerReference', ?))`,
|
||||
[input.tenantId, input.orderId, `group-payment-${payment.insertId}`,
|
||||
payment.insertId, input.providerReference]
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveConfig(
|
||||
tenantId: string, storeId: string | null, provider: ThirdPartyProvider
|
||||
) {
|
||||
const [rows] = await this.pool.execute<ConfigRow[]>(
|
||||
`SELECT id, mode, credential_ref AS credentialRef, settings
|
||||
FROM qipai_third_party_configs
|
||||
WHERE tenant_id = ? AND provider = ? AND enabled = 1
|
||||
AND (store_id IS NULL OR store_id = ?)
|
||||
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
|
||||
[tenantId, provider, storeId]
|
||||
);
|
||||
if (!rows[0]) throw new ThirdPartyError('THIRD_PARTY_CONFIG_NOT_FOUND');
|
||||
return {
|
||||
id: String(rows[0].id),
|
||||
mode: rows[0].mode,
|
||||
credentialRef: rows[0].credentialRef,
|
||||
settings: typeof rows[0].settings === 'string'
|
||||
? JSON.parse(rows[0].settings) : rows[0].settings
|
||||
};
|
||||
}
|
||||
|
||||
private resolveCredential(reference: string) {
|
||||
if (!reference) return undefined;
|
||||
return this.credentials.get(reference)
|
||||
?? this.credentials.get(reference.replace(/^env:/, ''));
|
||||
}
|
||||
|
||||
private async resolveMappings(
|
||||
tenantId: string,
|
||||
provider: ThirdPartyProvider,
|
||||
externalStoreRef: string,
|
||||
externalRoomRef: string
|
||||
) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT resource_type AS resourceType, local_resource_id AS localResourceId
|
||||
FROM qipai_third_party_mappings
|
||||
WHERE tenant_id = ? AND provider = ?
|
||||
AND ((resource_type = 'STORE' AND external_ref = ?)
|
||||
OR (resource_type = 'ROOM' AND external_ref = ?))`,
|
||||
[tenantId, provider, externalStoreRef, externalRoomRef]
|
||||
);
|
||||
const storeId = rows.find((row) => row.resourceType === 'STORE')?.localResourceId;
|
||||
const roomId = rows.find((row) => row.resourceType === 'ROOM')?.localResourceId;
|
||||
if (storeId && roomId) {
|
||||
const [valid] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND id = ? AND store_id = ? AND deleted_at IS NULL`,
|
||||
[tenantId, roomId, storeId]
|
||||
);
|
||||
if (!valid[0]) return { storeId: null, roomId: null };
|
||||
}
|
||||
return {
|
||||
storeId: storeId ? String(storeId) : null,
|
||||
roomId: roomId ? String(roomId) : null
|
||||
};
|
||||
}
|
||||
|
||||
private async loadOwnedOrder(tenantId: string, userId: string, orderId: string) {
|
||||
const [access] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT 1 FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||
[tenantId, orderId, userId]
|
||||
);
|
||||
if (!access[0]) throw new ThirdPartyError('ORDER_ACCESS_FORBIDDEN');
|
||||
return this.loadOrder(tenantId, orderId);
|
||||
}
|
||||
|
||||
private async loadOrder(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool,
|
||||
lock = false
|
||||
) {
|
||||
const [rows] = await connection.execute<OrderRow[]>(
|
||||
`SELECT id, order_no AS orderNo, store_id AS storeId, status,
|
||||
total_amount_cents AS totalAmountCents,
|
||||
paid_amount_cents AS paidAmountCents
|
||||
FROM qipai_orders
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
||||
${lock ? 'FOR UPDATE' : ''}`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (!rows[0]) throw new ThirdPartyError('ORDER_NOT_FOUND');
|
||||
if (rows[0].status !== 'PENDING_PAYMENT') {
|
||||
throw new ThirdPartyError('ORDER_NOT_PAYABLE');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async releaseClaimOrder(tenantId: string, orderId: string) {
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET status = 'RELEASED', released_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_orders SET status = 'CLOSED'
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING_PAYMENT'`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
}
|
||||
|
||||
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 assertStoreAccess(access: AccessProfile, storeId: string) {
|
||||
if (access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN')
|
||||
|| (access.capabilities.includes('store.operation.write')
|
||||
&& access.storeIds.includes(String(storeId)))) return;
|
||||
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
|
||||
function hashValue(value: string) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function maskVoucher(value: string) {
|
||||
if (value.length <= 4) return '*'.repeat(value.length);
|
||||
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function sanitizePayload(value: Record<string, unknown>) {
|
||||
const copy = { ...value };
|
||||
delete copy.voucherCode;
|
||||
delete copy.phone;
|
||||
delete copy.openid;
|
||||
delete copy.token;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normalizeBooking(row: BookingRow) {
|
||||
return {
|
||||
bookingId: String(row.id),
|
||||
status: row.status,
|
||||
orderId: row.orderId ? String(row.orderId) : null,
|
||||
mapped: Boolean(row.storeId && row.roomId)
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,9 @@ const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domai
|
||||
const wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql');
|
||||
const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql');
|
||||
const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.sql');
|
||||
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 coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -252,5 +255,17 @@ assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_client_request/);
|
||||
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_callback/);
|
||||
assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/);
|
||||
assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i);
|
||||
for (const table of [
|
||||
'qipai_third_party_configs', 'qipai_third_party_mappings',
|
||||
'qipai_group_vouchers', 'qipai_group_redemptions', 'qipai_direct_bookings'
|
||||
]) {
|
||||
assert.match(thirdPartyUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(thirdPartyDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(thirdPartyVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
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);
|
||||
|
||||
console.log('PASS: M01-B through M05-B migration contracts are present.');
|
||||
console.log('PASS: M01-B through M05-C migration contracts are present.');
|
||||
|
||||
@@ -27,7 +27,8 @@ assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
|
||||
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, /2026062216_m05b_wechat_refunds\.up\.sql/);
|
||||
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
import {
|
||||
PaymentError, PaymentRepository
|
||||
} from '../dist/payments/payment-repository.js';
|
||||
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -42,6 +45,9 @@ const expectedTables = [
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_devices',
|
||||
'qipai_direct_bookings',
|
||||
'qipai_group_redemptions',
|
||||
'qipai_group_vouchers',
|
||||
'qipai_holiday_calendar',
|
||||
'qipai_legacy_table_mappings',
|
||||
'qipai_media_assets',
|
||||
@@ -77,6 +83,8 @@ const expectedTables = [
|
||||
'qipai_tenant_apps',
|
||||
'qipai_tenant_configs',
|
||||
'qipai_tenants',
|
||||
'qipai_third_party_configs',
|
||||
'qipai_third_party_mappings',
|
||||
'qipai_user_admin_profiles',
|
||||
'qipai_user_identities',
|
||||
'qipai_user_roles',
|
||||
@@ -102,12 +110,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']
|
||||
'2026062015', '2026062216', '2026062217']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1152,6 +1160,186 @@ async function assertPaymentDomain(pool, context) {
|
||||
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
|
||||
}
|
||||
|
||||
async function assertThirdPartyDomain(pool, context) {
|
||||
const [customerRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_identities i
|
||||
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
|
||||
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [roomRows] = await pool.query(
|
||||
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(roomRows[0].storeId);
|
||||
const roomId = String(roomRows[0].roomId);
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_third_party_configs
|
||||
(tenant_id, store_id, provider, mode, credential_ref, settings)
|
||||
VALUES (?, NULL, 'MEITUAN', 'MOCK', 'env:TP_TEST', JSON_OBJECT())`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const pricing = new PricingRepository(pool);
|
||||
const service = new ThirdPartyService(
|
||||
pool,
|
||||
pricing,
|
||||
new ThirdPartyClient({
|
||||
async request() {
|
||||
throw new Error('Live M05-C test must not call an external provider.');
|
||||
}
|
||||
}),
|
||||
new Map([['TP_TEST', { webhookSecret: 'm05c-webhook-secret' }]])
|
||||
);
|
||||
const startAt = new Date(Date.now() + 27 * 86400000);
|
||||
startAt.setUTCHours(2, 0, 0, 0);
|
||||
const endAt = new Date(startAt.getTime() + 2 * 3600000);
|
||||
const order = await pricing.reserve({
|
||||
tenantId: context.tenantId,
|
||||
userId: customerId,
|
||||
roomId,
|
||||
startAt,
|
||||
endAt,
|
||||
pricingMode: 'HOURLY'
|
||||
});
|
||||
const redeemed = await service.redeemVoucher({
|
||||
tenantId: context.tenantId,
|
||||
userId: customerId,
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
|
||||
orderId: order.orderId,
|
||||
clientRequestId: 'm05c-redeem-request-001'
|
||||
});
|
||||
assert.equal(redeemed.status, 'SUCCEEDED');
|
||||
const duplicate = await service.redeemVoucher({
|
||||
tenantId: context.tenantId,
|
||||
userId: customerId,
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
|
||||
orderId: order.orderId,
|
||||
clientRequestId: 'm05c-redeem-request-001'
|
||||
});
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
const [voucherRows] = await pool.query(
|
||||
`SELECT voucher_hash AS voucherHash, voucher_masked AS voucherMasked,
|
||||
(SELECT COUNT(*) FROM qipai_group_redemptions r
|
||||
WHERE r.voucher_id = v.id) AS redemptionCount
|
||||
FROM qipai_group_vouchers v WHERE tenant_id = ?`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.match(voucherRows[0].voucherHash, /^[a-f0-9]{64}$/);
|
||||
assert.equal(voucherRows[0].voucherMasked.includes('SENSITIVE'), false);
|
||||
assert.equal(Number(voucherRows[0].redemptionCount), 1);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO qipai_third_party_mappings
|
||||
(tenant_id, provider, resource_type, external_ref, local_resource_id)
|
||||
VALUES (?, 'MEITUAN', 'STORE', 'external-store-001', ?),
|
||||
(?, 'MEITUAN', 'ROOM', 'external-room-001', ?)`,
|
||||
[context.tenantId, storeId, context.tenantId, roomId]
|
||||
);
|
||||
const bookingStart = new Date(Date.now() + 29 * 86400000);
|
||||
bookingStart.setUTCHours(2, 0, 0, 0);
|
||||
const bookingEnd = new Date(bookingStart.getTime() + 2 * 3600000);
|
||||
const quote = await pricing.quote({
|
||||
tenantId: context.tenantId,
|
||||
roomId,
|
||||
startAt: bookingStart,
|
||||
endAt: bookingEnd,
|
||||
pricingMode: 'HOURLY'
|
||||
});
|
||||
const payload = {
|
||||
eventId: 'm05c-booking-event-001',
|
||||
externalBookingNo: 'm05c-booking-001',
|
||||
externalStoreRef: 'external-store-001',
|
||||
externalRoomRef: 'external-room-001',
|
||||
customerRef: 'private-customer-ref',
|
||||
startsAt: bookingStart.toISOString(),
|
||||
endsAt: bookingEnd.toISOString(),
|
||||
amountCents: quote.totalCents
|
||||
};
|
||||
const rawBody = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', 'm05c-webhook-secret')
|
||||
.update(rawBody).digest('hex');
|
||||
const received = await service.receiveDirectBooking({
|
||||
tenantId: context.tenantId,
|
||||
provider: 'MEITUAN',
|
||||
signature,
|
||||
rawBody,
|
||||
eventId: payload.eventId,
|
||||
externalBookingNo: payload.externalBookingNo,
|
||||
externalStoreRef: payload.externalStoreRef,
|
||||
externalRoomRef: payload.externalRoomRef,
|
||||
customerRef: payload.customerRef,
|
||||
startsAt: bookingStart,
|
||||
endsAt: bookingEnd,
|
||||
amountCents: payload.amountCents,
|
||||
payload
|
||||
});
|
||||
assert.equal(received.status, 'READY_TO_CLAIM');
|
||||
assert.equal((await service.receiveDirectBooking({
|
||||
tenantId: context.tenantId,
|
||||
provider: 'MEITUAN',
|
||||
signature,
|
||||
rawBody,
|
||||
eventId: payload.eventId,
|
||||
externalBookingNo: payload.externalBookingNo,
|
||||
externalStoreRef: payload.externalStoreRef,
|
||||
externalRoomRef: payload.externalRoomRef,
|
||||
customerRef: payload.customerRef,
|
||||
startsAt: bookingStart,
|
||||
endsAt: bookingEnd,
|
||||
amountCents: payload.amountCents,
|
||||
payload
|
||||
})).idempotent, true);
|
||||
const claimed = await service.claimDirectBooking({
|
||||
tenantId: context.tenantId,
|
||||
userId: customerId,
|
||||
bookingId: received.bookingId
|
||||
});
|
||||
const [claimedRows] = await pool.query(
|
||||
`SELECT b.status, b.customer_ref_hash AS customerRefHash,
|
||||
o.status AS orderStatus, o.paid_amount_cents AS paidAmountCents,
|
||||
o.total_amount_cents AS totalAmountCents
|
||||
FROM qipai_direct_bookings b
|
||||
INNER JOIN qipai_orders o ON o.id = b.order_id AND o.tenant_id = b.tenant_id
|
||||
WHERE b.id = ?`,
|
||||
[received.bookingId]
|
||||
);
|
||||
assert.equal(claimedRows[0].status, 'CLAIMED');
|
||||
assert.match(claimedRows[0].customerRefHash, /^[a-f0-9]{64}$/);
|
||||
assert.equal(claimedRows[0].orderStatus, 'PAID');
|
||||
assert.equal(claimedRows[0].paidAmountCents, claimedRows[0].totalAmountCents);
|
||||
assert.match(claimed.orderId, /^[1-9]\d*$/);
|
||||
|
||||
const unmappedPayload = {
|
||||
...payload,
|
||||
eventId: 'm05c-booking-event-unmapped',
|
||||
externalBookingNo: 'm05c-booking-unmapped',
|
||||
externalRoomRef: 'missing-room'
|
||||
};
|
||||
const unmappedBody = JSON.stringify(unmappedPayload);
|
||||
const unmapped = await service.receiveDirectBooking({
|
||||
tenantId: context.tenantId,
|
||||
provider: 'MEITUAN',
|
||||
signature: createHmac('sha256', 'm05c-webhook-secret')
|
||||
.update(unmappedBody).digest('hex'),
|
||||
rawBody: unmappedBody,
|
||||
eventId: unmappedPayload.eventId,
|
||||
externalBookingNo: unmappedPayload.externalBookingNo,
|
||||
externalStoreRef: unmappedPayload.externalStoreRef,
|
||||
externalRoomRef: unmappedPayload.externalRoomRef,
|
||||
customerRef: unmappedPayload.customerRef,
|
||||
startsAt: bookingStart,
|
||||
endsAt: bookingEnd,
|
||||
amountCents: unmappedPayload.amountCents,
|
||||
payload: unmappedPayload
|
||||
});
|
||||
assert.equal(unmapped.status, 'PENDING_MAPPING');
|
||||
}
|
||||
|
||||
async function assertContentManagement(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
@@ -1255,7 +1443,8 @@ try {
|
||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
||||
{ version: '2026062216', name: 'm05b_wechat_refunds' }
|
||||
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
||||
{ version: '2026062217', name: 'm05c_third_party' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -1270,13 +1459,14 @@ try {
|
||||
await assertOrderAdjustments(pool, loginContext);
|
||||
await assertOrderShares(pool, loginContext);
|
||||
await assertPaymentDomain(pool, loginContext);
|
||||
await assertThirdPartyDomain(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-B tables.');
|
||||
console.log('PASS: down removed all M01-B through M05-C tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -1297,7 +1487,8 @@ try {
|
||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
||||
{ version: '2026062015', name: 'm05a_payment_domain' },
|
||||
{ version: '2026062216', name: 'm05b_wechat_refunds' }
|
||||
{ version: '2026062216', name: 'm05b_wechat_refunds' },
|
||||
{ version: '2026062217', name: 'm05c_third_party' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -1380,6 +1571,11 @@ try {
|
||||
'test adapter explicit non-production gate',
|
||||
'Wechat refund idempotency and callback indexes',
|
||||
'Wechat reconciliation request history'
|
||||
,
|
||||
'group voucher hash-only storage and single redemption',
|
||||
'third-party booking webhook idempotency',
|
||||
'mapped booking claim creates a paid order',
|
||||
'unmapped booking enters manual queue'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import {
|
||||
ThirdPartyClient, ThirdPartyError
|
||||
} from '../dist/third-party/third-party-client.js';
|
||||
|
||||
const client = new ThirdPartyClient({
|
||||
async request() {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
accepted: true,
|
||||
amountCents: 3600,
|
||||
productId: 'product-sanitized',
|
||||
voucherCode: 'must-be-redacted'
|
||||
})
|
||||
};
|
||||
}
|
||||
});
|
||||
const body = JSON.stringify({ eventId: 'event-001', amountCents: 3600 });
|
||||
const signature = createHmac('sha256', 'test-webhook-secret')
|
||||
.update(body).digest('hex');
|
||||
client.verifyWebhook('test-webhook-secret', body, signature);
|
||||
assert.throws(
|
||||
() => client.verifyWebhook('test-webhook-secret', `${body} `, signature),
|
||||
(error) => error instanceof ThirdPartyError
|
||||
&& error.code === 'THIRD_PARTY_SIGNATURE_INVALID'
|
||||
);
|
||||
assert.equal((await client.redeemVoucher({
|
||||
mode: 'MOCK',
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: 'MOCK-ACCEPT',
|
||||
orderNo: 'QP001',
|
||||
expectedAmountCents: 3600,
|
||||
settings: {}
|
||||
})).status, 'SUCCEEDED');
|
||||
assert.equal((await client.redeemVoucher({
|
||||
mode: 'MOCK',
|
||||
provider: 'DOUYIN',
|
||||
voucherCode: 'FAIL-REJECT',
|
||||
orderNo: 'QP002',
|
||||
expectedAmountCents: 3600,
|
||||
settings: {}
|
||||
})).status, 'FAILED');
|
||||
const apiResult = await client.redeemVoucher({
|
||||
mode: 'API',
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: 'API-ACCEPT',
|
||||
orderNo: 'QP003',
|
||||
expectedAmountCents: 3600,
|
||||
settings: { redeemEndpoint: 'https://partner.example.test/redeem' },
|
||||
credential: { apiToken: 'test-token' }
|
||||
});
|
||||
assert.equal(apiResult.amountCents, 3600);
|
||||
assert.equal('voucherCode' in apiResult.response, false);
|
||||
|
||||
const secret = 'third-party-route-secret-with-32-characters';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let redeemInput;
|
||||
let notifyInput;
|
||||
const routeApp = await buildApp({
|
||||
thirdParty: {
|
||||
jwtSecret: secret,
|
||||
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: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile() {
|
||||
return {
|
||||
roles: ['TENANT_ADMIN'],
|
||||
capabilities: ['tenant.manage'],
|
||||
storeIds: []
|
||||
};
|
||||
}
|
||||
},
|
||||
service: {
|
||||
async redeemVoucher(input) {
|
||||
redeemInput = input;
|
||||
return { redemptionId: '51', status: 'SUCCEEDED' };
|
||||
},
|
||||
async redeemVoucherManually() {
|
||||
return { redemptionId: '52', status: 'SUCCEEDED' };
|
||||
},
|
||||
async receiveDirectBooking(input) {
|
||||
notifyInput = input;
|
||||
return { bookingId: '61', status: 'PENDING_MAPPING' };
|
||||
},
|
||||
async claimDirectBooking() {
|
||||
return { bookingId: '61', orderId: '71' };
|
||||
},
|
||||
async listRecords() {
|
||||
return { bookings: [], redemptions: [] };
|
||||
},
|
||||
async saveConfig() {
|
||||
return { configId: '81', created: true };
|
||||
},
|
||||
async saveMapping() {
|
||||
return { mapped: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const redeemed = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/group-vouchers/redeem',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
provider: 'MEITUAN',
|
||||
voucherCode: '1234567890',
|
||||
orderId: '31',
|
||||
clientRequestId: 'redeem-request-001'
|
||||
}
|
||||
});
|
||||
assert.equal(redeemed.statusCode, 200);
|
||||
assert.equal(redeemInput.userId, '21');
|
||||
|
||||
const bookingPayload = {
|
||||
eventId: 'event-001',
|
||||
externalBookingNo: 'booking-001',
|
||||
externalStoreRef: 'store-external',
|
||||
externalRoomRef: 'room-external',
|
||||
customerRef: 'customer-private',
|
||||
startsAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
endsAt: new Date(Date.now() + 90000000).toISOString(),
|
||||
amountCents: 3600
|
||||
};
|
||||
const notified = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/third-party/MEITUAN/tenants/7/bookings/notify',
|
||||
headers: { 'x-third-party-signature': 'signature-sanitized' },
|
||||
payload: bookingPayload
|
||||
});
|
||||
assert.equal(notified.statusCode, 200);
|
||||
assert.equal(notifyInput.tenantId, '7');
|
||||
assert.equal(notifyInput.provider, 'MEITUAN');
|
||||
assert.equal(notifyInput.rawBody, JSON.stringify(bookingPayload));
|
||||
|
||||
await routeApp.close();
|
||||
console.log('PASS: M05-C adapters, webhook authentication and third-party routes.');
|
||||
Reference in New Issue
Block a user