Square明細インポートと売上伝票突き合わせ機能の実装
個人事業主の税務申告サポートツールにおいて、Square決済明細のCSVインポート機能と、売上伝票との突き合わせチェック機能を実装した。この機能により、売上計上漏れの早期発見が可能になる。
目的と背景
なぜ突き合わせが必要か
Square明細と売上伝票を突き合わせる目的は明確だ。
- 売上計上漏れの検出: Squareで決済されているのに売上伝票がないケースは「売上を抜いている」ことになる
- 信頼関係の維持: 顧問先との信頼関係に関わる重要なチェックポイント
- 自動化による効率化: 手動チェックは見落としリスクがあるため、システム化が有効
仕訳の流れ
Square決済 → スクエア仮勘定(≒売掛金)→ 入金時に消込
現金売上 → 事業主勘定(個人事業主のため)
実装フェーズ
Phase 1: データ層の実装
まずSQLiteにSquare明細を格納するテーブルを作成した。
# db.py
def init_square_transactions_table(conn):
"""Square明細テーブルを初期化"""
conn.execute("""
CREATE TABLE IF NOT EXISTS square_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, -- 取引日(YYYY-MM-DD)
tx_no TEXT, -- 取引番号(入金はNULL)
type TEXT NOT NULL, -- 種別(お取引/入金)
amount INTEGER NOT NULL, -- 金額
fee INTEGER DEFAULT 0, -- 手数料
net_amount INTEGER, -- 入金額
description TEXT, -- 内容
source_file TEXT NOT NULL, -- 取込元ファイル
check_status TEXT DEFAULT 'unchecked', -- チェック状態
check_message TEXT, -- チェック詳細メッセージ
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, tx_no, amount, source_file)
)
""")
重複判定はdate、tx_no、amount、source_fileの組み合わせで行う。ただし入金レコードはtx_noがNULLになるため、判定ロジックに工夫が必要だった。
Phase 2: CSVインポート機能
# import_square.py
def import_square_csv(csv_path: str, db_path: str) -> dict:
"""Square明細CSVをインポート"""
conn = sqlite3.connect(db_path)
init_square_transactions_table(conn)
imported = 0
skipped = 0
with open(csv_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
# 日付フォーマット変換(M/D → YYYY-MM-DD)
date_str = row['日付']
date_obj = datetime.strptime(date_str, '%m/%d')
# 年は別途推定(ファイル名や明細から)
normalized_date = date_obj.strftime('%Y-%m-%d')
try:
conn.execute("""
INSERT INTO square_transactions
(date, tx_no, type, amount, fee, net_amount,
description, source_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
normalized_date,
row.get('取引番号'),
row['種別'],
int(row['金額'].replace(',', '')),
int(row.get('手数料', '0').replace(',', '')),
int(row.get('入金額', '0').replace(',', '')),
row.get('内容', ''),
os.path.basename(csv_path)
))
imported += 1
except sqlite3.IntegrityError:
skipped += 1 # 重複スキップ
conn.commit()
return {'imported': imported, 'skipped': skipped}
Phase 3: 突き合わせチェック機能
ここが本実装の核心部分。売上計上漏れを検出するロジックを実装した。
# db.py
def check_square_against_sales(conn) -> dict:
"""Square明細と売上伝票を突き合わせ"""
# 売上伝票がある月のみをチェック対象に
# (まだスキャンしていない月を不一致にしても意味がない)
result = conn.execute("""
WITH sales_months AS (
SELECT DISTINCT strftime('%Y-%m', date) as ym
FROM receipts
WHERE status = 'confirmed'
AND doc_type = 'sales_slip'
)
SELECT
sq.id,
sq.date,
sq.amount,
sq.tx_no,
(SELECT COUNT(*) FROM receipts r
WHERE r.date = sq.date
AND r.total = sq.amount
AND r.status = 'confirmed'
AND r.doc_type = 'sales_slip') as receipt_count
FROM square_transactions sq
WHERE sq.type = 'お取引'
AND strftime('%Y-%m', sq.date) IN (SELECT ym FROM sales_months)
""").fetchall()
matched = 0
unmatched = 0
for row in result:
sq_id, date, amount, tx_no, receipt_count = row
if receipt_count >= 1:
# 一致:売上伝票が1件以上ある
status = 'ok'
message = f'Square 1件 = 売上伝票 {receipt_count}件'
matched += 1
else:
# 不一致:売上計上漏れの可能性
status = 'ng'
message = 'Square 1件に対し売上伝票が0件。売上計上漏れの可能性'
unmatched += 1
conn.execute("""
UPDATE square_transactions
SET check_status = ?, check_message = ?
WHERE id = ?
""", (status, message, sq_id))
# 売上伝票がない月はuncheckedのままにする
conn.execute("""
UPDATE square_transactions
SET check_status = 'unchecked', check_message = NULL
WHERE type = 'お取引'
AND strftime('%Y-%m', date) NOT IN (
SELECT DISTINCT strftime('%Y-%m', date)
FROM receipts
WHERE status = 'confirmed'
AND doc_type = 'sales_slip'
)
""")
conn.commit()
return {'matched': matched, 'unmatched': unmatched}
日付フォーマット不一致の修正
当初、突き合わせが全く機能しなかった。原因は日付フォーマットの不一致だった。
Square明細: 2024-02-15(YYYY-MM-DD)
売上伝票DB: 2/15(M/D形式で格納されていた)
売上伝票の取得関数を修正して、日付フォーマットを統一した。
def get_sales_slip_by_date_amount(conn, date: str, amount: int):
"""日付と金額で売上伝票を検索(日付フォーマット正規化)"""
# YYYY-MM-DD形式に統一して比較
return conn.execute("""
SELECT * FROM receipts
WHERE date = ?
AND total = ?
AND status = 'confirmed'
AND doc_type = 'sales_slip'
""", (date, amount)).fetchall()
この修正後、一致件数が0件から39件に増加した。
Phase 4: フロントエンド実装
Vue.jsで年月スプリットビューUIを実装した。
<!-- SquareMatchingView.vue -->
<template>
<div class="square-matching-view">
<!-- 種別フィルター(お取引/入金) -->
<div class="type-filter-column">
<div class="filter-header">種別</div>
<div
v-for="t in typeOptions"
:key="t.value"
:class="['filter-item', { active: selectedType === t.value }]"
@click="selectType(t.value)"
>
{{ t.label }}
</div>
</div>
<!-- 年フィルター -->
<YearFilterColumn
:years="years"
:selected-year="selectedYear"
@select="selectYear"
/>
<!-- 月フィルター -->
<MonthFilterColumn
:months="months"
:selected-month="selectedMonth"
@select="selectMonth"
/>
<!-- 明細テーブル -->
<div class="data-section">
<div class="summary-bar">
全{{ total }}件 |
<span class="unmatched">不一致 {{ unmatched }}件</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>日付</th>
<th>金額</th>
<th>結果</th>
<th>チェック詳細</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in filteredItems"
:key="`${item.id}-${index}`"
:class="{ 'row-ng': item.check_status === 'ng' }"
>
<td class="index-cell">{{ index + 1 }}</td>
<td>{{ formatDate(item.date) }}</td>
<td class="amount-cell">{{ formatAmount(item.amount) }}</td>
<td>
<span :class="['status-badge', item.check_status]">
{{ statusLabel(item.check_status) }}
</span>
</td>
<td class="message-cell">{{ item.check_message }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
チェック詳細メッセージの追加
単に「一致/不一致」だけでなく、建設的なメッセージを表示するようにした。
| 状態 | メッセージ例 |
|---|---|
| 一致 | Square 1件 = 売上伝票 1件 |
| 一致(複数) | Square 1件 = 売上伝票 2件 |
| 不一致 | Square 1件に対し売上伝票が0件。売上計上漏れの可能性 |
| 未チェック | (売上伝票がない月は表示なし) |
「Square 1件 = 売上伝票 2件」のパターンは、売上明細側にユニークな情報がないが、少なくとも1件はあるのでOK判定としている。
売上伝票がある月のみをチェック対象に
当初の実装では、まだ売上伝票を読み込ませていない月も「不一致」と表示されてしまっていた。これは実用上おかしい。
改善点:
- 確定済み売上伝票が存在する月のみをチェック対象にする
- 売上伝票がない月のSquare取引は
uncheckedのままにする - チェック実行時に、売上伝票がない月は
uncheckedにリセット
# チェック対象を売上伝票がある月に限定
sales_months = conn.execute("""
SELECT DISTINCT strftime('%Y-%m', date) as ym
FROM receipts
WHERE status = 'confirmed'
AND doc_type = 'sales_slip'
""").fetchall()
手動売上伝票登録機能
不一致が出た場合の対応として、手動で売上伝票を登録できる機能も追加した。
def get_or_create_manual_batch(conn) -> str:
"""手動登録用のバッチを取得または作成"""
batch_id = "manual"
existing = conn.execute(
"SELECT batch_id FROM batches WHERE batch_id = ?",
(batch_id,)
).fetchone()
if not existing:
conn.execute("""
INSERT INTO batches (batch_id, doc_type, created_at)
VALUES (?, 'sales_slip', CURRENT_TIMESTAMP)
""", (batch_id,))
conn.commit()
return batch_id
読み取り一覧タブに「手動登録」バッチを追加し、そこから新規登録フォームを開けるようにした。
実装後の課題と対応
人間の判断が必要なケース
一部の不一致は、手書き伝票の転記ミスが原因だった。
- 年の間違い(2023年2月 → 実際は2024年2月)
- 月の間違い
- 金額と日付は一致するが年が違う
これらはシステムで自動修正せず、人間が確認して判断する運用とした。ツールの役割は「候補を提示する」までとし、最終判断は人間が行う。
URLクエリパラメータ対応
タブの状態をURLに反映し、ブラウザの戻るボタンで履歴を辿れるようにした。
// クエリパラメータ構造
?tab=square&squareType=transaction&squareYear=2024&squareMonth=02
ローカルストレージとURLの役割分担:
- URL: 共有可能な状態(タブ、年月、選択アイテム)
- localStorage: 最後に見ていた位置の復元(共有不要)
まとめ
Square明細インポートと突き合わせ機能の実装を通じて、以下の知見を得た。
- 日付フォーマットの統一は重要: 異なるソースからのデータは、早い段階で正規化する
- チェック対象の絞り込み: まだ準備ができていないデータをエラー扱いしない
- 建設的なメッセージ: 単なるOK/NGではなく、次のアクションにつながる情報を提供
- 人間の判断領域を明確に: すべてを自動化しようとせず、ツールの役割を限定する
次のステップとして、クレジットカード明細のインポート機能と、マネーフォワード会計への仕訳プレビュー機能を実装予定。