Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

入金消込のreconcilePaymentは入金額を照合せず入金済にしていた

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

入金消込は、紐付け処理自体が成功しさえすれば入金額を請求額と一切比較せずに契約を「入金済」にしていたため、部分入金・過入金・別契約の取り違えがサイレントに正常終了していた。

結論

入金の消込・取引紐付け処理は、処理自体がエラーなく完了しさえすれば、入金額を契約の請求額と一切比較せずに契約を「入金済」にしていた。 部分入金・過入金・別契約との取り違えのいずれも、処理としては正常終了するため気づく手段が無かった。レビュー指摘(H-1)を受けて、純関数paymentDiscrepancyで差額を判定し、消込自体は止めずにSlack警告と監査ログで知らせる「警告のみ」方式に直した。

症状

reconcilePayment(消込)とlinkTransactionToContract(取引紐付け)は、どちらも次のような実装だった。取引を契約に紐付ける処理そのものにしか関心が無く、金額は読みに行っていない。

// actions.ts(修正前の reconcilePayment 相当)
// 二重発行ガード用に変更前の入金状態を取得(既に入金済の再消込で領収証を再送しない)。
const { data: before } = await supabase
  .from("contracts")
  .select("payment_status")
  .eq("id", contractId)
  .maybeSingle();
// …(中略:入金日の決定など)…
// この後、入金額を一度も参照せずに payment_status を「入金済」へ更新する。

staffが入金一覧から取引を選んで「消込」ボタンを押すと、選んだ取引の金額が契約の請求額とどれだけ違っていても、紐付け自体が成功する限り契約は「入金済」になる。10万円の請求に3万円しか入金されていなくても、逆に別の顧客の入金を取り違えて紐付けても、画面上は同じ「入金済」の表示になり、エラーも警告も出ない。

原因

このコードは「取引を契約に紐付ける」という操作の成否だけを見ており、「紐付けた取引の金額が、その契約の請求額と整合しているか」という別の検証を一度も行っていなかった。処理が成功する条件(DB更新がエラーなく通ること)と、業務的に正しい条件(入金額が請求額と一致すること)が別物であるにもかかわらず、前者だけで後者を保証したつもりになっていた。会計処理では、部分入金・過入金・取引の取り違えはいずれも「処理としては成功する」操作であり、金額という別の軸での照合を持たない限り、これらは仕組み上サイレントに通る。

直し方

請求総額(税込)と入金額の差を判定する純関数paymentDiscrepancyを新設した。契約のamount列は税別だが、顧客が実際に払うのは請求書の税込総額なので、税込(gross)で比較することで税率差による誤検知を避けている。

// billing.ts
export const PAYMENT_MISMATCH_TOLERANCE_YEN = 1000;
export function paymentDiscrepancy(
  contractAmount: number | null | undefined,
  taxIncluded: boolean,
  paidAmount: number | null | undefined,
  toleranceYen = PAYMENT_MISMATCH_TOLERANCE_YEN
): { gross: number; paid: number; diff: number; mismatched: boolean } | null {
  if (contractAmount == null || paidAmount == null) return null;
  const gross = calcTotalsFor(Number(contractAmount), taxIncluded).gross;
  const paid = Math.round(Number(paidAmount));
  const diff = paid - gross;
  return { gross, paid, diff, mismatched: Math.abs(diff) > toleranceYen };
}

差の許容幅(既定1000円)は振込手数料程度に設定している。手数料差引による少額の不足まで毎回警告すると、staffが警告に慣れて本当に見るべき差異を見落とすようになるためだ。

この判定をreconcilePaymentとlinkTransactionToContractの両方に組み込んだが、消込自体は止めない設計にした。

// actions.ts(修正後)
const disc = paymentDiscrepancy(
  before?.amount as number | null,
  (before?.tax_included as boolean | undefined) ?? false,
  txnAmount
);
if (disc?.mismatched) {
  const sign = disc.diff > 0 ? "過入金" : "不足";
  await notifySlack(
    `⚠️ 入金額の差異: 請求(税込) ${formatYen(disc.gross)} / 入金 ${formatYen(
      disc.paid
    )}(${sign} ${formatYen(Math.abs(disc.diff))}) ${slackLink("/admin/finance", "入金突合を確認")}`
  );
}

消込をエラーで止める(fail-closed)設計にすると、正当な分割払いのような部分入金まで止めてしまい、既存の運用を壊す。そのため差異を検知してもSlack通知と監査ログへの記録だけ行い、処理自体は通す「警告のみ」方式を選んでいる。画面側(finance/page.tsx)にも、消込前の自動選択候補と、すでに突合済みの明細の両方に、差額をamber色の警告として表示するようにした。

// page.tsx
{sugDisc?.mismatched && (
  <div className="mt-1 rounded bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
    ⚠ 候補の入金額 {formatYen(sugDisc.paid)} が請求(税込){" "}
    {formatYen(sugDisc.gross)} と差額(
    {sugDisc.diff > 0 ? "過入金" : "不足"}{" "}
    {formatYen(Math.abs(sugDisc.diff))})— 確認のうえ消込してください
  </div>
)}

再発防止

paymentDiscrepancyは入出力が明確な純関数として切り出されており、境界値・税区分・文字列として渡された数値への耐性を含む8件のテストが追加されている(551 testsのうちの一部としてCIで常時実行される)。金額判定のロジックがreconcilePayment・linkTransactionToContract・画面表示の3箇所から同じ関数を呼ぶ形になっているため、将来この3箇所のどこかだけを直して判定基準がズレる、という事態も起きにくい。

よくある質問

Q1入金消込で入金額を照合していないと何が起きますか?

ある契約の取引に、別契約の入金や、本来の請求額より少ない部分入金・多い過入金を紐付けても、紐付け処理自体はエラーを出さずに成功し、契約は一律「入金済」になる。差額に気づく手段がどこにも無い。

Q2なぜ金額チェックをエラーで止めずに警告だけにしたのですか?

消込自体を止める(fail-closed)設計にすると、正当な部分入金(分割払いなど)まで止まってしまい既存運用を壊す。差額を検知したらSlack警告と監査ログへの記録だけ行い、消込は通す「警告のみ」方式にして、staffが後から気づけるようにした。

Q3請求額との比較で税の扱いはどうしていますか?

契約のamount列は税別だが、顧客が実際に支払うのは請求書の税込総額。税込総額(gross)と入金額を比較することで、税率差による誤検知を避けている。

確認した環境

  • Next.js App Router / Supabase(kimiteras-portal の admin/finance)
  • 2026-06-23 レビュー指摘H-1で発覚、同日 commit dd87ea4b で修正

この記事の根拠

  • TypeScriptファイル 217〜360行目コミット dd87ea4
  • TypeScriptファイル 28〜240行目コミット dd87ea4

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。