forked from wangziqi/gongxue-base
feat: add payment provider webhooks
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
import { config as appConfig } from '../../core/config.js';
|
||||
import { query, queryOne } from './db.js';
|
||||
import { HttpError } from './http.js';
|
||||
import { config as appConfig } from './config.js';
|
||||
|
||||
export type TenantSecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system';
|
||||
|
||||
@@ -11,17 +11,20 @@ export interface TenantSecretConfig {
|
||||
secretJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TenantAuthProviderConfig {
|
||||
export interface TenantProviderConfig {
|
||||
tenantId: string;
|
||||
provider: string;
|
||||
status: 'active' | 'testing' | 'disabled';
|
||||
status: string;
|
||||
configPublic: Record<string, unknown>;
|
||||
secret: TenantSecretConfig | null;
|
||||
}
|
||||
|
||||
interface TenantAuthProviderRow {
|
||||
export type TenantAuthProviderConfig = TenantProviderConfig;
|
||||
export type TenantPaymentProviderConfig = TenantProviderConfig;
|
||||
|
||||
interface ProviderRow {
|
||||
provider: string;
|
||||
status: 'active' | 'testing' | 'disabled';
|
||||
status: string;
|
||||
configPublic: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ function parseSecretRef(value: unknown): { scope: TenantSecretScope; key: string
|
||||
function defaultSecretScope(provider: string): TenantSecretScope {
|
||||
const normalized = provider.toLowerCase().replace(/[_\s]/g, '-');
|
||||
if (normalized.includes('sms') || normalized === 'aliyun' || normalized === 'tencent') return 'sms';
|
||||
if (normalized.includes('pay') || normalized.includes('alipay')) return 'payment';
|
||||
return 'oauth';
|
||||
}
|
||||
|
||||
@@ -83,31 +87,33 @@ async function loadTenantSecret(
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTenantAuthProviderConfig(
|
||||
tenantId: string,
|
||||
providers: string[],
|
||||
): Promise<TenantAuthProviderConfig | null> {
|
||||
const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))];
|
||||
async function loadTenantProviderConfig(input: {
|
||||
table: 'tenant_auth_providers' | 'tenant_payment_accounts';
|
||||
tenantId: string;
|
||||
providers: string[];
|
||||
allowedStatuses: string[];
|
||||
}): Promise<TenantProviderConfig | null> {
|
||||
const uniqueProviders = [...new Set(input.providers.map(item => item.trim()).filter(Boolean))];
|
||||
if (uniqueProviders.length === 0) return null;
|
||||
|
||||
const rows = await query<TenantAuthProviderRow>(
|
||||
const rows = await query<ProviderRow>(
|
||||
`
|
||||
select provider, status, config_public as "configPublic"
|
||||
from public.tenant_auth_providers
|
||||
from public.${input.table}
|
||||
where tenant_id = $1
|
||||
and provider = any($2::text[])
|
||||
and status in ('active', 'testing')
|
||||
and status = any($3::text[])
|
||||
`,
|
||||
[tenantId, uniqueProviders],
|
||||
[input.tenantId, uniqueProviders, input.allowedStatuses],
|
||||
);
|
||||
|
||||
const row = uniqueProviders.map(provider => rows.find(item => item.provider === provider)).find(Boolean);
|
||||
if (!row) return null;
|
||||
|
||||
const configPublic = objectValue(row.configPublic);
|
||||
const secret = await loadTenantSecret(tenantId, configPublic, row.provider);
|
||||
const secret = await loadTenantSecret(input.tenantId, configPublic, row.provider);
|
||||
return {
|
||||
tenantId,
|
||||
tenantId: input.tenantId,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
configPublic,
|
||||
@@ -115,7 +121,25 @@ export async function loadTenantAuthProviderConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function requirePublicString(config: TenantAuthProviderConfig, keys: string[], code: string) {
|
||||
export function loadTenantAuthProviderConfig(tenantId: string, providers: string[]) {
|
||||
return loadTenantProviderConfig({
|
||||
table: 'tenant_auth_providers',
|
||||
tenantId,
|
||||
providers,
|
||||
allowedStatuses: ['active', 'testing'],
|
||||
});
|
||||
}
|
||||
|
||||
export function loadTenantPaymentProviderConfig(tenantId: string, providers: string[]) {
|
||||
return loadTenantProviderConfig({
|
||||
table: 'tenant_payment_accounts',
|
||||
tenantId,
|
||||
providers,
|
||||
allowedStatuses: ['active'],
|
||||
});
|
||||
}
|
||||
|
||||
export function requirePublicString(config: TenantProviderConfig, keys: string[], code: string) {
|
||||
for (const key of keys) {
|
||||
const value = config.configPublic[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
@@ -123,7 +147,7 @@ export function requirePublicString(config: TenantAuthProviderConfig, keys: stri
|
||||
throw new HttpError(503, `${config.provider} public config is missing ${keys[0]}`, code);
|
||||
}
|
||||
|
||||
export function optionalPublicString(config: TenantAuthProviderConfig, keys: string[]) {
|
||||
export function optionalPublicString(config: TenantProviderConfig, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = config.configPublic[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
@@ -140,41 +164,46 @@ function isAllowedHost(hostname: string, allowedHosts: string[]) {
|
||||
}
|
||||
|
||||
export function providerEndpoint(
|
||||
authProvider: TenantAuthProviderConfig,
|
||||
providerConfig: TenantProviderConfig,
|
||||
fallback: string,
|
||||
allowedHosts: string[],
|
||||
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
|
||||
) {
|
||||
const raw = optionalPublicString(authProvider, ['endpoint']) || fallback;
|
||||
const raw = optionalPublicString(providerConfig, ['endpoint']) || fallback;
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(raw);
|
||||
} catch {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint is invalid`, code);
|
||||
throw new HttpError(503, `${providerConfig.provider} endpoint is invalid`, code);
|
||||
}
|
||||
|
||||
const localDev = !appConfig.isProduction && isLocalDevHost(endpoint.hostname);
|
||||
if (!localDev && endpoint.protocol !== 'https:') {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint must use HTTPS`, code);
|
||||
throw new HttpError(503, `${providerConfig.provider} endpoint must use HTTPS`, code);
|
||||
}
|
||||
if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint host is not allowed`, code);
|
||||
throw new HttpError(503, `${providerConfig.provider} endpoint host is not allowed`, code);
|
||||
}
|
||||
endpoint.username = '';
|
||||
endpoint.password = '';
|
||||
return endpoint.toString();
|
||||
}
|
||||
|
||||
export function optionalPublicObject(config: TenantAuthProviderConfig, key: string) {
|
||||
export function optionalPublicObject(config: TenantProviderConfig, key: string) {
|
||||
return objectValue(config.configPublic[key]);
|
||||
}
|
||||
|
||||
export function optionalPublicArray(config: TenantAuthProviderConfig, key: string) {
|
||||
export function optionalPublicArray(config: TenantProviderConfig, key: string) {
|
||||
const value = config.configPublic[key];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function secretString(config: TenantAuthProviderConfig, keys: string[]) {
|
||||
export function optionalPublicBoolean(config: TenantProviderConfig, key: string, fallback = false) {
|
||||
const value = config.configPublic[key];
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function secretString(config: TenantProviderConfig, keys: string[]) {
|
||||
const secret = config.secret;
|
||||
if (!secret) return '';
|
||||
if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim();
|
||||
@@ -185,7 +214,7 @@ export function secretString(config: TenantAuthProviderConfig, keys: string[]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function requireSecretString(config: TenantAuthProviderConfig, keys: string[], code: string) {
|
||||
export function requireSecretString(config: TenantProviderConfig, keys: string[], code: string) {
|
||||
const value = secretString(config, keys);
|
||||
if (value) return value;
|
||||
throw new HttpError(503, `${config.provider} secret is missing ${keys[0]}`, code);
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
type TenantAuthProviderConfig,
|
||||
} from './provider-config.js';
|
||||
} from '../../core/tenant-provider-config.js';
|
||||
|
||||
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
|
||||
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
providerEndpoint,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
} from './provider-config.js';
|
||||
} from '../../core/tenant-provider-config.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
clientIpFrom,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
confirmManualPaymentRoute,
|
||||
createPaymentRoute,
|
||||
createOrderRoute,
|
||||
entitlementCheckRoute,
|
||||
entitlementsRoute,
|
||||
ordersRoute,
|
||||
paymentNotifyRoute,
|
||||
redeemActivationCodeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
@@ -13,6 +15,10 @@ export const commerceRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/commerce/orders', ordersRoute],
|
||||
['GET', '/api/commerce/entitlements', entitlementsRoute],
|
||||
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
|
||||
['POST', '/api/commerce/payments/create', createPaymentRoute],
|
||||
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
|
||||
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
|
||||
['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute],
|
||||
['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute],
|
||||
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
|
||||
];
|
||||
|
||||
420
apps/api/src/features/commerce/providers.ts
Normal file
420
apps/api/src/features/commerce/providers.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
import { config as appConfig } from '../../core/config.js';
|
||||
import {
|
||||
optionalPublicBoolean,
|
||||
optionalPublicString,
|
||||
providerEndpoint,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
type TenantPaymentProviderConfig,
|
||||
} from '../../core/tenant-provider-config.js';
|
||||
|
||||
export type PaymentProviderName = 'wechat_pay' | 'alipay' | 'manual';
|
||||
|
||||
export interface PaymentOrderInput {
|
||||
tenantId: string;
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountCents: number;
|
||||
userId: string;
|
||||
openId?: string;
|
||||
clientIp?: string;
|
||||
returnUrl?: string;
|
||||
quitUrl?: string;
|
||||
}
|
||||
|
||||
export interface PaymentCreateResult {
|
||||
provider: PaymentProviderName;
|
||||
method: string;
|
||||
providerTradeNo?: string;
|
||||
paymentParams: Record<string, unknown>;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PaymentNotificationResult {
|
||||
provider: PaymentProviderName;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
orderNo: string;
|
||||
providerTradeNo: string;
|
||||
amountCents: number;
|
||||
paidAt?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
name: PaymentProviderName;
|
||||
createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult>;
|
||||
parseNotification(input: {
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
rawBody: string;
|
||||
}): Promise<PaymentNotificationResult>;
|
||||
}
|
||||
|
||||
function base64urlDecode(value: string) {
|
||||
return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function requiredBodyString(body: Record<string, unknown>, key: string, code: string) {
|
||||
const value = body[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
throw new HttpError(400, `${key} is required`, code);
|
||||
}
|
||||
|
||||
function optionalBodyString(body: Record<string, unknown>, key: string) {
|
||||
const value = body[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function rsaSignSha256(privateKey: string, message: string) {
|
||||
return crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64');
|
||||
}
|
||||
|
||||
function rsaVerifySha256(publicKey: string, message: string, signature: string) {
|
||||
return crypto.createVerify('RSA-SHA256').update(message, 'utf8').verify(publicKey, signature, 'base64');
|
||||
}
|
||||
|
||||
function normalizePem(value: string, label: 'PRIVATE KEY' | 'PUBLIC KEY') {
|
||||
if (value.includes('-----BEGIN')) return value;
|
||||
const wrapped = value.match(/.{1,64}/g)?.join('\n') || value;
|
||||
return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function canonicalForm(params: Record<string, string>) {
|
||||
return Object.keys(params)
|
||||
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function encodedForm(params: Record<string, string>) {
|
||||
return Object.keys(params)
|
||||
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
|
||||
.sort()
|
||||
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function wechatPrivateKey(config: TenantPaymentProviderConfig) {
|
||||
return normalizePem(requireSecretString(config, ['privateKey', 'merchantPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
|
||||
}
|
||||
|
||||
function randomNonce(size = 16) {
|
||||
return crypto.randomBytes(size).toString('base64url');
|
||||
}
|
||||
|
||||
class WechatPayProvider implements PaymentProvider {
|
||||
readonly name = 'wechat_pay' as const;
|
||||
|
||||
constructor(private readonly config: TenantPaymentProviderConfig) {}
|
||||
|
||||
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
|
||||
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const endpoint = providerEndpoint(
|
||||
this.config,
|
||||
'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi',
|
||||
['api.mch.weixin.qq.com'],
|
||||
'PAYMENT_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const openId = input.openId || optionalPublicString(this.config, ['testOpenId']);
|
||||
if (!openId) {
|
||||
throw new HttpError(400, 'openId is required for WeChat JSAPI payment', 'WECHAT_OPENID_REQUIRED');
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
appid: appId,
|
||||
mchid: mchId,
|
||||
description: input.description.slice(0, 120),
|
||||
out_trade_no: input.orderNo,
|
||||
notify_url: notifyUrl,
|
||||
amount: {
|
||||
total: input.amountCents,
|
||||
currency: 'CNY',
|
||||
},
|
||||
payer: {
|
||||
openid: openId,
|
||||
},
|
||||
});
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const nonce = randomNonce();
|
||||
const url = new URL(endpoint);
|
||||
const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n';
|
||||
const signature = rsaSignSha256(wechatPrivateKey(this.config), message);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const raw = safeJson(await response.json().catch(() => ({})));
|
||||
if (!response.ok) {
|
||||
throw new HttpError(502, 'WeChat Pay create transaction failed', 'PAYMENT_PROVIDER_CREATE_FAILED');
|
||||
}
|
||||
|
||||
const prepayId = typeof raw.prepay_id === 'string' ? raw.prepay_id : '';
|
||||
if (!prepayId) throw new HttpError(502, 'WeChat Pay response is missing prepay_id', 'PAYMENT_PROVIDER_RESPONSE_INVALID');
|
||||
const payTimestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const payNonce = randomNonce();
|
||||
const packageValue = `prepay_id=${prepayId}`;
|
||||
const paySignMessage = [appId, payTimestamp, payNonce, packageValue].join('\n') + '\n';
|
||||
const paySign = rsaSignSha256(wechatPrivateKey(this.config), paySignMessage);
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
method: 'jsapi',
|
||||
providerTradeNo: prepayId,
|
||||
paymentParams: {
|
||||
appId,
|
||||
timeStamp: payTimestamp,
|
||||
nonceStr: payNonce,
|
||||
package: packageValue,
|
||||
signType: 'RSA',
|
||||
paySign,
|
||||
},
|
||||
raw: {
|
||||
prepayId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async parseNotification(input: {
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
rawBody: string;
|
||||
}): Promise<PaymentNotificationResult> {
|
||||
const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey'])
|
||||
|| requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED');
|
||||
const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false);
|
||||
if (publicKey && input.headers['wechatpay-signature']) {
|
||||
const timestamp = input.headers['wechatpay-timestamp'] || '';
|
||||
const nonceHeader = input.headers['wechatpay-nonce'] || '';
|
||||
const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`;
|
||||
if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) {
|
||||
throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
|
||||
}
|
||||
} else if (!allowMockSignature) {
|
||||
throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED');
|
||||
}
|
||||
|
||||
const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED');
|
||||
const resource = objectValue(input.body.resource);
|
||||
const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
const associatedData = optionalBodyString(resource, 'associated_data');
|
||||
|
||||
let decrypted: Record<string, unknown>;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
|
||||
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
||||
const encrypted = base64urlDecode(ciphertext);
|
||||
const authTag = encrypted.subarray(encrypted.length - 16);
|
||||
const cipherText = encrypted.subarray(0, encrypted.length - 16);
|
||||
decipher.setAuthTag(authTag);
|
||||
const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
||||
decrypted = safeJson(JSON.parse(buffer.toString('utf8')));
|
||||
} catch {
|
||||
throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
}
|
||||
|
||||
const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
if (tradeState !== 'SUCCESS') {
|
||||
throw new HttpError(409, `WeChat Pay trade state is ${tradeState}`, 'PAYMENT_NOT_PAID');
|
||||
}
|
||||
const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
const providerTradeNo = requiredBodyString(decrypted, 'transaction_id', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
const amount = objectValue(decrypted.amount);
|
||||
const amountCents = Number(amount.total);
|
||||
if (!Number.isInteger(amountCents) || amountCents < 0) {
|
||||
throw new HttpError(400, 'WeChat Pay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'),
|
||||
eventType: optionalBodyString(input.body, 'event_type') || 'TRANSACTION.SUCCESS',
|
||||
orderNo,
|
||||
providerTradeNo,
|
||||
amountCents,
|
||||
paidAt: optionalBodyString(decrypted, 'success_time') || undefined,
|
||||
raw: {
|
||||
notification: input.body,
|
||||
transaction: decrypted,
|
||||
headers: {
|
||||
serial: input.headers['wechatpay-serial'] || '',
|
||||
timestamp: input.headers['wechatpay-timestamp'] || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay' as const;
|
||||
|
||||
constructor(private readonly config: TenantPaymentProviderConfig) {}
|
||||
|
||||
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
|
||||
const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED');
|
||||
const returnUrl = input.returnUrl || optionalPublicString(this.config, ['returnUrl']);
|
||||
const quitUrl = input.quitUrl || optionalPublicString(this.config, ['quitUrl']);
|
||||
const gateway = providerEndpoint(
|
||||
this.config,
|
||||
'https://openapi.alipay.com/gateway.do',
|
||||
['openapi.alipay.com'],
|
||||
'PAYMENT_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY');
|
||||
const bizContent: Record<string, unknown> = {
|
||||
out_trade_no: input.orderNo,
|
||||
total_amount: (input.amountCents / 100).toFixed(2),
|
||||
subject: input.description.slice(0, 120),
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
};
|
||||
if (quitUrl) bizContent.quit_url = quitUrl;
|
||||
const params: Record<string, string> = {
|
||||
app_id: appId,
|
||||
method: 'alipay.trade.wap.pay',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'RSA2',
|
||||
timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||
version: '1.0',
|
||||
notify_url: notifyUrl,
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
if (returnUrl) params.return_url = returnUrl;
|
||||
params.sign = rsaSignSha256(privateKey, canonicalForm(params));
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
method: 'wap',
|
||||
paymentParams: {
|
||||
gateway,
|
||||
method: 'GET',
|
||||
query: params,
|
||||
url: `${gateway}?${encodedForm(params)}`,
|
||||
},
|
||||
raw: {
|
||||
outTradeNo: input.orderNo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async parseNotification(input: {
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
rawBody: string;
|
||||
}): Promise<PaymentNotificationResult> {
|
||||
const alipayPublicKey = normalizePem(
|
||||
requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'),
|
||||
'PUBLIC KEY',
|
||||
);
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input.body)) {
|
||||
if (typeof value === 'string') params[key] = value;
|
||||
}
|
||||
const sign = params.sign || '';
|
||||
const signType = params.sign_type || 'RSA2';
|
||||
delete params.sign;
|
||||
delete params.sign_type;
|
||||
if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID');
|
||||
if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) {
|
||||
throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID');
|
||||
}
|
||||
if (!['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(params.trade_status || '')) {
|
||||
throw new HttpError(409, `Alipay trade status is ${params.trade_status || 'unknown'}`, 'PAYMENT_NOT_PAID');
|
||||
}
|
||||
|
||||
const totalAmount = Number(params.total_amount || params.receipt_amount);
|
||||
const amountCents = Math.round(totalAmount * 100);
|
||||
if (!Number.isInteger(amountCents) || amountCents < 0) {
|
||||
throw new HttpError(400, 'Alipay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
eventId: params.notify_id || `${params.trade_no}:${params.trade_status}`,
|
||||
eventType: params.trade_status || 'TRADE_SUCCESS',
|
||||
orderNo: params.out_trade_no,
|
||||
providerTradeNo: params.trade_no,
|
||||
amountCents,
|
||||
paidAt: params.gmt_payment || params.notify_time || undefined,
|
||||
raw: {
|
||||
notification: input.body,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ManualPaymentProvider implements PaymentProvider {
|
||||
readonly name = 'manual' as const;
|
||||
|
||||
async createPayment(input: PaymentOrderInput): Promise<PaymentCreateResult> {
|
||||
return {
|
||||
provider: this.name,
|
||||
method: 'manual',
|
||||
paymentParams: {
|
||||
orderNo: input.orderNo,
|
||||
amountCents: input.amountCents,
|
||||
},
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
|
||||
async parseNotification(): Promise<PaymentNotificationResult> {
|
||||
throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePaymentProvider(value: string): PaymentProviderName {
|
||||
const normalized = value.toLowerCase().replace(/[-\s]/g, '_');
|
||||
if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay';
|
||||
if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay';
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
export function paymentProviderAliases(value: string) {
|
||||
const provider = normalizePaymentProvider(value);
|
||||
if (provider === 'wechat_pay') return ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay'];
|
||||
if (provider === 'alipay') return ['alipay', 'ali_pay'];
|
||||
return ['manual'];
|
||||
}
|
||||
|
||||
export function createPaymentProvider(
|
||||
name: string,
|
||||
config?: TenantPaymentProviderConfig | null,
|
||||
): PaymentProvider {
|
||||
const provider = normalizePaymentProvider(name);
|
||||
if (provider === 'wechat_pay') {
|
||||
if (!config) throw new HttpError(503, 'WeChat Pay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED');
|
||||
return new WechatPayProvider(config);
|
||||
}
|
||||
if (provider === 'alipay') {
|
||||
if (!config) throw new HttpError(503, 'Alipay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED');
|
||||
return new AlipayProvider(config);
|
||||
}
|
||||
return new ManualPaymentProvider();
|
||||
}
|
||||
|
||||
export function allowsLocalMockPayment(config: TenantPaymentProviderConfig | null) {
|
||||
return config ? optionalPublicBoolean(config, 'allowLocalMock', false) : false;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalInteger,
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { createOrderNo, grantSvipEntitlement } from './service.js';
|
||||
import {
|
||||
createPaymentProvider,
|
||||
normalizePaymentProvider,
|
||||
paymentProviderAliases,
|
||||
type PaymentProviderName,
|
||||
} from './providers.js';
|
||||
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
|
||||
|
||||
interface PlanRow {
|
||||
id: string;
|
||||
@@ -29,6 +36,25 @@ interface OrderRow {
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface PaymentRow {
|
||||
id: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
}
|
||||
|
||||
interface PaymentOrderRow {
|
||||
id: string;
|
||||
order_no: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
product_name: string | null;
|
||||
pay_provider: string | null;
|
||||
pay_method: string | null;
|
||||
days: number | null;
|
||||
user_id: string | null;
|
||||
region_id: string | null;
|
||||
}
|
||||
|
||||
interface EntitlementRow {
|
||||
id: string;
|
||||
entitlementType: string;
|
||||
@@ -322,6 +348,366 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
return { item };
|
||||
}
|
||||
|
||||
function requestHeaders(ctx: RequestContext) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(ctx.req.headers)) {
|
||||
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readWebhookBody(ctx: RequestContext) {
|
||||
const contentLength = Number(getHeader(ctx.req, 'content-length') || 0);
|
||||
const maxBytes = 1024 * 1024;
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new HttpError(413, 'Webhook body is too large', 'WEBHOOK_BODY_TOO_LARGE');
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
for await (const chunk of ctx.req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
ctx.req.destroy();
|
||||
throw new HttpError(413, 'Webhook body is too large', 'WEBHOOK_BODY_TOO_LARGE');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
const rawBody = Buffer.concat(chunks).toString('utf8');
|
||||
const contentType = getHeader(ctx.req, 'content-type').toLowerCase();
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
return {
|
||||
rawBody,
|
||||
body: Object.fromEntries(new URLSearchParams(rawBody).entries()),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = rawBody.trim() ? JSON.parse(rawBody) : {};
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new HttpError(400, 'Webhook JSON body must be an object', 'INVALID_WEBHOOK_BODY');
|
||||
}
|
||||
return { rawBody, body: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw new HttpError(400, 'Invalid webhook body', 'INVALID_WEBHOOK_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
function paidAtValue(value?: string) {
|
||||
return value || new Date().toISOString();
|
||||
}
|
||||
|
||||
async function loadPaymentProvider(tenantId: string, providerName: string) {
|
||||
const provider = normalizePaymentProvider(providerName);
|
||||
const providerConfig =
|
||||
provider === 'manual' ? null : await loadTenantPaymentProviderConfig(tenantId, paymentProviderAliases(provider));
|
||||
return createPaymentProvider(provider, providerConfig);
|
||||
}
|
||||
|
||||
export async function createPaymentRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const orderNo = requiredString(body, 'orderNo');
|
||||
|
||||
const order = await queryOne<PaymentOrderRow>(
|
||||
`
|
||||
select id, order_no, status, amount_cents, product_name, pay_provider, pay_method,
|
||||
days, user_id, region_id
|
||||
from public.orders
|
||||
where tenant_id = $1 and order_no = $2 and user_id = $3
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, orderNo, userId],
|
||||
);
|
||||
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
if (order.status === 'paid') throw new HttpError(409, 'Order is already paid', 'ORDER_ALREADY_PAID');
|
||||
if (order.status !== 'pending') throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
|
||||
|
||||
const providerName = optionalString(body, 'provider') || order.pay_provider || 'manual';
|
||||
const provider = await loadPaymentProvider(tenantId, providerName);
|
||||
const payment = await provider.createPayment({
|
||||
tenantId,
|
||||
orderId: order.id,
|
||||
orderNo: order.order_no,
|
||||
description: order.product_name || `题库会员 ${order.order_no}`,
|
||||
amountCents: order.amount_cents,
|
||||
userId,
|
||||
openId: optionalString(body, 'openId') || undefined,
|
||||
clientIp: getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || ctx.req.socket.remoteAddress || undefined,
|
||||
returnUrl: optionalString(body, 'returnUrl') || undefined,
|
||||
quitUrl: optionalString(body, 'quitUrl') || undefined,
|
||||
});
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const updatedPayment = await client.query(
|
||||
`
|
||||
update public.payments
|
||||
set provider_trade_no = coalesce($4, provider_trade_no),
|
||||
method = $5,
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $6::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and order_id = $2
|
||||
and provider = $3
|
||||
and status = 'pending'
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
order.id,
|
||||
payment.provider,
|
||||
payment.providerTradeNo || null,
|
||||
payment.method,
|
||||
JSON.stringify({ createPayment: payment.raw }),
|
||||
],
|
||||
);
|
||||
|
||||
if (!updatedPayment.rows[0]) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, provider_trade_no, raw_payload)
|
||||
values ($1, $2, $3, $4, 'pending', $5, $6, $7::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
order.id,
|
||||
payment.provider,
|
||||
payment.method,
|
||||
order.amount_cents,
|
||||
payment.providerTradeNo || null,
|
||||
JSON.stringify({ createPayment: payment.raw }),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.orders
|
||||
set pay_provider = $3, pay_method = $4, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, order.id, payment.provider, payment.method],
|
||||
);
|
||||
|
||||
return {
|
||||
orderNo: order.order_no,
|
||||
provider: payment.provider,
|
||||
method: payment.method,
|
||||
paymentParams: payment.paymentParams,
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function paymentNotifyRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const providerName = ctx.url.pathname.split('/').at(-1) || optionalString(Object.fromEntries(ctx.url.searchParams), 'provider');
|
||||
const provider = await loadPaymentProvider(tenantId, providerName);
|
||||
const { rawBody, body } = await readWebhookBody(ctx);
|
||||
const notification = await provider.parseNotification({
|
||||
headers: requestHeaders(ctx),
|
||||
body,
|
||||
rawBody,
|
||||
});
|
||||
const eventKey = `${tenantId}:${notification.eventId}`;
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const eventResult = await client.query<{
|
||||
id: string;
|
||||
processedAt: string | null;
|
||||
error: string | null;
|
||||
}>(
|
||||
`
|
||||
insert into public.payment_events (
|
||||
tenant_id, provider, event_type, event_id, signature_valid, payload
|
||||
)
|
||||
values ($1, $2, $3, $4, true, $5::jsonb)
|
||||
on conflict (provider, event_id)
|
||||
do update set payload = case
|
||||
when public.payment_events.processed_at is null or public.payment_events.error is not null
|
||||
then excluded.payload
|
||||
else public.payment_events.payload
|
||||
end,
|
||||
signature_valid = true
|
||||
returning id, processed_at as "processedAt", error
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
notification.provider,
|
||||
notification.eventType,
|
||||
eventKey,
|
||||
JSON.stringify(notification.raw),
|
||||
],
|
||||
);
|
||||
|
||||
const event = eventResult.rows[0];
|
||||
if (event.processedAt && !event.error) {
|
||||
return {
|
||||
status: 'processed',
|
||||
idempotent: true,
|
||||
eventId: notification.eventId,
|
||||
orderNo: notification.orderNo,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const orderResult = await client.query<OrderRow>(
|
||||
`
|
||||
select id, order_no, status, amount_cents, days, user_id, region_id
|
||||
from public.orders
|
||||
where tenant_id = $1 and order_no = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, notification.orderNo],
|
||||
);
|
||||
const order = orderResult.rows[0];
|
||||
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
if (!order.user_id) throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
|
||||
if (order.amount_cents !== notification.amountCents) {
|
||||
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
|
||||
}
|
||||
|
||||
let payment = (
|
||||
await client.query<PaymentRow>(
|
||||
`
|
||||
select id, status, amount_cents
|
||||
from public.payments
|
||||
where tenant_id = $1 and order_id = $2 and provider = $3
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, order.id, notification.provider],
|
||||
)
|
||||
).rows[0];
|
||||
|
||||
if (!payment) {
|
||||
const inserted = await client.query<PaymentRow>(
|
||||
`
|
||||
insert into public.payments (
|
||||
tenant_id, order_id, provider, method, status, amount_cents, provider_trade_no, paid_at, raw_payload
|
||||
)
|
||||
values ($1, $2, $3, 'webhook', 'pending', $4, $5, $6::timestamptz, $7::jsonb)
|
||||
returning id, status, amount_cents
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
order.id,
|
||||
notification.provider,
|
||||
notification.amountCents,
|
||||
notification.providerTradeNo,
|
||||
paidAtValue(notification.paidAt),
|
||||
JSON.stringify({ notification: notification.raw }),
|
||||
],
|
||||
);
|
||||
payment = inserted.rows[0];
|
||||
}
|
||||
|
||||
if (payment.amount_cents !== notification.amountCents) {
|
||||
throw new HttpError(409, 'Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH');
|
||||
}
|
||||
|
||||
if (order.status === 'paid') {
|
||||
await client.query(
|
||||
`
|
||||
update public.payment_events
|
||||
set payment_id = $2, processed_at = coalesce(processed_at, now()), error = null
|
||||
where id = $1
|
||||
`,
|
||||
[event.id, payment.id],
|
||||
);
|
||||
return {
|
||||
status: 'paid',
|
||||
idempotent: true,
|
||||
eventId: notification.eventId,
|
||||
orderNo: order.order_no,
|
||||
};
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.orders
|
||||
set status = 'paid', trade_no = $3, paid_at = $4::timestamptz,
|
||||
pay_provider = $5, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
order.id,
|
||||
notification.providerTradeNo,
|
||||
paidAtValue(notification.paidAt),
|
||||
notification.provider,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.payments
|
||||
set status = 'paid', provider_trade_no = $3, paid_at = $4::timestamptz,
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
payment.id,
|
||||
notification.providerTradeNo,
|
||||
paidAtValue(notification.paidAt),
|
||||
JSON.stringify({ notification: notification.raw }),
|
||||
],
|
||||
);
|
||||
|
||||
const entitlement = await grantSvipEntitlement(client, {
|
||||
tenantId,
|
||||
userId: order.user_id,
|
||||
days: order.days || 0,
|
||||
regionId: order.region_id,
|
||||
sourceType: 'order',
|
||||
sourceId: order.id,
|
||||
metadata: {
|
||||
orderNo: order.order_no,
|
||||
paymentProvider: notification.provider,
|
||||
providerTradeNo: notification.providerTradeNo,
|
||||
},
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.payment_events
|
||||
set payment_id = $2, processed_at = now(), error = null
|
||||
where id = $1
|
||||
`,
|
||||
[event.id, payment.id],
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'paid',
|
||||
idempotent: false,
|
||||
eventId: notification.eventId,
|
||||
orderNo: order.order_no,
|
||||
entitlement,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query(
|
||||
`
|
||||
update public.payment_events
|
||||
set error = $2
|
||||
where id = $1
|
||||
`,
|
||||
[event.id, error instanceof Error ? error.message : 'Unknown payment notification error'],
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function redeemActivationCodeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
|
||||
Reference in New Issue
Block a user