電子インボイス(JP PINT)の受領データをClaudeで読み、PDFと照合する
JP PINTのXMLから登録番号・税率別の対価と税額を取り出し、紙・PDFのインボイスと突合表にする手順。ClaudeにはXML抽出の実行と差異の読み解きを任せる。
電子インボイスで届いたXMLと、同じ取引で届いた紙・PDFの請求書は、金額が食い違うことがあります。この食い違いを人が目で追うのは手間なので、XMLの数字はスクリプトで機械的に取り出し、PDF側の読み取りと差異の説明をClaudeに任せる分担にします。
この記事では、JP PINT(日本のデジタルインボイス標準仕様)のXMLのどの項目を読めば登録番号・税率別の対価・税額が取れるかを示し、Claude Codeで抽出して突合表を作る手順を説明します。
JP PINTとは何か
JP PINTは、電子インボイスの国際標準仕様Peppolをベースにした、日本のデジタルインボイス標準仕様です。デジタル庁がJapan Peppol Authorityとして管理しています。
デジタル庁のページには、次の3つの仕様が載っています。
- Peppol BIS Standard Invoice JP PINT
- JP BIS Self Billing Invoice
- JP BIS Invoice for Non-tax Registered Businesses
2026年6月8日に、3つともVer. 1.1.3に更新されました。受領側で読むのは、通常は1つ目の標準インボイスです。
標準インボイスの識別子は urn:peppol:pint:billing-1@jp-1 で、ファイル先頭の cbc:CustomizationID に入ります。XMLの形式はUBL Invoice 2.1(名前空間は urn:oasis:names:specification:ubl:schema:xsd:Invoice-2)です。受け取ったファイルの CustomizationID がこの値かどうかが、最初の確認になります。
適格請求書の記載事項は、XMLのどこにあるか
適格請求書に必要な記載事項は、JP PINTの仕様書が意味モデルの項目番号と対応づけて示しています。受領側が突合に使う項目だけを抜き出すと、次のとおりです。
| 記載事項 | 項目番号 | XMLの要素(ublルートからの相対) |
|---|---|---|
| 発行者の氏名・名称 | 項目番号IBT-027 | XMLの要素(ublルートからの相対)cac:AccountingSupplierParty/cac:Party/cac:PartyLegalEntity/cbc:RegistrationName |
| 登録番号 | 項目番号IBT-031 | XMLの要素(ublルートからの相対)cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:CompanyID |
| 税率ごとの対価の額 | 項目番号IBT-116 | XMLの要素(ublルートからの相対)cac:TaxTotal/cac:TaxSubtotal/cbc:TaxableAmount |
| 税率ごとの税額 | 項目番号IBT-117 | XMLの要素(ublルートからの相対)cac:TaxTotal/cac:TaxSubtotal/cbc:TaxAmount |
| 税率 | 項目番号IBT-118・IBT-119 | XMLの要素(ublルートからの相対)cac:TaxSubtotal/cac:TaxCategory/cbc:ID と cbc:Percent |
| 受領者の氏名・名称 | 項目番号IBT-044 | XMLの要素(ublルートからの相対)cac:AccountingCustomerParty 配下 |
仕様書のUBL例では、登録番号は T1234567890123 のように T から始まる値として PartyTaxScheme の CompanyID に入っています。法人番号などの別の識別子は PartyLegalEntity 側の CompanyID(IBT-030)で、取り違えないようにします。
仕様書の記載事項の表には、次の注記があります。
- 税率ごとの対価の額は、税抜の金額だけが対応している
- 請求書の通貨が円以外のときは、税額(IBT-117)が請求通貨建てになり、法定要件を満たさない。円建ての税額はIBT-190で表す
税率は標準税率10%と軽減税率8%の2つで、軽減税率の取引では、その旨を明示する記載が必要です。突合表には「軽減税率の対象である旨がどこに書かれているか」の列を残しておくと、PDF側との差が追えます。
突合表の設計
突合の単位は「税率区分」です。1枚のインボイスに10%と8%が混在するとき、合計額だけを比べても食い違いの場所が分かりません。
| 突合項目 | XML側の値 | PDF側の値 | 判定 |
|---|---|---|---|
| 登録番号 | XML側の値T+13桁 | PDF側の値請求書に印字された番号 | 判定完全一致 |
| 10%対象の対価(税抜) | XML側の値IBT-116 | PDF側の値「10%対象」の欄 | 判定数値一致 |
| 10%の税額 | XML側の値IBT-117 | PDF側の値「消費税」の欄 | 判定数値一致 |
| 8%対象の対価(税抜) | XML側の値IBT-116 | PDF側の値「8%対象」の欄 | 判定数値一致 |
| 8%の税額 | XML側の値IBT-117 | PDF側の値同上 | 判定数値一致 |
| 請求合計 | XML側の値PayableAmount | PDF側の値「ご請求額」 | 判定数値一致 |
判定は「一致」「不一致」「PDFで読めず」の3段階にします。ここで「だいたい合っている」という段階を作らないのがポイントです。
手順1: XMLの抽出は決定的なスクリプトに任せる
XMLの数字は、Claudeに読ませず、プログラムで取り出します。構造が決まったデータを言語モデルに読ませると、桁を落とすなどの誤りが入り込む余地が生まれるためです。Claude Codeには、抽出スクリプトを書かせて実行させます。
プロジェクトの CLAUDE.md に、次のような規約を置いておきます。
# 電子インボイス突合の規約
- JP PINTのXML(UBL Invoice)の金額は、必ずPythonのElementTreeで抽出する。
LLMが目で読んだ数字を突合表に入れない。
- 抽出結果は `out/xml_extract.json` に保存する。
- 金額は Decimal で扱い、浮動小数点を使わない。
- PDFから読み取った値は `out/pdf_extract.json` に別ファイルで保存し、
突合は2つのJSONの比較だけで行う。抽出スクリプトの例です。税率区分ごとの TaxSubtotal を拾います。
import json
import sys
import xml.etree.ElementTree as ET
from decimal import Decimal
NS = {
"inv": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
}
def text(node, path):
el = node.find(path, NS)
return el.text.strip() if el is not None and el.text else None
root = ET.parse(sys.argv[1]).getroot()
supplier = root.find("cac:AccountingSupplierParty/cac:Party", NS)
# ルート要素で Invoice(380など)と CreditNote(381)を見分ける
doc_type = ET.QName(root).localname
result = {
"doc_type": doc_type,
"customization_id": text(root, "cbc:CustomizationID"),
"invoice_number": text(root, "cbc:ID"),
"currency": text(root, "cbc:DocumentCurrencyCode"),
"seller_name": text(supplier, "cac:PartyLegalEntity/cbc:RegistrationName"),
"seller_tax_id": text(supplier, "cac:PartyTaxScheme/cbc:CompanyID"),
"tax_breakdown": [],
"payable": text(root, "cac:LegalMonetaryTotal/cbc:PayableAmount"),
}
# TaxTotalが複数あるとき(通貨違い)も全部拾い、通貨属性を残す
for total in root.findall("cac:TaxTotal", NS):
for sub in total.findall("cac:TaxSubtotal", NS):
taxable = sub.find("cbc:TaxableAmount", NS)
tax = sub.find("cbc:TaxAmount", NS)
result["tax_breakdown"].append({
"category": text(sub, "cac:TaxCategory/cbc:ID"),
"percent": text(sub, "cac:TaxCategory/cbc:Percent"),
"taxable": str(Decimal(taxable.text)),
"tax": str(Decimal(tax.text)),
"currency": tax.attrib.get("currencyID"),
})
print(json.dumps(result, ensure_ascii=False, indent=2))Claude Codeに渡す指示は、次の程度で足ります。
invoices/inbox/ のJP PINT XMLを全件、CLAUDE.mdの規約どおりに抽出して
out/xml_extract.json にまとめて。CustomizationIDが
urn:peppol:pint:billing-1@jp-1 でないファイルは別リストに分けて抽出後は、ファイル数と xml_extract.json の件数が一致するかを数えます。次に、1通ごとに税率別の対価の合計に税額の合計を足した値が、TaxInclusiveAmount と合うかを確かめます。仕様書の計算式では、税込合計は税抜合計に税額を加えた値だからです。ここで合わない1通は、突合に進む前にXMLそのものを疑います。
スクリプトを書かせたら、実行結果の先頭数件を自分の目で確認してください。仕様書のUBL例と同じ構造のファイルなら、登録番号が T 始まりで取れているはずです。
手順2: PDF側の読み取りと突合
Claude CodeのReadツールは、短いPDFならまとめて読み、10ページを超えるPDFは pages で範囲(1回20ページまで)を指定して読みます。範囲指定の読み取りにはpoppler-utilsの pdftoppm が必要で、無いと pdftoppm is not installed で失敗します。丸ごと添付したPDFが100ページ・20MBを超えるとエラーになり、パスワード付きのPDFも読めません。請求書は1通ずつ別ファイルにして渡すと、これらの上限に当たりません。
請求書の束をAPIで一括処理する場合は、別の条件が適用されます。Claude APIのPDFサポートは、リクエスト全体で32MB、1リクエスト最大600ページ(コンテキストウィンドウが100万トークン未満のモデルでは100ページ)、暗号化のない標準PDFのみです。
PDFにはXMLのような決まった構造がないので、読み取り結果は必ず「読み取った値」と「読み取った位置(ページ・欄名)」をセットで返させます。
invoices/pdf/ のPDFから、発行者名、登録番号(T+13桁)、税率別の
対価(税抜)と消費税額、請求合計を読み取って out/pdf_extract.json に保存して。
読めなかった項目は値を空にして "unreadable" と理由を書くこと。
推測で埋めない。各値に、読み取った欄の見出しを添える2つのJSONが揃ったら、突合はClaudeに文章で頼まず、比較スクリプトで行わせます。
import json
from decimal import Decimal
xml = json.load(open("out/xml_extract.json")) # 請求書ごとの辞書のリスト
pdf = json.load(open("out/pdf_extract.json")) # 請求書番号をキーにした辞書
def same(a, b, as_number=True):
if a is None or b is None:
return "PDFで読めず"
if as_number:
return "一致" if Decimal(a) == Decimal(b) else "不一致"
return "一致" if a == b else "不一致"
for x in xml:
no = x["invoice_number"]
p = pdf.get(no, {})
print(" | ".join([no, "登録番号",
same(x["seller_tax_id"], p.get("seller_tax_id"), as_number=False)]))
for t in x["tax_breakdown"]:
rate = format(Decimal(t["percent"]).normalize(), "f") # "10.00" -> "10"
q = p.get("tax_breakdown", {}).get(rate, {})
print(" | ".join([no, f"{rate}%対象の対価", same(t["taxable"], q.get("taxable"))]))
print(" | ".join([no, f"{rate}%の税額", same(t["tax"], q.get("tax"))]))
print(" | ".join([no, "請求合計", same(x["payable"], p.get("payable"))]))PDFの抽出指示では、tax_breakdown を税率の数字("10"、"8")をキーにした辞書で保存させます。
差異が出た行だけを、Claudeに原因の候補として説明させます。このときの指示は「この行の差の原因として考えられるものを3つまで挙げ、PDFのどこを見れば切り分けられるか書く」の形にします。原因の断定ではなく、確認先の提示にとどめるためです。
差異が出たときの切り分け
差異の多くは、次のどれかに当てはまります。
- 登録番号が不一致のとき: 桁の読み違い、または
Tの有無。法人番号(IBT-030)と取り違えていないかも確認します - 税額が1円ずれるとき: 端数処理の違い。仕様書は、税率ごとの税額を整数に丸め、その結果が切り捨てと切り上げの範囲に収まる必要があると説明しています。PDF側が品目ごとに税額を計算していると、合計がずれることがあります
- 対価が税込と税抜で食い違うとき: XML側のIBT-116は税抜の金額だけが対応しています。PDFが税込で書いていれば、そのままでは比べられません
- 通貨が円以外のとき: 税額が請求通貨建てです。円建ての税額はIBT-190に入っているため、IBT-117と比べないようにします
このうち、端数のずれは機械的に判定できます。XMLの税額が、対価に税率を掛けた値の切り捨てと切り上げの間に入っているか、スクリプトで確かめれば、Claudeの出番はありません。
標準インボイス以外が届いたとき
受領データには、標準インボイス以外の仕様のファイルが混ざることがあります。登録番号の突合は、仕様ごとに意味が変わります。
- JP BIS Self Billing Invoice: 仕入側が請求書を作成し、売り手に確認してもらう取引で使われます。仕様書は、課税事業者が自分で作成したこの請求書を保存して仕入税額控除を受けられる、と説明しています。作成者は仕入側なので、PDFや取引先の控えと突き合わせるときは、誰が作成した書類かを先に確認します
- JP BIS Invoice for Non-tax Registered Businesses: 適格請求書発行事業者ではない事業者が出す書類に対応します。仕様書によれば、2023年10月1日から2029年9月30日までは経過措置があり、帳簿とこの書類の保存で仕入税額控除を受けられます。登録番号がない書類であっても、直ちに不備とは判断せず、この経過措置の対象かを確かめます
どちらも CustomizationID で見分けます。手順1のスクリプトで、標準インボイス以外を別リストに分けておくのはこのためです。
請求書と納品書を組み合わせて成立するケース
仕様書は、請求書に納品書番号しか書かれていない合算請求書の例を挙げています。この場合、品名は納品書側にあり、請求書と納品書を合わせて保存することで適格請求書の要件を満たす場合がある、という説明です。
突合表の「品名・取引内容」の列が空欄でも、すぐ不備扱いにしないでください。XMLの IBT-153 に納品書番号が入っていれば、納品書のデータを探して紐づけるのが次の作業になります。この紐づけも、番号の完全一致で機械的に行えます。
返品・値引きの請求書
返品に伴う適格返還請求書にも、登録番号と税率ごとの金額が必要です。値引きは、請求行と値引き行が1枚に載る場合、対価の額から直接減らす形の例が仕様書に示されています。
XMLでの届き方は2通りあります。仕様書によれば、日本では通常の請求書(文書型コード380)にマイナスの数量・金額を入れた負の請求書で返品や訂正を表すのが普通です。この場合、ルート要素は Invoice のままで、金額の符号が負になります。
もう1通りは、UBLのCreditNote(文書型コード381)で、外国の取引先から届くことがあると仕様書は書いています。名前空間が urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2 で、ルート要素が CreditNote に変わります。金額の符号は、取り消す元の請求書と同じプラスです。取り消しの意味は文書型コードが担うため、そのまま集計に足すと返品が売上の加算になります。
手順1のスクリプトは、doc_type にルート要素名を残しています。突合では、XMLの値を書類に書かれたまま(PDFに印字された値と同じ符号)で比べます。仕訳や集計に回す段階で、doc_type が CreditNote の行だけ符号を反転させます。
sign = -1 if x["doc_type"] == "CreditNote" else 1
signed_taxable = sign * Decimal(t["taxable"])PDFには「値引き」行が別立てで印字され、XMLでは対価の額に反映済み、という食い違いも起こりえます。値引きや返品を含む請求書は、PDFとXMLの差がそのまま不一致に見えるので、突合表に「値引き・返品あり」の印を付けて分けて見るのが確実です。
運用上の注意
電子インボイスをPeppolネットワーク経由で受け取るには、Peppol Certified Service Provider(認定サービスプロバイダー)を通す必要があります。日本の認定事業者の一覧は、デジタル庁のページで公開されています。この記事は、受け取ったXMLが手元にある前提です。
取引先の請求書をClaudeに渡すときは、契約上の秘密保持条項も確認が必要です。取引先データの扱いはClaudeに限定提供データを入力してよいかにまとめています。
PDF同士や書類間の照合は、Claudeで通関書類を照合する手順が近い型です。複数の契約書PDFから項目を機械的に抜き出す方法は、契約台帳をPDFから一括抽出する手順で扱っています。
結果をどう持ち込むか
突合表は、不一致と「PDFで読めず」の行だけを人が見る運用にすると、確認の量を減らせます。一致した行は、XMLが正で、PDFがそれと同じ値だったという記録になります。
仕訳や支払処理の前段として使うなら、XMLの抽出結果(out/xml_extract.json)を会計システムへの取り込み元にします。PDFの読み取り結果は取り込まず、差異の確認にだけ使います。