Example shop (Next.js + Prisma)
A complete, runnable storefront with a cart, server-side orders, 3D Secure and refunds.
examples/next-shop is a
small shop in this repository. It uses the App Router, Prisma with SQLite and better-payment from
the workspace, and it runs without credentials because MockProvider is the default.
What the example contains
- a product list and a cookie cart that stores only product ids and quantities
- an order created on the server, with the amount summed from database prices
- a checkout form with card fields and installments from
installmentInfo - 3D Secure through
toNextJsHandler, a verified callback andcallbackRedirect - orders updated only by
payment.succeeded/payment.pending/payment.failed - an admin page that refunds a paid order through a privileged
refundroute - one Playwright test for the happy path with
MockProvider
The flow in five steps
app/page.tsxadds a product to the cart with a server action.lib/cart-actions.tscreates the order from the cart; the browser sends no amount.transformRequestinlib/payment.tsrebuilds the payment request from the order row.- The mock bank posts the 3D Secure result to the handler, which verifies it and updates the order through payment events.
callbackRedirectsends the browser to/orders/<id>, which renders the database status.
Run it
corepack pnpm@10.33.0 install
corepack pnpm@10.33.0 --filter next-shop devOpen http://localhost:3000. The dev script runs prisma generate and prisma db push before
Next.js starts, so a fresh checkout needs no extra step. On the checkout page, pay with the
MockProvider success card 4242424242424242, any future expiry and any CVC. Other magic cards on
that page simulate a decline, a failed 3D Secure and a lost response.
To use the iyzico sandbox instead, copy .env.example to .env.local, set IYZICO_API_KEY and
IYZICO_SECRET_KEY, and restart. The example defaults to MockProvider when those values are
empty; it never ships credentials.
The payment object
The handler only needs the actions this shop uses, and transformRequest replaces every amount the
client sent with data from the order:
async function buildRequestFromDatabase(ctx: HandlerContext): Promise<object> {
if (ctx.action !== 'payment/init-3ds') return ctx.body ?? {};
const order = await getOrder(String(ctx.body?.orderId));
if (!order || !isPayable(order.status)) throw new Error('Order is not payable');
const total = formatKurus(order.amountKurus);
const paymentCard = readCard(ctx.body?.paymentCard);
// For installments > 1, ask the provider what it will charge.
const paidPrice = await quotedPaidPrice(paymentCard.cardNumber, total, order.id, order.installment);
return {
price: total, // never a browser value
paidPrice,
currency: order.currency,
installment: order.installment,
paymentCard,
buyer: demoBuyer(order.id),
basketItems: order.items.map(toBasketItem),
callbackUrl: `${new URL(ctx.request.url).origin}/api/pay/${PROVIDER_ID}/payment/complete-3ds`,
};
}For an installment above one, the example asks the provider for the quote of that count and stores
the quoted total as paidPriceKurus, so the refund returns exactly what was charged. The card is
passed to the provider and never written to the database; the Prisma schema has no card-data fields.
The route handler
// app/api/pay/[...path]/route.ts
import { toNextJsHandler } from 'better-payment/next';
import { getBetterPayment } from '@/lib/payment';
export const runtime = 'nodejs'; // Prisma's SQLite client is not edge-compatible
export const { GET, POST } = toNextJsHandler(getBetterPayment);getBetterPayment is a lazy singleton, so next build does not need any environment variable. See
the Next.js guide for the full integration.
Orders from events
The order status is written by payment events, never by the browser return:
payment.on('payment.succeeded', async (event) => {
await prisma.order.updateMany({
where: { id: event.conversationId ?? '' },
data: { status: 'PAID', paymentId: event.paymentId ?? null },
});
});
payment.on('payment.failed', async (event) => {
await prisma.order.updateMany({
where: { id: event.conversationId ?? '', status: { notIn: ['PAID', 'REFUNDED'] } },
data: { status: 'FAILED', errorCode: event.code ?? 'UNKNOWN' },
});
});The same events fire for a browser 3D Secure return and a provider notification, so the two paths cannot disagree, and a forged callback emits nothing. See events.
Refunds and authorize
refund is a privileged action, so the handler refuses to start without an authorize hook. The
example lets provider callbacks and the customer checkout through, and requires the demo admin
cookie for refunds:
function authorize(ctx: HandlerContext): boolean {
// Signed provider callbacks and the customer checkout are public
if (CALLBACK_HANDLER_ACTIONS.includes(ctx.action)) return true;
if (ctx.action === 'payment/init-3ds' || ctx.action === 'installment') return true;
// Refunds need the shop's admin session (a demo cookie in the example)
if (ctx.action === 'refund') return hasDemoAdmin(ctx.request.headers);
return false;
}The refund amount and the provider payment id are loaded from the order in transformRequest, so
the admin page sends only the order id. Replace the demo cookie with a real session and an admin
role check before using this in production.
The example is for learning, not for production. Use a shared idempotency store, real authentication, HTTPS callback URLs, Prisma migrations and the provider's hosted card fields. The README lists each item.
What to change for production
- pass a shared
IdempotencyStoretohandler.idempotency.storewhen running more than one instance - replace the demo admin cookie with your session and an
adminrole check - derive
callbackUrlfrom a configured HTTPS origin instead ofrequest.url - use
prisma migrate deployinstead ofprisma db push - keep card data out of your server with the provider's hosted fields
- reconcile a
pendingresult withgetPaymentbefore retrying a payment
Tests
corepack pnpm@10.33.0 --filter next-shop test:e2eThe Playwright test adds a product to the cart, pays with the success card, waits for the 3D Secure
callback, checks that the order is PAID, refunds it from the admin page and checks the recorded
refund. It starts its own dev server on port 3310 and needs no credentials.