feat: mount wechat pay webhook router

This commit is contained in:
2026-07-06 02:20:19 -07:00
parent f634462eb6
commit e6808e74e5
4 changed files with 296 additions and 0 deletions

View File

@@ -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<HonoEnv>;
}) => Promise<AutumnContext | null>;
export type WeChatPayWebhookRouterConfig = {
buildCtx?: WeChatPayWebhookContextBuilder;
configService?: Pick<
DomesticPaymentProcessorConfigService,
"getEnabledConfig"
>;
resolveSecret: DomesticPaymentSecretResolver;
signMessage?: WeChatPaySignMessage;
verifyMessage?: WeChatPayVerifyMessage;
decryptAesGcm?: WeChatPayDecryptAesGcm;
intentService?: Pick<DomesticPaymentIntentService, "processParsedNotification">;
};
export function createWeChatPayWebhookRouter({
buildCtx,
configService = new DomesticPaymentProcessorConfigService(),
resolveSecret,
signMessage,
verifyMessage,
decryptAesGcm,
intentService,
}: WeChatPayWebhookRouterConfig) {
const router = new Hono<HonoEnv>();
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<HonoEnv>;
}) {
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,
};
}

View File

@@ -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);

View File

@@ -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<DomesticPaymentProcessorConfig | null> {
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;
}
}

View File

@@ -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",
});
});
});