Payments
Multi-provider checkout and webhooks for the Nuxt template — Creem, Stripe, and the ZPay demo.
Payments are an end-to-end billing flow you can build on. A provider factory registers each provider only when its env vars are present. Creem and Stripe are the env-selectable providers; ZPay is a China-oriented (Alipay) demo provider with its own webhook. Checkout, the orders and billing pages, subscription state, and the Creem/Stripe webhooks (including the subscription lifecycle and refunds) all run against real data.
Pick a provider
Creem
Hosted checkout + signed webhooks. Default provider.
Stripe
Stripe Checkout, subscriptions, lifetime, and the billing portal.
ZPay
Alipay-style demo provider with an MD5-signed callback.
The provider factory
server/payment/index.ts exposes getPaymentProviderFactory() (a singleton).
The factory registers a provider only when its env vars are present:
- ZPay when
NUXT_ZPAY_URL,NUXT_ZPAY_MERCH_ID, andNUXT_ZPAY_MERCH_KEYare all set. - Creem when
NUXT_CREEM_API_KEYis set. - Stripe when
NUXT_STRIPE_API_KEYis set.
The default provider used by the checkout flow comes from
getPaymentProvider() — creem unless NUXT_PUBLIC_PAYMENT_PROVIDER=stripe.
(ZPay is reachable by its own webhook but is not selectable through
NUXT_PUBLIC_PAYMENT_PROVIDER.)
Each provider implements the interface in server/payment/types.ts:
interface PaymentProvider {
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
handleCallback(callbackParams: unknown): Promise<CallbackVerificationResult>;
}Providers live in server/payment/providers/{creem,stripe,zpay}-provider.ts.
Config sources
| File | Drives | Notes |
|---|---|---|
app/config/payment-config.ts | The checkout API's product/amount resolution | getPaymentProvider + getPaymentProducts; Creem or Stripe IDs from runtimeConfig.public |
app/config/price-config.ts | The pricing UI display | Free / Pro (monthly + yearly) / Lifetime copy and amounts |
shared/config/payment.ts | The ZPay demo charge | An EXAMPLE single hardcoded product + amountCNY; replace before production |
app/config/payment-config.ts is authoritative for the create route's
recorded amount/currency. shared/config/payment.ts is only a sample used by
the ZPay flow — see the ZPay amount caveat.
Checkout flow
Create the order
POST /api/payment/create (server/api/payment/create.post.ts):
- Requires login (
requireAuth) — guest checkout is not supported (401). - Zod-validates
{ productId }; the email comes from the session. - Resolves amount/currency via
getPaymentProduct(productId)(400 on unknown). - Builds an
orderNo, inserts apaymentrow, and calls the active provider'screatePayment. - Returns
{ orderNo, thirdOrderNo, checkoutUrl }.
Redirect and poll
The client sends the user to the provider checkoutUrl, which returns to
/pay-success?orderId=<orderNo>. That page can poll
GET /api/payment/query?orderId=<orderNo> (query.get.ts) for the order status.
Receive the webhook
The provider calls its matching webhook, which verifies the signature and
updates the payment (and for subscriptions, subscription) tables.
Webhook paths
| Provider | Path & method | Verification |
|---|---|---|
| Creem | POST /api/payment/notify/creem | HMAC-SHA256 over the raw body vs. the creem-signature header (NUXT_CREEM_WEBHOOK_SECRET) |
| Stripe | POST /api/payment/notify/stripe | constructEvent over the raw body vs. the stripe-signature header (NUXT_STRIPE_WEBHOOK_SECRET) |
| ZPay | GET /api/payment/notify/zpay | MD5 sign over sorted params + merchant key |
Creem and Stripe handle the full subscription lifecycle and refunds (see their pages). ZPay, as a demo, only handles its success/failure callback.
What's wired up
- The orders page (
account/order.vue) lists the user's realpaymenthistory viaGET /api/payment/orders. - The subscription page (
account/subscription.vue) shows live plan, status, period, and cancel state. - The billing page (
setting/billing.vue) renders the plan summary and billing history fromGET /api/user/billing, and links Stripe customers to the Billing Portal. - The order-status query (
query.get.ts) is authenticated and scoped to the signed-in user.
