小中学生向けの「お手伝い × お小遣い × 金融学習」アプリ Finoko(フィノコ) を一人で作って、家族で毎日使っています。企画・設計・実装・運用まで全部やる中で、「子どもの残高を絶対に狂わせない」ために採った設計が一番の学びだったので、そこを中心に書きます。
何を作ったか
子どもが「ゴミ出し」「くつをそろえる」といった家のお手伝いを報告し、保護者が承認するとお小遣いの残高が増える。貯めたお小遣いは「つかう」「あげる」ことができ、クイズや疑似投資でお金の感覚を学べる、というアプリです。
技術構成はこうなっています。
| レイヤー | 採用 |
|---|---|
| フロント | Nuxt 4(ssr: false の SPA)+ Nuxt UI 4 + Tailwind CSS v4 + Pinia |
| バックエンド | Supabase(PostgreSQL + RLS + Realtime + Storage + Edge Functions) |
| ホスティング | Cloudflare Pages |
| 通知 | Web Push(VAPID) |
| 認証 | Google OAuth |
| テスト | Vitest 137 件 / Playwright E2E 153 件 |
共通しているのは「常時稼働のサーバーを持たない」こと。一人で運用するので、深夜に落ちる要素を最初から持たない構成にしました。
なぜ SSR ではなく SPA なのか
Nuxt は SSR が売りですが、Finoko は ssr: false です。理由は単純で、ほぼ全ての画面がログイン後の画面だから。検索エンジンに見せたいのはランディングページと法務ページくらいで、そこは別に静的に出せば済みます。
// nuxt.config.ts
export default defineNuxtConfig({
ssr: false,
nitro: { preset: 'cloudflare-pages' }
})
SPA にすると dist/ を置くだけで動き、Worker のコールドスタートやサーバー側の状態管理を考えなくてよくなります。PWA(ホーム画面に追加して使う)とも相性がいい。ログイン後のアプリなら、SSR を使わない選択肢を最初に検討する価値はあります。
設計の核:残高を触れるのは RPC だけ
ここが一番大事なところです。クライアントから残高テーブルを直接 INSERT / UPDATE することはできません。 残高が動く操作はすべて PostgreSQL の関数(RPC)にまとめています。
理由は 2 つ。
1. 途中で落ちても壊れない。 「承認フラグを立てる → 残高を足す → 履歴を書く → 通知を作る」を別々の API 呼び出しにすると、2 番目で通信が切れたときに「承認されたのに残高が増えていない」が起きます。1 つのトランザクションなら全部成功するか全部失敗するかのどちらかです。
2. クライアントを信用しない。 「いくら足すか」をクライアントから受け取る設計にすると、devtools から好きな額を足せます。
実際にあった穴
初期の実装では、クイズの得点加算がこうなっていました。
-- 危ない例:点数を引数で受け取っている
create function add_quiz_points(
p_child_id uuid, p_family_id uuid, p_points integer, ...
)
認可は「同じ家族か」しか見ていないので、子どもが devtools から add_quiz_points(自分, 自分の家族, 99999, ...) を呼べば通ります。問題と正解がクライアントにある限り、サーバーは「本当に正解したか」を検証できません。
そこで、問題を DB に置き、採点と金額の決定をサーバー側に寄せました。クライアントが送るのは「この問題にこの選択肢で答えた」だけです。
承認 → 加算 → 履歴 → 通知を 1 関数に
お手伝いの承認はこういう形です(要点だけ抜粋)。
create or replace function approve_chore_and_pay(p_completion_id uuid)
returns void
language plpgsql
security definer
as $$
declare
v_completion record;
v_reward integer;
begin
-- 操作者が保護者で、同じ家族か
if not is_parent_of_family(auth.uid(), family_id_of(p_completion_id)) then
raise exception 'forbidden';
end if;
select * into v_completion
from chore_completions where id = p_completion_id for update;
if v_completion.status <> 'pending' then
raise exception 'already reviewed'; -- 二重承認を防ぐ
end if;
-- 金額は「クライアントが送った値」ではなく、タスク定義から読む
select reward_amount into v_reward from chores where id = v_completion.chore_id;
update chore_completions
set status = 'approved', reviewed_at = now()
where id = p_completion_id;
update wallet_balances
set total_balance = total_balance + v_reward
where child_id = v_completion.child_id;
insert into transactions (child_id, type, amount, reference_id) values (...);
insert into notifications (to_profile_id, kind, reference_id) values (...);
end;
$$;
ポイントは 3 つ。
-
金額の出どころが DB の中(
chores.reward_amount)。クライアントは「この報告を承認する」としか言えない -
for updateと状態チェックで二重承認を防ぐ(連打やリトライで 2 回加算されない) - 通知も同じトランザクション。承認だけ成功して通知が飛ばない、が起きない
実装用の関数には __impl の接尾辞を付けて REVOKE し、認可チェックを通る公開関数からしか呼べないようにしています。
RLS は境界、RPC は権限
すべてのテーブルに Row Level Security を有効にし、ポリシーは基本的に「自分の family_id と一致する行だけ」です。
create policy "family scope" on transactions
for select using (family_id = get_my_family_id());
ただし RLS は「行が見えるか」を決めるもので、「操作が正しいか」までは保証しません。 「同じ家族の子どもが、きょうだいの残高を減らす」は RLS だけでは止まらないので、RPC の中で「操作者は保護者か」「対象は自分か」を確認します。
この住み分けを意識してからポリシーがシンプルになりました。RLS に権限判定まで詰め込もうとすると、ポリシーが複雑になって自分でも読めなくなります。
秘密はポリシーを 1 つも持たないテーブルに置く
共有タブレット用の PIN・パスコードは専用テーブルに置き、RLS を有効にしたうえでポリシーを 1 つも作りません(=クライアントからは一切読めない)。
create table public.access_codes (
id uuid primary key default gen_random_uuid(),
family_id uuid not null references families(id) on delete cascade,
profile_id uuid references profiles(id) on delete cascade, -- NULL なら保護者パスコード
code_hash text not null,
failed_attempts integer not null default 0,
locked_until timestamptz
);
alter table public.access_codes enable row level security;
-- ポリシーは作らない
読み書きは security definer の RPC 経由のみで、照合関数は真偽だけ返します。ハッシュは pgcrypto の crypt(code, gen_salt('bf'))、総当たり対策は failed_attempts / locked_until によるサーバー側のロックです。
これは反省からの設計です。初期実装では列名が pin_hash なのに中身は平文の JSON 配列で、照合もクライアント側。しかも家族スコープの RLS なので子どものアカウントからきょうだいの PIN が読めました。PIN が何も守っていなかったので実害はゼロでしたが、PIN に意味を持たせる以上この方式は使えません。
通知:アプリは「テーブルに書く」だけ
「報告が来た」「承認された」を届けるのに Web Push を使っていますが、アプリ本体は送信処理を知りません。
- アプリの処理が
notificationsテーブルに 1 行 INSERT する - Supabase の Database Webhook がその INSERT を拾って Edge Function を呼ぶ
- Edge Function が VAPID で Web Push を送る
送り方を変えたくなったら Edge Function だけ直せばよく、通知が失敗してもアプリ側の処理は完了しています。定期的なリマインドも pg_cron → notifications → 同じ Edge Function の経路で、cron は送信の仕組みを知りません。
一人で回すからこそテストを書く
単体テスト(Vitest)137 件と E2E(Playwright)153 件があります。E2E は「テスト用の家族を作る → 子どもを追加 → タスクを作る → 報告 → 承認 → 残高を確認」を直列に流します。
一人開発だとテストを書く時間が惜しくなりますが、逆でした。レビューしてくれる人がいないので、**テストが唯一の「自分以外の目」**になります。本番で家族が毎日使っているアプリなので、残高が狂うバグを出すわけにはいきません。
環境も dev / prod の 2 つの Supabase プロジェクトに分け、マイグレーションは必ず dev に先に適用しています。
法律の線引きを先に決めた
子どもの「おこづかい残高」を扱う以上、避けて通れませんでした。やってはいけない設計を先に固定しています。
- 課金と残高を接続しない。 残高が「対価を得て発行される」と資金決済法上の前払式支払手段になり、供託・届出の義務が乗る
- 残高を実在の物品・デジタルコンテンツと交換できるようにしない。 交換できると前払式支払手段の要件を満たす。出金・換金・送金を足すと資金移動業の登録が必要
- 現実の株価を使わない。 疑似投資機能は架空銘柄。実際の権利も払込みも無いので金商法上の有価証券に当たらない
ちなみに疑似投資で実在の株価を使えなかったのは、法律より先にデータ提供業者の規約が壁でした。5 社確認して、無料枠でエンドユーザーに株価を表示できる正規ルートは存在しませんでした(多くが "personal, non-commercial" または "internal, non-display" 指定)。
アプリ内通貨・ポイント・残高のようなものを個人開発で持つなら、ここは早めに確認したほうがいいです。
まとめ
- ログイン後が中心のアプリなら、SSR を使わない選択肢を先に検討する
- 残高のような「間違えると困る値」は、更新を RPC に閉じ込めて 1 トランザクションで。金額を決める材料はサーバー側に置く
-
for update+ 状態チェックで二重実行を防ぐ - RLS は境界、RPC は権限。役割を分けるとポリシーがシンプルになる
- 秘密はポリシーを持たないテーブル +
security definerの RPC - 通知は「テーブルに書くだけ」。送信の仕組みをアプリに持ち込まない
- 一人開発ほどテストが効く
- お金に関わるものは、法律の線引きを先に決めて「やらないこと」を固定する
タスクの繰り返し設計、クイズの在庫が枯れない仕組み、疑似投資の上限計算などは Zenn の連載 に分けて書いています。
