@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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +90 -0
- package/package.json +44 -3
- package/src/attention.ts +41 -0
- package/src/components/finance/AddAssetForm.svelte +58 -0
- package/src/components/finance/AddTransactionForm.svelte +127 -0
- package/src/components/finance/AssetDrawer.svelte +133 -0
- package/src/components/finance/AssetItem.svelte +96 -0
- package/src/components/finance/AssetView.svelte +165 -0
- package/src/components/finance/AssetsSheet.svelte +122 -0
- package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
- package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
- package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
- package/src/components/finance/BudgetItemDrawer.svelte +116 -0
- package/src/components/finance/BudgetItemNameCell.svelte +5 -0
- package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
- package/src/components/finance/BudgetItemsTable.svelte +206 -0
- package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
- package/src/components/finance/BudgetPlans.svelte +419 -0
- package/src/components/finance/EndlessTimeline.svelte +174 -0
- package/src/components/finance/FinanceReadout.svelte +229 -0
- package/src/components/finance/MonthSection.svelte +255 -0
- package/src/components/finance/RiverSlice.svelte +125 -0
- package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
- package/src/components/finance/TimelineRowItem.svelte +319 -0
- package/src/components/finance/TransactionDrawer.svelte +480 -0
- package/src/components/finance/TransactionSelectionBar.svelte +200 -0
- package/src/components/finance/timeline-months.svelte.ts +37 -0
- package/src/components/finance/transaction-draft.ts +121 -0
- package/src/data/assets.ts +48 -0
- package/src/data/budgets.ts +123 -0
- package/src/data/definitions.ts +135 -0
- package/src/data/finance-timeline.ts +305 -0
- package/src/data/forecast.ts +256 -0
- package/src/data/planned-days.ts +57 -0
- package/src/data/repositories.ts +20 -0
- package/src/data/transactions.ts +452 -0
- package/src/finance/categories.ts +29 -0
- package/src/finance/category-pick.ts +8 -0
- package/src/finance/category-suggestions.ts +65 -0
- package/src/finance/floor.ts +29 -0
- package/src/finance/phone-balance.ts +22 -0
- package/src/finance/timeline.ts +113 -0
- package/src/pages/index.svelte +19 -0
- package/src/pages/index.ts +7 -0
- package/src/pages/layout.svelte +13 -0
- package/src/pages/layout.ts +18 -0
- package/src/pages/month.svelte +909 -0
- package/src/pages/month.ts +99 -0
- package/src/server/category-pick.ts +44 -0
- package/src/server/index.ts +11 -0
- package/src/styles.css +12 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { sql, type RawBuilder } from 'kysely';
|
|
2
|
+
import type { ReadModelSession } from '@hearthlog/sdk/data';
|
|
3
|
+
import { withTimeout } from '@hearthlog/sdk/data';
|
|
4
|
+
import type { TimelineKind, TimelineSection, TimelineShow } from '../finance/timeline.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads for the endless Finance timeline. Every running balance, grouping,
|
|
8
|
+
* low point and total is computed here in SQLite; the page only lays the rows
|
|
9
|
+
* out and asks for the months that scroll into view.
|
|
10
|
+
*
|
|
11
|
+
* Balance model: current assets plus every plan. A plan is an active row
|
|
12
|
+
* dated today or later. A row is done once it is marked done or its day has
|
|
13
|
+
* passed: done rows are history that assets already reflect, so they never
|
|
14
|
+
* move a balance. Balances therefore exist from today on only; after the last
|
|
15
|
+
* plan they stay constant.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DB_TIMEOUT_MS = 5000;
|
|
19
|
+
|
|
20
|
+
export interface TimelineRow {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
amount: number;
|
|
24
|
+
date: string;
|
|
25
|
+
category: string;
|
|
26
|
+
status: 'active' | 'archived';
|
|
27
|
+
kind: TimelineKind;
|
|
28
|
+
section: TimelineSection;
|
|
29
|
+
/** Running balance after this row for plans; null for done rows. */
|
|
30
|
+
balanceAfter: number | null;
|
|
31
|
+
/** First visible row of its day within its section. */
|
|
32
|
+
firstOfDay: boolean;
|
|
33
|
+
/** The last plan of the day with the lowest balance ahead. */
|
|
34
|
+
isLow: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TimelineMonth {
|
|
38
|
+
month: string;
|
|
39
|
+
/** Income and expenses of the rows the month's header describes. */
|
|
40
|
+
income: number;
|
|
41
|
+
expense: number;
|
|
42
|
+
/** Projected balance entering the month: available now up to today's month. */
|
|
43
|
+
start: number;
|
|
44
|
+
/** Projected balance after the month's plans. */
|
|
45
|
+
end: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TimelineOutlook {
|
|
49
|
+
available: number;
|
|
50
|
+
/** Lowest end-of-day balance across every plan ahead (today when nothing dips). */
|
|
51
|
+
low: { amount: number; date: string };
|
|
52
|
+
/** Balance after the last plan. */
|
|
53
|
+
end: number;
|
|
54
|
+
/** Day of the last plan; null when nothing is planned. */
|
|
55
|
+
endDate: string | null;
|
|
56
|
+
/** Day of the latest done row, which is ahead of today when marked done early; null when nothing is done. */
|
|
57
|
+
lastDone: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BalanceRange {
|
|
61
|
+
min: number;
|
|
62
|
+
max: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TimelineRowsOptions {
|
|
66
|
+
today: string;
|
|
67
|
+
start: string;
|
|
68
|
+
end: string;
|
|
69
|
+
show?: TimelineShow;
|
|
70
|
+
/** The lowest point is searched from today through this day (all plans when omitted). */
|
|
71
|
+
lowThrough?: string;
|
|
72
|
+
limit?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FAR_FUTURE = '9999-12-31';
|
|
76
|
+
|
|
77
|
+
function toNumber(value: unknown): number {
|
|
78
|
+
const parsed = Number(value ?? 0);
|
|
79
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toNullableNumber(value: unknown): number | null {
|
|
83
|
+
return value === null || value === undefined ? null : toNumber(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shared CTEs: typed transactions and the current asset total. */
|
|
87
|
+
function baseCtes(session: ReadModelSession, today: string): RawBuilder<unknown> {
|
|
88
|
+
return sql`
|
|
89
|
+
typed AS (
|
|
90
|
+
SELECT
|
|
91
|
+
id,
|
|
92
|
+
COALESCE(name, '') AS name,
|
|
93
|
+
amount,
|
|
94
|
+
substr(trim(due_date), 1, 10) AS day,
|
|
95
|
+
COALESCE(NULLIF(transaction_category, ''), 'unknown') AS category,
|
|
96
|
+
CASE WHEN transaction_status = 'archived' THEN 'archived' ELSE 'active' END AS status,
|
|
97
|
+
CASE
|
|
98
|
+
WHEN transaction_status = 'archived' OR substr(trim(due_date), 1, 10) < ${today} THEN 'done'
|
|
99
|
+
ELSE 'planned'
|
|
100
|
+
END AS kind,
|
|
101
|
+
COALESCE(created_at, '') AS created_at
|
|
102
|
+
FROM ${session.table('transactions')} AS transactions
|
|
103
|
+
),
|
|
104
|
+
asset_total AS (
|
|
105
|
+
SELECT COALESCE(SUM(amount), 0) AS amount
|
|
106
|
+
FROM ${session.table('assets')} AS assets
|
|
107
|
+
WHERE asset_type = 'asset'
|
|
108
|
+
)`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** End-of-day balances on every day with plans. Must follow `WITH`. */
|
|
112
|
+
function dayEndCtes(session: ReadModelSession, today: string): RawBuilder<unknown> {
|
|
113
|
+
return sql`
|
|
114
|
+
${baseCtes(session, today)},
|
|
115
|
+
planned_days AS (
|
|
116
|
+
SELECT day, SUM(amount) AS amount FROM typed WHERE kind = 'planned' GROUP BY day
|
|
117
|
+
),
|
|
118
|
+
day_end AS (
|
|
119
|
+
SELECT day, (SELECT amount FROM asset_total) + SUM(amount) OVER (
|
|
120
|
+
ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
121
|
+
) AS balance
|
|
122
|
+
FROM planned_days
|
|
123
|
+
)`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createTimelineRepository(session: ReadModelSession) {
|
|
127
|
+
return {
|
|
128
|
+
/** Ordered rows for a window: done up to Today, then the rest with running balances for plans. */
|
|
129
|
+
async getRows(options: TimelineRowsOptions): Promise<TimelineRow[]> {
|
|
130
|
+
const { today, start, end, show = 'planned', limit } = options;
|
|
131
|
+
const lowThrough = options.lowThrough ?? FAR_FUTURE;
|
|
132
|
+
const result = await withTimeout(
|
|
133
|
+
session.execute(sql<{
|
|
134
|
+
id: string; name: string; amount: unknown; date: string; category: string; status: string;
|
|
135
|
+
kind: TimelineKind; section: unknown; balance_after: unknown; first_of_day: unknown; is_low: unknown;
|
|
136
|
+
}>`
|
|
137
|
+
WITH ${baseCtes(session, today)},
|
|
138
|
+
running AS (
|
|
139
|
+
SELECT
|
|
140
|
+
id,
|
|
141
|
+
day,
|
|
142
|
+
(SELECT amount FROM asset_total) + SUM(amount) OVER (
|
|
143
|
+
ORDER BY day, created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
144
|
+
) AS balance_after,
|
|
145
|
+
ROW_NUMBER() OVER (PARTITION BY day ORDER BY created_at DESC, id DESC) AS from_day_end
|
|
146
|
+
FROM typed
|
|
147
|
+
WHERE kind = 'planned' AND day <= max(${lowThrough}, ${end})
|
|
148
|
+
),
|
|
149
|
+
low_day AS (
|
|
150
|
+
SELECT day FROM running
|
|
151
|
+
WHERE from_day_end = 1 AND day <= ${lowThrough} AND balance_after < (SELECT amount FROM asset_total)
|
|
152
|
+
ORDER BY balance_after ASC, day ASC
|
|
153
|
+
LIMIT 1
|
|
154
|
+
),
|
|
155
|
+
placed AS (
|
|
156
|
+
SELECT
|
|
157
|
+
typed.*,
|
|
158
|
+
CASE WHEN typed.kind = 'done' AND typed.day <= ${today} THEN 0 ELSE 1 END AS section
|
|
159
|
+
FROM typed
|
|
160
|
+
WHERE (${show} = 'all' OR typed.kind = ${show}) AND typed.day >= ${start} AND typed.day <= ${end}
|
|
161
|
+
)
|
|
162
|
+
SELECT
|
|
163
|
+
placed.id,
|
|
164
|
+
placed.name,
|
|
165
|
+
placed.amount,
|
|
166
|
+
placed.day AS date,
|
|
167
|
+
placed.category,
|
|
168
|
+
placed.status,
|
|
169
|
+
placed.kind,
|
|
170
|
+
placed.section,
|
|
171
|
+
running.balance_after,
|
|
172
|
+
ROW_NUMBER() OVER (
|
|
173
|
+
PARTITION BY placed.section, placed.day ORDER BY placed.created_at, placed.id
|
|
174
|
+
) = 1 AS first_of_day,
|
|
175
|
+
CASE WHEN running.from_day_end = 1 AND running.day = (SELECT day FROM low_day)
|
|
176
|
+
THEN 1 ELSE 0 END AS is_low
|
|
177
|
+
FROM placed
|
|
178
|
+
LEFT JOIN running ON running.id = placed.id
|
|
179
|
+
ORDER BY placed.section, placed.day, placed.created_at, placed.id
|
|
180
|
+
${limit ? sql`LIMIT ${limit}` : sql``}
|
|
181
|
+
`),
|
|
182
|
+
DB_TIMEOUT_MS,
|
|
183
|
+
'Timeline query timed out',
|
|
184
|
+
);
|
|
185
|
+
return result.rows.map((row) => ({
|
|
186
|
+
id: String(row.id),
|
|
187
|
+
name: String(row.name ?? ''),
|
|
188
|
+
amount: toNumber(row.amount),
|
|
189
|
+
date: String(row.date),
|
|
190
|
+
category: String(row.category),
|
|
191
|
+
status: row.status === 'archived' ? 'archived' : 'active',
|
|
192
|
+
kind: row.kind,
|
|
193
|
+
section: toNumber(row.section) as TimelineSection,
|
|
194
|
+
balanceAfter: toNullableNumber(row.balance_after),
|
|
195
|
+
firstOfDay: toNumber(row.first_of_day) === 1,
|
|
196
|
+
isLow: toNumber(row.is_low) === 1,
|
|
197
|
+
}));
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Income and expenses, and the projected balance around them, for every
|
|
202
|
+
* month from `from` to `to`. The flows count what the month's header
|
|
203
|
+
* describes: done rows under Done, plans under Planned, and under All
|
|
204
|
+
* done rows before today's month and plans from it on.
|
|
205
|
+
*/
|
|
206
|
+
async getMonths(options: { today: string; from: string; to: string; show?: TimelineShow }): Promise<TimelineMonth[]> {
|
|
207
|
+
const { today, from, to, show = 'planned' } = options;
|
|
208
|
+
const result = await withTimeout(
|
|
209
|
+
session.execute(sql<{ month: string; income: unknown; expense: unknown; start_balance: unknown; end_balance: unknown }>`
|
|
210
|
+
WITH RECURSIVE ${baseCtes(session, today)},
|
|
211
|
+
months(month) AS (
|
|
212
|
+
SELECT substr(${from}, 1, 7)
|
|
213
|
+
UNION ALL
|
|
214
|
+
SELECT strftime('%Y-%m', date(month || '-01', '+1 month')) FROM months
|
|
215
|
+
WHERE month < substr(${to}, 1, 7)
|
|
216
|
+
),
|
|
217
|
+
month_flows AS (
|
|
218
|
+
SELECT
|
|
219
|
+
substr(day, 1, 7) AS month,
|
|
220
|
+
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
|
221
|
+
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense
|
|
222
|
+
FROM typed
|
|
223
|
+
WHERE day >= ${from} AND day <= ${to} AND CASE ${show}
|
|
224
|
+
WHEN 'all' THEN kind = 'planned' OR substr(day, 1, 7) < substr(${today}, 1, 7)
|
|
225
|
+
ELSE kind = ${show}
|
|
226
|
+
END
|
|
227
|
+
GROUP BY substr(day, 1, 7)
|
|
228
|
+
)
|
|
229
|
+
SELECT
|
|
230
|
+
months.month,
|
|
231
|
+
COALESCE(month_flows.income, 0) AS income,
|
|
232
|
+
COALESCE(month_flows.expense, 0) AS expense,
|
|
233
|
+
(SELECT amount FROM asset_total) + COALESCE((
|
|
234
|
+
SELECT SUM(amount) FROM typed WHERE kind = 'planned' AND substr(day, 1, 7) < months.month
|
|
235
|
+
), 0) AS start_balance,
|
|
236
|
+
(SELECT amount FROM asset_total) + COALESCE((
|
|
237
|
+
SELECT SUM(amount) FROM typed WHERE kind = 'planned' AND substr(day, 1, 7) <= months.month
|
|
238
|
+
), 0) AS end_balance
|
|
239
|
+
FROM months
|
|
240
|
+
LEFT JOIN month_flows ON month_flows.month = months.month
|
|
241
|
+
ORDER BY months.month
|
|
242
|
+
`),
|
|
243
|
+
DB_TIMEOUT_MS,
|
|
244
|
+
'Timeline months query timed out',
|
|
245
|
+
);
|
|
246
|
+
return result.rows.map((month) => ({
|
|
247
|
+
month: String(month.month),
|
|
248
|
+
income: toNumber(month.income),
|
|
249
|
+
expense: toNumber(month.expense),
|
|
250
|
+
start: toNumber(month.start_balance),
|
|
251
|
+
end: toNumber(month.end_balance),
|
|
252
|
+
}));
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Available now, the lowest balance across every plan ahead, the balance after the last plan and the latest done day. */
|
|
256
|
+
async getOutlook(options: { today: string }): Promise<TimelineOutlook> {
|
|
257
|
+
const { today } = options;
|
|
258
|
+
const result = await withTimeout(
|
|
259
|
+
session.execute(sql<{ available: unknown; low_amount: unknown; low_date: unknown; end_balance: unknown; end_date: unknown; last_done: unknown }>`
|
|
260
|
+
WITH ${dayEndCtes(session, today)},
|
|
261
|
+
lowest AS (
|
|
262
|
+
SELECT day, balance FROM day_end ORDER BY balance ASC, day ASC LIMIT 1
|
|
263
|
+
)
|
|
264
|
+
SELECT
|
|
265
|
+
(SELECT amount FROM asset_total) AS available,
|
|
266
|
+
CASE WHEN (SELECT balance FROM lowest) < (SELECT amount FROM asset_total)
|
|
267
|
+
THEN (SELECT balance FROM lowest) ELSE (SELECT amount FROM asset_total) END AS low_amount,
|
|
268
|
+
CASE WHEN (SELECT balance FROM lowest) < (SELECT amount FROM asset_total)
|
|
269
|
+
THEN (SELECT day FROM lowest) ELSE ${today} END AS low_date,
|
|
270
|
+
(SELECT amount FROM asset_total) + COALESCE((SELECT SUM(amount) FROM planned_days), 0) AS end_balance,
|
|
271
|
+
(SELECT MAX(day) FROM planned_days) AS end_date,
|
|
272
|
+
(SELECT MAX(day) FROM typed WHERE kind = 'done') AS last_done
|
|
273
|
+
`),
|
|
274
|
+
DB_TIMEOUT_MS,
|
|
275
|
+
'Timeline outlook query timed out',
|
|
276
|
+
);
|
|
277
|
+
const row = result.rows[0];
|
|
278
|
+
return {
|
|
279
|
+
available: toNumber(row?.available),
|
|
280
|
+
low: { amount: toNumber(row?.low_amount), date: row?.low_date ? String(row.low_date) : today },
|
|
281
|
+
end: toNumber(row?.end_balance),
|
|
282
|
+
endDate: row?.end_date ? String(row.end_date) : null,
|
|
283
|
+
lastDone: row?.last_done ? String(row.last_done) : null,
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/** Lowest and highest balance from now through every plan: available now and each plan day's end. */
|
|
288
|
+
async getRange(options: { today: string }): Promise<BalanceRange> {
|
|
289
|
+
const { today } = options;
|
|
290
|
+
const result = await withTimeout(
|
|
291
|
+
session.execute(sql<{ min_balance: unknown; max_balance: unknown }>`
|
|
292
|
+
WITH ${dayEndCtes(session, today)}
|
|
293
|
+
SELECT
|
|
294
|
+
min(COALESCE((SELECT MIN(balance) FROM day_end), amount), amount) AS min_balance,
|
|
295
|
+
max(COALESCE((SELECT MAX(balance) FROM day_end), amount), amount) AS max_balance
|
|
296
|
+
FROM asset_total
|
|
297
|
+
`),
|
|
298
|
+
DB_TIMEOUT_MS,
|
|
299
|
+
'Timeline range query timed out',
|
|
300
|
+
);
|
|
301
|
+
const row = result.rows[0];
|
|
302
|
+
return { min: toNumber(row?.min_balance), max: toNumber(row?.max_balance) };
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ReadModelSession } from '@hearthlog/sdk/data';
|
|
2
|
+
import { withTimeout } from '@hearthlog/sdk/data';
|
|
3
|
+
import { endOfMonth, format, startOfMonth } from 'date-fns';
|
|
4
|
+
import { sql } from 'kysely';
|
|
5
|
+
|
|
6
|
+
export type ForecastPoint = { date: string; amount: number };
|
|
7
|
+
|
|
8
|
+
/** A forecast series plus the summary values charts and widgets render. */
|
|
9
|
+
export interface ForecastSummary {
|
|
10
|
+
/** Full generated series, for charts. */
|
|
11
|
+
points: ForecastPoint[];
|
|
12
|
+
/** Points inside the requested month, for the Today sparkline. */
|
|
13
|
+
monthPoints: ForecastPoint[];
|
|
14
|
+
first: number;
|
|
15
|
+
end: number;
|
|
16
|
+
low: number;
|
|
17
|
+
monthEnd: number;
|
|
18
|
+
monthLow: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ForecastRow {
|
|
22
|
+
date: string;
|
|
23
|
+
amount: number | string | null;
|
|
24
|
+
current_assets: number | string | null;
|
|
25
|
+
forecast_end: number | string | null;
|
|
26
|
+
forecast_low: number | string | null;
|
|
27
|
+
month_end: number | string | null;
|
|
28
|
+
month_low: number | string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FORECAST_ASSET_TYPE = 'asset';
|
|
32
|
+
const DB_TIMEOUT_MS = 5000;
|
|
33
|
+
|
|
34
|
+
function toNumber(value: number | string | bigint | null | undefined): number {
|
|
35
|
+
const parsed = Number(value ?? 0);
|
|
36
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function forecastRowsQuery(
|
|
40
|
+
session: ReadModelSession,
|
|
41
|
+
today: string,
|
|
42
|
+
horizonEnd: string,
|
|
43
|
+
monthStart: string,
|
|
44
|
+
monthEnd: string,
|
|
45
|
+
) {
|
|
46
|
+
return sql<ForecastRow>`
|
|
47
|
+
WITH RECURSIVE days(day) AS (
|
|
48
|
+
SELECT ${today}
|
|
49
|
+
UNION ALL
|
|
50
|
+
SELECT date(day, '+1 day') FROM days WHERE day < ${horizonEnd}
|
|
51
|
+
),
|
|
52
|
+
asset_total AS (
|
|
53
|
+
SELECT COALESCE(SUM(amount), 0) AS amount
|
|
54
|
+
FROM ${session.table('assets')} AS assets
|
|
55
|
+
WHERE asset_type = ${FORECAST_ASSET_TYPE}
|
|
56
|
+
),
|
|
57
|
+
daily_plans AS (
|
|
58
|
+
SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
|
|
59
|
+
FROM ${session.table('transactions')} AS transactions
|
|
60
|
+
WHERE transaction_status = 'active'
|
|
61
|
+
AND due_date >= ${today}
|
|
62
|
+
AND due_date <= ${horizonEnd}
|
|
63
|
+
GROUP BY substr(trim(due_date), 1, 10)
|
|
64
|
+
),
|
|
65
|
+
forecast AS (
|
|
66
|
+
SELECT
|
|
67
|
+
days.day AS date,
|
|
68
|
+
asset_total.amount + COALESCE(
|
|
69
|
+
SUM(COALESCE(daily_plans.amount, 0)) OVER (
|
|
70
|
+
ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
71
|
+
),
|
|
72
|
+
0
|
|
73
|
+
) AS amount,
|
|
74
|
+
asset_total.amount AS current_assets
|
|
75
|
+
FROM days
|
|
76
|
+
CROSS JOIN asset_total
|
|
77
|
+
LEFT JOIN daily_plans ON daily_plans.day = days.day
|
|
78
|
+
)
|
|
79
|
+
SELECT
|
|
80
|
+
date,
|
|
81
|
+
amount,
|
|
82
|
+
current_assets,
|
|
83
|
+
FIRST_VALUE(amount) OVER (ORDER BY date DESC) AS forecast_end,
|
|
84
|
+
MIN(amount) OVER () AS forecast_low,
|
|
85
|
+
(
|
|
86
|
+
SELECT amount FROM forecast AS month_end_forecast
|
|
87
|
+
WHERE month_end_forecast.date >= ${monthStart}
|
|
88
|
+
AND month_end_forecast.date <= ${monthEnd}
|
|
89
|
+
ORDER BY month_end_forecast.date DESC
|
|
90
|
+
LIMIT 1
|
|
91
|
+
) AS month_end,
|
|
92
|
+
(
|
|
93
|
+
SELECT MIN(amount) FROM forecast AS month_low_forecast
|
|
94
|
+
WHERE month_low_forecast.date >= ${monthStart}
|
|
95
|
+
AND month_low_forecast.date <= ${monthEnd}
|
|
96
|
+
) AS month_low
|
|
97
|
+
FROM forecast
|
|
98
|
+
ORDER BY date
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function monthForecastRowsQuery(
|
|
103
|
+
session: ReadModelSession,
|
|
104
|
+
today: string,
|
|
105
|
+
horizonEnd: string,
|
|
106
|
+
monthStart: string,
|
|
107
|
+
monthEnd: string,
|
|
108
|
+
) {
|
|
109
|
+
return sql<Pick<ForecastRow, 'date' | 'amount'>>`
|
|
110
|
+
WITH RECURSIVE days(day) AS (
|
|
111
|
+
SELECT ${today}
|
|
112
|
+
UNION ALL
|
|
113
|
+
SELECT date(day, '+1 day') FROM days WHERE day < ${horizonEnd}
|
|
114
|
+
),
|
|
115
|
+
asset_total AS (
|
|
116
|
+
SELECT COALESCE(SUM(amount), 0) AS amount
|
|
117
|
+
FROM ${session.table('assets')} AS assets
|
|
118
|
+
WHERE asset_type = ${FORECAST_ASSET_TYPE}
|
|
119
|
+
),
|
|
120
|
+
daily_plans AS (
|
|
121
|
+
SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
|
|
122
|
+
FROM ${session.table('transactions')} AS transactions
|
|
123
|
+
WHERE transaction_status = 'active'
|
|
124
|
+
AND due_date >= ${today}
|
|
125
|
+
AND due_date <= ${horizonEnd}
|
|
126
|
+
GROUP BY substr(trim(due_date), 1, 10)
|
|
127
|
+
),
|
|
128
|
+
forecast AS (
|
|
129
|
+
SELECT
|
|
130
|
+
days.day AS date,
|
|
131
|
+
asset_total.amount + COALESCE(
|
|
132
|
+
SUM(COALESCE(daily_plans.amount, 0)) OVER (
|
|
133
|
+
ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
134
|
+
),
|
|
135
|
+
0
|
|
136
|
+
) AS amount
|
|
137
|
+
FROM days
|
|
138
|
+
CROSS JOIN asset_total
|
|
139
|
+
LEFT JOIN daily_plans ON daily_plans.day = days.day
|
|
140
|
+
)
|
|
141
|
+
SELECT date, amount
|
|
142
|
+
FROM forecast
|
|
143
|
+
WHERE date >= ${monthStart}
|
|
144
|
+
AND date <= ${monthEnd}
|
|
145
|
+
ORDER BY date
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function forecastBounds(year: number, month: number, today: string) {
|
|
150
|
+
const requestedMonth = new Date(year, month - 1, 1);
|
|
151
|
+
const requestedMonthEnd = format(endOfMonth(requestedMonth), 'yyyy-MM-dd');
|
|
152
|
+
return {
|
|
153
|
+
horizonEnd: requestedMonthEnd < today ? today : requestedMonthEnd,
|
|
154
|
+
monthStart: format(startOfMonth(requestedMonth), 'yyyy-MM-dd'),
|
|
155
|
+
monthEnd: requestedMonthEnd,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toPoints(rows: Array<Pick<ForecastRow, 'date' | 'amount'>>): ForecastPoint[] {
|
|
160
|
+
return rows.map((row) => ({ date: String(row.date), amount: toNumber(row.amount) }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createForecastRepository(session: ReadModelSession) {
|
|
164
|
+
return {
|
|
165
|
+
async getForecast(year: number, month: number, today: string): Promise<ForecastPoint[]> {
|
|
166
|
+
const { horizonEnd, monthStart, monthEnd } = forecastBounds(year, month, today);
|
|
167
|
+
const result = await withTimeout(
|
|
168
|
+
session.execute(forecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
|
|
169
|
+
DB_TIMEOUT_MS,
|
|
170
|
+
'Forecast query timed out',
|
|
171
|
+
);
|
|
172
|
+
return toPoints(result.rows);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/** Daily points and summary values, filtered and aggregated in SQLite. */
|
|
176
|
+
async getForecastSummary(year: number, month: number, today: string): Promise<ForecastSummary> {
|
|
177
|
+
const { horizonEnd, monthStart, monthEnd } = forecastBounds(year, month, today);
|
|
178
|
+
const [forecastResult, monthResult] = await withTimeout(
|
|
179
|
+
Promise.all([
|
|
180
|
+
session.execute(forecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
|
|
181
|
+
session.execute(monthForecastRowsQuery(session, today, horizonEnd, monthStart, monthEnd)),
|
|
182
|
+
]),
|
|
183
|
+
DB_TIMEOUT_MS,
|
|
184
|
+
'Forecast summary queries timed out',
|
|
185
|
+
);
|
|
186
|
+
const summary = forecastResult.rows[0];
|
|
187
|
+
return {
|
|
188
|
+
points: toPoints(forecastResult.rows),
|
|
189
|
+
monthPoints: toPoints(monthResult.rows),
|
|
190
|
+
first: toNumber(summary?.current_assets),
|
|
191
|
+
end: toNumber(summary?.forecast_end),
|
|
192
|
+
low: toNumber(summary?.forecast_low),
|
|
193
|
+
monthEnd: toNumber(summary?.month_end),
|
|
194
|
+
monthLow: toNumber(summary?.month_low),
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/** Full-year balance points for the static year-wide chart.
|
|
199
|
+
* Past dates show the actual balance (current assets minus all
|
|
200
|
+
* transactions that occurred after that date). Future dates show
|
|
201
|
+
* the forecast (current assets plus active planned transactions). */
|
|
202
|
+
async getYearForecast(year: number, today: string): Promise<ForecastPoint[]> {
|
|
203
|
+
const yearStart = format(new Date(year, 0, 1), 'yyyy-MM-dd');
|
|
204
|
+
const yearEnd = format(new Date(year, 11, 31), 'yyyy-MM-dd');
|
|
205
|
+
const pastEnd = yearEnd < today ? yearEnd : today;
|
|
206
|
+
|
|
207
|
+
const query = sql<Pick<ForecastRow, 'date' | 'amount'>>`
|
|
208
|
+
WITH RECURSIVE days(day) AS (
|
|
209
|
+
SELECT ${yearStart}
|
|
210
|
+
UNION ALL
|
|
211
|
+
SELECT date(day, '+1 day') FROM days WHERE day < ${yearEnd}
|
|
212
|
+
),
|
|
213
|
+
asset_total AS (
|
|
214
|
+
SELECT COALESCE(SUM(amount), 0) AS amount
|
|
215
|
+
FROM ${session.table('assets')} AS assets
|
|
216
|
+
WHERE asset_type = ${FORECAST_ASSET_TYPE}
|
|
217
|
+
),
|
|
218
|
+
year_start_balance AS (
|
|
219
|
+
SELECT
|
|
220
|
+
(SELECT amount FROM asset_total)
|
|
221
|
+
- COALESCE((
|
|
222
|
+
SELECT SUM(amount)
|
|
223
|
+
FROM ${session.table('transactions')}
|
|
224
|
+
WHERE due_date >= ${yearStart} AND due_date <= ${pastEnd}
|
|
225
|
+
), 0) AS start_balance
|
|
226
|
+
),
|
|
227
|
+
daily_effects AS (
|
|
228
|
+
SELECT substr(trim(due_date), 1, 10) AS day, SUM(amount) AS amount
|
|
229
|
+
FROM ${session.table('transactions')} AS transactions
|
|
230
|
+
WHERE
|
|
231
|
+
(due_date >= ${yearStart} AND due_date <= ${pastEnd})
|
|
232
|
+
OR (transaction_status = 'active' AND due_date > ${pastEnd} AND due_date <= ${yearEnd})
|
|
233
|
+
GROUP BY substr(trim(due_date), 1, 10)
|
|
234
|
+
),
|
|
235
|
+
balance AS (
|
|
236
|
+
SELECT
|
|
237
|
+
days.day AS date,
|
|
238
|
+
(SELECT start_balance FROM year_start_balance)
|
|
239
|
+
+ COALESCE(SUM(daily_effects.amount) OVER (
|
|
240
|
+
ORDER BY days.day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
241
|
+
), 0) AS amount
|
|
242
|
+
FROM days
|
|
243
|
+
LEFT JOIN daily_effects ON daily_effects.day = days.day
|
|
244
|
+
)
|
|
245
|
+
SELECT date, amount FROM balance ORDER BY date
|
|
246
|
+
`;
|
|
247
|
+
|
|
248
|
+
const result = await withTimeout(
|
|
249
|
+
session.execute(query),
|
|
250
|
+
DB_TIMEOUT_MS,
|
|
251
|
+
'Year forecast query timed out',
|
|
252
|
+
);
|
|
253
|
+
return toPoints(result.rows);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { withTimeout, type ReadModelSession } from '@hearthlog/sdk/data';
|
|
3
|
+
|
|
4
|
+
const DB_TIMEOUT_MS = 5000;
|
|
5
|
+
|
|
6
|
+
export interface PlannedDay {
|
|
7
|
+
/** `YYYY-MM-DD`. */
|
|
8
|
+
day: string;
|
|
9
|
+
count: number;
|
|
10
|
+
total: number;
|
|
11
|
+
/** The largest plans of the day, comma separated. */
|
|
12
|
+
names: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toCount(value: number | string | null | undefined): number {
|
|
16
|
+
const count = Number(value ?? 0);
|
|
17
|
+
return Number.isFinite(count) ? count : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createPlannedDaysRepository(session: ReadModelSession) {
|
|
21
|
+
return {
|
|
22
|
+
/** Plans (active transactions) per day in `[from, until]`: how many, their net, and the largest names. */
|
|
23
|
+
async plannedDays(from: string, until: string, limit: number): Promise<PlannedDay[]> {
|
|
24
|
+
const result = await withTimeout(
|
|
25
|
+
session.execute(sql<{ day: string; count: number | string; total: number | string | null; names: string | null }>`
|
|
26
|
+
WITH planned AS (
|
|
27
|
+
SELECT SUBSTR(TRIM(t.due_date), 1, 10) AS day,
|
|
28
|
+
COALESCE(NULLIF(TRIM(t.name), ''), 'Unnamed') AS name,
|
|
29
|
+
t.amount AS amount,
|
|
30
|
+
ROW_NUMBER() OVER (
|
|
31
|
+
PARTITION BY SUBSTR(TRIM(t.due_date), 1, 10)
|
|
32
|
+
ORDER BY ABS(t.amount) DESC, t.id
|
|
33
|
+
) AS pos
|
|
34
|
+
FROM ${session.table('transactions')} AS t
|
|
35
|
+
WHERE t.transaction_status = 'active'
|
|
36
|
+
AND SUBSTR(TRIM(t.due_date), 1, 10) >= ${from}
|
|
37
|
+
AND SUBSTR(TRIM(t.due_date), 1, 10) <= ${until}
|
|
38
|
+
), ranked AS (SELECT * FROM planned ORDER BY day, pos)
|
|
39
|
+
SELECT day, COUNT(*) AS count, SUM(amount) AS total,
|
|
40
|
+
GROUP_CONCAT(CASE WHEN pos <= 3 THEN name END, ', ') AS names
|
|
41
|
+
FROM ranked
|
|
42
|
+
GROUP BY day
|
|
43
|
+
ORDER BY day ASC
|
|
44
|
+
LIMIT ${limit}
|
|
45
|
+
`),
|
|
46
|
+
DB_TIMEOUT_MS,
|
|
47
|
+
'Planned days query timed out',
|
|
48
|
+
);
|
|
49
|
+
return result.rows.map((row) => ({
|
|
50
|
+
day: row.day,
|
|
51
|
+
count: toCount(row.count),
|
|
52
|
+
total: Number(row.total ?? 0),
|
|
53
|
+
names: row.names ?? '',
|
|
54
|
+
}));
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Finance's read repositories, added to `queryDatabase` by the composer. */
|
|
2
|
+
|
|
3
|
+
import type { ReadModelSession } from '@hearthlog/sdk/data';
|
|
4
|
+
import { createAssetRepository } from './assets.js';
|
|
5
|
+
import { createBudgetRepository } from './budgets.js';
|
|
6
|
+
import { createTimelineRepository } from './finance-timeline.js';
|
|
7
|
+
import { createForecastRepository } from './forecast.js';
|
|
8
|
+
import { createPlannedDaysRepository } from './planned-days.js';
|
|
9
|
+
import { createTransactionRepository } from './transactions.js';
|
|
10
|
+
|
|
11
|
+
export function createRepositories(session: ReadModelSession) {
|
|
12
|
+
return {
|
|
13
|
+
assets: createAssetRepository(session),
|
|
14
|
+
budgets: createBudgetRepository(session),
|
|
15
|
+
forecasts: createForecastRepository(session),
|
|
16
|
+
plannedDays: createPlannedDaysRepository(session),
|
|
17
|
+
timeline: createTimelineRepository(session),
|
|
18
|
+
transactions: createTransactionRepository(session),
|
|
19
|
+
};
|
|
20
|
+
}
|