レジ会計
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_amount で closed。
予約に紐づく来店会計を visit として永続化し、施術・店販・オプション・割引の明細、分割決済、釣銭、返金台帳 (visit_refund)、税額スナップショットを一貫したトランザクションで扱う。
Phase 1 は手入力 POS と割り切る。端末連携・領収書 PDF・インボイス帳票・Smart Pay は対象外(Phase 2 SRS-PAY-001 / SRS-REG-003)。
visit_payments.method は cash / card / electronic の 3 値固定。Smart Pay は contract から完全除外。
2. ユーザーストーリー
Section titled “2. ユーザーストーリー”- 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 税率改定後も過去会計の税額が変わらないようにしたい
3. ユースケース
Section titled “3. ユースケース”3.1 主シナリオ
Section titled “3.1 主シナリオ”- オペレーターが
service_completedの予約を開く - UI が
reservation_menu_linesを初期明細として展開 - オペレーターが
visit_lineを調整、必要ならgoods/option/discount行を追加 - UI が
store_settings.rounding_policyに従って line 単位で税抜・税額・税込を計算 - オペレーターが 1..N 件の
visit_paymentを入力 - API が 1 トランザクションで全行 INSERT、
reservation.status='paid'、reservation_event+operator_action_log二重書き、現金分はcash_movement(category='sales_cash')を自動生成
3.2 代替フロー
Section titled “3.2 代替フロー”- 分割決済:
visit_payment複数行 - 予約由来明細の修正:
visit.version楽観ロック PATCH - 返金:
visit_refund1 行 INSERT +visit.refunded_amount加算 UPDATE。method=‘cash’ のみcash_movement(category='refund')を同時挿入 - 予約なし会計: Phase 1 では非対応(
visit.reservation_id必須)
3.3 例外フロー
Section titled “3.3 例外フロー”- 同一予約に 2 回目の会計作成: 409
VISIT.ALREADY_EXISTS - payment 合計と請求合計が不一致: 422
VISIT.PAYMENT_TOTAL_MISMATCH cash以外でtendered_amount指定: 422VISIT.TENDERED_NOT_ALLOWEDcashでtendered_amount < amount: 422VISIT.TENDERED_TOO_SMALLregister_closing済みのclosing_dateの visit 修正: 409VISIT.CLOSED_PERIOD_LOCKED(assertNotClosedDate関数で強制)version不一致: 409VISIT.CONFLICT- 過剰返金: 422
VISIT.REFUND_EXCEEDS_PAID discount以外の行で負数: 422VISIT.NON_DISCOUNT_NEGATIVE_FORBIDDEN
4. UI仕様
Section titled “4. UI仕様”4.1 主要要素
Section titled “4.1 主要要素”- 明細グリッド:
type,name,qty,税抜単価,税額,税込金額 - 支払グリッド:
method,amount,tendered_amount - 差額表示: 支払合計と請求合計の一致 / 不一致
- 釣銭表示:
cash行ごとのtendered_amount - amount - 修正導線: closing 前のみ表示
- 返金導線: closing 前のみ表示、理由入力必須
4.2 バリデーション
Section titled “4.2 バリデーション”discount以外の行で負数禁止discount行はquantity >= 1、unit_price_excl_tax <= 0visit_paymentは 1 行以上必須cash行はtendered_amount必須、それ以外は NULL 強制- 請求合計 = 支払合計 が必須
5. API仕様
Section titled “5. API仕様”5.1 エンドポイント一覧
Section titled “5.1 エンドポイント一覧”| Method | Path | 用途 | permission |
|---|---|---|---|
| POST | /api/admin/visits | 会計作成 + paid 遷移 | admin:register:operate |
| GET | /api/admin/visits/:id | 会計詳細 | admin:register:operate |
| PATCH | /api/admin/visits/:id | closing 前会計修正 | 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)。
5.2 zodスキーマ
Section titled “5.2 zodスキーマ”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}5.3 エラーコード
Section titled “5.3 エラーコード”VISIT.ALREADY_EXISTSVISIT.PAYMENT_TOTAL_MISMATCHVISIT.TENDERED_NOT_ALLOWEDVISIT.TENDERED_TOO_SMALLVISIT.DISCOUNT_SIGN_INVALIDVISIT.NON_DISCOUNT_NEGATIVE_FORBIDDENVISIT.CONFLICTVISIT.CLOSED_PERIOD_LOCKEDVISIT.REFUND_EXCEEDS_PAID
6. データモデル影響
Section titled “6. データモデル影響”6.1 スキーマ
Section titled “6.1 スキーマ”visits
Section titled “visits”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | FK |
reservation_id | uuid | UNIQUE(store_id, reservation_id), 複合 FK |
customer_id | uuid | FK(集計用冗長) |
staff_id | uuid | NULL(集計用冗長) |
paid_at | timestamptz | NOT NULL |
closing_date | date | NOT NULL(paid_at AT TIME ZONE store.timezone で算出、stored generated か trigger で維持) |
total_amount_excl_tax | bigint | NOT NULL |
total_tax_amount | bigint | NOT NULL |
total_amount_incl_tax | bigint | NOT NULL |
paid_amount | bigint | NOT NULL |
refunded_amount | bigint | NOT NULL DEFAULT 0 |
version | integer | NOT NULL DEFAULT 1 |
created_at, updated_at | timestamptz | NOT NULL |
| 制約 | UNIQUE(store_id, id) | |
| 制約 | CHECK(refunded_amount >= 0 AND refunded_amount <= paid_amount) |
visit_lines
Section titled “visit_lines”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | FK |
visit_id | uuid | FK |
type | varchar(20) | CHECK IN (service, goods, option, discount) |
source_menu_id | uuid | NULL |
name_snapshot | varchar(200) | NOT NULL |
quantity | integer | CHECK >= 1 |
unit_price_excl_tax | bigint | NOT NULL |
unit_price_incl_tax | bigint | NOT NULL |
amount_excl_tax | bigint | NOT NULL |
amount_incl_tax | bigint | NOT NULL |
tax_amount | bigint | NOT NULL |
tax_rate_pct | integer | NOT NULL |
sort_order | integer | CHECK >= 1 |
| 制約 | CHECK(type='discount' OR unit_price_excl_tax >= 0) | |
| 制約 | CHECK(type<>'discount' OR unit_price_excl_tax <= 0) |
visit_payments
Section titled “visit_payments”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | FK |
visit_id | uuid | FK |
method | varchar(20) | CHECK IN (cash, card, electronic) |
amount | bigint | CHECK >= 1 |
tendered_amount | bigint | cash のみ NOT NULL |
sort_order | integer | CHECK >= 1 |
created_at | timestamptz | NOT NULL |
| 制約 | CHECK(method='cash' OR tendered_amount IS NULL) | |
| 制約 | CHECK(method<>'cash' OR (tendered_amount IS NOT NULL AND tendered_amount >= amount)) |
visit_refund(v0.2 で導入)
Section titled “visit_refund(v0.2 で導入)”| カラム | 型 | 制約 |
|---|---|---|
id | uuid | PK |
store_id | uuid | FK |
visit_id | uuid | FK |
method | varchar(20) | CHECK IN (cash, card, electronic) |
amount | bigint | CHECK >= 1 |
reason | text | NOT NULL, CHECK btrim(reason) <> '' |
operator_id | uuid | NOT NULL(TEN-002 後に FK) |
occurred_at | timestamptz | NOT NULL |
created_at | timestamptz | NOT NULL |
| 制約 | UNIQUE(store_id, id) |
6.2 マイグレーション計画
Section titled “6.2 マイグレーション計画”0020_visit_register_closing.sql で:
visits/visit_lines/visit_payments/visit_refund作成register_closing/cash_movement/daily_sales_summary作成(REG-002)closing_date列の generated by stored 計算- RLS ENABLE + FORCE
appロールへSELECT/INSERT/UPDATEGRANT(DELETEなし、§7.17 物理削除禁止)- permission backfill(
admin:register:operate / close、REG-002 と共有)
0022_visit_amend_supersede.sql(§7.8 amend 対応)で:
visit_lines.superseded_at timestamptz NULL追加visit_payments.superseded_at timestamptz NULL追加- partial index
(store_id, visit_id) WHERE superseded_at IS NULLを両表に追加(current 明細取得高速化) - RLS / GRANT は現状維持(
SELECT/INSERT/UPDATEのみ)
7. 業務ルール
Section titled “7. 業務ルール”7.1 reservation:visit cardinality
Section titled “7.1 reservation:visit cardinality”visits.reservation_id は UNIQUE(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_refundINSERT +visit.refunded_amountUPDATE +operator_action_log三重記録 - 現金返金は
cash_movement(category='refund')も同時挿入
7.3 釣銭
Section titled “7.3 釣銭”visit_payments.tendered_amount は cash のみ必須。釣銭は導出値 (tendered_amount - amount)。
7.4 税率履歴保持(親 §7.16)
Section titled “7.4 税率履歴保持(親 §7.16)”visit_lines.tax_rate_pctは会計時点のスナップショットtax_rateマスタは Phase 1 では空運用reservation_menu_lines.tax_rate_pct CHECK IN (8,10)は0011で撤去済(親 §7.16)
7.5 明細タイプ規律
Section titled “7.5 明細タイプ規律”service/goods/option:unit_price_*非負discount:unit_price_*非正、quantity >= 1、理由はname_snapshot- line 単位 rounding は
store_settings.rounding_policyに従う
7.6 支払い方法
Section titled “7.6 支払い方法”Phase 1 contract: cash / card / electronic の 3 値。Smart Pay は Phase 2 SRS-PAY-001 で別途追加。
7.7 トランザクション境界(visit 作成)
Section titled “7.7 トランザクション境界(visit 作成)”1 トランザクション:
assertNotClosedDate(storeId, paid_at の closing_date)を呼ぶ → 違反なら 409visitsINSERTvisit_linesINSERT × Nvisit_paymentsINSERT × M- 現金分:
cash_movement(category='sales_cash')INSERT reservations.status='paid'UPDATE(version検証)reservation_eventINSERT(trigger='operator')operator_action_log(action='visit.create')INSERT(actor_kind='operator')operator_action_log(action='reservation.transition')INSERT
途中失敗は全 rollback。
7.8 来店処理修正
Section titled “7.8 来店処理修正”admin:register:operate権限でPATCH /api/admin/visits/:id- request body は
POST /api/admin/visitsと同 shape のlines[]/payments[]を受け、明細を 全置換 する。部分更新は Phase 1 対象外 If-Match: <visit.version>または bodyversionで楽観ロック必須。一致時のみvisits.version = version + 1paid_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)を記録
7.9 締め後改変禁止(親 §7.18)
Section titled “7.9 締め後改変禁止(親 §7.18)”assertNotClosedDate(storeId, date) 共通関数:
register_closing WHERE store_id=? AND closing_date=? AND is_voided=falseの存在確認- 存在すれば
VISIT.CLOSED_PERIOD_LOCKED409 - 全 visit / cash_movement mutation で必ず通る
7.10 監査
Section titled “7.10 監査”visit.create / amend / refund- 各操作で
actor_kind='operator',operator_id NOT NULL
8. 非機能要件
Section titled “8. 非機能要件”- 会計作成 API p95 300ms
- 1 会計あたり line 50 行、payment 10 行を Phase 1 上限
- 失敗時は 5xx + Sentry、業務エラーは 4xx 日本語
9. セキュリティ・認可
Section titled “9. セキュリティ・認可”| 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 /visits→visit/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→ 422VISIT.TENDERED_TOO_SMALL - GWT-6 非現金 tendered 禁止:
card amount=8,000, tendered_amount=10,000→ 422VISIT.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_logにvisit.create+reservation.transitionがactor_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→ 409VISIT.CLOSED_PERIOD_LOCKED - GWT-18 現金返金:
paid_amount=10,000でrefund=2,000 cash→refunded_amount=2,000、visit_refund1 行、cash_movement(refund)1 行 - GWT-19 非現金返金: card 会計の card 返金 →
refunded_amountのみ更新、visit_refund1 行、cash_movement増えず - GWT-20 過剰返金禁止:
paid_amount=10,000, refunded_amount=9,000でrefund=2,000→ 422VISIT.REFUND_EXCEEDS_PAID - GWT-21 tenant 分離: 別店舗の
visit_id取得 → 404 / RLS
11. テスト計画
Section titled “11. テスト計画”- 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)
12. 関連ジョブ
Section titled “12. 関連ジョブ”aggregate-daily-sales-for-store(REG-002 / WRK-001)
13. Open Questions
Section titled “13. Open Questions”| # | 内容 | 扱い |
|---|---|---|
| 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-04 | admin:register:read の分離 | Phase 2 |
14. 変更履歴
Section titled “14. 変更履歴”| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-05-05 | Codex / yudai | 初版ドラフト |
| 0.2 | 2026-05-05 | yudai (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 |