feat: mount wechat pay webhook router
This commit is contained in:
143
server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts
vendored
Normal file
143
server/src/external/domestic-payments/wechat/wechatPayWebhookRouter.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user