クーポン値引きの明細按分と税率別の端数処理 — プリミティブ型執着 × 値オブジェクト × 最大剰余法(Bad→Good 7点)

2026-09-28 月曜(Day 137) A コーディング/アルゴリズム ★★★★★ 書籍 第5章・第3章・第10章 合成10万注文で実測 国税庁 インボイスQ&A 問57・問69

概要

🪙

金額は int の円、float は入口で拒む

Yen 値オブジェクトが float・負数・True を拒む。math.floor(n*0.1/1.1) は 1〜10万円の 1.6% で 1 円ずれる(実測: 165 円 → 14 円)。

🧾

税額は税率ごとに1回だけ丸める

インボイスQ&A 問57: 商品ごとに丸めて合計するのは不可。同じ切り捨てでも、明細ごとと税率ごとで税額が変わる注文が 42%(実測)。

⚖️

按分は最大剰余法

全部切り捨て→余りの円を端数の大きい行へ。合計が必ずクーポン額に一致し、問69 の記載例(604 円/396 円)をそのまま再現する。

↩️

返品で按分をやり直さない

Bad は最高額明細の返品で平均 550 円の過大返金(40,352 件)。Good は保存した按分から累計の切り捨て差で返金し、合計が支払額に一致する。

問題

MOps 販促システムチームは、カートで入力されたクーポン値引き(税込・注文全体に1枚)を注文確定時に明細へ按分し、レシート(適格簡易請求書)に税率別の対象額と消費税額を出す order_pricing.py を持っている。総額表示(税込価格が正本)で、消費税額の端数処理は社内規程で切り捨て。

経理からの問い合わせ(本問の前提・架空)

問い合わせ中身
Q1 レシートの合計が合わない明細の値引き後金額の合計と請求額が 1 円ずれるレシートが毎日出る
Q2 税額の計算方法税理士から「明細ごとに税額を丸めて足していないか。インボイスでは認められない」
Q3 ワインが 8%酒類が軽減税率で計算されたレシートがある
Q4 返品の過大返金クーポン併用注文の一部返品で、支払額より多く返金している。残りの請求額がマイナスになる例も

設問

  1. Bad の問題点を7つ以上、Q1〜Q4 のどれに効くかと合わせて
  2. 金額・税率・明細を値オブジェクトにし、按分と税額計算を直したコード(Google スタイル docstring・インラインコメント・名前付き定数)
  3. 按分の合計=クーポン額、分割返品の返金合計=支払額を例ではなく性質で確かめるテスト
  4. 国税庁 Q&A の記載例(問57・問69)を再現するテスト
  5. 迷った案(とくに「値引きを 10% 対象から優先して引く」)と採らなかった理由、切り替える条件

制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis 可)。単価は税込の整数円。税率は 10%/8% の2つで、課税区分は商品マスタが持つ。端数は切り捨て。返品は明細・数量単位で何回にも分かれる。ポイント・送料・複数クーポンは扱わない。

悪いコード (Before)

既存テストは「1明細・クーポンなし」の2本だけで緑。それでも 7つの問題 がある。
order_pricing.py(現行)
def calc_order(items, coupon):
    # items: [{"sku": str, "price": int(税込単価), "qty": int, "food": bool}]
    total = 0.0
    for it in items:
        total += it["price"] * it["qty"]

    tax = 0
    lines = []
    for it in items:
        amt = it["price"] * it["qty"]
        d = int(coupon * amt / total)
        net = amt - d
        rate = 0.08 if it["food"] else 0.1
        t = round(net * rate / (1 + rate))
        tax += t
        lines.append({"sku": it["sku"], "net": net, "tax": t})

    return {"total": total - coupon, "tax": tax, "lines": lines}


def calc_refund(items, coupon, sku, qty):
    # 返品: 返品後に残る明細で按分をやり直し、差額を返金する
    before = calc_order(items, coupon)
    remain = [...]  # 返品分を除いた明細
    if not remain:
        return before["total"]
    after = calc_order(remain, coupon)
    return before["total"] - after["total"]
問題点サマリー(7点)
1float と dict の金額 — 請求額が常に float。負数・数量0・True が通る
2int() 切り捨て按分 — 按分合計がクーポンより最大「明細数−1」円少ない
3明細ごとに税額を丸めて合計 — 問57 で不可
4float と round() — 規程(切り捨て)と違う。floor に直しても float 誤差
5food: bool と 0.08/0.1 — 酒類は食品でも 10%
6税率別の値引き後合計がない — 問69 のレシート記載を作れない
7返品で按分をやり直す — 過大返金・マイナスの請求額

ヒント(段階的開示)

ヒント1 — 方向性
Q1〜Q4 はどれも「丸める場所と回数」の問題。Bad が丸めている場所(int()、round())を全部数え、それぞれ「何円ずれうるか」「ずれた1円はどこへ消えるか」を考える。もう一つ、返品のとき按分をやり直すと何が変わるか。
ヒント2 — アプローチ
  • 金額は int の円。float は入口で拒む(Yen 値オブジェクト)。税額は Fraction で厳密に出して最後に1回だけ切り捨て
  • 按分は「全部切り捨て→余りの円を端数の大きい行へ1円ずつ」(最大剰余法 / Hamilton 方式)
  • 税額は税率ごとの値引き後合計に対して1回だけ端数処理(インボイスQ&A 問57)
  • 「食品か」の bool をやめ、課税区分 TaxCategory と税率表を1か所に
  • 返品は確定時に保存した按分だけから。数量分割は「累計の切り捨ての差」
ヒント3 — 骨格
def allocate(total: Yen, weights: Sequence[Yen]) -> list[Yen]:
    exact = [Fraction(total.amount * w.amount, weight_sum) for w in weights]
    shares = [int(x) for x in exact]                      # まず全部切り捨て
    leftover = total.amount - sum(shares)                 # 余りは行数未満の円
    order = sorted(range(len(weights)), key=lambda i: (-(exact[i] - shares[i]), i))
    for i in order[:leftover]:
        shares[i] += 1                                    # 端数の大きい行から1円ずつ

def refund_for_return(priced_line, already_returned, returning) -> Yen:
    net, qty = priced_line.net.amount, priced_line.line.quantity
    return Yen(net * (already_returned + returning) // qty - net * already_returned // qty)

問題点分析(7点・実測)

実測の条件: 合成注文 100,000 件(シード 20260928)。分布は仮定 — 明細数 1〜8、単価 98〜19,800 円の20種、数量 1〜3、軽減税率 35%、クーポン 300/500/1,000/2,000 円(注文額がクーポンの3倍以上のとき 60%)。クーポン適用は 51,887 件。Python 3.14.7、テストは 3.13.12 + pytest 9.1.1 + hypothesis 6.168.2。

#問題効く問い実測・根拠
1金額が float と素の dict(プリミティブ型執着)Q1請求額が float: 100,000/100,000 件 実測
2int() 切り捨て按分で、消えた円が明細合計と請求額のずれになるQ1按分合計≠クーポン額: 39,778 件(クーポン注文の 77%)、ずれ合計 74,046 円・平均 1.86 円 実測
3明細ごとに税額を丸めて合計(消令70の10、基通1-8-15、問57 で不可)Q2丸め方を切り捨てに揃えても税額が変わる注文 42,040 件(42%)、差の合計 65,787 円 実測
4float と round()。規程と違い、math.floor に直すと float 誤差Q21〜100,000 円で floor がずれる金額: 10% 1,616 個(165 円→14、正しくは 15)、8% 1,030 個。1980*0.1/1.1 = 179.99999999999997 実測
5food: bool と 0.08/0.1。酒類は飲食料品でも軽減税率の対象外Q3実行例のワイン(値引き後 2,412 円): 8% で 179 円、正しくは 10% で 219 円。40 円の過少 実測
6値引き後の税率別合計を出さない(問69 のレシート記載事項)Q2戻り値に税率別の対象額がない
7返品のたびに按分をやり直す。返品明細は値引き前の額で返金Q4最高額明細の返品で過大返金 40,352 件・合計 2,221.5 万円・平均 550.5 円。支払総額超え 6,867 件。全部1行ずつ返品すると途中でマイナス返金 11,640 件 実測
補足: 1行ずつ全部返品したときの返金合計は Bad でも支払額に一致した(不一致 0 件)。「最後に帳尻が合う」ように見えるが、途中で過大に払い最後にマイナスで取り返す形。マイナス返金は実行できないので、そのまま損になる。

丸めの3か所(SVG)

丸めるのは3か所だけ。それぞれに「合計が一致する」性質を持たせる 値引き前の明細 OrderLine(税込 int) + クーポン Yen 丸め① allocate 最大剰余法 Σ按分 = クーポン額 Bad: int() で最大 n−1 円消える PricedLine を保存 gross / discount / net frozen。確定後は作り直さない 返品はここだけを読む 丸め② 税率ごとに1回だけ Σnet(率) × 率/(100+率) → 切り捨て 問57。Bad: 明細ごとに丸めて足す(42% で差) 丸め③ 返品は累計の切り捨て差 ⌊net·(r+k)/q⌋ − ⌊net·r/q⌋ Σ返金 = net。Bad: 再按分で平均 550 円過大 最大剰余法の中身: 国税庁 インボイスQ&A 問69 の例(雑貨 3,300 円・牛肉 2,160 円に 1,000 円引き) 1) 厳密な比率で割る 10% 雑貨 604.39… 8% 牛肉 395.60… 2) 全部切り捨て(Bad はここで終わる) 604 395 合計 999 円 → 1 円が消え、レシートの明細合計と請求額がずれる(Q1) 3) 余り 1 円を端数の大きい行へ 604(端数 .39) 395 + 1 = 396(端数 .60) 合計 1,000 円。国税庁の記載例(604 円/396 円)と一致 → test_..._qa_69_example 同点は先頭の行を優先するので、同じ入力なら常に同じ結果。按分額は明細額を超えない(total ≤ Σweights のとき)。 税額: 10% 2,696 円 × 10/110 = 245.09 → 245 円 / 8% 1,764 円 × 8/108 = 130.66 → 130 円(税率ごとに1回)

上段は Good の計算の流れ。丸めは ①按分 ②税率別の税額 ③返品 の3か所だけで、①の結果を保存して②③はそれを読む。下段は ① の最大剰余法の手順。

Bad→Good 対比(要所)

Before
total = 0.0
d = int(coupon * amt / total)
rate = 0.08 if it["food"] else 0.1
t = round(net * rate / (1 + rate))
tax += t
return {"total": ..., "tax": tax, "lines": lines}

after = calc_order(remain, coupon)
return before["total"] - after["total"]
After
Yen(amount: int)  # float・負数・bool を拒む
discounts = allocate(coupon, [ln.gross for ln in lines])
TAX_RATE_PERCENT[TaxCategory.STANDARD]  # 区分→率の表
gross = Yen.sum(pl.net for pl in priced if 区分一致)
tax = tax_included_in(gross, rate)  # Fraction→1回切り捨て
PricedOrder(lines, breakdown=(TaxBreakdown(率, 対象額, 税),))

refund_for_return(saved_line, already_returned, returning)

模範解答(改善後コード)

"""order_pricing.py(改善後・Good).

クーポン値引きを明細へ按分し、適格請求書(インボイス)の要件どおり
「税率ごとに 1 回だけ」消費税額の端数処理を行う。返品時は注文確定時に
保存した按分結果だけから返金額を出し、按分をやり直さない。
"""

from __future__ import annotations

from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from enum import StrEnum
from fractions import Fraction


# ---------------------------------------------------------------- 値オブジェクト
@dataclass(frozen=True, slots=True, order=True)
class Yen:
    """日本円の金額(1 円未満を持たない非負の整数).

    float を受け付けないことで 0.1 + 0.2 型の誤差を型の入口で止める。
    """

    amount: int

    def __post_init__(self) -> None:
        # bool は int の派生型なので明示的に弾く(True が 1 円として通るのを防ぐ)
        if type(self.amount) is not int:
            raise TypeError(f"Yen は int だけを受け付けます: {self.amount!r}")
        if self.amount < 0:
            raise ValueError(f"金額は 0 円以上: {self.amount}")

    def __add__(self, other: Yen) -> Yen:
        return Yen(self.amount + other.amount)

    def __sub__(self, other: Yen) -> Yen:
        # 負になる引き算は Yen の生成で ValueError になる(値引き超過を早期に検出)
        return Yen(self.amount - other.amount)

    @classmethod
    def sum(cls, values: Iterable[Yen]) -> Yen:
        return cls(sum(v.amount for v in values))


class TaxCategory(StrEnum):
    """商品マスタが持つ課税区分。「食品かどうか」の bool では表せない区分を持つ."""

    STANDARD = "standard"          # 標準税率 10%
    REDUCED_FOOD = "reduced_food"  # 飲食料品(酒類を除く)8%
    # 酒類は食品でも STANDARD。一体資産(税抜1万円以下かつ食品部分2/3以上)の
    # 判定は商品マスタ登録時に行い、ここへは結果の区分だけが届く前提。


# 税率はコードに散らさず、区分→率の表を 1 か所に置く(%の整数で持つ)
TAX_RATE_PERCENT: dict[TaxCategory, int] = {
    TaxCategory.STANDARD: 10,
    TaxCategory.REDUCED_FOOD: 8,
}


@dataclass(frozen=True, slots=True)
class OrderLine:
    """注文明細。単価は総額表示(税込)が正本."""

    sku: str
    unit_price: Yen  # 税込単価
    quantity: int
    tax_category: TaxCategory

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValueError(f"数量は 1 以上: {self.sku} qty={self.quantity}")

    @property
    def gross(self) -> Yen:
        """値引き前の明細金額(税込)."""
        return Yen(self.unit_price.amount * self.quantity)


# ---------------------------------------------------------------- 按分(純粋関数)
def allocate(total: Yen, weights: Sequence[Yen]) -> list[Yen]:
    """total を weights の比で整数按分する(最大剰余法 / Hamilton 方式).

    1. 各行に floor(total * w / W) を配る
    2. 余りの円を、切り捨てた端数が大きい行から 1 円ずつ配る
       (同点は先頭の行を優先し、同じ入力なら常に同じ結果になる)

    Args:
        total: 按分する金額。
        weights: 按分の重み(ここでは明細の税込金額)。

    Returns:
        weights と同じ順の按分額。合計は必ず total に一致し、
        total <= sum(weights) なら各行の按分額は重みを超えない。

    Raises:
        ValueError: 重みの合計が 0、または total が重みの合計を超える場合。
    """
    weight_sum = sum(w.amount for w in weights)
    if weight_sum == 0:
        raise ValueError("重みの合計が 0 のため按分できません")
    if total.amount > weight_sum:
        raise ValueError(f"按分額 {total.amount} が重みの合計 {weight_sum} を超えます")

    exact = [Fraction(total.amount * w.amount, weight_sum) for w in weights]
    shares = [int(x) for x in exact]  # Fraction の int() は 0 方向切り捨て(非負なので floor)
    leftover = total.amount - sum(shares)
    # 端数(exact - floor)の大きい順。同点は元の並び順で決める
    order = sorted(range(len(weights)), key=lambda i: (-(exact[i] - shares[i]), i))
    for i in order[:leftover]:
        shares[i] += 1
    return [Yen(s) for s in shares]


def tax_included_in(gross: Yen, rate_percent: int) -> Yen:
    """税込金額に含まれる消費税額(切り捨て).

    内税の税額は gross × rate / (100 + rate)。Fraction で厳密に計算し、
    最後に 1 回だけ切り捨てる(端数処理の方法は事業者が選べる。当社規程は切り捨て)。
    """
    return Yen(int(Fraction(gross.amount * rate_percent, 100 + rate_percent)))


# ---------------------------------------------------------------- 注文確定
@dataclass(frozen=True, slots=True)
class PricedLine:
    """按分後の明細。注文確定時にこのまま保存し、返品はこれだけを読む."""

    line: OrderLine
    discount: Yen  # この明細に按分されたクーポン値引き(税込)

    @property
    def net(self) -> Yen:
        return self.line.gross - self.discount


@dataclass(frozen=True, slots=True)
class TaxBreakdown:
    """適格請求書の記載単位: 税率ごとの対象額(税込)と消費税額."""

    rate_percent: int
    taxable_gross: Yen
    tax: Yen


@dataclass(frozen=True, slots=True)
class PricedOrder:
    lines: tuple[PricedLine, ...]
    breakdown: tuple[TaxBreakdown, ...]

    @property
    def total(self) -> Yen:
        """請求額。明細の値引き後金額の合計(=値引き前合計−クーポン)."""
        return Yen.sum(pl.net for pl in self.lines)


def price_order(lines: Sequence[OrderLine], coupon: Yen) -> PricedOrder:
    """クーポンを明細に按分し、税率ごとに 1 回だけ税額を端数処理する.

    Args:
        lines: 注文明細(1 行以上)。
        coupon: クーポン値引き額(税込)。

    Returns:
        按分済み明細と税率別内訳。

    Raises:
        ValueError: 明細が空、またはクーポンが注文金額を超える場合。
    """
    if not lines:
        raise ValueError("明細が 0 行の注文は作れません")
    discounts = allocate(coupon, [ln.gross for ln in lines])
    priced = tuple(PricedLine(ln, d) for ln, d in zip(lines, discounts, strict=True))

    breakdown: list[TaxBreakdown] = []
    for category, rate in TAX_RATE_PERCENT.items():
        gross = Yen.sum(pl.net for pl in priced if pl.line.tax_category is category)
        if gross.amount == 0:
            continue
        # ここが唯一の端数処理。明細ごとに丸めて足すことはしない
        breakdown.append(TaxBreakdown(rate, gross, tax_included_in(gross, rate)))
    return PricedOrder(priced, tuple(breakdown))


# ---------------------------------------------------------------- 返品
def refund_for_return(priced_line: PricedLine, already_returned: int, returning: int) -> Yen:
    """保存済みの按分結果から、今回返品する数量の返金額を出す.

    返金額 = floor(net × 返品後累計 / 数量) − floor(net × 返品前累計 / 数量)。
    累計で考えるので、何回に分けて返品しても返金の合計は必ず net に一致し、
    支払った額を超えない。按分は注文確定時の 1 回きりで、やり直さない。

    Raises:
        ValueError: 返品数が 1 未満、または累計が購入数を超える場合。
    """
    qty = priced_line.line.quantity
    if returning < 1 or already_returned < 0 or already_returned + returning > qty:
        raise ValueError(
            f"返品数が不正: 購入 {qty}, 返品済み {already_returned}, 今回 {returning}"
        )
    net = priced_line.net.amount
    after = net * (already_returned + returning) // qty
    before = net * already_returned // qty
    return Yen(after - before)
パターン書籍の章コードの場所
値オブジェクト(Yen・OrderLine・PricedLine)第3章 カプセル化float・負数・bool を生成時に拒む(完全コンストラクタ)
プリミティブ型執着の解消第5章dict と float の金額 → 型。Yen.__sub__ が負なら例外で、値引き超過を型が止める
不変(frozen=True)第4章按分結果を作り直さず保存し、返品で読む
マジックナンバー排除・区分の列挙型第10章TaxCategory(StrEnum)と TAX_RATE_PERCENT の1か所
純粋関数への分離第6章 関心の分離allocate・tax_included_in は入出力だけ。性質テストで網羅的に叩ける

テストと実行例

性質テスト+国税庁の記載例(10件)

import pytest
from hypothesis import given, settings, strategies as st

from good import (OrderLine, TaxCategory, Yen, allocate, price_order,
                  refund_for_return, tax_included_in)

S, R = TaxCategory.STANDARD, TaxCategory.REDUCED_FOOD


def test_allocate_sum_matches_coupon_three_equal_lines():
    # 1000 円を 3 等分: floor で 333×3=999、余り 1 円は先頭へ
    assert allocate(Yen(1000), [Yen(500)] * 3) == [Yen(334), Yen(333), Yen(333)]


def test_tax_rounded_once_per_rate_not_per_line():
    # 税込 108 円の食品 3 点: 明細ごと 8 円×3=24 円 / 税率ごと 324×8/108=24 円
    # 税込 110 円の雑貨 10 行: 明細ごと 10×10=100 / 1100×10/110=100
    # 端数が出る例: 税込 105 円 ×3 行(10%): 明細ごと floor(9.54)=9×3=27 / 315→floor(28.63)=28
    order = price_order([OrderLine(f"x{i}", Yen(105), 1, S) for i in range(3)], Yen(0))
    assert order.breakdown[0].tax == Yen(28)


def test_float_and_bool_are_rejected():
    with pytest.raises(TypeError):
        Yen(100.0)
    with pytest.raises(TypeError):
        Yen(True)


def test_coupon_exceeding_order_is_rejected():
    with pytest.raises(ValueError):
        price_order([OrderLine("a", Yen(500), 1, S)], Yen(501))


def test_refund_in_parts_never_exceeds_net():
    order = price_order([OrderLine("a", Yen(1000), 3, S), OrderLine("b", Yen(500), 1, R)], Yen(700))
    pl = order.lines[0]
    parts = [refund_for_return(pl, 0, 1), refund_for_return(pl, 1, 1), refund_for_return(pl, 2, 1)]
    assert Yen.sum(parts) == pl.net


def test_over_return_is_rejected():
    order = price_order([OrderLine("a", Yen(1000), 2, S)], Yen(0))
    with pytest.raises(ValueError):
        refund_for_return(order.lines[0], 1, 2)


line_st = st.builds(
    OrderLine,
    sku=st.text(min_size=1, max_size=3),
    unit_price=st.integers(1, 50_000).map(Yen),
    quantity=st.integers(1, 5),
    tax_category=st.sampled_from(list(TaxCategory)),
)


@settings(max_examples=2000)
@given(lines=st.lists(line_st, min_size=1, max_size=12), data=st.data())
def test_invariants(lines, data):
    gross = sum(ln.gross.amount for ln in lines)
    coupon = Yen(data.draw(st.integers(0, gross)))
    order = price_order(lines, coupon)
    # 1. 按分の合計=クーポン、請求額=値引き前合計−クーポン
    assert Yen.sum(pl.discount for pl in order.lines) == coupon
    assert order.total.amount == gross - coupon.amount
    # 2. どの明細も値引き後がマイナスにならない(Yen が保証)かつ按分は明細額以下
    assert all(pl.discount <= pl.line.gross for pl in order.lines)
    # 3. 税率別対象額の合計=請求額
    assert sum(b.taxable_gross.amount for b in order.breakdown) == order.total.amount
    # 4. 按分は明細の順序を入れ替えても各 SKU 行の額の多重集合が変わらない…は同点で変わりうるので検査しない
    # 5. 全数量を 1 個ずつ返品すると返金合計=請求額
    refunded = sum(
        refund_for_return(pl, k, 1).amount for pl in order.lines for k in range(pl.line.quantity)
    )
    assert refunded == order.total.amount


@given(st.integers(0, 10_000_000), st.sampled_from([8, 10]))
def test_tax_matches_decimal_floor(gross, rate):
    assert tax_included_in(Yen(gross), rate).amount == gross * rate // (100 + rate)


def test_matches_nta_invoice_qa_69_example():
    # 国税庁インボイスQ&A 問69: 雑貨3,300円・牛肉2,160円に1,000円引き → 10%604円/8%396円
    order = price_order([OrderLine("zakka", Yen(3300), 1, S), OrderLine("beef", Yen(2160), 1, R)], Yen(1000))
    assert [pl.discount for pl in order.lines] == [Yen(604), Yen(396)]
    by_rate = {b.rate_percent: b for b in order.breakdown}
    assert by_rate[10].taxable_gross == Yen(2696) and by_rate[8].taxable_gross == Yen(1764)


def test_matches_nta_invoice_qa_57_example():
    # 問57: 10%対象60,000円→5,454円 / 8%対象40,000円→2,962円
    order = price_order([OrderLine("a", Yen(60000), 1, S), OrderLine("b", Yen(40000), 1, R)], Yen(0))
    assert [b.tax for b in order.breakdown] == [Yen(5454), Yen(2962)]

実行結果 実測

$ uv run --no-project --with pytest --with hypothesis python -m pytest -q test_good.py
..........                                                               [100%]
10 passed in 2.77s

# 同じ性質を Bad に当てた3本は3本とも赤
FAILED test_bad_nta57       - assert 8418 == (5454 + 2962)   # 問57の例で2円多い
FAILED test_bad_three_equal - assert 501 == 500.0            # 1,000円3等分で明細合計が1円多い
FAILED test_bad_105x3       - assert 30 == 28                # 明細ごと10×3 / 税率ごと28

実行例(input → output) 実測

Bad(ワインは food: True)
total 7132.0  tax 558
TOWEL-01  net 1737
COFFEE-02 net 2984
WINE-03   net 2412  tax 179 # 8% で計算
明細合計 7,133 ≠ 請求額 7,132
コーヒー3個の返品: 3,402 円(支払 2,984)
Good
TOWEL-01   gross= 1980 discount= 244 net= 1736
COFFEE-02  gross= 3402 discount= 418 net= 2984
WINE-03    gross= 2750 discount= 338 net= 2412
10%対象 4148円 消費税 377円
8%対象 2984円 消費税 221円
total 7132
refund COFFEE 1個目, 2個目, 3個目: [994, 995, 995]
refund COFFEE 3個まとめて: 2984

税額 558 円 対 598 円の 40 円差はワインを 8% にしたため。返品は 418 円の過大返金(=コーヒーに按分された値引き)。

迷った案と採らなかった理由

案採らなかった理由切り替える条件
値引きを 10% 対象から優先して引く(問69 後段で「合理的に区分」と認められる)クーポン 1,000 円あたり 3.65 円の納税額減 導出(クーポン総額 4,027.8 万円で 147,190 円)。だが (a) 8% だけ返品した人と 10% だけ返品した人の返金差が大きく、返金規約で説明が要る (b) 経理・税理士の合意が要る経理・税理士が合意し、返品規約に「標準税率の商品から優先して差し引く」を明記したとき。allocate の前に税率グループで2段階にするだけ
最後の明細に端数を寄せる簡単で広く使われるが、結果が並び順で変わり、最後が安い明細だと値引きが明細額を超えうる明細が常に1〜2行で並び順が業務上固定のとき
Decimal を全面に円に1円未満は無く int で足りる。丸めモード・コンテキストの設定漏れという別の事故の元。比率だけ Fraction で厳密にすれば十分外貨(セント)や1円未満の単価を扱うとき
お金のライブラリ(py-moneyed 等)多通貨・換算の要件がなく、依存を増やす価値がない越境 EC で多通貨表示を始めたとき
返品時に再按分(Bad)過大返金・マイナス返金(#7)採らない
返金を切り上げ(顧客有利)合計が支払額を超える。累計差方式なら差は1個あたり最大1円で合計は一致採らない。分割返品で最後の1点で端数調整と説明文に書く
明細ごとに税額を保存して合計問57 で明示的に不可採らない

直した後に見るもの・止め方

見るものしきい値超えたら
日次で「明細の値引き後合計 − 請求額 ≠ 0」の注文数0 件レシート再発行の対象として経理へ
返金累計 > 支払額 の明細0 件返品 API を止め、手動返金に切り替える
TaxCategory 未設定の商品マスタ0 件商品登録の入口で登録を拒否

ポイント解説

1丸めは「回数」を設計する — Bad は明細数ぶん按分で、明細数ぶん税額で丸め、返品のたびに全部やり直す。Good は3か所に限り、それぞれに「合計が一致する」性質を持たせた。
2最大剰余法は国税庁の記載例と一致する — 問69 の 604/396 円は「全部切り捨て→端数の大きい方に1円」と同じ結果。法令が按分方法を決めてはいないが、経理・税理士への説明材料になる。
3性質テストは例を思いつけない不具合を拾う — 按分合計・税率別合計=請求額・返品合計=支払額を 2,000 通りのランダム注文で確かめる。例ベース2本が緑だった Bad はどれも満たさない。
4float の罠は round より floor の方が深い — n×10/110 はちょうど .5 にならず銀行丸めの実害は出にくい。一方「切り捨てに直す」だけの修正は 1.6% で1円ずれる。型で float を入口から拒むのが確実。

実務への応用

  • MOps 販促: 9/26 の誕生日クーポン・9/27 のシルバーウィーククーポンの「使われた後」がこの按分。発行を直しても、按分と返品が壊れていれば予算外の値引きが返品経由で漏れる(合成データで返品1件平均 550 円)
  • dbt / BigQuery: mart で ROUND(coupon * amount / SUM(amount) OVER (...)) と書くと合計がずれる。按分はアプリが確定時に保存した値を正本にし、SQL で再計算しない
  • 会計: 返金額を保存済み按分から出すと、「売上値引」と「返品」の仕訳が明細単位で追える

扱わなかった範囲(と理由)

扱わなかったもの理由
返還インボイス(適格返還請求書)の税額税率ごとに1回で同じだが、少額(税込1万円未満)の交付義務免除など論点が増える。発展問題へ
ポイント利用値引きか支払手段かで税の扱いが変わり、制度設計次第
送料・手数料課税区分と按分対象に含めるかは業務ルール次第
返品でクーポン利用条件を割った場合値引きを取り消すか維持するかは規約の問題。Good は按分済みの値引きを維持
外税の BtoB 請求当社は BtoC の総額表示が正本。外税でも「税率ごとに1回」は同じ
一体資産の判定商品マスタ登録時の判定として前提に置いた

一次資料(参照日 2026-09-28)

  • 国税庁「消費税の仕入税額控除制度における適格請求書等保存方式に関するQ&A」(令和8年4月改訂)問57: 「一の適格請求書につき、税率ごとに1回の端数処理を行う必要があります」(消令70の10、基通1-8-15)。端数処理の方法は任意。 pdf/qa/57.pdf
  • 同 問69: 税率ごとの値引額が明らかでないときは価額の比率であん分。記載例 1,000×3,300/5,460≒604、1,000×2,160/5,460≒396。 pdf/qa/69.pdf
  • Python 公式ドキュメント round()(等距離なら偶数側へ丸める)、「浮動小数点演算、その問題と制限」

今日のまとめ

お金の端数は「どこで何回丸めるか」を設計するもので、丸める場所ごとに「合計が一致する」性質を持たせ、その性質をランダムな注文で確かめる。按分結果は確定時に保存し、返品でやり直さない。

次のステップ

  • 発展問題: 同じ注文の一部返品で返還インボイス(税率別の返還額・返還税額)を出す issue_return_receipt() を書け。税込1万円未満の交付義務免除の扱いも決めよ。さらに「10% 優先値引き」へ切り替えたとき、比例按分で保存済みの既存注文の返品をどちらの方式で返金するか、移行方針を書け
  • 参考: 最大剰余法(Hamilton method)、Alabama paradox、property-based testing、hypothesis、fractions.Fraction、適格簡易請求書、総額表示

自己評価(あとで記入)

自分の回答

気づき・メモ