請求まわりは「作った後に必ず要件が増える」領域です。
最初の設計を間違えると、修正のたびに過去データとの整合が崩れます。
請求書は「上書きしない」
発行済みの請求書を書き換えると、送った内容と記録が食い違います。
訂正は新しいレコードとして持ちます。
class Invoice(models.Model):
number = models.CharField(max_length=32, unique=True) # 請求書番号
issued_at = models.DateTimeField()
total = models.PositiveIntegerField()
voided_at = models.DateTimeField(null=True) # 取り消し
replaced_by = models.ForeignKey("self", null=True, ...) # 差し替え先
取り消して作り直すという流れにすると、履歴がそのまま監査証跡になります。
明細のスナップショットを持つ
商品マスタを参照するだけにすると、単価を変えた瞬間に過去の請求書が変わります。
class InvoiceLine(models.Model):
invoice = models.ForeignKey(Invoice, related_name="lines", ...)
name = models.CharField(max_length=200) # 発行時点の名称をコピー
unit_price = models.PositiveIntegerField() # 発行時点の単価をコピー
quantity = models.PositiveIntegerField()
tax_rate = models.DecimalField(max_digits=4, decimal_places=3)
マスタへの参照ではなく、発行時点の値をコピーして保存します。
これは冗長に見えますが、請求書では必須の設計です。
消費税の端数
税額の計算方法は事業者ごとに決まりがあります。実装として重要なのは、
どこで丸めるかを1か所に固定することです。
from decimal import Decimal, ROUND_DOWN
def tax_of(subtotal: int, rate: Decimal) -> int:
return int((Decimal(subtotal) * rate).quantize(Decimal("1"), rounding=ROUND_DOWN))
明細ごとに丸めるのか、税率ごとに合計してから丸めるのかで結果が変わります。
どちらでもよいが、混在させない。混在すると合計が合わなくなります。
部分返金に対応する
「全額返金」だけで作ると、後から必ず部分返金が要求されます。
返金も明細を持つレコードにしておきます。
class Refund(models.Model):
invoice = models.ForeignKey(Invoice, related_name="refunds", ...)
amount = models.PositiveIntegerField()
reason = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
返金可能額は必ずサーバー側で検証します。
refunded = invoice.refunds.aggregate(s=Sum("amount"))["s"] or 0
if amount > invoice.total - refunded:
raise ValidationError("返金可能額を超えています")
番号の採番
請求書番号に抜けや重複があると、経理側で問題になります。
アプリ側でカウントすると競合で重複します。DBのシーケンスを使います。
CREATE SEQUENCE invoice_number_seq;
with connection.cursor() as c:
c.execute("SELECT nextval('invoice_number_seq')")
n = c.fetchone()[0]
number = f"INV-{timezone.now():%Y}-{n:05d}"
まとめ
- 発行済みは上書きせず、取り消し + 再発行で表現する
- 明細は発行時点の値をコピーして持つ(マスタ参照にしない)
- 端数処理は1か所に固定し、混在させない
- 返金は最初から部分返金前提で設計する
- 採番はDBのシーケンスで行う