feat(M05-C): 完成团购验券与第三方直订
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user