@khalido/home 0.0.0-stage → 0.1.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 (217) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -2
  5. package/dist/clock.d.ts +17 -0
  6. package/dist/clock.js +43 -0
  7. package/dist/config.d.ts +273 -0
  8. package/dist/config.js +392 -0
  9. package/dist/connector.d.ts +34 -0
  10. package/dist/connector.js +47 -0
  11. package/dist/connectors/amber/client.d.ts +61 -0
  12. package/dist/connectors/amber/client.js +106 -0
  13. package/dist/connectors/amber/index.d.ts +30 -0
  14. package/dist/connectors/amber/index.js +94 -0
  15. package/dist/connectors/cast/codec.d.ts +38 -0
  16. package/dist/connectors/cast/codec.js +102 -0
  17. package/dist/connectors/cast/index.d.ts +60 -0
  18. package/dist/connectors/cast/index.js +428 -0
  19. package/dist/connectors/cast/schemas.d.ts +167 -0
  20. package/dist/connectors/cast/schemas.js +87 -0
  21. package/dist/connectors/cast/serve.d.ts +18 -0
  22. package/dist/connectors/cast/serve.js +57 -0
  23. package/dist/connectors/cast/state.d.ts +62 -0
  24. package/dist/connectors/cast/state.js +133 -0
  25. package/dist/connectors/cast/tls.d.ts +3 -0
  26. package/dist/connectors/cast/tls.js +67 -0
  27. package/dist/connectors/cast/writes.d.ts +80 -0
  28. package/dist/connectors/cast/writes.js +390 -0
  29. package/dist/connectors/daikin/client.d.ts +65 -0
  30. package/dist/connectors/daikin/client.js +221 -0
  31. package/dist/connectors/daikin/index.d.ts +41 -0
  32. package/dist/connectors/daikin/index.js +108 -0
  33. package/dist/connectors/enphase/client.d.ts +65 -0
  34. package/dist/connectors/enphase/client.js +173 -0
  35. package/dist/connectors/enphase/index.d.ts +43 -0
  36. package/dist/connectors/enphase/index.js +174 -0
  37. package/dist/connectors/enphase/schemas.d.ts +51 -0
  38. package/dist/connectors/enphase/schemas.js +34 -0
  39. package/dist/connectors/hue/client.d.ts +48 -0
  40. package/dist/connectors/hue/client.js +95 -0
  41. package/dist/connectors/hue/events.d.ts +41 -0
  42. package/dist/connectors/hue/events.js +156 -0
  43. package/dist/connectors/hue/index.d.ts +42 -0
  44. package/dist/connectors/hue/index.js +205 -0
  45. package/dist/connectors/hue/schemas.d.ts +65 -0
  46. package/dist/connectors/hue/schemas.js +48 -0
  47. package/dist/connectors/hue/state.d.ts +43 -0
  48. package/dist/connectors/hue/state.js +84 -0
  49. package/dist/connectors/printers/client.d.ts +28 -0
  50. package/dist/connectors/printers/client.js +74 -0
  51. package/dist/connectors/printers/codec.d.ts +76 -0
  52. package/dist/connectors/printers/codec.js +246 -0
  53. package/dist/connectors/printers/found.d.ts +15 -0
  54. package/dist/connectors/printers/found.js +35 -0
  55. package/dist/connectors/printers/index.d.ts +52 -0
  56. package/dist/connectors/printers/index.js +282 -0
  57. package/dist/connectors/printers/state.d.ts +60 -0
  58. package/dist/connectors/printers/state.js +99 -0
  59. package/dist/connectors/spotify/changes.d.ts +32 -0
  60. package/dist/connectors/spotify/changes.js +84 -0
  61. package/dist/connectors/spotify/client.d.ts +199 -0
  62. package/dist/connectors/spotify/client.js +270 -0
  63. package/dist/connectors/spotify/index.d.ts +92 -0
  64. package/dist/connectors/spotify/index.js +754 -0
  65. package/dist/connectors/up/client.d.ts +183 -0
  66. package/dist/connectors/up/client.js +308 -0
  67. package/dist/connectors/up/index.d.ts +91 -0
  68. package/dist/connectors/up/index.js +213 -0
  69. package/dist/connectors/up/spend.d.ts +78 -0
  70. package/dist/connectors/up/spend.js +213 -0
  71. package/dist/connectors/up/webhook.d.ts +13 -0
  72. package/dist/connectors/up/webhook.js +32 -0
  73. package/dist/connectors/weather/blocks.d.ts +51 -0
  74. package/dist/connectors/weather/blocks.js +106 -0
  75. package/dist/connectors/weather/bom.d.ts +37 -0
  76. package/dist/connectors/weather/bom.js +237 -0
  77. package/dist/connectors/weather/ftp.d.ts +5 -0
  78. package/dist/connectors/weather/ftp.js +46 -0
  79. package/dist/connectors/weather/google.d.ts +31 -0
  80. package/dist/connectors/weather/google.js +364 -0
  81. package/dist/connectors/weather/index.d.ts +53 -0
  82. package/dist/connectors/weather/index.js +280 -0
  83. package/dist/connectors/weather/open-meteo.d.ts +15 -0
  84. package/dist/connectors/weather/open-meteo.js +285 -0
  85. package/dist/connectors/weather/rain.d.ts +19 -0
  86. package/dist/connectors/weather/rain.js +51 -0
  87. package/dist/connectors/weather/source.d.ts +149 -0
  88. package/dist/connectors/weather/source.js +77 -0
  89. package/dist/connectors/weather/sun.d.ts +48 -0
  90. package/dist/connectors/weather/sun.js +65 -0
  91. package/dist/discover.d.ts +197 -0
  92. package/dist/discover.js +452 -0
  93. package/dist/energy.d.ts +61 -0
  94. package/dist/energy.js +162 -0
  95. package/dist/finders-entry.d.ts +1 -0
  96. package/dist/finders-entry.js +2 -0
  97. package/dist/finders.d.ts +48 -0
  98. package/dist/finders.js +176 -0
  99. package/dist/health.d.ts +28 -0
  100. package/dist/health.js +35 -0
  101. package/dist/home.d.ts +81 -0
  102. package/dist/home.js +114 -0
  103. package/dist/index.d.ts +11 -0
  104. package/dist/index.js +11 -0
  105. package/dist/lane.d.ts +17 -0
  106. package/dist/lane.js +74 -0
  107. package/dist/locate.d.ts +27 -0
  108. package/dist/locate.js +59 -0
  109. package/dist/manifest.d.ts +31 -0
  110. package/dist/manifest.js +4 -0
  111. package/dist/manifests.d.ts +5 -0
  112. package/dist/manifests.js +23 -0
  113. package/dist/node-fetch.d.ts +9 -0
  114. package/dist/node-fetch.js +25 -0
  115. package/dist/poll.d.ts +10 -0
  116. package/dist/poll.js +33 -0
  117. package/docs/cast.md +133 -0
  118. package/docs/cli.md +41 -0
  119. package/docs/config.md +36 -0
  120. package/docs/decisions.md +150 -0
  121. package/docs/devices.md +128 -0
  122. package/docs/enphase-notes.md +24 -0
  123. package/docs/google.md +92 -0
  124. package/docs/ideas.md +69 -0
  125. package/docs/matter.md +55 -0
  126. package/docs/performance.md +12 -0
  127. package/docs/philosophy.md +23 -0
  128. package/docs/up.md +84 -0
  129. package/docs/using.md +65 -0
  130. package/docs/weather-probe.md +63 -0
  131. package/examples/home.jsonc +74 -0
  132. package/examples/home.svg +76 -0
  133. package/home.schema.json +474 -0
  134. package/llms.txt +32 -0
  135. package/package.json +143 -4
  136. package/skills/draw-home/README.md +25 -0
  137. package/skills/draw-home/SKILL.md +80 -0
  138. package/skills/use-home/SKILL.md +86 -0
  139. package/src/cli/cache.ts +29 -0
  140. package/src/cli/cast.ts +172 -0
  141. package/src/cli/config.ts +52 -0
  142. package/src/cli/context.ts +118 -0
  143. package/src/cli/discover.ts +182 -0
  144. package/src/cli/enphase.ts +62 -0
  145. package/src/cli/hue.ts +124 -0
  146. package/src/cli/locate.ts +47 -0
  147. package/src/cli/main.ts +182 -0
  148. package/src/cli/printers.ts +21 -0
  149. package/src/cli/probe.ts +274 -0
  150. package/src/cli/spotify.ts +76 -0
  151. package/src/cli/status.ts +228 -0
  152. package/src/cli/up.ts +350 -0
  153. package/src/clock.ts +62 -0
  154. package/src/config.ts +578 -0
  155. package/src/connector.ts +77 -0
  156. package/src/connectors/amber/README.md +40 -0
  157. package/src/connectors/amber/client.ts +157 -0
  158. package/src/connectors/amber/index.ts +118 -0
  159. package/src/connectors/cast/README.md +44 -0
  160. package/src/connectors/cast/codec.ts +126 -0
  161. package/src/connectors/cast/index.ts +552 -0
  162. package/src/connectors/cast/schemas.ts +101 -0
  163. package/src/connectors/cast/serve.ts +82 -0
  164. package/src/connectors/cast/state.ts +203 -0
  165. package/src/connectors/cast/tls.ts +100 -0
  166. package/src/connectors/cast/writes.ts +572 -0
  167. package/src/connectors/daikin/README.md +78 -0
  168. package/src/connectors/daikin/client.ts +295 -0
  169. package/src/connectors/daikin/index.ts +145 -0
  170. package/src/connectors/enphase/README.md +64 -0
  171. package/src/connectors/enphase/client.ts +244 -0
  172. package/src/connectors/enphase/index.ts +241 -0
  173. package/src/connectors/enphase/schemas.ts +45 -0
  174. package/src/connectors/hue/README.md +50 -0
  175. package/src/connectors/hue/client.ts +157 -0
  176. package/src/connectors/hue/events.ts +182 -0
  177. package/src/connectors/hue/index.ts +261 -0
  178. package/src/connectors/hue/schemas.ts +60 -0
  179. package/src/connectors/hue/state.ts +124 -0
  180. package/src/connectors/printers/README.md +46 -0
  181. package/src/connectors/printers/client.ts +114 -0
  182. package/src/connectors/printers/codec.ts +292 -0
  183. package/src/connectors/printers/found.ts +46 -0
  184. package/src/connectors/printers/index.ts +385 -0
  185. package/src/connectors/printers/state.ts +182 -0
  186. package/src/connectors/spotify/README.md +83 -0
  187. package/src/connectors/spotify/changes.ts +127 -0
  188. package/src/connectors/spotify/client.ts +387 -0
  189. package/src/connectors/spotify/index.ts +937 -0
  190. package/src/connectors/up/README.md +35 -0
  191. package/src/connectors/up/client.ts +518 -0
  192. package/src/connectors/up/index.ts +370 -0
  193. package/src/connectors/up/spend.ts +280 -0
  194. package/src/connectors/up/webhook.ts +62 -0
  195. package/src/connectors/weather/README.md +84 -0
  196. package/src/connectors/weather/blocks.ts +173 -0
  197. package/src/connectors/weather/bom.ts +344 -0
  198. package/src/connectors/weather/ftp.ts +48 -0
  199. package/src/connectors/weather/google.ts +425 -0
  200. package/src/connectors/weather/index.ts +396 -0
  201. package/src/connectors/weather/open-meteo.ts +309 -0
  202. package/src/connectors/weather/rain.ts +73 -0
  203. package/src/connectors/weather/source.ts +236 -0
  204. package/src/connectors/weather/sun.ts +114 -0
  205. package/src/discover.ts +638 -0
  206. package/src/energy.ts +221 -0
  207. package/src/finders-entry.ts +2 -0
  208. package/src/finders.ts +250 -0
  209. package/src/health.ts +74 -0
  210. package/src/home.ts +205 -0
  211. package/src/index.ts +62 -0
  212. package/src/lane.ts +110 -0
  213. package/src/locate.ts +85 -0
  214. package/src/manifest.ts +43 -0
  215. package/src/manifests.ts +27 -0
  216. package/src/node-fetch.ts +41 -0
  217. package/src/poll.ts +33 -0
@@ -0,0 +1,370 @@
1
+ import { z } from "zod";
2
+ import { asOf, type Clock, realClock } from "../../clock.ts";
3
+ import { errorText } from "../../connector.ts";
4
+ import { envName, type Integration } from "../../home.ts";
5
+ import type { Manifest } from "../../manifest.ts";
6
+ import { poller } from "../../poll.ts";
7
+ import {
8
+ type Category,
9
+ type Transaction,
10
+ type TransactionQuery,
11
+ type UpClient,
12
+ upClient,
13
+ UpError,
14
+ } from "./client.ts";
15
+ import {
16
+ anomalies,
17
+ dollars,
18
+ type Household,
19
+ inHousehold,
20
+ isSpend,
21
+ type MerchantSpend,
22
+ type SpendSummary,
23
+ spendByCategory,
24
+ spendByMerchant,
25
+ uncategorised,
26
+ unknownCategories,
27
+ } from "./spend.ts";
28
+
29
+ export {
30
+ type Account,
31
+ type Category,
32
+ type Transaction,
33
+ type TransactionQuery,
34
+ type UpClient,
35
+ upClient,
36
+ UpError,
37
+ type Webhook,
38
+ type WebhookLog,
39
+ } from "./client.ts";
40
+ export {
41
+ type CategorySpend,
42
+ dollars,
43
+ type Household,
44
+ inHousehold,
45
+ isSpend,
46
+ merchantKey,
47
+ type MerchantSpend,
48
+ notSpend,
49
+ type Recurring,
50
+ recurring,
51
+ type SpendSummary,
52
+ spendByCategory,
53
+ spendByMerchant,
54
+ uncategorised,
55
+ type Unusual,
56
+ unusual,
57
+ type Window,
58
+ } from "./spend.ts";
59
+ export {
60
+ parseWebhookEvent,
61
+ SIGNATURE_HEADER,
62
+ verifyWebhook,
63
+ type WebhookEvent,
64
+ } from "./webhook.ts";
65
+
66
+ /** Poll every 15 minutes: spending moves slowly, and a webhook can nudge a read sooner. */
67
+ export const POLL_MS = 15 * 60_000;
68
+
69
+ const ACCOUNT_TYPES = ["TRANSACTIONAL", "SAVER", "HOME_LOAN"] as const;
70
+ const OWNERSHIP_TYPES = ["INDIVIDUAL", "JOINT"] as const;
71
+
72
+ /** Which accounts to read: by type and ownership, never by id (e.g. the joint everyday account). All of them when left out. */
73
+ const AccountFilter = z.object({
74
+ accountType: z.enum(ACCOUNT_TYPES).optional(),
75
+ ownershipType: z.enum(OWNERSHIP_TYPES).optional(),
76
+ });
77
+ export type AccountFilter = z.infer<typeof AccountFilter>;
78
+
79
+ /** Up in a home config: the token's env name, and which accounts count as the household's. */
80
+ export const configSchema = z.looseObject({
81
+ connector: z.literal("up"),
82
+ key: envName.describe("env name holding the Up personal access token"),
83
+ account: AccountFilter.optional().describe(
84
+ "which accounts to read, by type and ownership; every account when left out",
85
+ ),
86
+ household: z
87
+ .object({
88
+ categories: z.array(z.string()).optional(),
89
+ tags: z.array(z.string()).optional(),
90
+ })
91
+ .optional()
92
+ .describe(
93
+ "what counts as the household's: Up category ids (a parent takes in its children, e.g. home) and tag labels; everything when left out",
94
+ ),
95
+ });
96
+
97
+ export type UpConfig = {
98
+ token: string;
99
+ account?: AccountFilter;
100
+ household?: Household;
101
+ fetch: typeof fetch;
102
+ clock?: Clock;
103
+ timeZone?: string;
104
+ }; // timeZone: where a month starts (IANA), the runtime's by default
105
+
106
+ /** One month's spend: Up's figures through `spendByCategory`, plus the top merchants. */
107
+ export type MonthSpend = SpendSummary & {
108
+ month: string;
109
+ topMerchants: MerchantSpend[];
110
+ }; // month: "2026-10" in the home's zone
111
+
112
+ /** A transaction as the snapshot keeps it: no raw text, message, note, who spent, account or balance. */
113
+ export type RecentTransaction = Pick<
114
+ Transaction,
115
+ | "id"
116
+ | "createdAt"
117
+ | "description"
118
+ | "amountCents"
119
+ | "categoryId"
120
+ | "parentCategoryId"
121
+ | "status"
122
+ | "tags"
123
+ >;
124
+
125
+ export type UpSnapshot = {
126
+ asOf: string;
127
+ thisMonth: MonthSpend; // month to date
128
+ lastMonth: MonthSpend;
129
+ uncategorised: string[]; // ids of spend, both months, with no category yet
130
+ recent: RecentTransaction[]; // the latest 20 purchases: movements between your own accounts would name them
131
+ accounts: number; // how many accounts the filter matched; 0 without a filter (every account)
132
+ household?: Household; // what the totals and recent rows count; everything when absent
133
+ truncated: boolean; // the read stopped at its page cap: totals are short
134
+ };
135
+
136
+ /** Transactions from the accounts a filter names (every account without one), newest first. The one read the integration and `home up` share. */
137
+ export async function readTransactions(
138
+ client: UpClient,
139
+ account: AccountFilter | undefined,
140
+ query: TransactionQuery = {},
141
+ signal?: AbortSignal,
142
+ ) {
143
+ const accounts = account ? await client.accounts(account, signal) : undefined;
144
+ if (accounts && !accounts.length)
145
+ throw new UpError(
146
+ `no Up account is ${[account!.ownershipType, account!.accountType].filter(Boolean).join(" ").toLowerCase()}`,
147
+ );
148
+ const reads = await Promise.all(
149
+ (accounts ?? [undefined]).map((a) =>
150
+ client.transactions({ ...query, accountId: a?.id }, signal),
151
+ ),
152
+ );
153
+ const transactions = reads
154
+ .flatMap((r) => r.transactions)
155
+ .sort((a, b) => ((a.createdAt ?? "") < (b.createdAt ?? "") ? 1 : -1));
156
+ return {
157
+ transactions,
158
+ truncated: reads.some((r) => r.truncated),
159
+ accounts: accounts?.length ?? 0,
160
+ };
161
+ }
162
+
163
+ /** "2026-10" for each instant, in a time zone. */
164
+ const monthIn = (timeZone?: string) => {
165
+ const f = new Intl.DateTimeFormat("en-CA", {
166
+ timeZone,
167
+ year: "numeric",
168
+ month: "2-digit",
169
+ });
170
+ return (ms: number) => f.format(ms).slice(0, 7);
171
+ };
172
+ const monthBefore = (month: string) => {
173
+ const [y, m] = month.split("-").map(Number) as [number, number];
174
+ return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
175
+ };
176
+
177
+ /** The snapshot from two months of transactions: pure, so tests and a consumer's own reads build the same thing. */
178
+ export function toSnapshot(
179
+ txs: Transaction[],
180
+ categories: Category[],
181
+ at: {
182
+ now: number;
183
+ asOf: string;
184
+ timeZone?: string;
185
+ accounts: number;
186
+ truncated: boolean;
187
+ household?: Household;
188
+ },
189
+ ): UpSnapshot {
190
+ const monthOf = monthIn(at.timeZone);
191
+ const thisMonth = monthOf(at.now);
192
+ const lastMonth = monthBefore(thisMonth);
193
+ const shared = txs.filter((t) => inHousehold(t, at.household));
194
+ const byMonth = Map.groupBy(shared, (t) =>
195
+ t.createdAt === undefined ? "" : monthOf(Date.parse(t.createdAt)),
196
+ );
197
+ const of = (month: string) => byMonth.get(month) ?? [];
198
+ const month = (m: string): MonthSpend => ({
199
+ month: m,
200
+ ...spendByCategory(of(m), categories),
201
+ topMerchants: spendByMerchant(of(m)).slice(0, 5),
202
+ });
203
+ // To categorise looks at every purchase in the accounts: a category is how one joins the household.
204
+ const both = txs.filter(
205
+ (t) =>
206
+ t.createdAt !== undefined &&
207
+ [thisMonth, lastMonth].includes(monthOf(Date.parse(t.createdAt))),
208
+ );
209
+ return {
210
+ asOf: at.asOf,
211
+ thisMonth: month(thisMonth),
212
+ lastMonth: month(lastMonth),
213
+ uncategorised: uncategorised(both).map((t) => t.id),
214
+ recent: shared
215
+ .filter(isSpend)
216
+ .slice(0, 20)
217
+ .map(
218
+ ({
219
+ id,
220
+ createdAt,
221
+ description,
222
+ amountCents,
223
+ categoryId,
224
+ parentCategoryId,
225
+ status,
226
+ tags,
227
+ }) => ({
228
+ id,
229
+ createdAt,
230
+ description,
231
+ amountCents,
232
+ categoryId,
233
+ parentCategoryId,
234
+ status,
235
+ tags,
236
+ }),
237
+ ),
238
+ accounts: at.accounts,
239
+ household: at.household,
240
+ truncated: at.truncated,
241
+ };
242
+ }
243
+
244
+ /** Up as a home integration: two months of transactions now, then every 15 minutes. Reads only; it never writes. */
245
+ export function up(config: UpConfig): Integration<UpSnapshot, "up"> {
246
+ const client = upClient(config);
247
+ const clock = config.clock ?? realClock;
248
+ const poll = poller(clock);
249
+ return {
250
+ name: "up",
251
+ refresh: poll.nudge,
252
+ async run(signal, update) {
253
+ let last: UpSnapshot | undefined;
254
+ while (!signal.aborted) {
255
+ try {
256
+ const now = clock.now();
257
+ // 63 days back covers all of last month from any day of this one; months are cut in the home's zone below.
258
+ const since = new Date(now - 63 * 86_400_000).toISOString();
259
+ const categories = await client.categories(signal);
260
+ const read = await readTransactions(
261
+ client,
262
+ config.account,
263
+ { since },
264
+ signal,
265
+ );
266
+ last = toSnapshot(read.transactions, categories, {
267
+ now,
268
+ asOf: asOf(clock),
269
+ timeZone: config.timeZone,
270
+ accounts: read.accounts,
271
+ truncated: read.truncated,
272
+ household: config.household,
273
+ });
274
+ const odd = [
275
+ ...anomalies(read.transactions),
276
+ ...unknownCategories(config.household, categories),
277
+ last.truncated
278
+ ? "read stopped at its page cap; totals are short"
279
+ : "",
280
+ ].filter(Boolean);
281
+ update({
282
+ status: "connected",
283
+ anomaly: odd.length ? odd.join("; ") : undefined,
284
+ state: last,
285
+ });
286
+ } catch (error) {
287
+ if (signal.aborted) return; // stopped mid-read: not a failure
288
+ if (error instanceof UpError && error.status === 401)
289
+ return update({
290
+ status: "unavailable",
291
+ detail: "Up rejected the token",
292
+ state: last,
293
+ });
294
+ update({
295
+ status: "disconnected",
296
+ detail: errorText(error),
297
+ state: last,
298
+ });
299
+ }
300
+ await poll.wait(POLL_MS, signal);
301
+ }
302
+ },
303
+ };
304
+ }
305
+
306
+ const MONTHS = [
307
+ "Jan",
308
+ "Feb",
309
+ "Mar",
310
+ "Apr",
311
+ "May",
312
+ "Jun",
313
+ "Jul",
314
+ "Aug",
315
+ "Sep",
316
+ "Oct",
317
+ "Nov",
318
+ "Dec",
319
+ ];
320
+ const monthName = (month: string) =>
321
+ MONTHS[Number(month.slice(5, 7)) - 1] ?? month;
322
+
323
+ /** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
324
+ export function upText(s: UpSnapshot): string {
325
+ return [
326
+ `${monthName(s.thisMonth.month)} so far ${dollars(s.thisMonth.cents)}`,
327
+ `${monthName(s.lastMonth.month)} ${dollars(s.lastMonth.cents)}`,
328
+ s.uncategorised.length ? `${s.uncategorised.length} to categorise` : "",
329
+ ]
330
+ .filter(Boolean)
331
+ .join(" · ");
332
+ }
333
+
334
+ /** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
335
+ export const categoryLines = (m: MonthSpend) =>
336
+ m.byCategory
337
+ .slice(0, 5)
338
+ .map(
339
+ (p) =>
340
+ `${p.name ?? p.id} ${dollars(p.cents)}${p.children.length ? `: ${p.children.map((c) => `${(c.name ?? c.id).toLowerCase()} ${dollars(c.cents)}`).join(", ")}` : ""}`,
341
+ );
342
+
343
+ export const manifest: Manifest<UpSnapshot> = {
344
+ name: "up",
345
+ title: "Up Bank",
346
+ iotClass: "cloud_polling",
347
+ pollSeconds: POLL_MS / 1000,
348
+ access: "cloud HTTPS, UP_TOKEN; reads only, writes are `home up` verbs",
349
+ env: [
350
+ {
351
+ name: "UP_TOKEN",
352
+ example: "up:yeah:xxxxxxxx",
353
+ what: "Up personal access token (api.up.com.au/getting_started)",
354
+ },
355
+ {
356
+ name: "UP_ACCOUNT",
357
+ example: "JOINT TRANSACTIONAL",
358
+ optional: true,
359
+ what: "which accounts count, by ownership and type; every account when unset",
360
+ },
361
+ {
362
+ name: "UP_HOUSEHOLD",
363
+ example: "home tag:home",
364
+ optional: true,
365
+ what: "what counts as the household's: Up category ids and tag:labels; everything when unset",
366
+ },
367
+ ],
368
+ lines: ({ state }) =>
369
+ state ? [upText(state), ...categoryLines(state.thisMonth)] : [],
370
+ };
@@ -0,0 +1,280 @@
1
+ import type { Category, Transaction } from "./client.ts";
2
+
3
+ // Pure helpers over transactions, one owner per number. Amounts are cents; spend is reported as a positive number.
4
+
5
+ /**
6
+ * Up's own movements between your accounts that carry no `transferAccount` (unconfirmed: matched by description, the
7
+ * way up-finance-skills does). Most movements do carry it and are caught by that field.
8
+ */
9
+ const OWN_MOVEMENT = [
10
+ /^cover from /i,
11
+ /^forward to /i,
12
+ /^round up\b/i,
13
+ /^quick save transfer/i,
14
+ ]; // not "Transfer to …": paying someone else is spend
15
+
16
+ /** Why a transaction is not spend, or undefined when it is. The one place the rule lives. */
17
+ export function notSpend(t: Transaction): string | undefined {
18
+ if (t.status !== "SETTLED") return "held";
19
+ if (t.amountCents === undefined) return "no amount";
20
+ if (t.amountCents >= 0) return "money in";
21
+ if (t.transferAccountId) return "transfer between your accounts";
22
+ if (t.description && OWN_MOVEMENT.some((r) => r.test(t.description!)))
23
+ return "cover, forward or round-up";
24
+ return undefined;
25
+ }
26
+
27
+ /** Settled money out to someone else: not held, not a transfer, cover, forward or round-up between your own accounts. */
28
+ export const isSpend = (t: Transaction) => notSpend(t) === undefined;
29
+
30
+ /** Money spent, as a positive number of cents. */
31
+ const spent = (t: Transaction) => -(t.amountCents ?? 0);
32
+
33
+ /** A window of time by the transaction's creation, `[since, until)` as ISO strings; either end open. */
34
+ export type Window = { since?: string; until?: string };
35
+ const inWindow = (t: Transaction, w: Window = {}) => {
36
+ if (!w.since && !w.until) return true;
37
+ const at = Date.parse(t.createdAt ?? ""); // Up's times carry an offset (+11:00); compare instants, not text
38
+ return (
39
+ (!w.since || at >= Date.parse(w.since)) &&
40
+ (!w.until || at < Date.parse(w.until))
41
+ );
42
+ };
43
+
44
+ /** Money out that the rule leaves out (a transfer, cover, forward or round-up), counted so a total can be checked. */
45
+ const MOVED = new Set([
46
+ "transfer between your accounts",
47
+ "cover, forward or round-up",
48
+ ]);
49
+
50
+ export type CategorySpend = {
51
+ id: string;
52
+ name?: string;
53
+ cents: number;
54
+ count: number;
55
+ children: { id: string; name?: string; cents: number; count: number }[];
56
+ };
57
+ export type SpendSummary = {
58
+ cents: number; // spend in the window
59
+ count: number;
60
+ excluded: { count: number; cents: number }; // money out that is not spend (transfers, covers, round-ups), so the total can be checked
61
+ uncategorisedCents: number;
62
+ byCategory: CategorySpend[]; // parents, largest first, each with its children
63
+ };
64
+
65
+ /** Spend in a window grouped by Up's parent and child categories, with what was excluded counted and summed. */
66
+ export function spendByCategory(
67
+ txs: Transaction[],
68
+ categories: Category[],
69
+ window?: Window,
70
+ ): SpendSummary {
71
+ const name = new Map(categories.map((c) => [c.id, c.name]));
72
+ const parentOf = new Map(categories.map((c) => [c.id, c.parentId]));
73
+ const parents = new Map<string, CategorySpend>();
74
+ const summary: SpendSummary = {
75
+ cents: 0,
76
+ count: 0,
77
+ excluded: { count: 0, cents: 0 },
78
+ uncategorisedCents: 0,
79
+ byCategory: [],
80
+ };
81
+ for (const t of txs) {
82
+ if (!inWindow(t, window)) continue;
83
+ const why = notSpend(t);
84
+ if (why) {
85
+ if (MOVED.has(why))
86
+ (summary.excluded.count++, (summary.excluded.cents += spent(t)));
87
+ continue;
88
+ }
89
+ summary.cents += spent(t);
90
+ summary.count++;
91
+ if (!t.categoryId) {
92
+ summary.uncategorisedCents += spent(t);
93
+ continue;
94
+ }
95
+ const parentId =
96
+ t.parentCategoryId ?? parentOf.get(t.categoryId) ?? t.categoryId;
97
+ const p = parents.get(parentId) ?? {
98
+ id: parentId,
99
+ name: name.get(parentId),
100
+ cents: 0,
101
+ count: 0,
102
+ children: [],
103
+ };
104
+ parents.set(parentId, p);
105
+ p.cents += spent(t);
106
+ p.count++;
107
+ if (t.categoryId !== parentId) {
108
+ let c = p.children.find((x) => x.id === t.categoryId);
109
+ if (!c)
110
+ p.children.push(
111
+ (c = {
112
+ id: t.categoryId,
113
+ name: name.get(t.categoryId),
114
+ cents: 0,
115
+ count: 0,
116
+ }),
117
+ );
118
+ c.cents += spent(t);
119
+ c.count++;
120
+ }
121
+ }
122
+ summary.byCategory = [...parents.values()].sort((a, b) => b.cents - a.cents);
123
+ for (const p of summary.byCategory)
124
+ p.children.sort((a, b) => b.cents - a.cents);
125
+ return summary;
126
+ }
127
+
128
+ /** A merchant's name as grouped: lower case, digits and punctuation dropped ("WOOLWORTHS 1234" and "Woolworths" are one); a name with no letters stays itself. */
129
+ export const merchantKey = (description = "") =>
130
+ description
131
+ .toLowerCase()
132
+ .replace(/[^\p{L} ]+/gu, " ")
133
+ .replace(/\s+/g, " ")
134
+ .trim() || description;
135
+
136
+ export type MerchantSpend = { merchant: string; cents: number; count: number };
137
+
138
+ /** Spend per merchant, largest first; the merchant shown as its latest description. */
139
+ export function spendByMerchant(
140
+ txs: Transaction[],
141
+ window?: Window,
142
+ ): MerchantSpend[] {
143
+ const by = new Map<string, MerchantSpend>();
144
+ for (const t of txs) {
145
+ if (!inWindow(t, window) || !isSpend(t)) continue;
146
+ const key = merchantKey(t.description);
147
+ const m = by.get(key) ?? {
148
+ merchant: t.description ?? "?",
149
+ cents: 0,
150
+ count: 0,
151
+ };
152
+ by.set(key, m);
153
+ m.cents += spent(t);
154
+ m.count++;
155
+ }
156
+ return [...by.values()].sort((a, b) => b.cents - a.cents);
157
+ }
158
+
159
+ /** What the household shares: Up category ids (a parent takes in its children) and tag labels. */
160
+ export type Household = { categories?: string[]; tags?: string[] };
161
+
162
+ /** The one rule for what counts as the household's: a listed category or its parent, or a listed tag. Everything counts without a list. */
163
+ export function inHousehold(t: Transaction, h: Household | undefined): boolean {
164
+ if (!h?.categories?.length && !h?.tags?.length) return true;
165
+ const byCategory =
166
+ h.categories?.some((c) => c === t.categoryId || c === t.parentCategoryId) ??
167
+ false;
168
+ return byCategory || (t.tags?.some((tag) => h.tags?.includes(tag)) ?? false);
169
+ }
170
+
171
+ /** Household categories Up does not have, said in words: a typo would otherwise count nothing, silently. */
172
+ export const unknownCategories = (
173
+ h: Household | undefined,
174
+ categories: Category[],
175
+ ) =>
176
+ (h?.categories ?? [])
177
+ .filter((c) => !categories.some((k) => k.id === c))
178
+ .map((c) => `household category ${c} is not an Up category`);
179
+
180
+ /** Spend Up lets you categorise that has no category yet. Money in (salary, a payment received) is categorisable too, but is not spend. */
181
+ export const uncategorised = (txs: Transaction[]) =>
182
+ txs.filter((t) => t.isCategorizable && !t.categoryId && isSpend(t));
183
+
184
+ const median = (xs: number[]) => {
185
+ const s = [...xs].sort((a, b) => a - b);
186
+ const m = Math.floor(s.length / 2);
187
+ return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
188
+ };
189
+
190
+ export type Recurring = {
191
+ merchant: string;
192
+ months: number;
193
+ medianCents: number;
194
+ lastAt?: string;
195
+ };
196
+
197
+ /**
198
+ * A heuristic, since Up has no subscription endpoint: the same merchant in three or more distinct months, every amount
199
+ * within 20% of that merchant's median.
200
+ */
201
+ export function recurring(txs: Transaction[], timeZone?: string): Recurring[] {
202
+ const month = new Intl.DateTimeFormat("en-CA", {
203
+ timeZone,
204
+ year: "numeric",
205
+ month: "2-digit",
206
+ });
207
+ const by = new Map<string, Transaction[]>();
208
+ for (const t of txs) {
209
+ if (!isSpend(t) || !t.createdAt) continue;
210
+ const key = merchantKey(t.description);
211
+ by.set(key, [...(by.get(key) ?? []), t]);
212
+ }
213
+ const out: Recurring[] = [];
214
+ for (const list of by.values()) {
215
+ const months = new Set(
216
+ list.map((t) => month.format(new Date(t.createdAt!))),
217
+ );
218
+ if (months.size < 3) continue;
219
+ const mid = median(list.map(spent));
220
+ if (!list.every((t) => Math.abs(spent(t) - mid) <= mid * 0.2)) continue;
221
+ const latest = list.reduce((a, b) => (a.createdAt! > b.createdAt! ? a : b));
222
+ out.push({
223
+ merchant: latest.description ?? "?",
224
+ months: months.size,
225
+ medianCents: mid,
226
+ lastAt: latest.createdAt,
227
+ });
228
+ }
229
+ return out.sort((a, b) => b.medianCents - a.medianCents);
230
+ }
231
+
232
+ export type Unusual = {
233
+ id: string;
234
+ merchant: string;
235
+ cents: number;
236
+ medianCents: number;
237
+ at?: string;
238
+ };
239
+
240
+ /** Spend well above that merchant's usual: more than twice the median of at least three earlier purchases there. */
241
+ export function unusual(txs: Transaction[]): Unusual[] {
242
+ const sorted = txs
243
+ .filter((t) => isSpend(t) && t.createdAt)
244
+ .sort((a, b) => (a.createdAt! < b.createdAt! ? -1 : 1));
245
+ const seen = new Map<string, number[]>();
246
+ const out: Unusual[] = [];
247
+ for (const t of sorted) {
248
+ const key = merchantKey(t.description);
249
+ const prior = seen.get(key) ?? [];
250
+ if (prior.length >= 3) {
251
+ const mid = median(prior);
252
+ if (spent(t) > 2 * mid)
253
+ out.push({
254
+ id: t.id,
255
+ merchant: t.description ?? "?",
256
+ cents: spent(t),
257
+ medianCents: mid,
258
+ at: t.createdAt,
259
+ });
260
+ }
261
+ seen.set(key, [...prior, spent(t)]);
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /** Readings that look wrong, said in words: never fixed here. */
267
+ export function anomalies(txs: Transaction[]): string[] {
268
+ const out: string[] = [];
269
+ const noAmount = txs.filter((t) => t.amountCents === undefined).length;
270
+ if (noAmount) out.push(`${noAmount} transaction(s) without an amount`);
271
+ const foreign = txs.filter(
272
+ (t) => t.currency !== undefined && t.currency !== "AUD",
273
+ ).length;
274
+ if (foreign) out.push(`${foreign} transaction(s) not in AUD`);
275
+ return out;
276
+ }
277
+
278
+ /** Cents as dollars for reading: "$1,234" (whole dollars) or "$12.50". Display only. */
279
+ export const dollars = (cents: number, whole = true) =>
280
+ `${cents < 0 ? "-" : ""}$${(Math.abs(cents) / 100).toLocaleString("en-AU", { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 })}`;
@@ -0,0 +1,62 @@
1
+ // Receiving Up's webhooks is the consumer's job (it needs a public URL); these two pure functions are all it needs from
2
+ // here. The handler: verify, parse, then `home.refresh("up")`. Web Crypto only, so it runs on Bun, Node and workers.
3
+
4
+ /** The header Up signs each event with: the SHA-256 HMAC of the raw body under the webhook's `secretKey`, hex. */
5
+ export const SIGNATURE_HEADER = "X-Up-Authenticity-Signature";
6
+
7
+ const hex = (bytes: ArrayBuffer) =>
8
+ [...new Uint8Array(bytes)]
9
+ .map((b) => b.toString(16).padStart(2, "0"))
10
+ .join("");
11
+
12
+ /** True when `signature` (the header's value) is the HMAC of `rawBody` under `secret`. Compares in constant time. */
13
+ export async function verifyWebhook(
14
+ rawBody: string | Uint8Array,
15
+ signature: string | null | undefined,
16
+ secret: string,
17
+ ): Promise<boolean> {
18
+ if (!signature) return false;
19
+ const enc = new TextEncoder();
20
+ const key = await crypto.subtle.importKey(
21
+ "raw",
22
+ enc.encode(secret),
23
+ { name: "HMAC", hash: "SHA-256" },
24
+ false,
25
+ ["sign"],
26
+ );
27
+ const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
28
+ const want = hex(
29
+ await crypto.subtle.sign("HMAC", key, body as Uint8Array<ArrayBuffer>),
30
+ );
31
+ const got = signature.trim().toLowerCase();
32
+ let diff = want.length ^ got.length;
33
+ for (let i = 0; i < want.length; i++)
34
+ diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);
35
+ return diff === 0;
36
+ }
37
+
38
+ /** One webhook event: its type ("TRANSACTION_CREATED", "TRANSACTION_SETTLED", "TRANSACTION_DELETED", "PING") and the transaction it names. */
39
+ export type WebhookEvent = {
40
+ id?: string;
41
+ type?: string;
42
+ createdAt?: string;
43
+ transactionId?: string;
44
+ };
45
+
46
+ /** The event in a webhook body. Fields Up leaves out stay undefined; a body that is not JSON throws. */
47
+ export function parseWebhookEvent(body: unknown): WebhookEvent {
48
+ const json = (typeof body === "string" ? JSON.parse(body) : body) as {
49
+ data?: {
50
+ id?: string;
51
+ attributes?: { eventType?: string; createdAt?: string };
52
+ relationships?: { transaction?: { data?: { id?: string } } };
53
+ };
54
+ };
55
+ const d = json?.data;
56
+ return {
57
+ id: d?.id,
58
+ type: d?.attributes?.eventType,
59
+ createdAt: d?.attributes?.createdAt,
60
+ transactionId: d?.relationships?.transaction?.data?.id,
61
+ };
62
+ }