Next.js + Firebase で請求書を発行する SaaS を作っていて、「発行した PDF をどこに置くか」で設計を一度ひっくり返しました。
結論から書くと、どこにも置かないことにしました。リクエストのたびに生成して直接返します。Firebase Storage を使わなくなったので、Firebase は無料の Spark プランのままで動きます。
ただ、これを素直にやると過去に発行した請求書の内容が後から変わります。そこで踏んだ穴の話です。
なぜ Storage をやめたか
Firebase の Cloud Storage は、2024年10月以降、利用するのに Blaze プラン(従量課金)への切り替えが必要になりました。無料枠の範囲で使う場合でも、クレジットカードの登録が要ります。
自分の1プロジェクトだけなら登録すればいい話です。ただ今回作っていたのはボイラープレートで、使う人全員に「まずカードを登録してください」と言わせる構成になります。
請求書 PDF を置くためだけに、そこまでの前提を配りたくありませんでした。
Firebase 側で無料のまま使えるものと、そうでないものはこう分かれます。
| 機能 | Spark(無料) |
|---|---|
| Authentication | 使える |
| Firestore | 使える(1GiB、読み取り5万/日、書き込み2万/日) |
| Cloud Storage | Blaze が必要 |
| Cloud Functions | Blaze が必要 |
Functions が使えないのは、サーバー処理を全部 Next.js の Route Handlers に寄せていたので問題になりませんでした。残るは Storage だけです。
都度生成に変える
PDF は pdf-lib で生成しています。保存していたものを返す代わりに、リクエストのたびに Firestore から読んで組み立てて返します。
export const runtime = "nodejs"; // pdf-lib は edge runtime では動かない
export async function GET(_req: Request, { params }) {
const { invoiceNumber } = await params;
const user = await getSessionUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const snap = await adminDb().collection("invoices").doc(invoiceNumber).get();
if (!snap.exists) return new Response("Not Found", { status: 404 });
const invoice = snap.data() as Invoice;
if (user.tenantId !== invoice.tenant_id && !user.isAdmin) {
return new Response("Forbidden", { status: 403 });
}
const pdfBytes = await generateInvoicePdf({ /* ... */ });
return new Response(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${invoice.invoice_number}.pdf"`,
"Cache-Control": "private, no-store",
},
});
}
pdf-lib はフォント埋め込みで Node の API に依存するので、edge runtime では動きません。Route Handler の runtime は指定しなければ nodejs なので書かなくても動きますが、この経路は edge に移せないという制約をコードに残すために明示しています。
Cache-Control: private, no-store にしているのは、請求書がユーザー固有の機密情報だからです。都度生成なので CDN キャッシュの恩恵は元々ありません。
ここまでは素直です。問題はこの先でした。
穴:過去の請求書が書き換わる
最初の実装では、発行者情報(社名・登録番号・住所)を環境変数から読んでいました。
const issuer = issuerFromEnv(); // ← ここが穴
const pdfBytes = await generateInvoicePdf({ issuer, /* ... */ });
保存された PDF を返していたときは、これで問題ありませんでした。発行時点の情報が PDF に焼き付いていたからです。
都度生成に変えた瞬間、これは**「今の環境変数」で過去の請求書を作り直す**コードになります。
- 引っ越して住所の env を変えた → 3年前の請求書の住所まで変わる
- 屋号を変えた → 過去に発行した全請求書の発行者名が変わる
請求書は発行した時点の内容が保存されていなければならない書類です。「保存場所を変えただけ」のつもりが、書類の性質そのものを壊していました。
発行時にスナップショットを取る
直し方は単純で、PDF に必要な値を発行時に Firestore へ焼き付けます。
const record = {
invoice_number: invoiceNumber,
tenant_id: tenantId,
amount_jpy: amount,
tax_jpy: tax,
// 以下がスナップショット。発行時点の値をそのまま保存する
issuer: {
name: issuer.name,
registration_number: issuer.registrationNumber,
address: issuer.address,
email: issuer.email,
tel: issuer.tel,
},
recipient: { /* 同様 */ },
line_items: lineItems,
};
const batch = db.batch();
batch.create(db.collection("invoices").doc(invoiceNumber), record);
// 実際にはここに改ざん検出用の audit レコードと、Stripe webhook 再送用の
// dedup レコードも同じ batch で入れている
await batch.commit();
なぜ set ではなく create なのか、audit レコードを別コレクションに分ける理由は、前回書いた記事のほうに詳しく残しています。
PDF 生成時は、この保存済みの値だけを使います。
保存するのは PDF に出る値であって、参照 ID ではありません。tenant_id を保存して発行のたびにテナント設定を引き直すと、テナント側が社名を変えた瞬間に同じ問題が再発します。発行時に値をコピーするのが要点です。
env から補ってよいものと、いけないもの
全部をスナップショットにする必要はありませんでした。実装では2つに分けています。
const envIssuer = issuerFromEnv();
const pdfBytes = await generateInvoicePdf({
issuer: {
// 発行時点で固定する → スナップショットから読む
name: invoice.issuer.name,
registrationNumber: invoice.issuer.registration_number,
address: invoice.issuer.address,
email: invoice.issuer.email,
// 固定しなくてよい → env の最新値で補う
bankInfo: envIssuer.bankInfo,
paymentDueDays: envIssuer.paymentDueDays,
logoPath: envIssuer.logoPath,
},
// ...
});
適格請求書として要件になっている項目(発行者名・登録番号・取引年月日・品目・税率ごとの金額・受領者名)は、後から変わってはいけないのでスナップショットから読みます。住所やメールは法定の記載事項ではありませんが、「その時点の発行者はこうだった」を残したいので同じく固定しています。
一方、振込先口座やロゴは要件ではありません。むしろ口座を変えたなら最新の口座が出たほうが実務上正しいので、env から補います。
「全部固めるのが安全」と考えて全部スナップショットにすると、口座を変えたときに過去の請求書の再発行が使い物にならなくなります。何が固定されるべき情報かは、技術ではなく書類の要件側から決まります。
スナップショットが無い古いレコード
この設計に変える前に発行したレコードには、当然スナップショットがありません。
そこは env で埋めて生成する、ということをしませんでした。それをやると「内容の保証がない PDF」を正規の請求書として配ることになります。
if (!invoice.issuer || !invoice.recipient || !invoice.line_items?.length) {
return new Response(
"Legacy invoice without immutability snapshot. ...",
{ status: 410 },
);
}
410 を返して、ダッシュボード側もリンクを出さず「-」表示にしています。古い PDF が必要なら移行前のバックアップから取る、という運用に倒しました。
出せないものを、それらしく作って出さないのが大事なところだと思っています。
もう一つの穴:サーバーレスでフォントが消える
日本語 PDF なので、pdf-lib にフォントを埋め込む必要があります。
doc.registerFontkit(fontkit);
const fontBytes = await fs.readFile("public/fonts/NotoSansJP-Regular.otf");
const cjkFont = await doc.embedFont(fontBytes, { subset: false });
ローカルでは動きました。デプロイすると、環境によって ENOENT で落ちました。public/ のファイルはサーバー関数のバンドルに必ず含まれるとは限らないためです。
そこで探索順を多段にしています。
const candidates = [
process.env.INVOICE_FONT_PATH, // 明示指定を最優先
"public/fonts/NotoSansJP-Regular.otf",
".next/server/public/fonts/...", // ビルド出力側
"/var/task/public/fonts/...", // サーバーレス実行環境のパス
"/System/Library/Fonts/Hiragino Sans GB.ttc", // ローカル開発の保険
];
for (const p of candidates) {
try {
const buf = await fs.readFile(p);
if (buf.length > 100_000) return buf; // ← このガードが要る
} catch { continue }
}
// それでも見つからなければ同一オリジンの /fonts/ を HTTP で取りにいく
// (静的アセットとしては配信されているため)
buf.length > 100_000 のガードには理由があります。HTTP フォールバックで取りにいったとき、404 の HTML ページが 200 で返ってくる構成があり得ます。サイズを見ずに embedFont へ渡すと、HTML を OpenType としてパースしようとして意味の分からないエラーになります。日本語フォントが 100KB を下回ることはないので、閾値で弾いています。
最後の手段として Noto の配布元から取得するフォールバックも入れていますが、これは console.warn で CRITICAL を出します。外部ネットワークに依存した状態で本番を回してはいけないので、気づける必要があります。
フォントは pnpm setup:fonts で取得する前提にして、リポジトリにはバイナリを置いていません。
この構成で払っているもの
無料になった分、どこかにコストは移っています。
- リクエストごとに PDF を生成する CPU 時間。 Firebase の課金は消えましたが、ホスティング側の関数実行時間は消えていません。請求書は閲覧頻度が低いので今のところ割に合っています
-
キャッシュが効かない。 機密情報なので
no-storeです。同じ PDF を何度も作り直します - 生成が壊れると、過去の請求書が全部出せなくなる。 保存方式なら生成コードが壊れても既存 PDF は無事です。この構成では PDF 生成が単一障害点になります
3つ目が一番効きます。生成ロジックのテストは、保存方式のときより厚くする必要がありました。
まとめ
- Firebase Storage は Blaze が必要。PDF を都度生成すれば Spark のままで運用できる
- 都度生成にすると「現在の設定で過去の書類を作り直す」コードになる。発行時にスナップショットを取る
- スナップショットにするのは書類の要件で決まる項目だけ。口座やロゴは最新値でよい
- スナップショットが無い古いレコードは、それらしく生成せず 410 で断る
- サーバーレスでは
public/のファイルが読めないことがある。探索を多段にし、サイズでガードする
なお、この記事は「無料で運用できる」話であって、「無料が常に正しい」話ではありません。閲覧頻度の高いファイルを配るなら、素直に Storage と CDN を使ったほうがよいです。
作ったもの
この実装は、日本の商習慣に合わせた Next.js のスターターキット StartKit JP に入れているものです。Next.js 16 / React 19 / Firebase / Stripe 構成で、適格請求書ひな形の PDF 発行、消費税計算、テナント管理などを実装済みの状態から始められます。
なお、単一税率(10%)を前提としたひな形で、軽減税率8%の識別表示や複数税率の混在には対応していません。適格請求書として運用する場合は顧問税理士にご確認ください。