From e6808e74e54fd5fe0ba9321f049d852e50adcaa9 Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 6 Jul 2026 02:20:19 -0700 Subject: [PATCH] feat: mount wechat pay webhook router --- .../wechat/wechatPayWebhookRouter.ts | 143 ++++++++++++++++++ server/src/initHono.ts | 13 ++ .../DomesticPaymentProcessorConfigService.ts | 32 ++++ .../wechat-pay-webhook-router.test.ts | 108 +++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts create mode 100644 server/src/internal/domestic-payments/DomesticPaymentProcessorConfigService.ts create mode 100644 server/tests/unit/domestic-payments/wechat-pay-webhook-router.test.ts diff --git a/server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts b/server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts new file mode 100644 index 000000000..91ef0d585 --- /dev/null +++ b/server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts @@ -0,0 +1,143 @@ +import { + AppEnv, + AuthType, + DomesticPaymentProvider, + type Feature, + type Organization, +} from "@autumn/shared"; +import { Hono, type Context } from "hono"; +import { StatusCodes } from "http-status-codes"; +import { baseMiddleware } from "@/honoMiddlewares/baseMiddleware.js"; +import type { AutumnContext, HonoEnv } from "@/honoUtils/HonoEnv.js"; +import type { DomesticPaymentIntentService } from "@/internal/domestic-payments/DomesticPaymentIntentService.js"; +import { DomesticPaymentProcessorConfigService } from "@/internal/domestic-payments/DomesticPaymentProcessorConfigService.js"; +import { + createWeChatPayNativeAdapterFromConfig, + type DomesticPaymentSecretResolver, + type WeChatPayDecryptAesGcm, + type WeChatPaySignMessage, + type WeChatPayVerifyMessage, +} from "@/internal/domestic-payments/wechat/createWeChatPayNativeAdapterFromConfig.js"; +import { createWeChatPayWebhookHandler } from "@/internal/domestic-payments/wechat/handleWeChatPayWebhook.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +type WeChatPayWebhookContextBuilder = ({ + orgId, + env, + c, +}: { + orgId: string; + env: AppEnv; + c: Context; +}) => Promise; + +export type WeChatPayWebhookRouterConfig = { + buildCtx?: WeChatPayWebhookContextBuilder; + configService?: Pick< + DomesticPaymentProcessorConfigService, + "getEnabledConfig" + >; + resolveSecret: DomesticPaymentSecretResolver; + signMessage?: WeChatPaySignMessage; + verifyMessage?: WeChatPayVerifyMessage; + decryptAesGcm?: WeChatPayDecryptAesGcm; + intentService?: Pick; +}; + +export function createWeChatPayWebhookRouter({ + buildCtx, + configService = new DomesticPaymentProcessorConfigService(), + resolveSecret, + signMessage, + verifyMessage, + decryptAesGcm, + intentService, +}: WeChatPayWebhookRouterConfig) { + const router = new Hono(); + const resolveCtx = buildCtx ?? buildDefaultWebhookCtx; + + router.use("*", async (c, next) => { + if (buildCtx) { + return next(); + } + return baseMiddleware(c, next); + }); + + router.post("/webhooks/wechatpay/:orgId/:env", async (c) => { + const orgId = c.req.param("orgId"); + const env = c.req.param("env") as AppEnv; + const ctx = await resolveCtx({ orgId, env, c }); + + if (!ctx) { + return c.json( + { + code: "FAIL", + message: `Org ${orgId} not found`, + }, + StatusCodes.BAD_REQUEST, + ); + } + + c.set("ctx", ctx); + const config = await configService.getEnabledConfig({ + ctx, + provider: DomesticPaymentProvider.WeChatPay, + }); + + if (!config) { + return c.json( + { + code: "FAIL", + message: "WeChat Pay processor config is not enabled", + }, + StatusCodes.BAD_REQUEST, + ); + } + + const adapter = createWeChatPayNativeAdapterFromConfig({ + config, + resolveSecret, + ...(signMessage ? { signMessage } : {}), + ...(verifyMessage ? { verifyMessage } : {}), + ...(decryptAesGcm ? { decryptAesGcm } : {}), + }); + + return createWeChatPayWebhookHandler({ + adapter, + ...(intentService ? { intentService } : {}), + })(c); + }); + + return router; +} + +async function buildDefaultWebhookCtx({ + orgId, + env, + c, +}: { + orgId: string; + env: AppEnv; + c: Context; +}) { + const ctx = c.get("ctx"); + const data = await OrgService.getWithFeatures({ + db: ctx.db, + orgId, + env, + allowNotFound: true, + }); + + if (!data) { + return null; + } + + return { + ...ctx, + org: data.org as Organization, + features: data.features as Feature[], + env, + authType: AuthType.Unknown, + isPublic: true, + }; +} diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 7f9d98d8d..841fdc83a 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -2,6 +2,7 @@ import { type Context, Hono } from "hono"; import { cors } from "hono/cors"; import { createRequestScopedDb } from "./db/initDrizzle.js"; import { createAutumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js"; +import { createWeChatPayWebhookRouter } from "./external/domestic-payments/wechat/wechatPayWebhookRouter.js"; import { createRevenueCatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js"; import { createStripeWebhookRouter } from "./external/stripe/stripeWebhookRouter.js"; import { vercelTestApiRouter } from "./external/vercel/vercelTestApiRouter.js"; @@ -147,6 +148,17 @@ export const createHonoApp = (_env: Env) => { const getRevenueCatWebhookRouter = memoizeRouter(() => createRevenueCatWebhookRouter(), ); + const getWeChatPayWebhookRouter = memoizeRouter(() => + createWeChatPayWebhookRouter({ + resolveSecret: async (secretRef) => { + const value = _env[secretRef as keyof Env]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing domestic payment secret: ${secretRef}`); + } + return value; + }, + }), + ); const getPublicRouter = memoizeRouter(() => createPublicRouter()); const getApiRouter = memoizeRouter(() => createApiRouter()); @@ -231,6 +243,7 @@ export const createHonoApp = (_env: Env) => { mountRouter(app, "/webhooks/autumn", getAutumnWebhookRouter); mountRouter(app, "/webhooks/vercel", getVercelWebhookRouter); mountRouter(app, "/webhooks/revenuecat", getRevenueCatWebhookRouter); + mountRouter(app, "/webhooks/wechatpay", getWeChatPayWebhookRouter); if ((_env.NODE_ENV as string | undefined) !== "production") { mountRouter(app, "/__test/vercel/api", () => vercelTestApiRouter); diff --git a/server/src/internal/domestic-payments/DomesticPaymentProcessorConfigService.ts b/server/src/internal/domestic-payments/DomesticPaymentProcessorConfigService.ts new file mode 100644 index 000000000..f62d68a81 --- /dev/null +++ b/server/src/internal/domestic-payments/DomesticPaymentProcessorConfigService.ts @@ -0,0 +1,32 @@ +import { + domesticPaymentProcessorConfigs, + type DomesticPaymentProcessorConfig, + type DomesticPaymentProvider, +} from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +export class DomesticPaymentProcessorConfigService { + async getEnabledConfig({ + ctx, + provider, + }: { + ctx: AutumnContext; + provider: DomesticPaymentProvider; + }): Promise { + const [config] = await ctx.db + .select() + .from(domesticPaymentProcessorConfigs) + .where( + and( + eq(domesticPaymentProcessorConfigs.org_id, ctx.org.id), + eq(domesticPaymentProcessorConfigs.env, ctx.env), + eq(domesticPaymentProcessorConfigs.provider, provider), + eq(domesticPaymentProcessorConfigs.status, "enabled"), + ), + ) + .limit(1); + + return (config as DomesticPaymentProcessorConfig | undefined) ?? null; + } +} diff --git a/server/tests/unit/domestic-payments/wechat-pay-webhook-router.test.ts b/server/tests/unit/domestic-payments/wechat-pay-webhook-router.test.ts new file mode 100644 index 000000000..84fba3781 --- /dev/null +++ b/server/tests/unit/domestic-payments/wechat-pay-webhook-router.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + AppEnv, + DomesticPaymentProvider, + type DomesticPaymentProcessorConfig, +} from "@autumn/shared"; +import { createWeChatPayWebhookRouter } from "@/external/domestic-payments/wechat/wechatPayWebhookRouter.js"; + +const enabledConfig: DomesticPaymentProcessorConfig = { + id: "dppc_123", + org_id: "org_123", + env: AppEnv.Sandbox, + provider: DomesticPaymentProvider.WeChatPay, + status: "enabled", + config: { + app_id: "wx_app_123", + mch_id: "1900000109", + merchant_private_key_ref: "secret:wechat:merchant-private-key", + platform_public_key_ref: "secret:wechat:platform-public-key", + api_v3_key_ref: "secret:wechat:api-v3-key", + notify_url: "https://api.example.com/webhooks/wechatpay/org_123/sandbox", + }, + created_at: 1_774_000_000_000, + updated_at: 1_774_000_000_000, +}; + +describe("createWeChatPayWebhookRouter", () => { + test("processes a mounted WeChat webhook for org and env", async () => { + const processParsedNotification = mock(async () => ({ + intent: { id: "dp_intent_123" }, + event: { inserted: true, event: { id: "dp_event_123" } }, + })); + const router = createWeChatPayWebhookRouter({ + buildCtx: async ({ orgId, env }) => + ({ + org: { id: orgId }, + env, + db: {}, + }) as never, + configService: { + getEnabledConfig: mock(async () => enabledConfig), + }, + resolveSecret: async () => "secret_value", + signMessage: async () => "signed_test", + verifyMessage: async () => true, + decryptAesGcm: async () => + JSON.stringify({ + out_trade_no: "dp_order_123", + transaction_id: "wx_txn_123", + trade_state: "SUCCESS", + }), + intentService: { processParsedNotification } as never, + }); + + const response = await router.request("/webhooks/wechatpay/org_123/sandbox", { + method: "POST", + body: JSON.stringify({ + id: "notify_123", + event_type: "TRANSACTION.SUCCESS", + resource: { + algorithm: "AEAD_AES_256_GCM", + ciphertext: "ciphertext", + associated_data: "associated_data", + nonce: "nonce", + }, + }), + headers: { + "Wechatpay-Timestamp": "1774000000", + "Wechatpay-Nonce": "nonce_test", + "Wechatpay-Signature": "signature_test", + "Wechatpay-Serial": "serial_test", + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + code: "SUCCESS", + message: "成功", + }); + expect(processParsedNotification).toHaveBeenCalledTimes(1); + }); + + test("returns FAIL when no enabled config exists", async () => { + const router = createWeChatPayWebhookRouter({ + buildCtx: async ({ orgId, env }) => + ({ + org: { id: orgId }, + env, + db: {}, + }) as never, + configService: { + getEnabledConfig: mock(async () => null), + }, + resolveSecret: async () => "secret_value", + }); + + const response = await router.request("/webhooks/wechatpay/org_123/sandbox", { + method: "POST", + body: "{}", + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + code: "FAIL", + message: "WeChat Pay processor config is not enabled", + }); + }); +});