1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

銀行明細CSVをfreee・マネーフォワード形式に変換する — 6銀行のフォーマット差分をテーブル駆動設計で吸収

1
Posted at

3行まとめ

  • 銀行のネットバンキングからダウンロードした明細 CSV を、freee・マネーフォワードへのインポート用の形式へ変換するブラウザ完結ツールを作った。銀行明細という機密データをサーバーに送らずに変換できる
  • 6銀行(三菱UFJ・みずほ・三井住友・ゆうちょ・楽天・PayPay)のフォーマット差分は、銀行ごとのパーサーを書かず**設定オブジェクトの集合(テーブル駆動設計)**で吸収する
  • 文字コードは BOM → UTF-8(fatal: true)→ Shift_JIS の3段フォールバックで読み、出力は BOM 付き UTF-8 + CRLF で Excel でも化けない

freee やマネーフォワードには銀行口座の自動連携機能があるが、すべての銀行に対応しているわけではないし、過去の明細を遡って取り込みたい場合や、連携設定が引き継がれていない場合には CSV での手動インポートに落ちる。そこで待っているのが、銀行ごとに CSV の列構成・日付形式・文字コードが全部違うという現実。毎回 Excel で列を並べ替えて日付を整形するのは、月次の締めのたびにやる作業としてはつらい。

ぱんだツールズの銀行明細CSV変換は、6銀行の明細 CSV をヘッダーから自動判別して、freee / マネーフォワードのインポート形式に変換するツール。銀行明細は機密性の高いデータなので、処理はすべてブラウザ内で完結し、ファイルはサーバーに一切送信されない。

この記事では、フォーマット差分をどうデータ構造に落としたか、自動検出で踏んだ罠、文字コード処理、freee と マネーフォワードの出力仕様の違いを、実装ベースで解説する。

6銀行のCSVはどれくらいバラバラか

実装した6行分のフォーマット定義から差分を抜き出すとこうなる。

銀行 金額の持ち方 日付形式 ヘッダー検出キーワード
三菱UFJ銀行 出金・入金の2列 YYYY/MM/DD 「支払額」「受取額」
みずほ銀行 出金・入金の2列 YYYY.MM.DD 「預入金額」「差引残高」
三井住友銀行 出金・入金の2列 YYYYMMDD 「お支払金額」「お預り金額」
ゆうちょ銀行 出金・入金の2列 YYYY-MM-DD 「お支払い金額」「お預かり金額」
楽天銀行 1列(正負で入出金を表現) YYYY/MM/DD 「入出金金額」「取引内容」
PayPay銀行 出金・入金の2列 YYYY/MM/DD 「出金額」「入金額」

日付形式だけで4種類ある。区切りがスラッシュ・ドット・ハイフン・なし、と見事に散っている。金額も「出金列と入金列を分ける」多数派に対して、楽天銀行だけは1列に正負で詰める方式。そして三井住友の「お支払金額」とゆうちょの「お支払い金額」——送り仮名1文字しか違わない列名が別々の銀行に存在する。この微妙な差が、後述する自動検出の設計に効いてくる。

銀行ごとのパーサーを書かない — テーブル駆動設計

6銀行それぞれに parseMufg() parseMizuho() … と関数を書いていく道もあるが、上の表を見れば分かるとおり、差分は「どの列に何があるか」と「日付の書式」だけ。ならば差分をデータとして宣言し、変換ロジックは1本にできる。

export interface BankFormat {
  id: BankFormatId
  name: string
  /** ヘッダー行のキーワード(いずれか含まれれば一致) */
  headerKeywords: string[]
  dateCol: number
  descriptionCol: number
  /** 出金列(ない場合は -1) */
  debitCol: number
  /** 入金列(ない場合は -1) */
  creditCol: number
  /** 入出金を1列で±表現する場合のインデックス(ない場合は -1、正=入金) */
  singleAmountCol: number
  /** スキップするヘッダー行数 */
  skipRows: number
  dateFormat: string
}

銀行の定義はこの型のオブジェクトを並べるだけ。

export const BANK_FORMATS: Record<BankFormatId, BankFormat> = {
  mufg: {
    id: 'mufg',
    name: '三菱UFJ銀行',
    headerKeywords: ['三菱UFJ', 'MUFG', '支払額', '受取額'],
    dateCol: 0,
    descriptionCol: 2,
    debitCol: 3,
    creditCol: 4,
    singleAmountCol: -1,
    skipRows: 1,
    dateFormat: 'YYYY/MM/DD',
  },
  rakuten: {
    id: 'rakuten',
    name: '楽天銀行',
    headerKeywords: ['楽天銀行', '入出金金額', '取引内容'],
    dateCol: 0,
    descriptionCol: 1,
    debitCol: -1,
    creditCol: -1,
    singleAmountCol: 2,   // 入出金が1列・正負で表現
    skipRows: 1,
    dateFormat: 'YYYY/MM/DD',
  },
  // ...みずほ・三井住友・ゆうちょ・PayPay も同様
}

いわゆるテーブル駆動設計。新しい銀行への対応はオブジェクトを1個足すだけで、変換関数には手を入れない。銀行フォーマットのような「事実の集合」がロジックの中核にある機能では、事実をコードから分離してデータにしておくと、追加も検証も楽になる。

ヘッダー自動検出と部分文字列の罠

アップロードされた CSV がどの銀行のものかは、ヘッダー行のキーワードマッチで自動検出する。

export function detectBankFormat(headerRow: string[]): BankFormatId | null {
  const headerStr = headerRow.join(',').toLowerCase()
  for (const fmt of BANK_FORMAT_LIST) {
    if (fmt.headerKeywords.some((kw) => headerStr.includes(kw.toLowerCase()))) {
      return fmt.id
    }
  }
  return null
}

単純な includes の総当たりだが、キーワードの選び方に1つ罠があった。みずほ銀行のキーワードに「支払金額」を入れると、三井住友の「お支払金額」に部分一致して誤検出する。「支払金額」は「お支払金額」の部分文字列だからだ。実装にはこう注記が残っている。

mizuho: {
  // 「支払金額」は三井住友「お支払金額」の部分文字列になるため除外
  headerKeywords: ['みずほ', 'Mizuho', '預入金額', '差引残高'],
  // ...
}

includes ベースの検出では「その銀行にしか出ない語」を選ぶ必要があり、他行の列名の部分文字列になっていないかを常に疑うことになる。完全一致で照合すれば回避できるが、銀行 CSV はヘッダーに余計な空白や注記が入ることがあるので、部分一致の緩さは捨てたくない。緩い照合を採るなら、キーワード設計側で衝突を潰す。

そして自動検出は「便利機能」であって信頼の基盤にはしない。検出に失敗すれば null を返し、UI は「銀行名を手動で選択してください」に落とす。銀行がシステム更新で列構成を変えれば検出も変換も壊れうるので、手動選択という逃げ道は必ず残す。

文字コード: BOM → UTF-8(fatal)→ Shift_JIS の3段フォールバック

銀行の明細 CSV は大半が Shift_JIS で出てくる。一方で UTF-8 の銀行もあるので、読み込み時に判定が要る。

let text: string
const bytes = new Uint8Array(buffer)
const hasBom = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF
if (hasBom) {
  text = new TextDecoder('utf-8').decode(buffer)
} else {
  try {
    text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
  } catch {
    text = new TextDecoder('shift_jis').decode(buffer)
  }
}

TextDecoderfatal: true は、UTF-8 として不正なバイト列に当たると例外を投げるオプション。デフォルト(fatal: false)だと不正バイトは U+FFFD(�)に置換されて黙って通ってしまうので、「UTF-8 かどうかの試金石」として使うなら fatal が必須になる。BOM があれば確定、なければ UTF-8 として厳格に試し、例外が出たら Shift_JIS とみなす——候補が2つに絞れているときの最小実装がこれ。

候補が3つ以上ある一般の判定はもっと手がかかる。バイトパターンをスコアリングする方式は文字コード自動判定の記事で解説している。

デコード後は Papa Parse(papaparse 5.5.3)に文字列を渡して2次元配列にする。パースと変換の間に文字コードの心配事を持ち込まないよう、バイト列→文字列の変換はこの入口で完結させる。

変換本体 — 日付の正規化と符号の付け方

変換ロジックは BankFormat を引いて列を拾い、日付と金額を正規化していく。日付は4形式すべてを YYYY-MM-DD に寄せる。

export function normalizeDateStr(raw: string): string {
  const cleaned = raw.trim()

  // YYYY/MM/DD or YYYY.MM.DD
  const slashDot = cleaned.match(/^(\d{4})[/.](\d{2})[/.](\d{2})$/)
  if (slashDot) return `${slashDot[1]}-${slashDot[2]}-${slashDot[3]}`

  // YYYY-MM-DD(そのまま)
  const iso = cleaned.match(/^(\d{4})-(\d{2})-(\d{2})$/)
  if (iso) return cleaned

  // YYYYMMDD
  const compact = cleaned.match(/^(\d{4})(\d{2})(\d{2})$/)
  if (compact) return `${compact[1]}-${compact[2]}-${compact[3]}`

  return cleaned
}

金額は「1,234円」「¥1,234」のようなカンマ・通貨記号混じりを想定して、記号類を落としてから数値化する。

export function parseAmount(raw: string): number {
  const cleaned = raw.trim().replace(/[,円¥\s]/g, '')
  if (!cleaned) return 0
  const num = Number(cleaned)
  return isNaN(num) ? 0 : num
}

金額の符号付けはこう整理した。中間表現を「符号付き金額(出金が負・入金が正)」の1本に決めて、入力の2方式をそこへ潰す。

if (fmt.singleAmountCol >= 0) {
  // 楽天銀行のように入出金が1列で±表現
  amount = parseAmount(row[fmt.singleAmountCol] ?? '')
} else {
  const debit = fmt.debitCol >= 0 ? parseAmount(row[fmt.debitCol] ?? '') : 0
  const credit = fmt.creditCol >= 0 ? parseAmount(row[fmt.creditCol] ?? '') : 0
  amount = debit > 0 ? -debit : credit
}

// ゼロ金額行はスキップ(残高更新行・空行の混入等)
if (amount === 0) continue

金額ゼロの行を落としているのは、明細 CSV には利息計算日や残高更新など「取引ではない行」が混ざることがあるため。日付列が空の行と完全な空行もスキップし、最終的に1件も変換できなければ「銀行の選択が正しいか確認してください」というエラーを投げて、自動検出の誤りに気づけるようにしている。

出力はfreeeとマネーフォワードで形が違う

出口側にも方言がある。このツールでは freee 向けには3列・金額は符号付き、マネーフォワード クラウド向けには4列・金額は絶対値 + 摘要ラベル、という形式で出力する。

if (target === 'freee') {
  outputRows.push(['取引日', '内容', '金額(円)'])       // 出金は負数
} else {
  outputRows.push(['日付', '内容', '金額(円)', '摘要'])  // 摘要に「出金」「入金」
}
// ...
if (target === 'freee') {
  outputRows.push([date, description, String(amount)])
} else {
  outputRows.push([date, description, String(Math.abs(amount)), amount < 0 ? '出金' : '入金'])
}

同じ「入出金」という概念を、符号で表すソフトとラベルで表すソフトがある。つまりこのツールの全体像は、入力側6方言 → 中間表現(符号付き金額)→ 出力側2方言という小さなトランスパイラになっている。中間表現を1つ決めておけば、対応銀行や対応ソフトが増えても組み合わせ爆発しない。

書き出しは CRLF 区切り、カンマ・ダブルクォート・改行を含むセルは RFC 4180 準拠でクォートし、Blob には BOM を付ける。

const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })

(実装ではエスケープシーケンスではなくリテラルの BOM 文字を直接連結しているが、不可視文字なので記事上は \uFEFF で表記した)

変換結果の CSV はインポート前に Excel でダブルクリックして中身を確かめる人が多い。Excel は BOM なし UTF-8 の CSV を文字化けさせることがあるので、人間の目視確認を前提にする CSV は BOM 付き UTF-8 で出すのが安全圏。

純粋関数に切り出してテストする

変換ロジック全体は src/lib/csv/convertBankCsv.ts にあり、ファイル冒頭に「ブラウザ API に依存しない実装」と明記した純粋関数になっている。File / FileReader / Blob といったブラウザ依存は React コンポーネント側に隔離した。

おかげでユニットテストが素直に書ける。テストは290行で、normalizeDateStr の4形式、parseAmount のカンマ・通貨記号・負数、detectBankFormat の6銀行 + 不明ヘッダー、freee / マネーフォワード両出力の符号とラベル、空行スキップまでカバーしている。銀行フォーマットのように「正解が仕様書ではなく現物の CSV」で決まる機能では、テストケースがそのまま対応フォーマットの仕様書代わりになる。

まとめ

  • 銀行 CSV の差分は「列の位置・日付形式・金額の持ち方」に分解でき、BankFormat という設定オブジェクトで宣言的に表せる。パーサーは1本でいい
  • includes ベースの自動検出は部分文字列の衝突(「支払金額」⊂「お支払金額」)に注意。検出失敗時の手動選択フォールバックは必ず残す
  • 文字コードは BOM → UTF-8(fatal: true)→ Shift_JIS の3段フォールバック。fatal オプションが「UTF-8 の試金石」になる
  • 出力は BOM 付き UTF-8 + CRLF + RFC 4180 が Excel 目視確認まで含めた安全圏
  • 変換ロジックを純粋関数に切り出すと、6銀行と2ソフトの変換仕様を290行のテストで固定できる

月次の締めや確定申告前の「銀行明細をまとめて取り込みたい」場面でどうぞ。明細データはブラウザの外に出ない。

ぱんだツールズ では他にも CSV 文字コード変換・文字コード判定・PDF 処理・画像変換など、日本語の実務ファイルに強いブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?