家計簿アプリを作る #40:定期的な収支の自動登録(固定費対応)

家計簿アプリ作成シリーズの第40回です。家賃や給与のように毎月決まって発生する収支を、毎回手動で登録しなくても済むように「定期登録」機能を作ります。

実装方針

recurringTransactions テーブルを新設し、「毎月何日に、いくら、どの種類・カテゴリで発生するか」を登録できるようにします。収支一覧ページを開いたタイミングで、当月分がまだ生成されていない定期収支があれば自動的に transactions へ登録します。

スキーマの追加

export const recurringTransactions = sqliteTable('recurring_transactions', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull(),
  amount: int('amount').notNull(),
  type: text('type', { enum: ['income', 'expense'] }).notNull(),
  category: text('category').notNull(),
  memo: text('memo').notNull().default(''),
  dayOfMonth: int('day_of_month').notNull(),
});

さらに、生成された取引がどの定期登録から作られたかを追跡できるように、transactions テーブルに recurringId カラムを追加しました。

recurringId: text('recurring_id').notNull().default(''),

NOT NULL + DEFAULT '' で追加したので、既存データは自動的に recurringId: ''(定期登録由来ではない)として扱われ、マイグレーションでエラーになることもありませんでした。

指定日の入力はSelectで十分

dayOfMonth の入力は、日付ピッカーのような厳密なものではなく、1〜31のシンプルな Select にしました。

<Select id="dayOfMonth" name="dayOfMonth" bind:value={selectedDay} placeholder="" required>
  {#each Array.from({ length: 31 }, (_, i) => i + 1) as day}
    <option value={day}>{day}日</option>
  {/each}
</Select>

一覧の並び順にorderByで複数カラムを指定

定期収支一覧は「指定日→種類→カテゴリ→メモ」の昇順で表示したかったので、Drizzleの orderBy に複数のカラムを渡しました。

import { eq, asc } from 'drizzle-orm';

const allRecurringTransactions = await db.select().from(recurringTransactions)
  .where(eq(recurringTransactions.userId, locals.user!.id))
  .orderBy(
    asc(recurringTransactions.dayOfMonth),
    asc(recurringTransactions.type),
    asc(recurringTransactions.category),
    asc(recurringTransactions.memo)
  );

orderBy に渡した順番がそのままソートの優先順位になります。

当月分の自動登録ロジック

収支一覧ページの load で、以下の流れで当月分の未登録の定期収支を自動生成します。

  1. 「実際の今日時点のカレンダー月」を基準にする(閲覧中の月ではない)。過去の月を見るたびに生成されてしまうのを防ぐため。
  2. 当月の取引の中で recurringId を持つものを集め、登録済みの定期収支IDの集合を作る
  3. その集合に含まれない定期収支があれば、dayOfMonth から日付を組み立てて transactions にinsertする
  4. 新規登録があれば、同じURLに redirect してページをリロードし、最新状態を反映する
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const allRecurringTransactions = await db.select().from(recurringTransactions)
  .where(eq(recurringTransactions.userId, locals.user!.id));

const registeredRecurringTransactionIds = new Set(
  allTransactions
    .filter(t => t.date.startsWith(currentMonth) && t.recurringId)
    .map(t => t.recurringId)
);

const unregisteredRecurringTransactions = allRecurringTransactions
  .filter(r => !registeredRecurringTransactionIds.has(r.id));

if (unregisteredRecurringTransactions.length > 0) {
  for (const r of unregisteredRecurringTransactions) {
    const day = String(r.dayOfMonth).padStart(2, '0');
    await db.insert(transactions).values({
      userId: locals.user!.id,
      amount: r.amount,
      type: r.type,
      category: r.category,
      memo: r.memo,
      date: `${currentMonth}-${day}`,
      recurringId: r.id,
    });
  }
  redirect(303, url.pathname + url.search);
}

再取得のコードを自分で書く代わりに redirect でページをリロードさせることで、load 関数を最初から再実行させ、最新のDB状態を自然に反映させています。

ハマりポイント:一覧の並び順が崩れる

動作確認していると、収支一覧が日付の昇順で表示されなくなる現象がありました。原因は、allTransactions の取得時に明示的な ORDER BY を指定しておらず、DBの挿入順のまま取得していたためでした。今まではたまたま手動登録の順番が日付順に近かっただけで、定期収支の自動生成によって後から追加されたレコードが日付に関係なく末尾に付くようになり、順序の崩れが表面化しました。

// ✅ 明示的に日付順でソートする
const allTransactions = await db.select().from(transactions)
  .where(eq(transactions.userId, locals.user!.id))
  .orderBy(asc(transactions.date));

まとめ

ポイント 内容
生成元の追跡 transactions.recurringId で定期登録との紐付けを管理する
自動登録の基準月 閲覧中の月ではなく、実際の今日時点のカレンダー月を使う
未登録判定 当月の取引の中に該当recurringIdがあるかどうかで判定する
反映方法 再取得コードを書かず redirect でページをリロードさせる
一覧の並び順 orderBy(asc(...)) を明示しないと挿入順に依存してしまう
← トップページに戻る