顧客・口座・取引を別々の表にする理由から、データ設計を読み解きます。
重要なコードの抜粋へ ↓このページでわかること
- 主要な表のつながり
- 口座残高と帳簿の関係
- 変更履歴を残す設計
何を表として保存するか
customers は顧客、bank_accounts は口座、transfers は送金を表します。顧客と口座はIDで結び、送金は送金元と送金先の口座IDを持ちます。名前や口座番号だけで関係を表さず、変わらない識別子でつなぐ設計です。
残高は単独の数字では終わらない
bank_accounts には表示を速くする cached_balance があります。しかし正しい金額の根拠は、後述する journal_entries と journal_lines の記録です。キャッシュだけを書き換えると、なぜその残高になったか説明できなくなります。
金額は円の整数で扱います。小数の丸めによって取引の合計がずれることを避けるためです。
表の作成順序にも意味がある
口座は顧客を参照し、帳簿行は勘定科目や仕訳を参照します。参照先を先に作り、制約や権限を後から積み上げるのがマイグレーションの役割です。
v1の supabase/migrations を日付順にたどると、データ構造がどのように増えたか確認できます。
状態と制約をデータで表す
口座には active、frozen、closed などの状態があります。状態を文字列として保存するだけでなく、どの状態からどこへ移れるか、どの状態で送金できるかを決めます。顧客と口座の関係、口座番号の一意性、円だけを扱う制約も、アプリの画面だけに任せない設計です。
不正な組み合わせを保存できないようにする制約は、別のAPIや管理処理が増えたときにも効きます。
同じ情報を何度も持たない
顧客名、口座番号、送金額を一つの大きな表へ繰り返し保存すると、顧客名の変更や口座の状態変更で不一致が起こりやすくなります。顧客、口座、取引をIDで結ぶと、それぞれの意味と更新責任が明確になります。
ただし分けすぎると読むときに結合が必要です。どの情報が独立して変わるかを考えて表を切ります。
制約は最後の防波堤
金額が正の整数、口座番号が重複しない、送金元と送金先が同じでないといった条件は、画面とAPIに加えてデータベースの制約でも守れます。別の管理ツールや新しいAPIが増えても、壊れたデータを保存しにくくなります。
一方、顧客の本人確認が済んだかのように、複数の表や現在の状態を見る必要がある条件は、単純な列の制約だけでは表せません。処理関数の中で判定します。
履歴を残す表は更新方法が違う
プロフィールの名前は修正できますが、確定した仕訳を同じように上書きすると過去の取引を説明できなくなります。データごとに「更新可能」「状態だけ変更」「追記専用」を分けます。
取引を訂正するときは元の行を消すのではなく、関係の分かる新しい記録を追加します。表の設計は、保存する項目だけでなく変更の許し方まで含みます。
マイグレーションを公開する順序
新しい列を使うコードを先に公開すると、古いデータベースでエラーになります。逆に列をすぐ削除すると、古いコードがまだ参照しているかもしれません。互換性を保ちながら追加、切り替え、削除を段階的に進めます。
v1のマイグレーションを読むと、表の作成だけでなく権限、関数、制約が後から加わる様子が分かります。「今のスキーマ」だけでなく変化の順序を見ることが重要です。
データを消す前に考える
顧客が退会しても、その顧客が関わった取引や監査の記録を単純に連鎖削除すると、帳簿の意味が失われます。個人情報の扱いと、取引を説明するための保存を分けて設計します。v1を読む際も、外部キーを付けるだけで削除時の業務判断まで決まるわけではないと意識します。
読み取りの速さも後で必要になる
取引履歴が数件なら全件を読むだけでも動きますが、件数が増えると口座IDや時刻で絞り、決まった件数ずつ返す必要があります。外部キーや一意制約と同時に、よく使う検索条件へ索引を用意するか考えます。
速さのために残高をキャッシュする場合も、帳簿が正解であるという関係は変えません。読み取りを速くする工夫が、更新の正しさを壊さないことが条件です。
重要なコードを読む
v1.0.0 の実コードから、この章で押さえたい箇所だけを抜粋しました。前後の定義は省略しています。
円の金額を文字列で検証する
TypeScriptexport const MAX_SAFE_AMOUNT = BigInt(Number.MAX_SAFE_INTEGER);
export const jpyAmountSchema = z
.string()
.regex(/^[1-9]\d*$/, "amount must be a positive integer decimal string (no sign, decimal point, exponent, or leading zero)")
.refine((v) => BigInt(v) <= MAX_SAFE_AMOUNT, "amount exceeds the maximum safe integer");まず理解する金額は「10000」のような文字列で受け取り、正の整数だけを通します。0、小数、マイナス、先頭の0を含む値はこの形式に合いません。
一歩深くJSONの数値は、大きすぎると検証前に丸められる可能性があります。文字列のまま形式と上限を調べることで、その丸めを避けます。データベース側にも上限の制約があります。
出典:src/lib/validation/common.ts