きっかけ
バイト掛け持ちしてる友人から「103万とか106万とか130万とか、もうどれがどれか全然分からん」って連絡が来たのが発端です。
一緒に調べてみたら自分も正直ちゃんと把握できてなくて、しかも 2026 年に複数の制度が同時に変わっていて結構ぐちゃぐちゃな状態になってました。社労士に聞けって話なんですが、そういうわけにもいかないし、「ちゃんと計算してくれるアプリあればいいのに」と思ったら既存のアプリが思ったより微妙で、じゃあ作るか、という流れです。
課金とか広告とかは全く考えておらず、今も完全無料で公開しています。
作ったもの
「壁プラン」というアプリです。
シフト管理アプリとは少し違って、「このシフト入っていいか?」を判断するための道具として作りました。シフトを追加するたびに年収の壁との距離がリアルタイムで更新されて、「このまま働くと○月に106万超えそう」みたいなのが一目で分かる、という感じのアプリです。
壁が思ったより多かった
最初は「103万と130万だけ対応すればいいかな」くらいに思っていたんですが、ちゃんと調べると2026年時点でこれだけあります:
| 壁 | 閾値 | 主に影響する人 |
|---|---|---|
| 住民税 | 110万 | 全員 |
| 社保加入(被用者保険) | 106万 | 一定規模以上の会社でバイトしてる人(学生は除外) |
| 健康保険の扶養 | 130万 | 親や配偶者の扶養に入ってる人 |
| 配偶者控除 | 136万 | 配偶者がいる人 |
| 学生の特例 | 150万〜188万 | 19〜22歳の学生 |
| 所得税 | 178万 | 全員(2026〜2027年の時限措置) |
| 配偶者特別控除ゼロ | 201万 | 配偶者がいる人 |
特にしんどかったのは 106 万と 130 万の関係で、この 2 つは全然別の話なんですよね。106 万は「自分が社保に加入するかどうか」、130 万は「親や配偶者の扶養から外れるかどうか」。ユーザーからしたら同じように見えて、実は全く別の問題なのをどう説明するか、UI で結構悩みました。
あと 106 万は 2026 年 10 月に条件が変わる(月収 8.8 万の要件が撤廃される)ので、日付で内部ロジックを切り替える処理も書いています。
技術スタック
- Expo SDK 55 / React Native 0.83
- TypeScript(strict +
noUncheckedIndexedAccess: true) - expo-sqlite + Drizzle ORM
- Zustand + Jotai
- Reanimated + expo-blur
サーバーは持っていません。収入データをどこかに送りたくないというのが一番の理由です。Drizzle の useLiveQuery でシフト追加のたびに計算が流れる構成にしていて、これが思ったより使い勝手よかったです。
一番こだわったとこ:ドメインロジックの分離
税・社保のルールは React から完全に切り離しています。src/domain/walls 以下に純粋な TypeScript 関数だけが並んでいて、React の import は一行も書いていません。
型はこんな感じ:
// src/domain/walls/types.ts
export type WallZone = 'safe' | 'caution' | 'crossed';
export interface WallResult {
wallId: WallId;
zone: WallZone;
remaining: number; // 壁まで残り何円か
projectedIncome: number; // 年末時点での予測年収
consequence: string; // 「この壁を超えると〜になります」の説明文
}
画面に呼ばれる関数はこれだけ:
// src/domain/walls/evaluator.ts
export function evaluateWalls(
profile: Profile,
jobs: Job[],
shifts: Shift[],
year: number
): WallResult[] {
const applicableWalls = getApplicableWalls(profile); // プロフィールで絞る
const income = projectAnnualIncome(shifts, jobs, year);
return applicableWalls.map(wall => wall.evaluate(income, profile, year));
}
画面側は WallResult[] を受け取るだけで、税の計算とは無関係です。
// ホーム画面(抜粋)
const walls = useAtomValue(wallResultsAtom); // Jotaiで購読
const nextWall = walls.find(w => w.zone !== 'crossed'); // 最近い壁
この構成にしておくと、制度が変わったとき(毎年ある)に rules-2026.ts だけ差し替えればいいし、テストも React 関係なく純粋関数を叩くだけで書けるのが楽でした。
画面構成
タブは 4 つでシンプルにしています。
ホーム
次の壁まで「あと ○ 万円」を大きく表示して、その下にその壁を超えたら何が起きるかを書いています。数字の大きさで「今どのくらいやばいか」が分かるようにしています。
カレンダー
シフトの入力画面です。シフトを追加するたびにホームの数字がリアルタイムで変わります。
シナリオ
「今のペースで続けた場合」と「週 1 シフト減らした場合」みたいなパターンを複製して 12 ヶ月分並べて比較できます。ここが個人的に一番気に入っている画面で、「10 月から週 1 減らすと 130 万に引っかかっても大丈夫かも」みたいな検討ができます。
ちょっとハマったとこ
130 万の判定が 2026 年 4 月から変わっていた
2026 年 4 月から、130 万の扶養判定が「実際の収入」ではなく「労働契約上の収入」ベースになりました。残業で一時的に超えても扶養から外れなくなったやつです。
これを知らずに最初は「年間の実収入が 130 万超えたら crossed」という実装をしていて、テストを書いていたら「あれ、これ 2026 年のルールと違う」ってなって調べ直しました。地味にこういうのがあちこちに潜んでいます。
178 万の「恩恵は 2027 年 1 月まで来ない」問題
2026 年の所得税非課税ラインは 178 万に引き上げられていますが、源泉徴収の月次テーブルが更新されるのは 2027 年 1 月です。つまり 2026 年中は毎月のバイト代から所得税が引かれ続けて、メリットが来るのは年末調整か確定申告のときだけという話で。これをアプリ内にちゃんと書くかどうかで少し迷いました(結局注記として入れています)。
おわりに
制度系のアプリは「ルールが毎年変わる」という前提で設計しておかないと後からしんどいです。今回ドメインロジックを UI から完全に分離したのは結果的に正解で、10 月の制度変更も rules-2026.ts に date チェックを足すだけで対応できました。
同じような「法律や制度を扱うアプリ」を作っている人がいたら、早めに分離しておくことをおすすめします。後から剥がそうとすると本当につらいです(やりかけて途中でやめた経験があります)。
