Payments Setup
elements man payments/setup Read as markdownOne env value turns payments on. It is optional, so the app builds without it.
Config
// config.jsoc
{
// ...
stripe: {
secretKey: env("STRIPE_SECRET_KEY", ""),
// Development only, printed by `stripe listen`. Production registers its
// own endpoint and stores the secret (see `payments/webhook`).
webhookSecret: env("STRIPE_WEBHOOK_SECRET", ""),
},
}
Put empty placeholders in config/env/development.env, and nothing else:
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
development.env is committed, so the owner's key goes in their copy, or in
the shell environment, which env values read first. An edit to the env file
rebuilds the config and hot reloads the app and its open pages, so the setup
page below turns green the moment the key is saved. Live keys go in
config/env/production.env, which is gitignored.
The Client
// app/shared/stripe.ts
import Stripe from "stripe";
import config from "#config";
let client: Stripe | undefined;
export function stripeConfigured(): boolean {
return config.stripe.secretKey !== "";
}
export function stripe(): Stripe {
client ??= new Stripe(config.stripe.secretKey);
return client;
}
export function stripeMode(): "sandbox" | "live" {
return config.stripe.secretKey.startsWith("sk_live_") ? "live" : "sandbox";
}
Call stripeConfigured() before any Stripe call. Pages that sell something
pass it to their template, which shows the buy button when it is true and
"Payments are not set up yet" when it is false, with a link to
/admin/payments for admins.
The Setup Page
Build this page in every app that takes payments, even when the request only says "payments by Stripe". Without it the owner has no way to see whether payments work.
elements create page admin-payments, routed at /admin/payments and gated to
admins (elements man recipes/admin-roles). The route asks Stripe who the key
belongs to. A wrong key is a status, not a crash.
// app/pages/admin-payments/index.ts
import { Request, Response, getAppUrl, getEnv } from "@elements/app";
import config from "#config";
import { isUserAdminOrThrow } from "#app/shared/services/admin";
import { stripe, stripeConfigured, stripeMode } from "#app/shared/stripe";
import { ensureWebhook } from "#app/shared/stripe-webhook";
import html, { PaymentsStatus } from "./template";
export default async function route(req: Request, res: Response) {
isUserAdminOrThrow();
let status: PaymentsStatus = {
configured: stripeConfigured(),
mode: stripeMode(),
accountName: "",
keyError: "",
development: getEnv() === "development",
webhookUrl: `${getAppUrl()}/stripe/webhook`,
webhookReady: false,
};
if (status.configured) {
try {
let account = await stripe().accounts.retrieveCurrent();
status.accountName = account.settings?.dashboard?.display_name ?? account.id;
status.webhookReady = status.development
? config.stripe.webhookSecret !== ""
: await ensureWebhook();
} catch (err) {
status.keyError = (err as Error).message;
}
}
return new html({ status });
}
The template is a checklist. Each step shows done or what to do next:
- Stripe key. When unset: "Create a free Stripe account at
dashboard.stripe.com/register. You can use a sandbox right away, with no
business details. Open Developers, API keys, copy the secret key
(
sk_test_...), and add it toconfig/env/development.envasSTRIPE_SECRET_KEY=." When set: "Connected to {accountName}", with a Sandbox or Live badge, orkeyErrorwith the same instructions again. - Prices. Always done: "Prices come from this app. Nothing to create in Stripe."
- Webhook. In development, optional: returning buyers are fulfilled
without it. To test it, show the two commands:
stripe login, thenstripe listen --forward-to {webhookUrl}, and to add thewhsec_...it prints asSTRIPE_WEBHOOK_SECRET=. In production: "Registered at {webhookUrl}" onceensureWebhook()returns true. - Test payment. A button that starts a real Checkout for a $1 test item,
with the hint "Card 4242 4242 4242 4242, any future date, any CVC." Only
shown in sandbox mode. Record it apart from real sales, in its own table or
with a test flag on the payments row, so it never becomes an order, a
donation or revenue. Tag the session (
metadata: { kind: "test" }) and havefulfillCheckoutand the webhook record test sessions there. Stripe sends the admin back to/admin/payments, which shows the last test payment as paid.
Link the page from the admin navigation, and from wherever a buy button says payments are not set up.
Related
payments/checkout: the checkout rpc and fulfillment.payments/webhook:ensureWebhook()and the webhook route.config: env files andgetEnv().