クレジットカードのParserを追加

クレジットカード明細CSVの取込の土台を実装した。今回は「画面からアップロードして使えるようにする」ところまでは進めず、まずはアプリケーション内部から安全に呼べる import サービスと、カード会社ごとの差異を吸収する parser 群、その周辺のモデルとテストを固めた回になる。

今回作ったもの

入口は CreditCardImportServiceCreditCard と CSV文字列を渡すと、CreditCardStatementCreditCardImportBatchCreditCardStatementLine をまとめて保存する。

  • CreditCard
  • カード設定そのもの
  • parser_key で、どの形式のCSVとして読むかを決める
  • CreditCardStatement
  • 請求月単位のヘッダ
  • CreditCardImportBatch
  • どのCSVを、いつ、どの parser で取り込んだかの履歴
  • CreditCardStatementLine
  • CSVの各明細行の原本

対応した parser は今のところ次の3つ。

  • Orico
  • AEON
  • 楽天カード

加えて、generic_csv_v1 という自動判別モードも入れた。これはカード設定側で使うためのキーで、CSV内容から具体的な parser を選ぶ。

parser と保存を分けた

今回の実装で一番大きいのは、CSVを読む責務と、DBに保存する責務を分けたことだと思う。

各 parser は CSV 文字列を直接 Eloquent に流し込まず、いったん正規化済みの明細データを返すだけにした。保存は CreditCardImportService 側でまとめてやる。こうしておくと、parser のテストは「このCSVをどう読めるか」だけに集中できるし、import 側のテストは「読めた結果をどう保存するか」だけを見ればよくなる。

app/Data に追加した ParsedCreditCardStatementParsedCreditCardStatementLine は、その中間データを運ぶための薄い DTO。Eloquent ではなく、型付きの入れ物に近い。

会社ごとのズレは parser と override で吸収する

カード会社によって、

  • 締日
  • CSVがダウンロードできるようになる時期
  • 1ファイルがカバーする利用期間
  • CSV内に請求日や請求年が入っているかどうか

が違う。この差は parser 側で吸収しつつ、CSVだけでは確定できない値は import() の override 引数で補完する形にした。

たとえば楽天カードCSVは、支払月は入っていても請求年や請求日はCSV単体で確定できない場合がある。そのため、

  • statement_year
  • statement_month
  • billed_on
  • paid_on
  • period_start_on
  • period_end_on

を外から渡せるようにしてある。

ここを「とりあえずCSVの見た目から何とか推測する」で押し切ると、年またぎや遅延ダウンロード時に簡単に壊れるので、明示 override を残した。

同じ内容の行も潰さない

CSVはカード会社が出した原本なので、同じ店・同じ日・同じ金額の行が2件あれば、それは実際に2件あるものとして保存する。

以前は内容一致で duplicate 扱いに寄せる案もあったが、これは正当な複数利用を誤検知しやすい。今の実装では、取込時は全行を unreviewed で保存し、fingerprint は出現順込みの識別子として持つだけにした。自動 duplicate 判定は初版ではやっていない。

再取込は「置き換え」で扱う

同じ請求月を再取込した場合は、古い batch を inactive にして、その batch から作られた明細行と取引もまとめて無効化する。

これは「差分マージ」ではなく「新しいCSVを正として置き換える」方針。クレジットカード明細は、訂正版CSVを落とし直す方が自然で、途中の差分統合を頑張るより監査しやすい。

ただし、閉じた会計年度にぶら下がる取引があると、無効化できず例外で止まる。このあたりは既存の会計年度クローズのルールに従っている。

テストを厚めに入れた

今回は、正常系だけでなく異常系のテストをかなり増やした。

  • 形式違いのCSVを渡したときに例外になる
  • generic_csv_v1 で判別不能なCSVを渡したときに何も保存されない
  • 金額列や必須列が壊れているCSVで例外になる
  • 同一内容行が複数あっても全件 unreviewed で保存される
  • クォート内改行を含むCSVでも壊れない
  • 再取込時に旧 batch / lines / transactions が無効化される

後から画面やアップロード口をつなぐときに、入力導線の不具合と parser 自体の不具合を切り分けやすくするためにも、この段階でテストを厚くしておいた意味は大きい。

まだやっていないこと

今回のスコープ外にしたものも多い。

  • HTTP ファイルアップロード
  • Livewire 画面からの取込
  • 原本CSVファイルの Storage 保存
  • 明細レビューUI
  • Transaction 登録UI
  • 自動 duplicate 判定

つまり今の段階では、「CLIやサービス呼び出しからCSV文字列を渡せば、原本保存の基盤までは動く」という状態。エンドユーザー向けの操作導線はまだこれからになる。

次に考えるべき課題は、「こちらがまだ対応していないカード会社の明細をどう扱うか」だと思う。自分ひとりで使う前提なら、自分の持っているカード会社の種類だけparserを用意すれば十分。でも、多くの人に使ってもらうなら、それでは足りない。

世の中のクレジットカードは種類が多く、発行会社もCSV仕様もばらばらなので、「未対応です」で終わると入口がかなり狭くなる。かといって、最初からすべてのカード会社に専用 parser を用意するのも現実的ではない。

なので次のテーマは、専用 parser がない明細をどう受け止めるかになるはず。たとえば、

  • 汎用CSVとして列対応をユーザーに設定させるのか
  • 最低限のテンプレート変換を用意するのか
  • いったんインポート前にプレビューして、どこが読めないかを案内するのか

このあたりを考えないと、「多くの人が使える」に進めないが、明細CSVのためにカードを契約するのも本末転倒な話なので、なかなか難しい。