| name | payment-kit-integration |
| description | Use when adding Stripe or PAYUNi payments plus Taiwan Amego e-invoicing to a Cloudflare Workers project. Scaffolds the chosen gateway, D1 migration, env wiring, checkout/redirect, webhook/notify, and fulfillment around the @zhenheco/payment-kit library. |
Payment Kit Integration Playbook
Follow this playbook inside the user's Cloudflare Workers project. This repo is a reusable library plus integration playbook, not an app, MCP server, ops CLI, scaffolder binary, publishing flow, or repository creator. Adapt file paths, route style, auth, product lookup, and business side effects to the host project.
Use Stripe for overseas/card paths when the host wants Stripe Checkout. Use PAYUNi for Taiwan domestic payments and recurring/period payments. Both payment paths can feed the same Amego Taiwan e-invoice step for TWD invoices.
0. Prerequisites
Confirm the host project has:
- A Cloudflare Workers project, either Hono or plain
fetch.
- A D1 binding for invoice state, for example
env.DB.
- Amego seller credentials:
AMEGO_APP_KEY and AMEGO_MERCHANT_ID.
- For Stripe path:
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
- For PAYUNi path:
PAYUNI_MERCHANT_ID, PAYUNI_HASH_KEY, PAYUNI_HASH_IV, and an isTest/mode decision.
TWD checkout paths should collect invoice buyer data and issue Taiwan invoices. Overseas or USD paths usually skip Taiwan invoice issuance by returning null from the Stripe mapEvent, or by not calling fulfillInvoice from the PAYUNi handler.
1. Install
Install from the GitHub repo link:
pnpm add github:zhenheco/payment-kit
npm i github:zhenheco/payment-kit
yarn add github:zhenheco/payment-kit
The package has a prepare script, so dist/ is built automatically during Git install.
Top-level imports:
import { fromEnv, fulfillInvoice, D1Store } from "@zhenheco/payment-kit";
PAYUNi imports:
import { createEncryptedRequest, processResult } from "@zhenheco/payment-kit/payuni";
2. Env to Config
Stripe and Amego env names:
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
AMEGO_APP_KEY
AMEGO_MERCHANT_ID
APP_BASE_URL
AMEGO_ENDPOINT optional, defaults to Amego production endpoint
PAYUNi env names:
PAYUNI_MERCHANT_ID
PAYUNI_HASH_KEY
PAYUNI_HASH_IV
PAYUNI_MODE or equivalent host-owned flag used to set isTest
Production secrets go into Cloudflare:
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put AMEGO_APP_KEY
wrangler secret put AMEGO_MERCHANT_ID
wrangler secret put APP_BASE_URL
wrangler secret put PAYUNI_MERCHANT_ID
wrangler secret put PAYUNI_HASH_KEY
wrangler secret put PAYUNI_HASH_IV
Local development can use .dev.vars, but only with local secret values or secret references according to the host policy. Never hardcode secrets in source, migrations, docs, logs, or tests.
Map Stripe and Amego through the library and inject the resulting configs:
import { fromEnv } from "@zhenheco/payment-kit";
const { stripe, amego } = fromEnv(env);
Create PAYUNi config in the host and inject it:
import type { PayuniConfig } from "@zhenheco/payment-kit/payuni";
const payuni: PayuniConfig = {
merchantId: env.PAYUNI_MERCHANT_ID,
hashKey: env.PAYUNI_HASH_KEY,
hashIv: env.PAYUNI_HASH_IV,
isTest: env.PAYUNI_MODE !== "production",
};
All config branches must be dependency-injected. The package must not read global env itself.
3. D1 Migration
Use the canonical exported schema:
import { INVOICES_MIGRATION_SQL, splitSqlStatements } from "@zhenheco/payment-kit";
Option A: write the schema into the host migration folder and run Wrangler:
mkdir -p migrations
node --input-type=module -e 'import { INVOICES_MIGRATION_SQL } from "@zhenheco/payment-kit"; import { writeFileSync } from "node:fs"; writeFileSync("migrations/0001_invoices.sql", INVOICES_MIGRATION_SQL + "\n");'
wrangler d1 migrations apply DB
Use the host's actual D1 database name instead of DB if different.
Option B: execute statements from code during a controlled deploy/bootstrap step:
import { INVOICES_MIGRATION_SQL, splitSqlStatements } from "@zhenheco/payment-kit";
for (const statement of splitSqlStatements(INVOICES_MIGRATION_SQL)) {
await env.DB.prepare(statement).run();
}
4. Shared Invoice Form
For TWD paths, collect one Taiwan invoice choice and validate it with the exported schema:
import {
CERTIFICATE_CARRIER_REGEX,
DONATION_CODE_REGEX,
MOBILE_CARRIER_REGEX,
TAX_ID_REGEX,
carrierTypeEnum,
invoiceInputSchema,
toInvoiceBuyer,
} from "@zhenheco/payment-kit";
const parsed = invoiceInputSchema.parse(formData);
const buyer = toInvoiceBuyer(parsed);
Supported invoice choices:
- Cloud invoice: omit carrier and B2B fields; use
DEFAULT_CLOUD_INVOICE as a fallback snapshot.
- Mobile barcode:
carrierType: "MOBILE", carrierId must match MOBILE_CARRIER_REGEX.
- Citizen digital certificate:
carrierType: "CERTIFICATE", carrierId must match CERTIFICATE_CARRIER_REGEX.
- Donation:
carrierType: "DONATE", npoban must match DONATION_CODE_REGEX.
- B2B tax ID:
buyerTaxId must match TAX_ID_REGEX and requires buyerName.
The UI vocabulary is MOBILE, CERTIFICATE, and DONATE; toInvoiceBuyer maps carrier values to Amego carrier codes.
5. Stripe Playbook
The host owns product selection, auth, Stripe price IDs, and redirect URLs. The library creates Stripe Checkout Sessions and stores the Taiwan invoice buyer snapshot in Stripe metadata.
5.1 Checkout Endpoint
import {
createCreditCheckout,
createSubscriptionCheckout,
fromEnv,
invoiceInputSchema,
toInvoiceBuyer,
} from "@zhenheco/payment-kit";
const { stripe } = fromEnv(env);
if (!stripe) return Response.json({ error: "stripe_not_configured" }, { status: 503 });
const body = await request.json();
const buyer = toInvoiceBuyer(invoiceInputSchema.parse(body.invoice));
One-time TWD credit purchase:
const pack = resolveHostCreditPack(body.pack);
const session = await createCreditCheckout(stripe, {
userId: user.id,
pack: pack.id,
credits: pack.credits,
amount: pack.amountTwd,
currency: "twd",
buyer,
successUrl: `${env.APP_BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${env.APP_BASE_URL}/billing/cancel`,
productName: pack.label,
});
return Response.json({ id: session.id, url: session.url });
Recurring subscription:
const priceId = resolveHostStripePriceId(body.plan, body.period);
const session = await createSubscriptionCheckout(stripe, {
userId: user.id,
chapterId: body.chapterId,
plan: body.plan,
period: body.period,
priceId,
buyer,
successUrl: `${env.APP_BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${env.APP_BASE_URL}/billing/cancel`,
});
return Response.json({ id: session.id, url: session.url });
For overseas or USD products, the host can skip invoice collection and omit buyer.
5.2 Stripe Webhook Endpoint
Core rule: verify the raw Stripe payload before parsing or side effects. A verified Stripe event should return HTTP 200 even when Amego is down; failed invoice issuance is recorded and retried.
import {
D1Store,
DEFAULT_CLOUD_INVOICE,
extractTwInvoice,
fromEnv,
processStripeInvoiceEvent,
stripeMinorToMajor,
verifyStripeSignature,
} from "@zhenheco/payment-kit";
const { stripe, amego } = fromEnv(env);
if (!stripe) return Response.json({ error: "stripe_not_configured" }, { status: 503 });
const rawBody = await request.text();
const sig = request.headers.get("stripe-signature");
const valid = await verifyStripeSignature(rawBody, sig, stripe.webhookSecret);
if (!valid) return Response.json({ error: "invalid_signature" }, { status: 400 });
const event = JSON.parse(rawBody) as StripeEvent;
await runHostSideEffects(event, env);
const result = await processStripeInvoiceEvent(event, {
store: new D1Store(env.DB),
amegoConfig: amego,
mapEvent: (evt) => {
if (evt.type !== "checkout.session.completed") return null;
const session = evt.data.object;
if (String(session.currency).toLowerCase() !== "twd") return null;
return {
source: session.metadata.kind === "subscription" ? "subscription" : "credits",
orderId: session.id,
amount: stripeMinorToMajor(session.amount_total, session.currency),
currency: "TWD",
userId: session.metadata.user_id,
chapterId: session.metadata.chapter_id,
stripeSessionId: session.id,
buyer: extractTwInvoice(session.metadata) ?? DEFAULT_CLOUD_INVOICE,
creditPoints: Number(session.metadata.credits ?? 0),
planLabel: session.metadata.plan,
};
},
});
return Response.json({ received: true, invoiceAction: result?.action ?? "skipped" }, { status: 200 });
Exact function signatures used:
verifyStripeSignature(payload, sigHeader, secret, nowSeconds?, toleranceSeconds?)
processStripeInvoiceEvent(event, { store, amegoConfig, mapEvent }, now?)
fulfillInvoice(store, amegoConfig, input, now?)
retryInvoice(store, amegoConfig, row, now?)
Use fulfillInvoice directly when the host has already mapped the event to FulfillInvoiceInput. Use retryInvoice in a cron that loads rows from store.listPendingForRetry(new Date()).
6. PAYUNi Playbook
PAYUNi primitives live under @zhenheco/payment-kit/payuni. All cryptographic helpers are async and use Web Crypto.
6.1 One-Time Payment Request
Build a Taiwan domestic payment request with buildTradeParams and createEncryptedRequest, then POST or redirect the browser to getApiUrl(payuni).
import {
buildTradeParams,
createEncryptedRequest,
getApiUrl,
type PayuniConfig,
} from "@zhenheco/payment-kit/payuni";
const payuni: PayuniConfig = {
merchantId: env.PAYUNI_MERCHANT_ID,
hashKey: env.PAYUNI_HASH_KEY,
hashIv: env.PAYUNI_HASH_IV,
isTest: env.PAYUNI_MODE !== "production",
};
const params = {
orderId: `ord_${crypto.randomUUID().replaceAll("-", "")}`,
amount: pack.amountTwd,
itemDesc: pack.label,
email: user.email,
returnUrl: `${env.APP_BASE_URL}/billing/payuni/return`,
notifyUrl: `${env.APP_BASE_URL}/webhooks/payuni`,
payerName: user.name,
payerPhone: user.phone,
};
const tradeParams = buildTradeParams(params, payuni);
const encrypted = await createEncryptedRequest(params, payuni);
return Response.json({
action: "POST",
url: getApiUrl(payuni),
fields: {
MerID: payuni.merchantId,
Version: String(tradeParams.Version),
EncryptInfo: encrypted.EncryptInfo,
HashInfo: encrypted.HashInfo,
},
});
6.2 PAYUNi Notify / Return Endpoint
Verify HashInfo, decrypt EncryptInfo, parse the result, run host side effects idempotently, then issue an Amego invoice through fulfillInvoice. processResult is the convenience wrapper for verify + decrypt + parse when you do not need the decrypted payload.
import {
D1Store,
DEFAULT_CLOUD_INVOICE,
fulfillInvoice,
fromEnv,
} from "@zhenheco/payment-kit";
import {
decrypt,
parseResult,
processResult,
verifyHash,
} from "@zhenheco/payment-kit/payuni";
const form = await request.formData();
const encryptInfo = String(form.get("EncryptInfo") ?? "");
const hashInfo = String(form.get("HashInfo") ?? "");
if (!(await verifyHash(encryptInfo, hashInfo, payuni.hashKey, payuni.hashIv))) {
return Response.json({ error: "invalid_signature" }, { status: 400 });
}
const decrypted = await decrypt(encryptInfo, payuni.hashKey, payuni.hashIv);
const payuniResult = await parseResult(decrypted);
if (!payuniResult.success) {
await recordHostPaymentFailure(payuniResult, env);
return Response.json({ received: true, status: payuniResult.status }, { status: 200 });
}
await runHostSideEffects(payuniResult, env);
const { amego } = fromEnv(env);
const invoiceResult = await fulfillInvoice(new D1Store(env.DB), amego, {
source: "credits",
orderId: payuniResult.orderId ?? crypto.randomUUID(),
amount: payuniResult.amount ?? 0,
currency: "TWD",
userId: await resolveUserIdFromOrder(payuniResult.orderId),
buyer: DEFAULT_CLOUD_INVOICE,
creditPoints: await resolveCreditPointsFromOrder(payuniResult.orderId),
});
return Response.json({ received: true, invoiceAction: invoiceResult.action }, { status: 200 });
If the host needs direct Amego issuance instead of the D1 fulfillment flow, use issueInvoice(amegoConfig, { orderId, amount, description, buyer }), but prefer fulfillInvoice when invoice state and retry matter.
6.3 PAYUNi Recurring / Period Payments
Use buildPeriodTradeParams for recurring/period payments and send the encrypted payload to getPeriodApiUrl(payuni).
import {
buildPeriodTradeParams,
encrypt,
generateHash,
getPeriodApiUrl,
} from "@zhenheco/payment-kit/payuni";
const periodParams = buildPeriodTradeParams(
{
orderId: `sub_${crypto.randomUUID().replaceAll("-", "")}`,
periodParams: {
periodAmt: plan.amountTwd,
prodDesc: plan.label,
periodType: "month",
periodDate: "1",
periodTimes: 12,
firstType: "build",
payerEmail: user.email,
returnUrl: `${env.APP_BASE_URL}/billing/payuni/period-return`,
notifyUrl: `${env.APP_BASE_URL}/webhooks/payuni`,
},
},
payuni,
);
const encryptInfo = await encrypt(periodParams, payuni.hashKey, payuni.hashIv);
const hashInfo = await generateHash(encryptInfo, payuni.hashKey, payuni.hashIv);
return Response.json({
action: "POST",
url: getPeriodApiUrl(payuni),
fields: {
MerID: payuni.merchantId,
EncryptInfo: encryptInfo,
HashInfo: hashInfo,
},
});
7. Retry Failed Invoices
import { retryInvoice } from "@zhenheco/payment-kit";
const due = await store.listPendingForRetry(new Date());
for (const row of due) {
await retryInvoice(store, amego, row);
}
D1Store implements the InvoiceStore interface. Hosts can use another database by implementing InvoiceStore.
8. Test and Go Live Checklist
Before merging:
- Mock-test Stripe checkout creation, webhook signature failure, verified webhook success, duplicate webhook replay, and Amego failure/retry behavior.
- Mock-test PAYUNi encryption/decryption, invalid hash rejection, successful notify parsing, failed payment handling, duplicate notify replay, and recurring trade param generation.
- Confirm routes read raw request text or form data before parsing.
- Confirm all secrets come from env and configs are dependency-injected.
- Confirm D1 migration applies locally and remotely.
- Confirm host side effects are idempotent and separate from invoice issuance.
Before live:
- Provision an Amego TEST seller account.
- Provision Stripe products/prices and record host-owned price IDs if using Stripe.
- Provision PAYUNi test credentials if using PAYUNi.
- Fill Cloudflare secrets with
wrangler secret put.
- Send real Stripe webhook and/or PAYUNi notify traffic and verify HTTP 200 after valid signatures.
- Confirm invoice rows move through
pending, issued, or failed with retry.
- Confirm Amego
SalesAmount and TaxAmount rounding against real TWD totals.