• #Python
  • #Vue.js
  • #SQLite
  • #Square
  • #CSV
  • #税務
  • #突き合わせチェック
開発tax-assistant完了

Square明細インポートと売上伝票突き合わせ機能の実装

個人事業主の税務申告サポートツールにおいて、Square決済明細のCSVインポート機能と、売上伝票との突き合わせチェック機能を実装した。この機能により、売上計上漏れの早期発見が可能になる。

目的と背景

なぜ突き合わせが必要か

Square明細と売上伝票を突き合わせる目的は明確だ。

  1. 売上計上漏れの検出: Squareで決済されているのに売上伝票がないケースは「売上を抜いている」ことになる
  2. 信頼関係の維持: 顧問先との信頼関係に関わる重要なチェックポイント
  3. 自動化による効率化: 手動チェックは見落としリスクがあるため、システム化が有効

仕訳の流れ

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)
        )
    """)

重複判定はdatetx_noamountsource_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明細インポートと突き合わせ機能の実装を通じて、以下の知見を得た。

  1. 日付フォーマットの統一は重要: 異なるソースからのデータは、早い段階で正規化する
  2. チェック対象の絞り込み: まだ準備ができていないデータをエラー扱いしない
  3. 建設的なメッセージ: 単なるOK/NGではなく、次のアクションにつながる情報を提供
  4. 人間の判断領域を明確に: すべてを自動化しようとせず、ツールの役割を限定する

次のステップとして、クレジットカード明細のインポート機能と、マネーフォワード会計への仕訳プレビュー機能を実装予定。