コンテンツにスキップ

レジ会計

Document ID: SRS-REG-001 Parent: SRS-ROOT-001 v0.5 Version: 0.2 Status: Implemented (API + Web UI 最小) Last Updated: 2026-05-06 Depends on: SRS-TEN-001 v0.4, SRS-TEN-002 v0.2, SRS-TEN-003 v0.2, SRS-MST-002 v0.2, SRS-CUS-001 v0.2, SRS-RES-002 v0.5, SRS-RES-003 v0.2, SRS-RES-004 v0.3, SRS-RES-005 v0.4 依存される: SRS-REG-002, SRS-ANL-001, SRS-PAY-001, SRS-PAY-002

本書は SRS-ROOT-001 v0.5 に従う。会計済みの canonical status は SRS-RES-004 が所有し、本書は visit* モデルと会計処理を所有する。

OQ-RES-004-04(paid 予約の修正・返金)は本SRSの visit_refund + visit.refunded_amountclosed


予約に紐づく来店会計を visit として永続化し、施術・店販・オプション・割引の明細、分割決済、釣銭、返金台帳 (visit_refund)、税額スナップショットを一貫したトランザクションで扱う。

Phase 1 は手入力 POS と割り切る。端末連携・領収書 PDF・インボイス帳票・Smart Pay は対象外(Phase 2 SRS-PAY-001 / SRS-REG-003)。

visit_payments.methodcash / card / electronic の 3 値固定。Smart Pay は contract から完全除外。


  • As a receptionist, I want to reservation からそのまま会計を確定したい, so that 予約台帳と売上がズレない
  • As a manager, I want to 1 回の会計で現金 + カードの分割決済を登録したい
  • As a manager, I want to 誤った会計を closing 前に修正したい, so that 売上訂正を適法に残せる
  • As a manager, I want to 返金 (全額・部分) を ledger として残したい
  • As a owner, I want to 税率改定後も過去会計の税額が変わらないようにしたい

  1. オペレーターが service_completed の予約を開く
  2. UI が reservation_menu_lines を初期明細として展開
  3. オペレーターが visit_line を調整、必要なら goods / option / discount 行を追加
  4. UI が store_settings.rounding_policy に従って line 単位で税抜・税額・税込を計算
  5. オペレーターが 1..N 件の visit_payment を入力
  6. API が 1 トランザクションで全行 INSERT、reservation.status='paid'reservation_event + operator_action_log 二重書き、現金分は cash_movement(category='sales_cash') を自動生成
  • 分割決済: visit_payment 複数行
  • 予約由来明細の修正: visit.version 楽観ロック PATCH
  • 返金: visit_refund 1 行 INSERT + visit.refunded_amount 加算 UPDATE。method=‘cash’ のみ cash_movement(category='refund') を同時挿入
  • 予約なし会計: Phase 1 では非対応(visit.reservation_id 必須)
  • 同一予約に 2 回目の会計作成: 409 VISIT.ALREADY_EXISTS
  • payment 合計と請求合計が不一致: 422 VISIT.PAYMENT_TOTAL_MISMATCH
  • cash 以外で tendered_amount 指定: 422 VISIT.TENDERED_NOT_ALLOWED
  • cashtendered_amount < amount: 422 VISIT.TENDERED_TOO_SMALL
  • register_closing 済みの closing_date の visit 修正: 409 VISIT.CLOSED_PERIOD_LOCKEDassertNotClosedDate 関数で強制)
  • version 不一致: 409 VISIT.CONFLICT
  • 過剰返金: 422 VISIT.REFUND_EXCEEDS_PAID
  • discount 以外の行で負数: 422 VISIT.NON_DISCOUNT_NEGATIVE_FORBIDDEN

  • 明細グリッド: type, name, qty, 税抜単価, 税額, 税込金額
  • 支払グリッド: method, amount, tendered_amount
  • 差額表示: 支払合計と請求合計の一致 / 不一致
  • 釣銭表示: cash 行ごとの tendered_amount - amount
  • 修正導線: closing 前のみ表示
  • 返金導線: closing 前のみ表示、理由入力必須
  • discount 以外の行で負数禁止
  • discount 行は quantity >= 1unit_price_excl_tax <= 0
  • visit_payment は 1 行以上必須
  • cash 行は tendered_amount 必須、それ以外は NULL 強制
  • 請求合計 = 支払合計 が必須

MethodPath用途permission
POST/api/admin/visits会計作成 + paid 遷移admin:register:operate
GET/api/admin/visits/:id会計詳細admin:register:operate
PATCH/api/admin/visits/:idclosing 前会計修正admin:register:operate
POST/api/admin/visits/:id/refunds返金登録admin:register:operate
GET/api/admin/reservations/:id/visit予約紐付け会計取得admin:register:operate

Phase 1 は admin:register:operate 1 個に集約(admin:register:read 分離は Phase 2 OQ)。

POST /api/admin/visits:

{
reservation_id: uuid,
version: number, // reservation.version
lines: Array<{
type: 'service' | 'goods' | 'option' | 'discount',
source_menu_id?: uuid | null,
name_snapshot: string,
quantity: number,
unit_price_excl_tax: bigint,
unit_price_incl_tax: bigint,
tax_amount: bigint,
amount_excl_tax: bigint,
amount_incl_tax: bigint,
tax_rate_pct: number // 8 or 10
}>,
payments: Array<{
method: 'cash' | 'card' | 'electronic',
amount: bigint,
tendered_amount?: bigint | null
}>
}

POST /api/admin/visits/:id/refunds:

{
amount: bigint,
method: 'cash' | 'card' | 'electronic',
reason: string,
version: number // visit.version
}
  • VISIT.ALREADY_EXISTS
  • VISIT.PAYMENT_TOTAL_MISMATCH
  • VISIT.TENDERED_NOT_ALLOWED
  • VISIT.TENDERED_TOO_SMALL
  • VISIT.DISCOUNT_SIGN_INVALID
  • VISIT.NON_DISCOUNT_NEGATIVE_FORBIDDEN
  • VISIT.CONFLICT
  • VISIT.CLOSED_PERIOD_LOCKED
  • VISIT.REFUND_EXCEEDS_PAID

カラム制約
iduuidPK
store_iduuidFK
reservation_iduuidUNIQUE(store_id, reservation_id), 複合 FK
customer_iduuidFK(集計用冗長)
staff_iduuidNULL(集計用冗長)
paid_attimestamptzNOT NULL
closing_datedateNOT NULL(paid_at AT TIME ZONE store.timezone で算出、stored generated か trigger で維持)
total_amount_excl_taxbigintNOT NULL
total_tax_amountbigintNOT NULL
total_amount_incl_taxbigintNOT NULL
paid_amountbigintNOT NULL
refunded_amountbigintNOT NULL DEFAULT 0
versionintegerNOT NULL DEFAULT 1
created_at, updated_attimestamptzNOT NULL
制約UNIQUE(store_id, id)
制約CHECK(refunded_amount >= 0 AND refunded_amount <= paid_amount)
カラム制約
iduuidPK
store_iduuidFK
visit_iduuidFK
typevarchar(20)CHECK IN (service, goods, option, discount)
source_menu_iduuidNULL
name_snapshotvarchar(200)NOT NULL
quantityintegerCHECK >= 1
unit_price_excl_taxbigintNOT NULL
unit_price_incl_taxbigintNOT NULL
amount_excl_taxbigintNOT NULL
amount_incl_taxbigintNOT NULL
tax_amountbigintNOT NULL
tax_rate_pctintegerNOT NULL
sort_orderintegerCHECK >= 1
制約CHECK(type='discount' OR unit_price_excl_tax >= 0)
制約CHECK(type<>'discount' OR unit_price_excl_tax <= 0)
カラム制約
iduuidPK
store_iduuidFK
visit_iduuidFK
methodvarchar(20)CHECK IN (cash, card, electronic)
amountbigintCHECK >= 1
tendered_amountbigintcash のみ NOT NULL
sort_orderintegerCHECK >= 1
created_attimestamptzNOT NULL
制約CHECK(method='cash' OR tendered_amount IS NULL)
制約CHECK(method<>'cash' OR (tendered_amount IS NOT NULL AND tendered_amount >= amount))
カラム制約
iduuidPK
store_iduuidFK
visit_iduuidFK
methodvarchar(20)CHECK IN (cash, card, electronic)
amountbigintCHECK >= 1
reasontextNOT NULL, CHECK btrim(reason) <> ''
operator_iduuidNOT NULL(TEN-002 後に FK)
occurred_attimestamptzNOT NULL
created_attimestamptzNOT NULL
制約UNIQUE(store_id, id)

0020_visit_register_closing.sql で:

  1. visits / visit_lines / visit_payments / visit_refund 作成
  2. register_closing / cash_movement / daily_sales_summary 作成(REG-002)
  3. closing_date 列の generated by stored 計算
  4. RLS ENABLE + FORCE
  5. app ロールへ SELECT/INSERT/UPDATE GRANT(DELETE なし、§7.17 物理削除禁止)
  6. permission backfill(admin:register:operate / close、REG-002 と共有)

0022_visit_amend_supersede.sql(§7.8 amend 対応)で:

  1. visit_lines.superseded_at timestamptz NULL 追加
  2. visit_payments.superseded_at timestamptz NULL 追加
  3. partial index (store_id, visit_id) WHERE superseded_at IS NULL を両表に追加(current 明細取得高速化)
  4. RLS / GRANT は現状維持(SELECT/INSERT/UPDATE のみ)

visits.reservation_idUNIQUE(store_id, reservation_id)。1 予約 1 visit。 誤会計でも visit を消さず、version を増やして PATCH で書き換え、差分は operator_action_log.diff に残す。

7.2 refund の表現(ledger ベース

Section titled “7.2 refund の表現(ledger ベース)”

返金は visit_refund 子テーブル + visit.refunded_amount 集計列の 二重表現:

  • visit_refund が canonical ledger(method 別、reason 必須、operator_id 記録)
  • visit.refunded_amount = SUM(visit_refund.amount) をトリガまたは都度計算で維持
  • 返金時は同一トランザクションで visit_refund INSERT + visit.refunded_amount UPDATE + operator_action_log 三重記録
  • 現金返金は cash_movement(category='refund') も同時挿入

visit_payments.tendered_amountcash のみ必須。釣銭は導出値 (tendered_amount - amount)。

  • visit_lines.tax_rate_pct は会計時点のスナップショット
  • tax_rate マスタは Phase 1 では空運用
  • reservation_menu_lines.tax_rate_pct CHECK IN (8,10)0011 で撤去済(親 §7.16)
  • service / goods / option: unit_price_* 非負
  • discount: unit_price_* 非正、quantity >= 1、理由は name_snapshot
  • line 単位 rounding は store_settings.rounding_policy に従う

Phase 1 contract: cash / card / electronic の 3 値。Smart Pay は Phase 2 SRS-PAY-001 で別途追加。

7.7 トランザクション境界(visit 作成)

Section titled “7.7 トランザクション境界(visit 作成)”

1 トランザクション:

  1. assertNotClosedDate(storeId, paid_at の closing_date) を呼ぶ → 違反なら 409
  2. visits INSERT
  3. visit_lines INSERT × N
  4. visit_payments INSERT × M
  5. 現金分: cash_movement(category='sales_cash') INSERT
  6. reservations.status='paid' UPDATE(version 検証)
  7. reservation_event INSERT(trigger='operator'
  8. operator_action_log(action='visit.create') INSERT(actor_kind='operator'
  9. operator_action_log(action='reservation.transition') INSERT

途中失敗は全 rollback。

  • admin:register:operate 権限で PATCH /api/admin/visits/:id
  • request body は POST /api/admin/visits と同 shape の lines[] / payments[] を受け、明細を 全置換 する。部分更新は Phase 1 対象外
  • If-Match: <visit.version> または body version で楽観ロック必須。一致時のみ visits.version = version + 1
  • paid_at / closing_date / reservation_id / reservation.status / visit_refund は amend 対象外
  • assertNotClosedDate(storeId, visit.closing_date) 通過必須
  • visit_lines / visit_payments は物理削除せず、旧 active 行の superseded_at を更新し、新 active 行を INSERT することで全置換を表現する
  • 取得系 API (GET /api/admin/visits/:id 等) は superseded_at IS NULL の行のみを current 明細として返す
  • 現金支払合計差分は append-only ledger で補正し、増額は cash_movement(category='sales_cash')、減額は cash_movement(category='refund') を追加する。既存 movement の更新・削除はしない
  • reservation_event は発火しない(status 不変)
  • operator_action_log(action='visit.amend') に before/after(lines, payments, totals, cash_delta)を記録

assertNotClosedDate(storeId, date) 共通関数:

  • register_closing WHERE store_id=? AND closing_date=? AND is_voided=false の存在確認
  • 存在すれば VISIT.CLOSED_PERIOD_LOCKED 409
  • 全 visit / cash_movement mutation で必ず通る
  • visit.create / amend / refund
  • 各操作で actor_kind='operator', operator_id NOT NULL

  • 会計作成 API p95 300ms
  • 1 会計あたり line 50 行、payment 10 行を Phase 1 上限
  • 失敗時は 5xx + Sentry、業務エラーは 4xx 日本語

key用途初期付与
admin:register:operate会計作成・修正・返金owner / manager / receptionist

RLS は visit* 4 表すべて store_id ベース。DELETE GRANT なし(§7.17 法定保存)。


10. 受け入れ基準(Given-When-Then)

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1 通常会計: service_completed 予約 → POST /visitsvisit/visit_lines/visit_payments 作成、予約 paid
  • GWT-2 1:1 強制: 既に visit ある予約 → 409 VISIT.ALREADY_EXISTS
  • GWT-3 分割決済: 12,000 円を cash=2,000 + card=10,000 で支払 → 2 行の visit_payment 保存成功
  • GWT-4 現金釣銭: cash amount=8,000, tendered_amount=10,000 → 成功、UI 釣銭 2,000
  • GWT-5 現金不足: cash amount=8,000, tendered_amount=7,000 → 422 VISIT.TENDERED_TOO_SMALL
  • GWT-6 非現金 tendered 禁止: card amount=8,000, tendered_amount=10,000 → 422 VISIT.TENDERED_NOT_ALLOWED
  • GWT-7 discount 行: type='discount', unit_price_excl_tax=-1000 → 成功
  • GWT-8 非 discount 負数禁止: type='goods', unit_price_excl_tax=-1000 → 422
  • GWT-9 支払合計不一致: 422 VISIT.PAYMENT_TOTAL_MISMATCH
  • GWT-10 method = smartpay: zod schema reject(contract に存在しない)
  • GWT-11 tax snapshot 維持: tax_rate マスタ追加後も既存 visit_line.tax_rate_pct 不変
  • GWT-12 ハードコード CHECK 撤去後の互換: reservation_menu_lines.tax_rate_pct=5 でも会計作成可能(DB CHECK 撤去済)
  • GWT-13 operator audit: 会計作成成功で operator_action_logvisit.create + reservation.transitionactor_kind='operator' で 1 行ずつ
  • GWT-14 現金売上 movement: cash 支払あり → cash_movement(category='sales_cash') 同 tx で作成
  • GWT-15 会計修正成功: closing 前 version=3, If-Match: 3 で PATCH → version=4
  • GWT-16 会計修正競合: 古い version → 409 VISIT.CONFLICT
  • GWT-17 closing 後修正禁止: 当日 closing が is_voided=false → 409 VISIT.CLOSED_PERIOD_LOCKED
  • GWT-18 現金返金: paid_amount=10,000refund=2,000 cashrefunded_amount=2,000visit_refund 1 行、cash_movement(refund) 1 行
  • GWT-19 非現金返金: card 会計の card 返金 → refunded_amount のみ更新、visit_refund 1 行、cash_movement 増えず
  • GWT-20 過剰返金禁止: paid_amount=10,000, refunded_amount=9,000refund=2,000 → 422 VISIT.REFUND_EXCEEDS_PAID
  • GWT-21 tenant 分離: 別店舗の visit_id 取得 → 404 / RLS

  • domain unit: 金額集計、釣銭、discount sign、refund 上限
  • API integration: 作成、修正、返金、closing 後ロック、assertNotClosedDate 動作
  • DB integration: UNIQUE(store_id, reservation_id)、method/tendered CHECK、RLS
  • regression: reservation_event + operator_action_log の二重書き
  • shared Postgres harness(親 §8、Testcontainers Phase 3 OQ)

  • aggregate-daily-sales-for-store (REG-002 / WRK-001)

#内容扱い
OQ-REG-001-01返金 method 別の独立 ledger 表Phase 2
OQ-REG-001-02予約なし店販会計Phase 2 別 SRS
OQ-REG-001-03インボイス帳票 (適格請求書)Phase 2 SRS-REG-003
OQ-REG-001-04admin:register:read の分離Phase 2

VersionDateAuthorChange
0.12026-05-05Codex / yudai初版ドラフト
0.22026-05-05yudai (with Codex co-design)Round 2 反映: migration 番号 0020、visit_payments.method を 3 値固定(smartpay 完全除外)、visit_refund 子テーブル導入、assertNotClosedDate 共通関数明記、closing_date 列で日次集計境界を明示、permission を admin:register:operate 1 個に集約、OQ-RES-004-04 closed