Printgraph に請求書払いを入れた ── 金額は Stripe に計算させ、請求書 PDF は自分たちで作る

Printgraph に請求書払いを入れた ── 金額は Stripe に計算させ、請求書 PDF は自分たちで作る

Printgraph は、HTML テンプレートに JSON を差し込んで PDF を返すサービスです。料金は PDF 1 枚 0.3 円の従量課金で、これまではクレジットカードの自動引き落としだけに対応していました。

法人のお客さまからは「カードではなく、請求書をもらって振り込みたい」という声をいただきます。経理の決まりでカード払いができない会社も少なくありません。そこで、請求書払い(銀行振込)を追加しました。

この記事では、どこまでを Stripe に任せ、どこからを自分たちで作ったか、そして webhook まわりで気をつけたことを書きます。

役割の分け方

最初に決めたのは、次の分担です。

  • 金額の計算は Stripe に任せる:従量の集計(meter event)・税・月次の締めは、カード払いですでに Stripe のサブスクリプションで動いています。請求書払いのために計算ロジックを作り直すことはしません。
  • お客さまに届く請求書は自分たちで作る:PDF には適格請求書発行事業者の登録番号と振込先口座を載せる必要があります。また、請求書払いのお客さまから Stripe の存在が見えないようにしたいと考えました。そこで Stripe からの請求書メール送信は停止し、Printgraph の描画基盤で作った PDF を直接届けます。

PDF を作るのは Printgraph の本業です。自社のサービス基盤を用いて、自社の請求書を発行することにしました。

全体の流れ

  1. 請求書払いのご連絡をいただいたお客様にたいして弊社が、管理画面で組織の支払い方法を「請求書払い」に切り替える
  2. 月次の締めで Stripe が invoice を確定すると、invoice.finalized の webhook が届く
  3. invoice の明細・金額・税・期限・請求番号から請求書 PDF を作り、Cloud Storage に置き、Firestore にメタデータを記録する
  4. 組織の請求先メールアドレスに、PDF を添付して送る(SendGrid)
  5. 入金を確認したら、管理者が管理画面で「入金済みにする」を押す

組織のメンバーは、お支払い設定の画面から過去の請求書の一覧を見て、PDF をダウンロードできます。

サブスクリプションの切り替え処理

カード払いで使っている組織も、いつでも請求書払いに切り替えられるようにしました。サブスクリプションを作り直すと、従量の集計や締めの日がずれてしまいます。そのため、既存のサブスクリプションの collection_method を変更する方針をとりました。

  • カード → 請求書: collection_method: 'send_invoice' に変更し、days_until_due(支払期限までの日数。既定は 30 日)を組織の設定値にする
  • 請求書 → カード: charge_automatically に戻す。お客さまに既定の支払い方法(カード)が登録されていなければ、切り替えずにエラーにする

更新の順序も設計しました。Stripe の更新が成功してから Firestore に書き込みます。逆にすると、Stripe 側の更新が失敗したときに Firestore だけが「請求書払い」になってしまうためです。もし Stripe の更新後に Firestore の書き込みが失敗して一時的な不整合が生じても、定期実行する同期バッチと、Stripe から届く customer.subscription.updated の webhook 側で検知して Firestore の状態を補正するようにしています。

Stripe からのメール送信の二重停止

請求書払いのお客さまに、Stripe から直接請求書メールが届く事態は避けなければなりません。この送信は二重に停止しています。

  • コードから stripe.invoices.sendInvoice を呼ばない
  • Stripe ダッシュボードの「確定した請求書を顧客にメールで送信」を無効にする(テストモードと本番モードは独立した設定のため両方で実施する)

ダッシュボードの設定だけに頼ると、誰かが設定を戻したときに気づけません。逆にコード側だけを避けても、Stripe 側の自動送信が残る可能性があります。この設定を停止しても、カード払いのお客さまに届く支払い完了(領収書)メールには影響しないことも確認しました。

自社描画基盤の流用と利用量カウントの分離

PDF の生成には、お客さま向けの PDF を作成している基盤と同じ描画サーバー(render-api)を使います。HTML のテンプレートを Liquid でレンダリングし、ヘッドレスブラウザで A4 の PDF を出力する仕組みです。

ただし、実行経路は明確に分離しました。

  • テンプレートはお客さまの組織のものではなく、リポジトリで管理するシステム用の固定テンプレートを使用します
  • 生成は内部用のエンドポイント経由でのみ実行し、公開 API の認証やサブスクリプションの確認、利用量の記録を通りません。自社の請求書発行によってお客さまの利用量(0.3 円 × 枚数)が加算されることはありません
  • 発行者の社名・住所・登録番号・振込先口座は環境変数で渡し、コードには含めません。値が 1 つでも欠けていれば生成処理を例外で停止させ、設定漏れを早期に検知できるようにしています

あわせて、入力値のエスケープにも注意を払いました。組織名のように、利用者が入力して Stripe を経由した文字列をそのまま PDF に埋め込みます。テンプレート上のすべての変数出力にエスケープ処理を適用し、組織名に HTML タグが含まれていても文字として描画されるようにしています。

webhook の冪等性の担保

もっとも慎重に設計したのは、webhook の再送への備えです。Stripe の webhook は、エンドポイントが 5xx を返すと再送を行い、4xx を返した場合は再送しません。PDF の生成やメール送信が一時的に失敗してもイベントを破棄せず、かつ同一の請求書を二重に生成しない仕組みが求められます。

請求書ドキュメントと PDF の重複生成防止

Firestore の請求書ドキュメント(organizations/{id}/invoices/{stripeInvoiceId})の ID には、Stripe の invoice ID をそのまま使います。保存時は通常の上書き(set)ではなく、サーバー SDK の create メソッドを使用して新規作成として書き込みます。ドキュメントがすでに存在していれば ALREADY_EXISTS エラーとなって処理を中断するため、同一イベントが再送されても 2 件目のドキュメントは作成されず、PDF の再生成も走りません。

送信リースによるメールの重複配信防止

PDF とドキュメントの生成には成功したものの、メール送信だけが失敗するケースがあります。このときは 5xx を返して Stripe に再送を要求し、再送時にはメール送信のみを実行します。

しかし、再送リクエストが短時間に並行して届いた場合、同じメールを 2 通送ってしまう懸念が生じます。そこで、送信処理を開始する前に Firestore のトランザクションを実行し、「送信中」を示す有効期限付きのリース情報をドキュメントに記録します。リースが正常に取得できたプロセスだけがメール送信を実行し、送信が完了した時点でドキュメントの状態を「送信完了」に更新します。もし送信処理が途中でクラッシュした場合は、リースの有効期限が切れるのを待って次の再送プロセスが再度リースを取得し、送信を試みる設計にしました。

webhook の順序逆転への対処

Stripe の webhook は、発生順に到着するとは限りません。invoice.finalized の処理が完了する前に invoice.paid が届いたり、過去のイベントが遅れて到着したりすることがあります。

この順序逆転に対しては、次の二つの原則で対処しました。

  • 「入金済み」や「無効」のように確定した状態を、後から届いた古いイベントによって過去の状態へ巻き戻さない
  • 請求書ドキュメントが存在しない状態で状態変更イベントが届いた場合は、5xx を返して Stripe に再送させ、ドキュメントの生成完了を待ってから反映する

後者の再送要求には時間制限を設けています。5xx を長期間返し続けると、Stripe 側で webhook エンドポイント自体が無効化される危険があるためです。再送を要求するのはイベント発生時刻から 1 時間以内に限定し、それ以降は処理をスキップしてログ出力にとどめました。invoice.finalized のハンドラはドキュメント作成時に Stripe API から最新の invoice オブジェクトを取得し直すため、確定直後のわずかな追い越しを除けば、再送を繰り返さずとも最終的な状態は一致します。

手動による入金確認フロー

銀行振込の入金確認は、現時点では管理者が手作業で行っています。管理画面の請求書一覧で「入金済みにする」を実行すると、stripe.invoices.pay(id, { paid_out_of_band: true }) を呼び出し、Stripe 側の invoice を「外部で支払い済み」として Paid 状態に更新します。

この処理は外部 API 側で取り消せないため、実行前には請求番号・金額・支払期限を表示する確認ダイアログを挟んでいます。誰が入金済みに変更したかという監査情報は、操作した管理者の UID として Firestore のドキュメントに記録します。

組織間アクセス制御とデータの分離

Printgraph では、すべてのデータが組織のスコープに属しています。請求書の PDF をダウンロードするリクエストでは、URL に指定された請求書 ID のみで検索するのではなく、ログイン中のユーザーが現在選択している組織の配下からのみ請求書データを取得します。別の組織の請求書 ID を直接 URL に指定しても 404 が返る実装です。また、Firestore のセキュリティルールにおいても、請求書コレクションへの直接アクセスはクライアント SDK から一切禁止しています。

レビューで見つかった不具合と対策

実装後のコードレビューとローカル環境での通し確認において、いくつかの不具合が見つかりました。同様の構成を検討するエンジニアの参考として共有します。

  • 明細が 100 件を超えると欠落する問題:Stripe SDK の autoPagingToArray({ limit }) における limit オプションは、1 ページあたりの取得件数ではなく、配列全体の取得上限を指定するものでした。明細行の多い請求書において、101 件目以降が PDF に反映されない恐れがありました。
  • クレジット適用額の明細への反映漏れ:顧客の保有クレジットが適用された場合、請求の総額と実際に振り込むべき請求金額に差が生じます。会計上の正しさを担保するため、PDF の明細表記には総額と相殺額、最終請求額を分けて掲載し、全額相殺された場合には振込不要である旨を明記するように修正しました。
  • カード決済への変更時に過去の請求書が見えなくなる問題:請求書払いを利用中の組織にのみ一覧画面を表示していたため、途中でカード決済へ切り戻した組織において過去の請求書履歴が閲覧できなくなっていました。支払い方法に関わらず、過去の請求履歴は保持して表示を継続する形に改めました。

今後の課題と割り切り

初期リリースとしてのスコープを保つため、いくつかの要件は意図的に割り切っています。

  • PDF APIが一時的に停止している際は、webhook エンドポイントが 5xx を返して Stripe の自動再送機構に委ねる構成としており、自前でのジョブキューイングは導入していません。
  • 金額表記や消費税計算は日本国内(日本円)を前提とした実装にとどめています。
  • 請求書送信用メールテンプレートの本文は定型文としています。PDF の明細表記では全額相殺時の案内文を切り替えている一方、送信用メールの文面側は分岐処理の実装コストを考慮し、全額相殺された場合であっても振込を案内する固定文言のままとしています。
  • 未入金のまま支払期限を過ぎた組織の利用停止処理は、既存の別機能(管理者による手動での組織停止・再開)を用いて運用します。

おわりに

請求書払いの仕組みを計算処理からすべて自前で構築しようとすると、実装工数が大きく膨らみます。今回は、計算や課金ライフサイクルの管理を実績のある Stripe に委ね、自社のコア技術である「PDF の生成」に実装範囲を絞り込むことで、安全かつ小さな規模で機能をリリースできました。

Printgraph の請求書払いは、法人のお客さま向けに管理者側で切り替えを受け付けています。ご希望の方はお問い合わせください。

https://printgraph.jp

望月 涼太 / polidog

望月 涼太 / polidog

Co-Founder / Web Engineer