Build This Now
Build This Now
Was ist der Claude Code?Claude Code installierenClaude Code Native InstallerDein erstes Claude Code-Projekt
Failed Payment RecoveryLocalization and i18nPDF InvoicesReferral ProgramTwo-Factor AuthNext.js DevTools MCPNext.js Agent SetupSubscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows: 1.000 Subagents auf einer echten Codebase orchestrierenClaude Code Best PracticesClaude Opus 4.7 Best PracticesClaude Code auf einem VPSGit-IntegrationClaude Code ReviewClaude Code WorktreesClaude Code Remote ControlClaude Code ChannelsChannels, Routines, Teleport, DispatchGeplante Aufgaben mit Claude CodeClaude Code BerechtigungenClaude Code Auto-ModusStripe-Zahlungen mit Claude Code einbauenFeedback-LoopsTodo-WorkflowsClaude Code TasksProjekt-TemplatesClaude Code Preise und Token-NutzungClaude Code Preise: Was du wirklich zahlstClaude Code Ultra ReviewEine Next.js-App mit Claude Code bauenSupabase DatabaseVercel DeepsecTest-Driven DevelopmentSo baust du ein SaaS-MVP mit Claude CodeAuthentifizierung mit Claude Code einbauen (Supabase Auth)Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)Eine typsichere API mit Claude Code bauen (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchClaude Agent SDKKiro Migration GuideClaude Research AgentLeave Grok BuildRoute Subagent ModelsClaude Monorepo SetupCI Repair AgentVisual Regression TestsAgent Cost DashboardCursor Migration GuideAgentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingWas es 2026 kostet, ein SaaS mit Claude Code zu bauenParallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/PDF Invoices

PDF Invoices

Generate a branded PDF invoice in Next.js the moment Stripe fires invoice.paid: render with React-PDF inside an Inngest step, store it in a private Supabase bucket, email it through Resend, and serve billing history behind signed URLs.

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 30, 202610 min readHandbook hubWorkflow index

A customer pays, and 20 seconds later a branded PDF receipt lands in their inbox and shows up in their billing history. No cron job scanning for new payments, no PDF service, no headless Chrome. This guide builds that pipeline in Next.js 16: React-PDF renders the document inside an Inngest step, Supabase Storage holds it in a private bucket, Resend attaches it to the email, and signed URLs hand it back on demand.

What Runs When

The chain has one entry point and four steps. Stripe fires invoice.paid. Your webhook verifies the signature, resolves the Stripe customer to one of your users, sends a single Inngest event, and returns. Everything expensive happens after that, off the request path.

Inside the Inngest function, step one pulls the invoice detail from Stripe. Step two renders the PDF and uploads it to a private bucket. Step three writes a row to your invoices table with the storage path. Step four downloads the file back out and emails it through Resend. Each step is a durable checkpoint, so a Resend outage re-runs only the email, never the render.

That ordering is deliberate. Storage comes before the email because the file is the source of truth and the email is a courtesy copy. If you email first and the upload fails, the customer has a receipt your app cannot show them, which is the worst version of this feature. Writing the ledger row before the email also means billing history is correct even if the customer's mail provider bounces every message you send.

Claude Code will happily wire this up in one shot if you tell it the shape first. A short brief in CLAUDE.md keeps it from reaching for Puppeteer or making the bucket public:

## Invoice PDFs

- Trigger: Stripe invoice.paid webhook -> inngest.send -> generate-invoice-pdf.
- Webhook does signature verification and nothing else. No rendering in the route.
- Rendering uses @react-pdf/renderer renderToBuffer. Never Puppeteer or Chromium.
- Bucket "invoices" is PRIVATE. Files live at {userId}/{invoiceNumber}.pdf.
- Buffers never cross an Inngest step boundary. Store first, re-download later.
- Downloads are signed URLs (60s), minted only after an RLS-checked ownership read.

The Bucket and the Ledger

Two pieces of state. A private storage bucket for the bytes, and a table that records what was issued so billing history does not depend on listing files.

The table stores the Stripe invoice id with a unique constraint, which is what makes the whole pipeline idempotent. If Stripe redelivers the event, the upsert lands on the same row and the file is overwritten with identical content. Row level security lets a user read only their own invoices, and there is deliberately no insert or update policy: writes come from the service role inside the Inngest function.

-- supabase/migrations/20260730_invoices.sql
create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id) on delete cascade,
  stripe_invoice_id text not null unique,
  number text not null,
  currency text not null,
  amount_paid_cents integer not null,
  pdf_path text,
  issued_at timestamptz not null,
  created_at timestamptz not null default now()
);

create index invoices_user_issued_idx
  on public.invoices (user_id, issued_at desc);

alter table public.invoices enable row level security;

create policy "users read own invoices"
  on public.invoices for select
  to authenticated
  using (auth.uid() = user_id);

insert into storage.buckets (id, name, public)
values ('invoices', 'invoices', false)
on conflict (id) do nothing;

create policy "users read own invoice files"
  on storage.objects for select
  to authenticated
  using (
    bucket_id = 'invoices'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

The storage path is worth a second of thought before you write any code, because changing it later means migrating files. Putting the owner's user id first ({userId}/{invoiceNumber}.pdf) is what makes the storage policy above a one-line check: Supabase exposes storage.foldername(name) so the policy can compare the first path segment to auth.uid(). A flat namespace of invoice numbers would force a join against your table on every read, which storage policies cannot do cheaply.

The pipeline needs a Supabase client that bypasses RLS, because the Inngest function has no user session. Keep it in its own module so nothing in the client bundle can ever import it by accident.

// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

The Invoice Document

React-PDF is a React renderer that outputs PDF instead of DOM. You write components, it produces bytes. Version 4 supports React 19, and Next.js already opts the package out of server bundling, so no next.config.ts change is needed in most projects.

Install it alongside the rest of the pipeline:

npm install @react-pdf/renderer inngest resend stripe @supabase/supabase-js
npm install --save-dev inngest-cli

The document is a plain component tree. Document wraps Page, and inside you use View and Text with a Flexbox subset. There is no cascade and no class names, so styles come from StyleSheet.create and get applied per element. Money is stored in cents everywhere and only formatted at the last moment.

// lib/pdf/invoice-document.tsx
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";

export interface InvoiceLine {
  description: string;
  quantity: number;
  unitAmountCents: number;
  amountCents: number;
}

export interface InvoiceData {
  number: string;
  issuedAt: string;
  currency: string;
  customerName: string;
  customerEmail: string;
  issuerName: string;
  issuerAddress: string;
  footer: string;
  lines: InvoiceLine[];
  subtotalCents: number;
  taxCents: number;
  totalCents: number;
}

const styles = StyleSheet.create({
  page: { padding: 44, fontSize: 10, fontFamily: "Helvetica", color: "#18181b" },
  bar: { height: 6, backgroundColor: "#4f46e5", marginBottom: 28 },
  header: { flexDirection: "row", justifyContent: "space-between", marginBottom: 32 },
  brand: { fontSize: 18, fontFamily: "Helvetica-Bold" },
  muted: { color: "#71717a" },
  right: { textAlign: "right" },
  parties: { flexDirection: "row", justifyContent: "space-between", marginBottom: 28 },
  label: { fontSize: 8, letterSpacing: 1, color: "#71717a", marginBottom: 4 },
  row: { flexDirection: "row", paddingVertical: 7 },
  headRow: { borderBottomWidth: 1, borderBottomColor: "#18181b", paddingBottom: 5 },
  bodyRow: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7" },
  cDesc: { flex: 4 },
  cQty: { flex: 1, textAlign: "right" },
  cUnit: { flex: 1.4, textAlign: "right" },
  cAmt: { flex: 1.4, textAlign: "right" },
  totals: { marginTop: 18, marginLeft: "auto", width: 220 },
  totalRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 4 },
  grand: { fontFamily: "Helvetica-Bold", borderTopWidth: 1, borderTopColor: "#18181b", marginTop: 6, paddingTop: 8 },
  footer: { position: "absolute", bottom: 32, left: 44, right: 44, fontSize: 8, color: "#71717a" },
});

function money(cents: number, currency: string) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(cents / 100);
}

export function InvoiceDocument({ data }: { data: InvoiceData }) {
  return (
    <Document title={`Invoice ${data.number}`} author={data.issuerName}>
      <Page size="A4" style={styles.page}>
        <View style={styles.bar} />

        <View style={styles.header}>
          <View>
            <Text style={styles.brand}>{data.issuerName}</Text>
            <Text style={styles.muted}>{data.issuerAddress}</Text>
          </View>
          <View style={styles.right}>
            <Text style={styles.brand}>Invoice {data.number}</Text>
            <Text style={styles.muted}>Issued {data.issuedAt}</Text>
            <Text style={styles.muted}>Paid in full</Text>
          </View>
        </View>

        <View style={styles.parties}>
          <View>
            <Text style={styles.label}>BILLED TO</Text>
            <Text>{data.customerName}</Text>
            <Text style={styles.muted}>{data.customerEmail}</Text>
          </View>
        </View>

        <View style={[styles.row, styles.headRow]}>
          <Text style={styles.cDesc}>Description</Text>
          <Text style={styles.cQty}>Qty</Text>
          <Text style={styles.cUnit}>Unit</Text>
          <Text style={styles.cAmt}>Amount</Text>
        </View>

        {data.lines.map((line, index) => (
          <View key={index} style={[styles.row, styles.bodyRow]}>
            <Text style={styles.cDesc}>{line.description}</Text>
            <Text style={styles.cQty}>{line.quantity}</Text>
            <Text style={styles.cUnit}>{money(line.unitAmountCents, data.currency)}</Text>
            <Text style={styles.cAmt}>{money(line.amountCents, data.currency)}</Text>
          </View>
        ))}

        <View style={styles.totals}>
          <View style={styles.totalRow}>
            <Text style={styles.muted}>Subtotal</Text>
            <Text>{money(data.subtotalCents, data.currency)}</Text>
          </View>
          <View style={styles.totalRow}>
            <Text style={styles.muted}>Tax</Text>
            <Text>{money(data.taxCents, data.currency)}</Text>
          </View>
          <View style={[styles.totalRow, styles.grand]}>
            <Text>Total paid</Text>
            <Text>{money(data.totalCents, data.currency)}</Text>
          </View>
        </View>

        <Text style={styles.footer} fixed>
          {data.footer}
        </Text>
      </Page>
    </Document>
  );
}

A few constraints are worth internalizing before you fight the layout. There is no cascade, so a style on a parent View does not reach the Text inside it. Flexbox is supported but Grid is not, which is why the line item table is built from rows of flexed columns rather than a real table. The style prop accepts an array, and later entries win, which is how the header and body rows share a base row style. And position: "absolute" combined with the fixed prop on the footer is what pins the legal line to the bottom of every page instead of letting it float after the last line item.

Your company name, address, and legal footer do not change per invoice, so they should not be fetched per invoice either. This helper is a good fit for "use cache". Note that cached functions cannot read cookies, which is exactly why it uses the admin client rather than a request-scoped one.

// lib/billing/issuer.ts
import { cacheLife, cacheTag } from "next/cache";
import { supabaseAdmin } from "@/lib/supabase/admin";

export async function getIssuerProfile() {
  "use cache";
  cacheLife("days");
  cacheTag("issuer-profile");

  const { data } = await supabaseAdmin
    .from("billing_settings")
    .select("issuer_name, issuer_address, invoice_footer")
    .single();

  return {
    issuerName: data?.issuer_name ?? "Acme Inc.",
    issuerAddress: data?.issuer_address ?? "1 Market St, San Francisco, CA",
    footer: data?.invoice_footer ?? "Thanks for your business. Questions? billing@acme.com",
  };
}

Rendering Inside an Inngest Step

Here is the detail that costs people an afternoon. Everything a step.run returns gets serialized to JSON so Inngest can checkpoint it. A Node Buffer does not survive that trip, and neither do Date objects or Map instances. So the render and the upload live in the same step, and that step returns only the storage path (a string). The email step downloads the file back out of storage when it needs the bytes.

Because the function contains JSX, the file has to be a .tsx file. That is easy to miss when everything else in your Inngest folder is .ts.

// lib/inngest/functions/generate-invoice-pdf.tsx
import { renderToBuffer } from "@react-pdf/renderer";
import { Resend } from "resend";
import { inngest } from "@/lib/inngest/client";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase/admin";
import { getIssuerProfile } from "@/lib/billing/issuer";
import { InvoiceDocument } from "@/lib/pdf/invoice-document";

const resend = new Resend(process.env.RESEND_API_KEY!);

export const generateInvoicePdf = inngest.createFunction(
  { id: "generate-invoice-pdf", retries: 4, concurrency: { limit: 5 } },
  { event: "billing/invoice.paid" },
  async ({ event, step }) => {
    const { stripeInvoiceId, userId } = event.data;

    const data = await step.run("load-invoice", async () => {
      const invoice = await stripe.invoices.retrieve(stripeInvoiceId);
      const issuer = await getIssuerProfile();

      const subtotal = invoice.subtotal ?? 0;
      const total = invoice.total ?? 0;
      const paidAt = invoice.status_transitions?.paid_at ?? invoice.created;

      return {
        number: invoice.number ?? stripeInvoiceId,
        issuedAt: new Date(paidAt * 1000).toISOString().slice(0, 10),
        currency: invoice.currency,
        customerName: invoice.customer_name ?? "Customer",
        customerEmail: invoice.customer_email ?? "",
        issuerName: issuer.issuerName,
        issuerAddress: issuer.issuerAddress,
        footer: issuer.footer,
        lines: invoice.lines.data.map((line) => {
          const quantity = line.quantity ?? 1;
          return {
            description: line.description ?? "Subscription",
            quantity,
            unitAmountCents: quantity > 0 ? Math.round(line.amount / quantity) : line.amount,
            amountCents: line.amount,
          };
        }),
        subtotalCents: subtotal,
        taxCents: total - subtotal,
        totalCents: invoice.amount_paid,
      };
    });

    const pdfPath = await step.run("render-and-store", async () => {
      const buffer = await renderToBuffer(<InvoiceDocument data={data} />);
      const path = `${userId}/${data.number}.pdf`;

      const { error } = await supabaseAdmin.storage
        .from("invoices")
        .upload(path, buffer, { contentType: "application/pdf", upsert: true });

      if (error) throw new Error(`Upload failed: ${error.message}`);
      return path;
    });

    await step.run("record-invoice", async () => {
      const { error } = await supabaseAdmin.from("invoices").upsert(
        {
          user_id: userId,
          stripe_invoice_id: stripeInvoiceId,
          number: data.number,
          currency: data.currency,
          amount_paid_cents: data.totalCents,
          pdf_path: pdfPath,
          issued_at: new Date(data.issuedAt).toISOString(),
        },
        { onConflict: "stripe_invoice_id" },
      );

      if (error) throw new Error(`Ledger write failed: ${error.message}`);
    });

    await step.run("email-receipt", async () => {
      if (!data.customerEmail) return { skipped: true };

      const { data: file, error } = await supabaseAdmin.storage
        .from("invoices")
        .download(pdfPath);

      if (error || !file) throw new Error("Could not read the stored PDF");
      const buffer = Buffer.from(await file.arrayBuffer());

      await resend.emails.send({
        from: `${data.issuerName} Billing <billing@acme.com>`,
        to: data.customerEmail,
        subject: `Your receipt ${data.number}`,
        html: `<p>Thanks for your payment. Your receipt is attached, and every invoice is available in your billing history.</p>`,
        attachments: [{ filename: `${data.number}.pdf`, content: buffer }],
      });

      return { skipped: false };
    });

    return { pdfPath };
  },
);

Resend accepts an attachment as an object with filename and content, where content is a Buffer or a base64 string, with a 40MB ceiling per email after encoding. An invoice PDF is a few kilobytes, so that limit only matters if you ever batch a year of them into one message.

The client and the serve route are the standard Inngest setup. The serve handler exports all three HTTP methods so Inngest can introspect the function list as well as invoke it.

// lib/inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "billing-app" });
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { generateInvoicePdf } from "@/lib/inngest/functions/generate-invoice-pdf";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [generateInvoicePdf],
});

The Webhook That Starts It

The webhook does three things and nothing more: verify the signature against the raw body, map the Stripe customer to your user, and send one event. Passing an id on the Inngest event gives you deduplication, so a redelivered Stripe webhook does not produce a second run.

// app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { inngest } from "@/lib/inngest/client";
import { supabaseAdmin } from "@/lib/supabase/admin";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get("stripe-signature");

  if (!signature) {
    return new Response("Missing signature", { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type === "invoice.paid") {
    const invoice = event.data.object;
    const customerId =
      typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;

    if (customerId) {
      const { data: profile } = await supabaseAdmin
        .from("profiles")
        .select("id")
        .eq("stripe_customer_id", customerId)
        .maybeSingle();

      if (profile) {
        await inngest.send({
          name: "billing/invoice.paid",
          id: `invoice-paid-${invoice.id}`,
          data: { stripeInvoiceId: invoice.id, userId: profile.id },
        });
      }
    }
  }

  return Response.json({ received: true });
}

Serving Billing History Behind Signed URLs

Reads go through oRPC so the query shape is validated once and the page compiles against the same contract. The handler uses the request-scoped Supabase client, which means the RLS policy is what actually enforces ownership. The explicit user_id filter is there for index selectivity, not security.

// server/orpc/billing.ts
import { os, ORPCError } from "@orpc/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";

export interface Context {
  userId: string;
}

const base = os.$context<Context>();

export const listInvoices = base
  .input(z.object({ limit: z.number().int().min(1).max(100).default(24) }))
  .handler(async ({ input, context }) => {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from("invoices")
      .select("id, number, currency, amount_paid_cents, issued_at, pdf_path")
      .eq("user_id", context.userId)
      .order("issued_at", { ascending: false })
      .limit(input.limit);

    if (error) {
      throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message });
    }

    return data ?? [];
  });

export const router = { listInvoices };

The download route awaits params as Next.js 16 requires, reads the row through the user's own client so RLS decides whether they may see it, then mints a 60 second signed URL with the service role client. The download option sets the filename the browser saves, which is the difference between invoice-2026-0142.pdf and a random storage key.

// app/api/invoices/[id]/download/route.ts
import { createClient } from "@/lib/supabase/server";
import { supabaseAdmin } from "@/lib/supabase/admin";

interface RouteContext {
  params: Promise<{ id: string }>;
}

export async function GET(_request: Request, { params }: RouteContext) {
  const { id } = await params;

  const supabase = await createClient();
  const { data: claims } = await supabase.auth.getClaims();
  if (!claims?.claims.sub) {
    return new Response("Unauthorized", { status: 401 });
  }

  const { data: invoice } = await supabase
    .from("invoices")
    .select("number, pdf_path")
    .eq("id", id)
    .maybeSingle();

  if (!invoice?.pdf_path) {
    return new Response("Not found", { status: 404 });
  }

  const { data: signed, error } = await supabaseAdmin.storage
    .from("invoices")
    .createSignedUrl(invoice.pdf_path, 60, { download: `${invoice.number}.pdf` });

  if (error || !signed) {
    return new Response("Not found", { status: 404 });
  }

  return Response.redirect(signed.signedUrl, 302);
}

The billing page itself is a Server Component. It must not carry "use cache", because the data is per-user and session dependent. Caching it would be the exact bug this whole design exists to prevent.

// app/(app)/billing/page.tsx
import { createRouterClient } from "@orpc/server";
import { router } from "@/server/orpc/billing";
import { createContext } from "@/server/orpc/context";

export default async function BillingPage() {
  const client = createRouterClient(router, { context: await createContext() });
  const invoices = await client.listInvoices({ limit: 24 });

  return (
    <main className="mx-auto max-w-2xl py-12">
      <h1 className="mb-6 text-2xl font-bold">Billing history</h1>
      <ul className="divide-y divide-border rounded-lg border">
        {invoices.map((invoice) => (
          <li key={invoice.id} className="flex items-center justify-between p-4">
            <div>
              <p className="font-medium">{invoice.number}</p>
              <p className="text-sm text-muted-foreground">
                {new Date(invoice.issued_at).toLocaleDateString()}
              </p>
            </div>
            <div className="flex items-center gap-4">
              <span className="tabular-nums">
                {new Intl.NumberFormat("en-US", {
                  style: "currency",
                  currency: invoice.currency.toUpperCase(),
                }).format(invoice.amount_paid_cents / 100)}
              </span>
              <a
                href={`/api/invoices/${invoice.id}/download`}
                className="text-sm font-medium underline"
              >
                Download
              </a>
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

Testing the Chain Locally

Three terminals and one command replay the whole pipeline without waiting for a real customer. The Stripe CLI forwards live test-mode events to your local webhook, and the Inngest dev server picks up the function.

npm run dev
npx inngest-cli dev -u http://localhost:3000/api/inngest
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger invoice.paid

Open the Inngest dev UI at http://localhost:8288 and you can see each step, its output, and its timing. When something fails, you get the exact step that threw and the ability to replay from there instead of re-triggering Stripe. Ask Claude Code to run the trigger and read the run output, and it will trace a failing upload back to a missing bucket policy on its own.

The Gotchas That Bite

Fonts. React-PDF ships with the standard PDF fonts (Helvetica, Times, Courier). Anything else needs Font.register with a TTF the renderer can load at runtime, and a font that fails to load produces a blank document rather than an error. Start with Helvetica and add a brand font only once the pipeline is green.

Zero-decimal currencies. Dividing by 100 is wrong for JPY, KRW, and a handful of others where the smallest unit is the whole unit. If you sell in those, branch on the currency before formatting instead of assuming two decimal places.

Invoice numbers as paths. Stripe invoice numbers are unique per account and safe in a path, but only if you fall back to the invoice id when number is null (which happens on draft invoices). The code above does that fallback deliberately.

Tax fields drift. Stripe has reshaped its tax fields across API versions. Deriving tax as total minus subtotal, as the function does, keeps working across version bumps. Read the specific field only if you need the breakdown per rate.

Line items are paginated. invoice.lines is a list object, and Stripe returns a bounded page of it, not necessarily everything. For a normal subscription charge with one or two lines this never matters. For a metered or seat-heavy invoice it silently truncates the PDF, which is the kind of bug you find from a customer email. If your invoices can run long, page through the line items explicitly instead of reading invoice.lines.data once.

Function limits on serverless. Rendering is CPU work happening inside the Inngest step, which runs in your Next.js function. A one-page invoice renders fast, but a 40-page one with a registered font will push both memory and duration. Keep an eye on your function configuration, and if documents get large, split the render across steps by page group so no single invocation carries the whole job.

Do not put invoices in a public bucket. A public bucket means anyone who guesses a path reads a customer's name, email, and purchase history. Private bucket plus a signed URL that expires in 60 seconds is barely more code and a completely different risk profile.

Where the Harness Fits

A pipeline like this is five files that all have to agree: the webhook shape, the event payload, the PDF props, the storage path, and the table columns. Claude Code writes any one of them well, and the failure mode is drift between them. That is what the $29 Code Kit is for. It is a one-time purchase, no subscription, and it is a harness on top of Claude Code rather than a replacement for it (you still need a paid Anthropic plan). The loop is plan, build, evaluate, test, then quality gates that do not negotiate: zero type errors, zero lint errors, clean build. On a pipeline where a wrong type only shows up when a real customer pays, having that run automatically after every hand-off is the difference between shipping it and babysitting it.

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können
    Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.
  • Claude Code Best Practices
    Fünf Gewohnheiten trennen Entwickler, die mit Claude Code liefern: PRDs, modulare CLAUDE.md-Regeln, Custom-Slash-Commands, /clear-Resets und eine System-Evolutions-Denkweise.
  • Claude Code Auto-Modus
    Ein zweites Sonnet-Modell prüft jeden Claude Code-Tool-Aufruf, bevor er ausgeführt wird. Was der Auto-Modus blockiert, was er erlaubt, und die Erlaubnisregeln, die er in deine Einstellungen schreibt.
  • Channels, Routines, Teleport, Dispatch
    Die vier Claude-Code-Features, die Anthropic im März und April 2026 ausgeliefert hat und die die CLI in eine ereignisgesteuerte Koordinationsschicht über Handy, Web und Desktop verwandeln.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Grundlagen für Agenten
    Fünf Möglichkeiten, spezialisierte Agenten in Claude Code zu erstellen: Aufgaben-Unteragenten, .claude/agents YAML, benutzerdefinierte Slash-Befehle, CLAUDE.md Personas und perspektivische Aufforderungen.
  • Agent-Harness-Engineering
    Der Harness ist jede Schicht rund um deinen KI-Agenten, außer dem Modell selbst. Lern die fünf Steuerungshebel, das Constraint-Paradoxon und warum das Harness-Design die Performance des Agenten mehr bestimmt als das Modell.
  • Agenten-Muster
    Orchestrator, Fan-out, Validierungskette, Spezialistenrouting, Progressive Verfeinerung und Watchdog. Sechs Orchestrierungsformen, um Claude Code Sub-Agenten zu verdrahten.
  • Agent Teams Best Practices
    Bewährte Muster für Claude Code Agent Teams. Kontextreiche Spawn-Prompts, richtig bemessene Aufgaben, Datei-Eigentümerschaft, Delegate-Modus und Fixes für v2.1.33-v2.1.45.

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvkoen_salo

Localization and i18n

A complete Next.js i18n setup with next-intl: locale-routed segments, server-component message loading, hreflang tags, locale-aware Resend emails, and a Claude Code subagent that translates your catalogs on commit.

Referral Program

Build a refer-a-friend feature end to end in Next.js 16: unique referral codes, cookie plus server-side attribution, a Stripe coupon and account credit granted on the first paid invoice, self-referral and fraud guards, and a referrer dashboard.

On this page

What Runs When
The Bucket and the Ledger
The Invoice Document
Rendering Inside an Inngest Step
The Webhook That Starts It
Serving Billing History Behind Signed URLs
Testing the Chain Locally
The Gotchas That Bite
Where the Harness Fits

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →