概要
金額は 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 返品の過大返金 | クーポン併用注文の一部返品で、支払額より多く返金している。残りの請求額がマイナスになる例も |
設問
- Bad の問題点を7つ以上、Q1〜Q4 のどれに効くかと合わせて
- 金額・税率・明細を値オブジェクトにし、按分と税額計算を直したコード(Google スタイル docstring・インラインコメント・名前付き定数)
- 按分の合計=クーポン額、分割返品の返金合計=支払額を例ではなく性質で確かめるテスト
- 国税庁 Q&A の記載例(問57・問69)を再現するテスト
- 迷った案(とくに「値引きを 10% 対象から優先して引く」)と採らなかった理由、切り替える条件
制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis 可)。単価は税込の整数円。税率は 10%/8% の2つで、課税区分は商品マスタが持つ。端数は切り捨て。返品は明細・数量単位で何回にも分かれる。ポイント・送料・複数クーポンは扱わない。
悪いコード (Before)
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"]
True が通るヒント(段階的開示)
ヒント1 — 方向性
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 件 実測 |
| 2 | int() 切り捨て按分で、消えた円が明細合計と請求額のずれになる | Q1 | 按分合計≠クーポン額: 39,778 件(クーポン注文の 77%)、ずれ合計 74,046 円・平均 1.86 円 実測 |
| 3 | 明細ごとに税額を丸めて合計(消令70の10、基通1-8-15、問57 で不可) | Q2 | 丸め方を切り捨てに揃えても税額が変わる注文 42,040 件(42%)、差の合計 65,787 円 実測 |
| 4 | float と round()。規程と違い、math.floor に直すと float 誤差 | Q2 | 1〜100,000 円で floor がずれる金額: 10% 1,616 個(165 円→14、正しくは 15)、8% 1,030 個。1980*0.1/1.1 = 179.99999999999997 実測 |
| 5 | food: 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 件 実測 |
丸めの3か所(SVG)
上段は Good の計算の流れ。丸めは ①按分 ②税率別の税額 ③返品 の3か所だけで、①の結果を保存して②③はそれを読む。下段は ① の最大剰余法の手順。
Bad→Good 対比(要所)
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"]
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) 実測
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)
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 件 | 商品登録の入口で登録を拒否 |
ポイント解説
実務への応用
- 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、適格簡易請求書、総額表示