開発・技術選定

請求と返金を実装する — 会計とズレない設計

請求まわりは「作った後に必ず要件が増える」領域です。
最初の設計を間違えると、修正のたびに過去データとの整合が崩れます。

請求書は「上書きしない」

発行済みの請求書を書き換えると、送った内容と記録が食い違います
訂正は新しいレコードとして持ちます。

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のシーケンスで行う