🧾

請求書・見積書・領収書を Markdown で書いて A4 PDF にする — インボイス制度を JSON Schema に落とし込んだ話

に公開

請求書を Excel のひな形で作っていると、だいたいこうなる。

  • 「請求書」「見積書」「領収書」でファイルが 3 本に分かれる
  • 税率の区分を書き足したら 1 本だけ直して 2 本が古いまま残る
  • diff が取れないので、去年の請求書と今年の請求書の違いを人間が目で探す

Markdown で書けば diff は取れる。ただし請求書は自由記述ではなく、適格請求書(インボイス)としての要件を満たしているかどうかが機械で判定できないと意味がない。そこで frontmatter + JSON Schema にして、検証を通ったものだけ A4 PDF にする、というのを OSS で作っている。

この記事はその設計の話。特に、

  • インボイス制度の実務(登録番号・税率ごとの区分・経過措置注記)をどう型にしたか
  • 請求書・見積書・領収書を 1 スキーマ + 種別 で持ち、様式ごとのひな形をやめた話
  • 検証シートに独自 TSV を使った理由(git diff を壊さないため)

を実装から拾って書く。記事に載せている PDF 画像は、この記事のために実際に出力したものをそのまま貼っている。

リポジトリ: meta-taro/md-business(MIT)


まず出力

これが入力の Markdown。frontmatter のキーは日本語で書ける。

---
スキーマ: invoice/v1
請求書番号: INV-2026-0117
発行日: "2026-07-31"
支払期限: "2026-08-31"
発行元:
  名前: 合同会社サンプル制作所
  登録番号: T1234567890123
  郵便番号: 100-0001
  住所: 東京都千代田区千代田1-1
  電話: 03-0000-0000
請求先:
  名前: 株式会社テスト商会
  敬称: 御中
  郵便番号: 150-0001
  住所: 東京都渋谷区神宮前1-1
品目:
  - 名前: 業務委託費(2026 年 7 月分)
    数量: 1
    単位: 
    単価: 480000
    税率: 10
  - 名前: 追加改修(画面 3 本)
    数量: 3
    単位: 
    単価: 45000
    税率: 10
  - 名前: 打合せ用茶菓子
    数量: 1
    単位: 
    単価: 2800
    税率: 8
振込先:
  銀行: サンプル銀行
  支店: 千代田支店
  種別: 普通
  口座番号: "1234567"
  名義: サンプルセイサクシヨ
備考: お振込手数料は貴社にてご負担をお願いいたします。
印影:
: auto
テーマ: 
丸め: 切り捨て
---

# 請求書

下記の通りご請求申し上げます。

これが出力の PDF(1 ページ目)。

Markdown から出力した請求書の A4 PDF

金額はどこにも書いていない。書いたのは単価と数量と税率だけで、小計・税率別の消費税・合計は全部計算されて入っている。

  • 10%: 480,000 + 45,000×3 = 615,000 → 消費税 61,500
  • 8%: 2,800 → 消費税 224
  • 合計 617,800 + 61,724 = 679,524

インボイス制度をどう型にしたか

登録番号と免税事業者は排他

適格請求書発行事業者には登録番号がある。無い事業者(免税事業者)は登録番号を書けない。この 2 つは排他なので、スキーマ上も別のフィールドにして、どちらも無い/どちらもある を警告として拾う。

// packages/schema-invoice/src/autofill.ts
function checkIssuerQualification(issuer: unknown, warnings: AutofillWarning[]): void {
  const hasReg =
    typeof issuer['registrationNumber'] === 'string' && issuer['registrationNumber'].length > 0;
  const taxExempt = issuer['taxExemptIssuer'] === true;

  if (hasReg && taxExempt) { /* 両方指定は authoring ミス → warning */ }
  if (!hasReg && !taxExempt) { /* 登録番号が指定されていません → warning */ }
}

エラーではなく警告にしているのは、下書き段階の請求書を検証で弾いても嬉しくないから。壊れた出力が黙って出ることだけを防げばよく、書きかけを止める必要はない。

登録番号の形式は JSON Schema 側で押さえている。

"registrationNumber": { "type": "string", "pattern": "^T\\d{13}$" }

税率ごとの区分は「税率ごとに 1 回丸める」

適格請求書は税率ごとに区分した対価の額と消費税額の記載が要る。ここで実装上まちがえやすいのが丸めの位置で、明細ごとに丸めて足すと税率ごとに丸めた額とズレる。

なので税率バケットに集約してから 1 回だけ丸める。

// packages/schema-invoice/src/autofill.ts
function roundTax(raw: number, mode: TaxRounding): number {
  if (mode === 'floor') return Math.floor(raw);
  if (mode === 'ceil') return Math.ceil(raw);
  return Math.round(raw);
}

このモジュールの doc コメントにそのまま書いてある:

Tax is computed per-rate (compliance with 適格請求書: one rounding step per tax rate, not per line item). Default rounding is floor, which matches the dominant B2B convention in Japan.

既定は切り捨て。frontmatter で 丸め: 切り上げ / 四捨五入 に変えられる(内部では floor / ceil / round)。

税率は enum で 3 値に固定している。

"taxRate": { "type": "number", "enum": [0, 8, 10] }

軽減税率は「8% なら軽減」を既定にする

軽減税率対象には ※ 印を付けて、欄外に「※ は軽減税率対象」と書くのが通例。ここで 税率: 8軽減税率: true を両方書かせると、片方だけ書いて印が出ない事故になる。

なので 8% なら自動で立てる。

if (item['taxRate'] === 8 && item['isReducedRate'] === undefined) {
  item['isReducedRate'] = true;
}
if (item['taxRate'] === 8 && item['isReducedRate'] === false) {
  warnings.push(/* 8% なのに軽減税率でないと書かれている */);
}

8% だが軽減税率ではない と明示された場合だけ警告にして、値は書き手の指定を残す。自動で立てるが、否定は上書きしない。

上の PDF で 8% の行にだけ ※ が付いているのはこれ。

経過措置注記は「免税事業者かつ、その文書種別で意味がある場合」だけ

免税事業者が発行する請求書は、経過措置の範囲で仕入税額控除ができる。その案内は出したい。

// packages/renderer-pdf/src/template.ts
const isTaxExempt = invoice.issuer.taxExemptIssuer === true;

// 見積書は仕入税額控除の証憑にならないので、経過措置の案内をする場面がない。
const transitionNotice =
  isTaxExempt && labels.taxNotice
    ? `<section class="mdb-invoice__transition-notice">本${escapeHtml(labels.title)}は適格請求書発行事業者以外が発行したものです。インボイス制度の経過措置(2023年10月〜2029年9月)の範囲で仕入税額控除を行ってください。</section>`
    : '';

labels.taxNotice は文書種別ごとの表で持っていて、見積書だけ false。理由はコメントのとおりで、見積書は証憑にならないので経過措置の案内をする場面が無い。

出力するとこうなる(登録番号の行が無く、下部に注記が出ている)。

免税事業者が発行する請求書の PDF

なお 「※ 適格請求書ではありません」といった赤字の否定表記は出していない。商習慣上「免税事業者だとハッキリ書きたくない」ケースが多いためで、レンダラのコメントにその判断が書いてある。必要な情報(登録番号が無い・経過措置の案内)は出しつつ、余計な烙印は押さない、という線引き。


1 スキーマ + 種別 — ひな形を様式ごとに持たない

差分は 種別 の 1 行

さっきの請求書を見積書にする。差分はこれだけ。

 ---
 スキーマ: invoice/v1
-請求書番号: INV-2026-0117
+種別: 見積書
+見積書番号: EST-2026-0117
 発行日: "2026-07-31"
-支払期限: "2026-08-31"
+有効期限: "2026-08-31"

品目も発行元も振込先もそのまま。出力はこう変わる。

同じ品目を見積書として出力した PDF

表題が「見積書」になり、番号の見出しが「見積書番号」、期日の見出しが「有効期限」、宛先ブロックが「請求先」から「宛先」、合計欄が「ご請求金額(税込)」から「お見積金額(税込)」に変わっている。

領収書も同じ。種別: 領収書 にして 但し書き を足すだけ。

+種別: 領収書
+領収書番号: RCP-2026-0117
+但し書き: システム開発費用として

同じ品目を領収書として出力した PDF

「領収日」「領収金額(税込)」に変わり、但し書きの欄が出る。

ここが実務的に一番効くところで、受注時に見積書を作り、納品時に請求書を作り、入金後に領収書を作る、という一連の流れが同じファイルの派生で書ける。品目を 3 回転記しないので、金額がズレようがない。ひな形を様式ごとに持つ必要も無くなる。

なぜスキーマを 3 本に分けなかったか

documentType.ts の冒頭コメントがそのまま設計判断:

3 文書は 発行元・宛先・品目・税率別小計・合計・印影・テーマ・ロゴ・ファイル名が同じで、違うのは表題といくつかのラベル、それに領収書固有の 但し書き / 収入印紙欄だけ。スキーマを分けると税計算・和英辞書・エラー和訳・ファイル名・PDF テンプレを 3 重に持つことになり、片方だけ直る事故が起きる。よって 1 スキーマ + 種別で持つ。

そして表記は 1 箇所に集約する。これも理由が書いてある:

表記をこのモジュールに集約しているのは、PDF レンダラ・デスクトップのウィンドウタイトル・Chrome 拡張のプレビューがそれぞれ独自に「請求書」を書いていて、種別を足すと 3 箇所が別々にずれるため。

実体は種別ごとのラベル表。

const LABELS: Record<InvoiceDocumentType, InvoiceDocumentLabels> = {
  請求書: {
    title: '請求書', politeTitle: '御請求書',
    numberLabel: '請求書番号', dateLabel: '発行日', dueLabel: '支払期限',
    recipientLabel: '請求先', totalLabel: 'ご請求金額(税込)',
    fileNamePrefix: '請求書',
    taxNotice: true, receiptFields: false,
  },
  見積書: {
    title: '見積書', politeTitle: '御見積書',
    numberLabel: '見積書番号', dateLabel: '発行日', dueLabel: '有効期限',
    recipientLabel: '宛先', totalLabel: 'お見積金額(税込)',
    fileNamePrefix: '見積書',
    taxNotice: false, receiptFields: false,
  },
  領収書: {
    title: '領収書', politeTitle: '領収書',
    numberLabel: '領収書番号', dateLabel: '領収日', dueLabel: '支払期限',
    recipientLabel: '宛先', totalLabel: '領収金額(税込)',
    fileNamePrefix: '領収書',
    taxNotice: true, receiptFields: true,
  },
};

politeTitle が表で持たれているのは、領収書を「御領収書」と言わないから。丁寧形を機械的に作ると事故る、というのがコメントに書いてある。こういうのは規則を書くより表に持つほうが正しい。

receiptFields は但し書き・収入印紙欄を出せるか。領収書だけ true にしている理由もコメントにある:

請求書に 但し書き を書いても出さないのは、請求書に但し書きを刷る商習慣が無く、書き間違いを黙って印字すると受領側が用途を誤読するため。

書けてしまうが出さない、という選択。スキーマは 1 本なのでキー自体は存在するが、その種別で意味のないものは黙って捨てる。

種別を足しても既存ファイルの出力が 1 文字も変わらないようにする

種別が後から入った機能なので、既存の請求書 md が 1 文字でも変わると困る。なので既定(請求書)では属性を出さない。

// 既定(請求書)では属性を出さない。種別を書いていない既存ファイルの出力を
// 1 文字も変えないため。CSS からは `:not([data-document-type])` で拾える。
const documentTypeAttr =
  labels.title === '請求書' ? '' : ` data-document-type="${escapeHtml(labels.title)}"`;

CSS 側は :not([data-document-type]) で既定を拾えるので、表現力は落ちない。


日本語のキーで書いて、英語の正規形で検証する

frontmatter は日本語で書けるが、JSON Schema は英語のキーで書いてある。間に辞書を挟んで正規化する。

// packages/schema-invoice/src/dictionary.ja.ts
種別: 'documentType', 文書種別: 'documentType',
請求書番号: 'invoiceNumber', 見積書番号: 'invoiceNumber', 領収書番号: 'invoiceNumber',
支払期限: 'dueDate', 有効期限: 'dueDate',
登録番号: 'registrationNumber', インボイス番号: 'registrationNumber',
免税事業者: 'taxExemptIssuer', 免税: 'taxExemptIssuer', 非適格: 'taxExemptIssuer',

エイリアスを許す代わりに、同じ英語キーに 2 つの日本語キーが同時に解決したら警告を出す

Each scope has ONE canonical translation per English key plus optional aliases. When two source keys (e.g. 名前 and 名称) resolve to the same target, the normalizer warns so authors can converge on one term.

「表記ゆれは受け入れるが、1 ファイル内で混在していたら教える」という方針。辞書は root / party / item / payment / stamp / taxBucket のスコープごとに持っていて、種別 が root なら文書種別、振込先 の下なら口座種別(普通/当座)になる。同じ語が文脈で別の意味を持つのを、スコープで分けている。


パイプラインは 4 段、ゲートは最後の 1 段だけ

// packages/schema-invoice/src/parseInvoice.ts
// 1. splitFrontmatter            — YAML ブロックを切り出す
// 2. normalizeInvoiceFrontmatter — 日本語キー → 英語正規形
// 3. autofillInvoice             — 品目から税率別小計・合計を導出、丸めの既定
// 4. validateWithCompiled        — Ajv でコンパイル済みのスキーマ検証

doc コメントの言い方だと:

Steps 1–3 are pure data transforms; only step 4 is the gating check. Authors get a single function that lets them write a 3-item invoice in Japanese frontmatter with no totals block and have it render correctly.
validate is injected so this module stays Ajv-runtime-free for MV3 CSP.

後半が地味に重要で、バリデータを注入にしてある。Chrome 拡張(MV3)は CSP で new Function が使えず、Ajv のランタイムコンパイルが動かない。なので拡張側は事前コンパイル済みバリデータを渡し、MCP サーバー(Node)は実行時に Ajv でコンパイルしたものを渡す。パース側のコードは同じものが両方で動く。

autofill は上書きではなく差分を出す

計算は自動でやるが、税率別小計合計 を書き手が自分で書いていた場合はどうするか。上書きすると「手で直したのに戻る」になるし、書き手優先にすると計算間違いが黙って通る。

答えは計算値を使い、食い違いを警告にする

手で調整したい場面(値引きの端数を合わせる等)を潰さずに、間違いは表に出す。「どちらかに倒す」より情報が多い。


A4 に収まることは機械で確かめる

PDF 出力で一番よくある事故は「2 ページ目に 1 行だけはみ出る」。これは目視だと見落とすので、E2E で落とす。

// packages/renderer-pdf/tests-e2e/invoice-fits-a4.spec.ts
const pdf = await page.pdf({ preferCSSPageSize: true, printBackground: true });
const parsed = await pdfParse(pdf);
expect(parsed.numpages).toBe(1);

テンプレートを Chromium の印刷経路に通して、pdf-parse でページ数を数えて 1 であることを assert する。標準・免税事業者・インバウンド対応の 3 種類で回している。

レイアウト側でも、はみ出しやすいところは先に潰してある。

  • 印影は発行元名の隣にインラインで置く。 以前は絶対配置していて、Paged.js のエッジケースで印影だけ 2 ページ目に飛んだことがある。
  • 品目表は既定 5 行までパディングする。 日本の請求書は品目が 1 行でも表の枠が一定数あるほうが自然に見える、という商習慣に合わせている。
  • 金額が 0 の税率バケットは行ごと出さない。 8% の品目が無い請求書に「軽減税率 0 円」の行を出しても意味がない。

上の請求書 PDF で「標準税率」「軽減税率」の 2 行だけ出て「非課税」が出ていないのはこれ。

ロゴの URL は絞る

sanitizeLogoUrl で許すのは png / jpeg / gif / webp の base64 data URI と https:// のみ。SVG は除外しているdata:image/svg+xml<script> が入るため)。ロゴ画像を frontmatter に書ける以上、そこは入力として扱う。


検証シートは Markdown ではなく独自 TSV

ここは請求書と別の話だが、同じ「git diff が効くこと」という前提から出てきた設計なので書いておく。

テスト項目書のような表形式の文書は、Markdown の表で書くと列がズレる・セル内改行が書けない・行数が多いと編集不能になる。かといって CSV/Excel にすると diff が死ぬ。

なので 1 レコード = 1 物理行を絶対制約とした TSV を定義した。

#! md-business:test-spec-tsv/v1

1 行目のマーカーで判定する(拡張子ではなく内容で判定する)。

CSV 方式のクォートを使わない理由

escape.ts の doc コメントがそのまま答え:

このパッケージのフォーマットは「1 レコード = 1 物理行」を絶対制約とする。セル内にタブ・改行・復帰が混じっても行が分割されないよう、これらをバックスラッシュ表記に畳み込む。CSV 風の "..." クォートを使わない理由は、クォートだと改行セルが複数物理行に跨り、git の行単位 diff が壊れるため(md-business の核心価値 = diff/履歴レビューが効くこと)。

畳み込みは 4 つだけ。

文字 表記
タブ (U+0009) \t
改行 LF (U+000A) \n
復帰 CR (U+000D) \r
バックスラッシュ \\

セル内改行は「\n の 2 文字」としてファイルに入る。これでどんな内容を書いても 1 レコードが 1 行に収まり、git diff で「この行のこのセルが変わった」が読める

デコードは 1 パス走査で書く

ここは実装上の罠なので明示的にコメントが入っている。

unescapeCell は単一パス走査で実装する(replace チェーンにしない)。理由: \\t(エスケープされたバックスラッシュ + 文字 t)を \t(タブ)へ誤変換しないため。

.replace(/\\t/g, '\t').replace(/\\\\/g, '\\') のように順に置換すると、\\t(本来はバックスラッシュ + t)が先にタブへ化ける。左から 1 文字ずつ読んで、\ を見たら次の 1 文字で分岐する、と書けば起きない。

デスクトップアプリ側ではこれをグリッド(表)として編集できる。人は表として触り、git には 1 行 1 レコードで入る。


MCP サーバーを内蔵して AI に書かせる

ここまでが「人が書く」話。デスクトップアプリは MCP サーバーを内蔵していて(127.0.0.1)、Claude Code などから同じワークスペースを触れる。

公開しているツールは以下。

ツール 用途
list_schemas / get_schema 扱えるスキーマ一覧・JSON Schema 本体
search_documents ワークスペース内の文書検索
read_document / create_document / update_document Markdown 文書の読み書き
validate_document 検証だけ実行
read_tsv / append_tsv_row / update_tsv_row 検証シートを行単位
git_status / git_diff / git_commit 変更確認と記録
export_pdf アプリで開いて PDF ダイアログを出す

git_push は無い

git ツールは status / diff / commit の 3 本だけで、push は公開していない。確認せずに外へ出る操作は人の手に残すという線引き。

AI に「素のファイル編集をするな」と言う欄

MCP には instructions という、接続時に一度だけ渡る欄がある。ここが空だと、汎用のファイル読み書きツールを持っている AI は業務文書も素で書き換える。実装のコメントいわく:

汎用のファイル編集は確実に動くので、この欄が空だと業務文書まで素のファイル編集で触られる。結果としてスキーマ検証も画面反映も操作ログも素通りする(実運用で発生した)。ここはその唯一の伝達口なので、「何をしないか」「代わりに何を呼ぶか」「なぜか」の 3 点を必ず含める。

実際に渡している文面がこれ。

## このワークスペースの .md / .tsv は直接編集しない

汎用のファイル読み書きでも書き換えられるが、そうすると次の 3 つが失われる。

- スキーマ検証: 業務文書は JSON Schema に従う。素の編集では壊れたまま気づけない。
- 画面反映: 利用者はデスクトップアプリで同じファイルを開いている。
  ツール経由の書き込みだけが即座に画面へ出る。
- 操作ログ: AI が何を触ったかは MCP タブに残る。素の編集は記録されず、利用者から追えない。

禁止だけ書くと AI は迂回する。「代わりにこれを呼べ」と「なぜか」をセットで書く、というのが効いた点。

検証シートについてはもっと具体的に書いてある。

検証シート(.tsv)は read_tsv で読み、update_tsv_row / append_tsv_row で行単位に触る。
全文を書き直すと「1 レコード = 1 物理行」が崩れ、差分が読めなくなる。
read_tsv の rowIds が空でなければ、更新する行は行 ID で指す。
行 index は利用者が 1 行挿すだけでずれるので、
読んでから書くまでの間に編集が入ると別の行を書き換えてしまう。

行 index ではなく行 ID で指させる、というのは人と AI が同じファイルを同時に触る前提だと必須になる。AI が読んでから書くまでの間に、人が 1 行挿しうる。

書式の約束も渡す

表のセル・YAML のデータ値の未入力は空のままにする。`—` `N/A` `TBD` などで埋めない。
スキーマ宣言(frontmatter の schema / TSV 1 行目の #! 行)は書き換えない。

これは AI 特有の癖への対処。空欄を見ると埋めたがるが、業務文書における空欄は「未確定」という情報であって、N/A に置き換えると意味が変わる。放っておくと表全体がダッシュで埋まる。


実際どう使うか

デスクトップアプリの CHANGELOG(v0.6.0)にはこう書いた。

見積書と領収書を、請求書と同じ書式で書けるようになった。種別 を書き分けるだけで、表題・有効期限・但し書きが切り替わる。請求書のために作った書式をそのまま使えるので、様式ごとに別のひな形を持たなくてよい。

ファイル名も種別に追従する。既定のルールは種別ごとの接頭辞 + 番号で、

請求書_INV-2026-0117.pdf
見積書_EST-2026-0117.pdf
領収書_RCP-2026-0117.pdf

のようになる。{請求先}{敬称}_{YMD} のようなトークンでテンプレートを指定することもできる(Windows で使えない文字は _ に落とす)。


まとめ

  • 請求書は自由記述ではないので、JSON Schema に落とせる。 落とせば「登録番号が無い」「税率ごとの区分が無い」を機械で拾える
  • 税率ごとに 1 回丸める8% なら軽減税率を自動で立てる、といった実務の細部は、書き手に書かせずコードに持つ
  • 請求書・見積書・領収書はスキーマを分けない。 分けると税計算・辞書・テンプレを 3 重に持つことになり、片方だけ直る
  • git diff が効くことを設計の前提に置く。 TSV に CSV 風クォートを使わないのも、A4 1 ページを E2E で assert するのも、そこから来ている
  • MCP に渡す instructions は「何をしないか」だけでなく「代わりに何を呼ぶか」「なぜか」まで書く

コードは meta-taro/md-business にある。この記事で引用した箇所は主に packages/schema-invoice/src/documentType.ts / autofill.ts / parseInvoice.ts / dictionary.ja.ts)、packages/renderer-pdf/src/template.tspackages/schema-test-spec-tsv/src/escape.tspackages/mcp-server/src/server.ts あたり。

Discussion