@hearthlog/module-finance 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +11 -2
  3. package/hearthlog.module.json +90 -0
  4. package/package.json +44 -3
  5. package/src/attention.ts +41 -0
  6. package/src/components/finance/AddAssetForm.svelte +58 -0
  7. package/src/components/finance/AddTransactionForm.svelte +127 -0
  8. package/src/components/finance/AssetDrawer.svelte +133 -0
  9. package/src/components/finance/AssetItem.svelte +96 -0
  10. package/src/components/finance/AssetView.svelte +165 -0
  11. package/src/components/finance/AssetsSheet.svelte +122 -0
  12. package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
  13. package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
  14. package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
  15. package/src/components/finance/BudgetItemDrawer.svelte +116 -0
  16. package/src/components/finance/BudgetItemNameCell.svelte +5 -0
  17. package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
  18. package/src/components/finance/BudgetItemsTable.svelte +206 -0
  19. package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
  20. package/src/components/finance/BudgetPlans.svelte +419 -0
  21. package/src/components/finance/EndlessTimeline.svelte +174 -0
  22. package/src/components/finance/FinanceReadout.svelte +229 -0
  23. package/src/components/finance/MonthSection.svelte +255 -0
  24. package/src/components/finance/RiverSlice.svelte +125 -0
  25. package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
  26. package/src/components/finance/TimelineRowItem.svelte +319 -0
  27. package/src/components/finance/TransactionDrawer.svelte +480 -0
  28. package/src/components/finance/TransactionSelectionBar.svelte +200 -0
  29. package/src/components/finance/timeline-months.svelte.ts +37 -0
  30. package/src/components/finance/transaction-draft.ts +121 -0
  31. package/src/data/assets.ts +48 -0
  32. package/src/data/budgets.ts +123 -0
  33. package/src/data/definitions.ts +135 -0
  34. package/src/data/finance-timeline.ts +305 -0
  35. package/src/data/forecast.ts +256 -0
  36. package/src/data/planned-days.ts +57 -0
  37. package/src/data/repositories.ts +20 -0
  38. package/src/data/transactions.ts +452 -0
  39. package/src/finance/categories.ts +29 -0
  40. package/src/finance/category-pick.ts +8 -0
  41. package/src/finance/category-suggestions.ts +65 -0
  42. package/src/finance/floor.ts +29 -0
  43. package/src/finance/phone-balance.ts +22 -0
  44. package/src/finance/timeline.ts +113 -0
  45. package/src/pages/index.svelte +19 -0
  46. package/src/pages/index.ts +7 -0
  47. package/src/pages/layout.svelte +13 -0
  48. package/src/pages/layout.ts +18 -0
  49. package/src/pages/month.svelte +909 -0
  50. package/src/pages/month.ts +99 -0
  51. package/src/server/category-pick.ts +44 -0
  52. package/src/server/index.ts +11 -0
  53. package/src/styles.css +12 -0
@@ -0,0 +1,200 @@
1
+ <script lang="ts">
2
+ import type { TransactionSelectionSummary } from "../../data/transactions.js";
3
+ import { queryDatabase } from "@hearthlog/sdk/data";
4
+ import { formatMoney } from "@hearthlog/sdk/format";
5
+ import { Button } from "@hearthlog/sdk/ui";
6
+ import { DatePicker } from "@hearthlog/sdk/ui";
7
+ import { Field } from "@hearthlog/sdk/ui";
8
+ import { CommandOnly } from "@hearthlog/sdk/ui";
9
+ import { CommandModal } from "@hearthlog/sdk/ui";
10
+ import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
11
+ import type { RowLike } from "./TimelineRowItem.svelte";
12
+
13
+ let {
14
+ selected,
15
+ visibleCount,
16
+ today,
17
+ onClear,
18
+ onSelectAll,
19
+ onMove,
20
+ onSetDone,
21
+ onDelete,
22
+ }: {
23
+ /** Selected rows, in list order. */
24
+ selected: RowLike[];
25
+ visibleCount: number;
26
+ today: string;
27
+ onClear: () => void;
28
+ onSelectAll: () => void;
29
+ onMove: (rows: RowLike[], date: string) => Promise<void>;
30
+ /** Marks the selected plans done, or restores rows done ahead of their day to plans. */
31
+ onSetDone: (rows: RowLike[], done: boolean) => Promise<void>;
32
+ onDelete: (rows: RowLike[]) => Promise<void>;
33
+ } = $props();
34
+
35
+ const empty: TransactionSelectionSummary = { count: 0, incomeTotal: 0, expenseTotal: 0, netTotal: 0 };
36
+ let summary = $state<TransactionSelectionSummary>({ ...empty });
37
+ let summaryLoading = $state(false);
38
+ let busy = $state(false);
39
+ let moveOpen = $state(false);
40
+ let moveDate = $state("");
41
+ let moveError = $state("");
42
+ let confirmDeleteOpen = $state(false);
43
+
44
+ const count = $derived(selected.length);
45
+ const allSelected = $derived(count > 0 && count === visibleCount);
46
+ // Plans can be marked done early; a row done ahead of its day can go back
47
+ // to plans. Rows whose day has passed are done by date and offer neither.
48
+ const statusAction = $derived.by(() => {
49
+ const plans = selected.filter((row) => row.kind === "planned");
50
+ if (plans.length > 0) return { label: "Mark done", done: true, rows: plans };
51
+ const ahead = selected.filter((row) => row.kind === "done" && row.date >= today);
52
+ return ahead.length > 0 ? { label: "Restore", done: false, rows: ahead } : null;
53
+ });
54
+ const noun = (value: number) => (value === 1 ? "transaction" : "transactions");
55
+ const deleteMessage = $derived.by(() => {
56
+ const names = selected.map((row) => row.name || "Untitled");
57
+ if (names.length === 1) return `Delete “${names[0]}”?`;
58
+ return `Remove these ${names.length} transactions: ${names.slice(0, 10).join(", ")}${names.length > 10 ? ", …" : ""}?`;
59
+ });
60
+
61
+ // Totals are a separate SQL query so ticking a checkbox never re-scans rows.
62
+ $effect(() => {
63
+ const ids = selected.map((row) => row.id);
64
+ if (ids.length === 0) {
65
+ summary = { ...empty };
66
+ summaryLoading = false;
67
+ return;
68
+ }
69
+ let cancelled = false;
70
+ summaryLoading = true;
71
+ queryDatabase((repositories) => repositories.transactions.getSelectionSummary(ids))
72
+ .then((result) => { if (!cancelled) summary = result; })
73
+ .catch(() => { if (!cancelled) summary = { ...empty }; })
74
+ .finally(() => { if (!cancelled) summaryLoading = false; });
75
+ return () => { cancelled = true; };
76
+ });
77
+
78
+ function openMove(): void {
79
+ moveDate = selected.map((row) => row.date).sort()[0] ?? today;
80
+ moveError = "";
81
+ moveOpen = true;
82
+ }
83
+
84
+ async function run(action: () => Promise<void>): Promise<boolean> {
85
+ if (busy || count === 0) return false;
86
+ busy = true;
87
+ try {
88
+ await action();
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ } finally {
93
+ busy = false;
94
+ }
95
+ }
96
+
97
+ async function confirmMove(): Promise<void> {
98
+ const date = moveDate.trim().slice(0, 10);
99
+ if (!date) {
100
+ moveError = "Choose a date first.";
101
+ return;
102
+ }
103
+ moveError = "";
104
+ const targets = selected;
105
+ const moved = await run(() => onMove(targets, date));
106
+ if (moved) moveOpen = false;
107
+ else moveError = "The transactions could not be moved. Your values are still here.";
108
+ }
109
+
110
+ async function toggleDone(): Promise<void> {
111
+ const action = statusAction;
112
+ if (!action) return;
113
+ await run(() => onSetDone(action.rows, action.done));
114
+ }
115
+
116
+ async function confirmDelete(): Promise<void> {
117
+ confirmDeleteOpen = false;
118
+ const targets = selected;
119
+ await run(() => onDelete(targets));
120
+ }
121
+ </script>
122
+
123
+ {#snippet totals()}
124
+ {#if summaryLoading}
125
+ <span class="text-[var(--stor-muted-text)]">Calculating totals…</span>
126
+ {:else}
127
+ <div><span class="text-[var(--stor-muted-text)]">Income</span> <strong class="ml-1 tabular-nums">{formatMoney(summary.incomeTotal, { signed: true })}</strong></div>
128
+ <div><span class="text-[var(--stor-muted-text)]">Expenses</span> <strong class="ml-1 tabular-nums">{formatMoney(-summary.expenseTotal)}</strong></div>
129
+ <div><span class="text-[var(--stor-muted-text)]">Net</span> <strong class="ml-1 tabular-nums">{formatMoney(summary.netTotal)}</strong></div>
130
+ {/if}
131
+ {/snippet}
132
+
133
+ {#if count > 0}
134
+ <div class="sticky top-0 z-[4] hidden lg:block">
135
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1 bg-[var(--stor-surface-muted)] px-4 py-2 text-xs text-[var(--stor-muted-text)]" role="status">
136
+ {#if allSelected}
137
+ <span>All {count} {noun(count)} in this view {count === 1 ? "is" : "are"} selected.</span>
138
+ <button type="button" onclick={onClear} class="font-semibold text-[var(--stor-accent-strong)] hover:underline">Clear selection</button>
139
+ {:else}
140
+ <span>{count} of {visibleCount} {noun(visibleCount)} in this view {count === 1 ? "is" : "are"} selected.</span>
141
+ <button type="button" onclick={onSelectAll} class="font-semibold text-[var(--stor-accent-strong)] hover:underline">Select all {visibleCount}</button>
142
+ {/if}
143
+ </div>
144
+ <div class="flex flex-wrap items-center gap-x-6 gap-y-2 bg-[var(--stor-accent-soft)] px-4 py-2.5 text-sm" aria-label="Selected transaction totals">
145
+ <strong>{count} selected</strong>
146
+ {@render totals()}
147
+ <div class="ml-auto flex items-center gap-2">
148
+ <CommandOnly>
149
+ <Button size="sm" disabled={busy} onclick={openMove}>Move</Button>
150
+ {#if statusAction}<Button size="sm" variant="secondary" disabled={busy} onclick={() => void toggleDone()}>{statusAction.label}</Button>{/if}
151
+ <Button size="sm" variant="ghost" class="text-[var(--stor-negative)]" disabled={busy} onclick={() => (confirmDeleteOpen = true)}>Delete</Button>
152
+ </CommandOnly>
153
+ <button type="button" onclick={onClear} class="h-9 px-2 text-sm text-[var(--stor-accent-strong)]">Clear selection</button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <div class="fixed inset-x-3 bottom-[calc(var(--stor-mobile-bottom-inset)+var(--stor-mobile-nav-height)+var(--stor-mobile-stack-gap))] z-40 md:left-[116px] rounded-2xl bg-[var(--stor-surface-raised)] p-3 shadow-[var(--stor-elevation-3)] lg:hidden" aria-label="Selected transaction totals">
159
+ <div class="flex items-center justify-between gap-2">
160
+ <strong>{count} selected</strong>
161
+ <div class="flex items-center gap-3 text-sm">
162
+ {#if !allSelected}<button type="button" onclick={onSelectAll} class="min-h-11 text-[var(--stor-accent-strong)]">Select all {visibleCount}</button>{/if}
163
+ <button type="button" onclick={onClear} class="min-h-11 text-[var(--stor-accent-strong)]">Cancel</button>
164
+ </div>
165
+ </div>
166
+ <div class="mt-1 grid grid-cols-3 gap-2 text-sm [&_div]:flex [&_div]:flex-col">
167
+ {@render totals()}
168
+ </div>
169
+ <CommandOnly>
170
+ <div class="mt-3 grid gap-2 {statusAction ? 'grid-cols-3' : 'grid-cols-2'}">
171
+ {#if statusAction}<Button class="md:hidden" disabled={busy} onclick={() => void toggleDone()}>{statusAction.label === "Mark done" ? "Done" : statusAction.label}</Button>{/if}
172
+ <Button class={statusAction ? "max-md:!bg-transparent max-md:!text-[var(--stor-ink)] max-md:!shadow-none" : ""} disabled={busy} onclick={openMove}>Move</Button>
173
+ {#if statusAction}<Button variant="secondary" class="max-md:hidden" disabled={busy} onclick={() => void toggleDone()}>{statusAction.label}</Button>{/if}
174
+ <Button variant="ghost" class="bg-[var(--stor-negative-soft)] text-[var(--stor-negative)]" disabled={busy} onclick={() => (confirmDeleteOpen = true)}>Delete</Button>
175
+ </div>
176
+ </CommandOnly>
177
+ </div>
178
+ {/if}
179
+
180
+ <CommandModal show={moveOpen} title={count === 1 ? "Move 1 transaction" : `Move ${count} transactions`} onClose={() => (moveOpen = false)}>
181
+ <p class="text-sm text-[var(--stor-muted-text)]">Pick the new date for the selected {noun(count)}.</p>
182
+ <Field label="New date" htmlFor="move-transactions-date" class="mt-4">
183
+ <DatePicker id="move-transactions-date" bind:value={moveDate} clearable={false} />
184
+ </Field>
185
+ {#if moveError}
186
+ <p class="mt-2 text-sm text-[var(--stor-negative)]" role="alert">{moveError}</p>
187
+ {/if}
188
+ <div class="mt-5 flex items-center justify-end gap-2">
189
+ <Button variant="secondary" onclick={() => (moveOpen = false)} disabled={busy}>Cancel</Button>
190
+ <Button onclick={() => void confirmMove()} disabled={busy || !moveDate.trim()}>{busy ? "Moving…" : "Move here"}</Button>
191
+ </div>
192
+ </CommandModal>
193
+
194
+ <CommandConfirmDialog
195
+ show={confirmDeleteOpen}
196
+ title={count === 1 ? "Delete transaction?" : `Delete ${count} transactions?`}
197
+ message={deleteMessage}
198
+ onConfirm={confirmDelete}
199
+ onCancel={() => (confirmDeleteOpen = false)}
200
+ />
@@ -0,0 +1,37 @@
1
+ import { BucketStore } from '@hearthlog/sdk/endless';
2
+ import { queryDatabase } from '@hearthlog/sdk/data';
3
+ import type { TimelineMonth, TimelineRow } from '../../data/finance-timeline.js';
4
+ import { monthWindow, type TimelineShow } from '../../finance/timeline.js';
5
+
6
+ export interface MonthData {
7
+ key: string;
8
+ stats: TimelineMonth;
9
+ /** SQL-ordered rows of the kind shown. */
10
+ rows: TimelineRow[];
11
+ }
12
+
13
+ /**
14
+ * Loaded months of the endless timeline. Each batch of months that scroll
15
+ * into view is one SQL session: month totals plus rows per month.
16
+ */
17
+ export class TimelineMonths extends BucketStore<MonthData> {
18
+ constructor(params: () => { today: string; show: TimelineShow }) {
19
+ super(async (keys) => {
20
+ const { today, show } = params();
21
+ const result = await queryDatabase(async (repositories) => {
22
+ const [stats, ...rows] = await Promise.all([
23
+ repositories.timeline.getMonths({ today, from: monthWindow(keys[0]).start, to: monthWindow(keys[keys.length - 1]).end, show }),
24
+ ...keys.map((key) => repositories.timeline.getRows({ today, ...monthWindow(key), show })),
25
+ ]);
26
+ return { stats, rows };
27
+ });
28
+ const statsByMonth = new Map(result.stats.map((month) => [month.month, month]));
29
+ const months = new Map<string, MonthData>();
30
+ keys.forEach((key, index) => {
31
+ const stats = statsByMonth.get(key);
32
+ if (stats) months.set(key, { key, stats, rows: result.rows[index] });
33
+ });
34
+ return months;
35
+ }, '[Finance] Could not load timeline months');
36
+ }
37
+ }
@@ -0,0 +1,121 @@
1
+ import { addDays, format, parseISO } from "date-fns";
2
+ import type { Transaction } from "../../data/transactions.js";
3
+
4
+ /** A daily repeat adds at most a year of transactions in one save. */
5
+ export const MAX_REPEAT_DAYS = 366;
6
+
7
+ export interface TransactionDraft {
8
+ id?: string;
9
+ name: string;
10
+ amount: number;
11
+ dueDate: string;
12
+ category: string;
13
+ status: "active" | "archived";
14
+ direction: "expense" | "income";
15
+ /**
16
+ * Where the category came from (`manual`, `history`, `ai:<model>`). Left
17
+ * undefined when the category was not touched, so a save keeps what is stored.
18
+ */
19
+ categorySource?: string;
20
+ }
21
+
22
+ export function createTransactionDraft(
23
+ dueDate: string,
24
+ transaction?: Transaction,
25
+ ): TransactionDraft {
26
+ if (!transaction) {
27
+ return {
28
+ name: "",
29
+ amount: 0,
30
+ dueDate,
31
+ category: "unknown",
32
+ status: "active",
33
+ direction: "expense",
34
+ };
35
+ }
36
+
37
+ return {
38
+ id: transaction.id,
39
+ name: transaction.name ?? "",
40
+ amount: Math.abs(transaction.amount),
41
+ dueDate: transaction.due_date?.slice(0, 10) ?? dueDate,
42
+ category: transaction.transaction_category || "unknown",
43
+ status:
44
+ transaction.transaction_status === "archived"
45
+ ? "archived"
46
+ : "active",
47
+ direction: transaction.amount >= 0 ? "income" : "expense",
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Only an entry dated today asks whether it already happened: earlier days
53
+ * are done by their date and later days are plans.
54
+ */
55
+ export function canMarkHappened(draft: TransactionDraft, today: string): boolean {
56
+ return draft.dueDate !== "" && draft.dueDate === today;
57
+ }
58
+
59
+ /** "Already happened" starts checked for a transaction that is done, and unchecked for a plan or a new entry. */
60
+ export function defaultHappened(initialStatus: TransactionDraft["status"]): boolean {
61
+ return initialStatus === "archived";
62
+ }
63
+
64
+ /**
65
+ * An existing row marked done ahead of its day. It stays done on another
66
+ * later date, while a row that is done only because its day passed turns
67
+ * back into a plan.
68
+ */
69
+ export function isDoneAhead(initial: TransactionDraft, today: string): boolean {
70
+ return !!initial.id && initial.status === "archived" && today !== "" && initial.dueDate >= today;
71
+ }
72
+
73
+ /**
74
+ * The status a save stores. Anything dated before today is done, today
75
+ * follows "Already happened", and a later date is a plan unless the row was
76
+ * already done ahead of its day.
77
+ */
78
+ export function resolveDraftStatus(
79
+ draft: TransactionDraft,
80
+ today: string,
81
+ alreadyHappened: boolean,
82
+ doneAhead = false,
83
+ ): TransactionDraft["status"] {
84
+ if (!draft.dueDate || !today) return draft.id ? draft.status : "active";
85
+ if (draft.dueDate < today) return "archived";
86
+ if (draft.dueDate === today) return alreadyHappened ? "archived" : "active";
87
+ return doneAhead ? "archived" : "active";
88
+ }
89
+
90
+ /** A daily repeat covers two to {@link MAX_REPEAT_DAYS} whole days. */
91
+ export function isRepeatDays(days: number): boolean {
92
+ return Number.isInteger(days) && days >= 2 && days <= MAX_REPEAT_DAYS;
93
+ }
94
+
95
+ /**
96
+ * The transactions one save creates: the draft itself, or one per day for a
97
+ * daily repeat of a new transaction. Days before today are done, today
98
+ * follows "Already happened", and later days stay plans.
99
+ */
100
+ export function expandDraft(
101
+ draft: TransactionDraft,
102
+ days: number,
103
+ today: string,
104
+ alreadyHappened: boolean,
105
+ doneAhead = false,
106
+ ): TransactionDraft[] {
107
+ if (draft.id || !draft.dueDate || !isRepeatDays(days)) return [{ ...draft, status: resolveDraftStatus(draft, today, alreadyHappened, doneAhead) }];
108
+ const start = parseISO(draft.dueDate);
109
+ return Array.from({ length: days }, (_, index) => {
110
+ const day = { ...draft, dueDate: format(addDays(start, index), "yyyy-MM-dd") };
111
+ return { ...day, status: resolveDraftStatus(day, today, alreadyHappened) };
112
+ });
113
+ }
114
+
115
+ /** `28 Sep – 11 Oct`, naming the years only when the range crosses one. */
116
+ export function formatDayRange(first: string, last: string): string {
117
+ const start = parseISO(first);
118
+ const end = parseISO(last);
119
+ const pattern = start.getFullYear() === end.getFullYear() ? "d MMM" : "d MMM yyyy";
120
+ return `${format(start, pattern)} – ${format(end, pattern)}`;
121
+ }
@@ -0,0 +1,48 @@
1
+ import type { Asset } from '@hearthlog/sdk/data';
2
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
3
+ import { withTimeout } from '@hearthlog/sdk/data';
4
+
5
+ export type { Asset };
6
+
7
+ const DB_TIMEOUT_MS = 5000;
8
+
9
+ export function createAssetRepository(session: ReadModelSession) {
10
+ return {
11
+ getAssets(): Promise<Asset[]> {
12
+ return withTimeout(
13
+ session.selectFrom('assets')
14
+ .selectAll()
15
+ .orderBy('created_at', 'asc')
16
+ .orderBy('id', 'asc')
17
+ .execute(),
18
+ DB_TIMEOUT_MS,
19
+ 'getAssets query timed out',
20
+ );
21
+ },
22
+
23
+ async getSumOfAssetsByType(category: string): Promise<number> {
24
+ const result = await withTimeout(
25
+ session.selectFrom('assets')
26
+ .select(({ fn }) => fn.sum('amount').as('sum'))
27
+ .where('asset_type', '=', category)
28
+ .executeTakeFirst(),
29
+ DB_TIMEOUT_MS,
30
+ 'getSumOfAssetsByType query timed out',
31
+ );
32
+ return Number(result?.sum ?? 0);
33
+ },
34
+
35
+ getAssetsByType(assetType: string): Promise<Asset[]> {
36
+ return withTimeout(
37
+ session.selectFrom('assets')
38
+ .selectAll()
39
+ .where('asset_type', '=', assetType)
40
+ .orderBy('created_at', 'asc')
41
+ .orderBy('id', 'asc')
42
+ .execute(),
43
+ DB_TIMEOUT_MS,
44
+ 'getAssetsByType query timed out',
45
+ );
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,123 @@
1
+ import type { BudgetItem, BudgetPlan } from '@hearthlog/sdk/data';
2
+ import type { ReadModelSession } from '@hearthlog/sdk/data';
3
+ import { withTimeout } from '@hearthlog/sdk/data';
4
+ import { sql } from 'kysely';
5
+
6
+ const DB_TIMEOUT_MS = 5000;
7
+
8
+ export interface BudgetPlanSummary extends BudgetPlan {
9
+ income_total: number;
10
+ expense_total: number;
11
+ item_count: number;
12
+ }
13
+
14
+ /** Name or amount sorting for one budget section; absent means creation order. */
15
+ export type BudgetItemSort = 'name-asc' | 'name-desc' | 'amount-asc' | 'amount-desc';
16
+
17
+ /** Income, expenses and net of a picked selection, aggregated in SQLite. */
18
+ export interface BudgetItemSelectionSummary {
19
+ count: number;
20
+ incomeTotal: number;
21
+ expenseTotal: number;
22
+ /** Income minus expenses. */
23
+ netTotal: number;
24
+ }
25
+
26
+ /** Reads the `incomeSort` / `expenseSort` URL value; anything unknown falls back to creation order. */
27
+ export function parseBudgetItemSort(value: string | null | undefined): BudgetItemSort | null {
28
+ return value === 'name-asc' || value === 'name-desc' || value === 'amount-asc' || value === 'amount-desc'
29
+ ? value
30
+ : null;
31
+ }
32
+
33
+ function toNumber(value: number | string | bigint | null | undefined): number {
34
+ const parsed = Number(value ?? 0);
35
+ return Number.isFinite(parsed) ? parsed : 0;
36
+ }
37
+
38
+ export function createBudgetRepository(session: ReadModelSession) {
39
+ return {
40
+ async getPlans(): Promise<BudgetPlanSummary[]> {
41
+ const result = await withTimeout(
42
+ session.execute(sql<BudgetPlan & {
43
+ income_total: number | string | null;
44
+ expense_total: number | string | null;
45
+ item_count: number | string | null;
46
+ }>`
47
+ SELECT plans.*,
48
+ COALESCE(SUM(CASE WHEN items.item_type = 'income' THEN items.amount ELSE 0 END), 0) AS income_total,
49
+ COALESCE(SUM(CASE WHEN items.item_type = 'expense' THEN items.amount ELSE 0 END), 0) AS expense_total,
50
+ COUNT(items.id) AS item_count
51
+ FROM ${session.table('budget_plans')} AS plans
52
+ LEFT JOIN ${session.table('budget_items')} AS items
53
+ ON items.tenant_id = plans.tenant_id
54
+ AND items.log_id = plans.log_id
55
+ AND items.plan_id = plans.id
56
+ GROUP BY plans.id
57
+ ORDER BY plans.created_at ASC, plans.id ASC
58
+ `),
59
+ DB_TIMEOUT_MS,
60
+ 'Budget plans query timed out',
61
+ );
62
+ return result.rows.map((row) => ({
63
+ ...row,
64
+ income_total: toNumber(row.income_total),
65
+ expense_total: toNumber(row.expense_total),
66
+ item_count: toNumber(row.item_count),
67
+ }));
68
+ },
69
+
70
+ getPlanItems(planId: string, itemType: 'income' | 'expense', sort: BudgetItemSort | null = null): Promise<BudgetItem[]> {
71
+ const items = session.selectFrom('budget_items')
72
+ .selectAll()
73
+ .where('plan_id', '=', planId)
74
+ .where('item_type', '=', itemType);
75
+ // Ordering stays in SQL so the page never re-sorts loaded rows in JavaScript.
76
+ let ordered = items.orderBy('created_at', 'asc').orderBy('id', 'asc');
77
+ if (sort === 'amount-asc') ordered = items.orderBy('amount', 'asc').orderBy('id', 'asc');
78
+ else if (sort === 'amount-desc') ordered = items.orderBy('amount', 'desc').orderBy('id', 'asc');
79
+ else if (sort === 'name-asc') ordered = items.orderBy(sql`name COLLATE NOCASE`, 'asc').orderBy('id', 'asc');
80
+ else if (sort === 'name-desc') ordered = items.orderBy(sql`name COLLATE NOCASE`, 'desc').orderBy('id', 'asc');
81
+ return withTimeout(
82
+ ordered.execute(),
83
+ DB_TIMEOUT_MS,
84
+ 'Budget items query timed out',
85
+ );
86
+ },
87
+
88
+ /**
89
+ * Income, expenses and net of a picked set of items. Selection totals are
90
+ * aggregated in SQLite so the page never sums rows in JavaScript.
91
+ */
92
+ async getSelectionSummary(ids: string[]): Promise<BudgetItemSelectionSummary> {
93
+ if (ids.length === 0) {
94
+ return { count: 0, incomeTotal: 0, expenseTotal: 0, netTotal: 0 };
95
+ }
96
+ const result = await withTimeout(
97
+ session.execute(sql<{
98
+ selected_count: number | string | null;
99
+ income_total: number | string | null;
100
+ expense_total: number | string | null;
101
+ net_total: number | string | null;
102
+ }>`
103
+ SELECT
104
+ COUNT(*) AS selected_count,
105
+ COALESCE(SUM(CASE WHEN item_type = 'income' THEN amount ELSE 0 END), 0) AS income_total,
106
+ COALESCE(SUM(CASE WHEN item_type = 'expense' THEN amount ELSE 0 END), 0) AS expense_total,
107
+ COALESCE(SUM(CASE WHEN item_type = 'income' THEN amount ELSE -amount END), 0) AS net_total
108
+ FROM ${session.table('budget_items')} AS items
109
+ WHERE id IN (${sql.join(ids)})
110
+ `),
111
+ DB_TIMEOUT_MS,
112
+ 'Budget item selection summary timed out',
113
+ );
114
+ const row = result.rows[0];
115
+ return {
116
+ count: toNumber(row?.selected_count),
117
+ incomeTotal: toNumber(row?.income_total),
118
+ expenseTotal: toNumber(row?.expense_total),
119
+ netTotal: toNumber(row?.net_total),
120
+ };
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Finance's persisted entities. Their names, tables, event prefixes, and
3
+ * fields are deployed data contracts: never rename them.
4
+ */
5
+
6
+ import type { EntityDefinition } from '@hearthlog/sdk/entities';
7
+
8
+ export const ENTITY_DEFINITIONS: EntityDefinition[] = [
9
+ {
10
+ name: "asset",
11
+ feature: "finance",
12
+ table: "assets",
13
+ fields: [
14
+ {
15
+ name: "name",
16
+ type: "string",
17
+ payloadKey: "name",
18
+ required: true,
19
+ },
20
+ {
21
+ name: "amount",
22
+ type: "number",
23
+ payloadKey: "amount",
24
+ required: true,
25
+ },
26
+ {
27
+ name: "asset_type",
28
+ type: "string",
29
+ payloadKey: "asset_type",
30
+ default: "asset",
31
+ },
32
+ ],
33
+ indexes: [{ name: "idx_assets_type", columns: ["asset_type"] }],
34
+ },
35
+ {
36
+ name: "transaction",
37
+ feature: "finance",
38
+ table: "transactions",
39
+ fields: [
40
+ { name: "name", type: "string", payloadKey: "name" },
41
+ {
42
+ name: "amount",
43
+ type: "number",
44
+ payloadKey: "amount",
45
+ required: true,
46
+ },
47
+ {
48
+ name: "due_date",
49
+ type: "datetime",
50
+ payloadKey: "due_date",
51
+ default: "datetime('now')",
52
+ },
53
+ {
54
+ name: "transaction_status",
55
+ type: "string",
56
+ payloadKey: "transaction_status",
57
+ default: "active",
58
+ },
59
+ {
60
+ name: "transaction_category",
61
+ type: "string",
62
+ payloadKey: "transaction_category",
63
+ default: "unknown",
64
+ },
65
+ // Where the category came from: `manual`, `history`, or `ai:<model>`.
66
+ // Empty for rows saved before provenance was kept.
67
+ {
68
+ name: "category_source",
69
+ type: "string",
70
+ payloadKey: "category_source",
71
+ default: "",
72
+ optionalInPayload: true,
73
+ },
74
+ ],
75
+ calendar: {
76
+ key: "tx-due",
77
+ dateField: "due_date",
78
+ titleField: "name",
79
+ subtitleField: "transaction_category",
80
+ label: "Transaction due",
81
+ presentation: { icon: "cash-clock", tone: "info", href: "/finance/{year}/{month}", rank: 30 },
82
+ group: { id: "transactions", label: "Transactions", icon: "cash-clock" },
83
+ },
84
+ indexes: [
85
+ {
86
+ name: "idx_transactions_scope_due_date",
87
+ columns: ["tenant_id", "log_id", "due_date"],
88
+ },
89
+ { name: "idx_transactions_due_date", columns: ["due_date"] },
90
+ {
91
+ name: "idx_transactions_status",
92
+ columns: ["transaction_status"],
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ name: "budget_plan",
98
+ feature: "finance",
99
+ displayName: "Budget plan",
100
+ table: "budget_plans",
101
+ fields: [
102
+ { name: "name", type: "string", required: true },
103
+ ],
104
+ indexes: [
105
+ {
106
+ name: "idx_budget_plans_scope_created",
107
+ columns: ["tenant_id", "log_id", "created_at"],
108
+ },
109
+ ],
110
+ },
111
+ {
112
+ name: "budget_item",
113
+ feature: "finance",
114
+ displayName: "Budget item",
115
+ table: "budget_items",
116
+ fields: [
117
+ {
118
+ name: "plan_id",
119
+ type: "string",
120
+ payloadKey: "planId",
121
+ required: true,
122
+ },
123
+ { name: "name", type: "string", required: true },
124
+ { name: "amount", type: "number", required: true },
125
+ { name: "item_type", type: "string", payloadKey: "itemType", required: true },
126
+ { name: "category", type: "string", default: "other" },
127
+ ],
128
+ indexes: [
129
+ {
130
+ name: "idx_budget_items_scope_plan_type",
131
+ columns: ["tenant_id", "log_id", "plan_id", "item_type", "created_at"],
132
+ },
133
+ ],
134
+ },
135
+ ];