@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.
- package/.env.example +25 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +428 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +18 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +754 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +280 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +197 -0
- package/dist/discover.js +452 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +48 -0
- package/dist/finders.js +176 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +41 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +25 -0
- package/skills/draw-home/SKILL.md +80 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +182 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +396 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { asOf, realClock } from "../../clock.js";
|
|
3
|
+
import { errorText } from "../../connector.js";
|
|
4
|
+
import { envName } from "../../home.js";
|
|
5
|
+
import { poller } from "../../poll.js";
|
|
6
|
+
import { upClient, UpError, } from "./client.js";
|
|
7
|
+
import { anomalies, dollars, inHousehold, isSpend, spendByCategory, spendByMerchant, uncategorised, unknownCategories, } from "./spend.js";
|
|
8
|
+
export { upClient, UpError, } from "./client.js";
|
|
9
|
+
export { dollars, inHousehold, isSpend, merchantKey, notSpend, recurring, spendByCategory, spendByMerchant, uncategorised, unusual, } from "./spend.js";
|
|
10
|
+
export { parseWebhookEvent, SIGNATURE_HEADER, verifyWebhook, } from "./webhook.js";
|
|
11
|
+
/** Poll every 15 minutes: spending moves slowly, and a webhook can nudge a read sooner. */
|
|
12
|
+
export const POLL_MS = 15 * 60_000;
|
|
13
|
+
const ACCOUNT_TYPES = ["TRANSACTIONAL", "SAVER", "HOME_LOAN"];
|
|
14
|
+
const OWNERSHIP_TYPES = ["INDIVIDUAL", "JOINT"];
|
|
15
|
+
/** Which accounts to read: by type and ownership, never by id (e.g. the joint everyday account). All of them when left out. */
|
|
16
|
+
const AccountFilter = z.object({
|
|
17
|
+
accountType: z.enum(ACCOUNT_TYPES).optional(),
|
|
18
|
+
ownershipType: z.enum(OWNERSHIP_TYPES).optional(),
|
|
19
|
+
});
|
|
20
|
+
/** Up in a home config: the token's env name, and which accounts count as the household's. */
|
|
21
|
+
export const configSchema = z.looseObject({
|
|
22
|
+
connector: z.literal("up"),
|
|
23
|
+
key: envName.describe("env name holding the Up personal access token"),
|
|
24
|
+
account: AccountFilter.optional().describe("which accounts to read, by type and ownership; every account when left out"),
|
|
25
|
+
household: z
|
|
26
|
+
.object({
|
|
27
|
+
categories: z.array(z.string()).optional(),
|
|
28
|
+
tags: z.array(z.string()).optional(),
|
|
29
|
+
})
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("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"),
|
|
32
|
+
});
|
|
33
|
+
/** Transactions from the accounts a filter names (every account without one), newest first. The one read the integration and `home up` share. */
|
|
34
|
+
export async function readTransactions(client, account, query = {}, signal) {
|
|
35
|
+
const accounts = account ? await client.accounts(account, signal) : undefined;
|
|
36
|
+
if (accounts && !accounts.length)
|
|
37
|
+
throw new UpError(`no Up account is ${[account.ownershipType, account.accountType].filter(Boolean).join(" ").toLowerCase()}`);
|
|
38
|
+
const reads = await Promise.all((accounts ?? [undefined]).map((a) => client.transactions({ ...query, accountId: a?.id }, signal)));
|
|
39
|
+
const transactions = reads
|
|
40
|
+
.flatMap((r) => r.transactions)
|
|
41
|
+
.sort((a, b) => ((a.createdAt ?? "") < (b.createdAt ?? "") ? 1 : -1));
|
|
42
|
+
return {
|
|
43
|
+
transactions,
|
|
44
|
+
truncated: reads.some((r) => r.truncated),
|
|
45
|
+
accounts: accounts?.length ?? 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** "2026-10" for each instant, in a time zone. */
|
|
49
|
+
const monthIn = (timeZone) => {
|
|
50
|
+
const f = new Intl.DateTimeFormat("en-CA", {
|
|
51
|
+
timeZone,
|
|
52
|
+
year: "numeric",
|
|
53
|
+
month: "2-digit",
|
|
54
|
+
});
|
|
55
|
+
return (ms) => f.format(ms).slice(0, 7);
|
|
56
|
+
};
|
|
57
|
+
const monthBefore = (month) => {
|
|
58
|
+
const [y, m] = month.split("-").map(Number);
|
|
59
|
+
return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
|
|
60
|
+
};
|
|
61
|
+
/** The snapshot from two months of transactions: pure, so tests and a consumer's own reads build the same thing. */
|
|
62
|
+
export function toSnapshot(txs, categories, at) {
|
|
63
|
+
const monthOf = monthIn(at.timeZone);
|
|
64
|
+
const thisMonth = monthOf(at.now);
|
|
65
|
+
const lastMonth = monthBefore(thisMonth);
|
|
66
|
+
const shared = txs.filter((t) => inHousehold(t, at.household));
|
|
67
|
+
const byMonth = Map.groupBy(shared, (t) => t.createdAt === undefined ? "" : monthOf(Date.parse(t.createdAt)));
|
|
68
|
+
const of = (month) => byMonth.get(month) ?? [];
|
|
69
|
+
const month = (m) => ({
|
|
70
|
+
month: m,
|
|
71
|
+
...spendByCategory(of(m), categories),
|
|
72
|
+
topMerchants: spendByMerchant(of(m)).slice(0, 5),
|
|
73
|
+
});
|
|
74
|
+
// To categorise looks at every purchase in the accounts: a category is how one joins the household.
|
|
75
|
+
const both = txs.filter((t) => t.createdAt !== undefined &&
|
|
76
|
+
[thisMonth, lastMonth].includes(monthOf(Date.parse(t.createdAt))));
|
|
77
|
+
return {
|
|
78
|
+
asOf: at.asOf,
|
|
79
|
+
thisMonth: month(thisMonth),
|
|
80
|
+
lastMonth: month(lastMonth),
|
|
81
|
+
uncategorised: uncategorised(both).map((t) => t.id),
|
|
82
|
+
recent: shared
|
|
83
|
+
.filter(isSpend)
|
|
84
|
+
.slice(0, 20)
|
|
85
|
+
.map(({ id, createdAt, description, amountCents, categoryId, parentCategoryId, status, tags, }) => ({
|
|
86
|
+
id,
|
|
87
|
+
createdAt,
|
|
88
|
+
description,
|
|
89
|
+
amountCents,
|
|
90
|
+
categoryId,
|
|
91
|
+
parentCategoryId,
|
|
92
|
+
status,
|
|
93
|
+
tags,
|
|
94
|
+
})),
|
|
95
|
+
accounts: at.accounts,
|
|
96
|
+
household: at.household,
|
|
97
|
+
truncated: at.truncated,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Up as a home integration: two months of transactions now, then every 15 minutes. Reads only; it never writes. */
|
|
101
|
+
export function up(config) {
|
|
102
|
+
const client = upClient(config);
|
|
103
|
+
const clock = config.clock ?? realClock;
|
|
104
|
+
const poll = poller(clock);
|
|
105
|
+
return {
|
|
106
|
+
name: "up",
|
|
107
|
+
refresh: poll.nudge,
|
|
108
|
+
async run(signal, update) {
|
|
109
|
+
let last;
|
|
110
|
+
while (!signal.aborted) {
|
|
111
|
+
try {
|
|
112
|
+
const now = clock.now();
|
|
113
|
+
// 63 days back covers all of last month from any day of this one; months are cut in the home's zone below.
|
|
114
|
+
const since = new Date(now - 63 * 86_400_000).toISOString();
|
|
115
|
+
const categories = await client.categories(signal);
|
|
116
|
+
const read = await readTransactions(client, config.account, { since }, signal);
|
|
117
|
+
last = toSnapshot(read.transactions, categories, {
|
|
118
|
+
now,
|
|
119
|
+
asOf: asOf(clock),
|
|
120
|
+
timeZone: config.timeZone,
|
|
121
|
+
accounts: read.accounts,
|
|
122
|
+
truncated: read.truncated,
|
|
123
|
+
household: config.household,
|
|
124
|
+
});
|
|
125
|
+
const odd = [
|
|
126
|
+
...anomalies(read.transactions),
|
|
127
|
+
...unknownCategories(config.household, categories),
|
|
128
|
+
last.truncated
|
|
129
|
+
? "read stopped at its page cap; totals are short"
|
|
130
|
+
: "",
|
|
131
|
+
].filter(Boolean);
|
|
132
|
+
update({
|
|
133
|
+
status: "connected",
|
|
134
|
+
anomaly: odd.length ? odd.join("; ") : undefined,
|
|
135
|
+
state: last,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (signal.aborted)
|
|
140
|
+
return; // stopped mid-read: not a failure
|
|
141
|
+
if (error instanceof UpError && error.status === 401)
|
|
142
|
+
return update({
|
|
143
|
+
status: "unavailable",
|
|
144
|
+
detail: "Up rejected the token",
|
|
145
|
+
state: last,
|
|
146
|
+
});
|
|
147
|
+
update({
|
|
148
|
+
status: "disconnected",
|
|
149
|
+
detail: errorText(error),
|
|
150
|
+
state: last,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
await poll.wait(POLL_MS, signal);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const MONTHS = [
|
|
159
|
+
"Jan",
|
|
160
|
+
"Feb",
|
|
161
|
+
"Mar",
|
|
162
|
+
"Apr",
|
|
163
|
+
"May",
|
|
164
|
+
"Jun",
|
|
165
|
+
"Jul",
|
|
166
|
+
"Aug",
|
|
167
|
+
"Sep",
|
|
168
|
+
"Oct",
|
|
169
|
+
"Nov",
|
|
170
|
+
"Dec",
|
|
171
|
+
];
|
|
172
|
+
const monthName = (month) => MONTHS[Number(month.slice(5, 7)) - 1] ?? month;
|
|
173
|
+
/** "Oct so far $2,140 · Sep $1,980 · 3 to categorise": this month's spend against last month's. */
|
|
174
|
+
export function upText(s) {
|
|
175
|
+
return [
|
|
176
|
+
`${monthName(s.thisMonth.month)} so far ${dollars(s.thisMonth.cents)}`,
|
|
177
|
+
`${monthName(s.lastMonth.month)} ${dollars(s.lastMonth.cents)}`,
|
|
178
|
+
s.uncategorised.length ? `${s.uncategorised.length} to categorise` : "",
|
|
179
|
+
]
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join(" · ");
|
|
182
|
+
}
|
|
183
|
+
/** One line per parent category this month, largest five: "home $1,030: groceries $620, utilities $410". */
|
|
184
|
+
export const categoryLines = (m) => m.byCategory
|
|
185
|
+
.slice(0, 5)
|
|
186
|
+
.map((p) => `${p.name ?? p.id} ${dollars(p.cents)}${p.children.length ? `: ${p.children.map((c) => `${(c.name ?? c.id).toLowerCase()} ${dollars(c.cents)}`).join(", ")}` : ""}`);
|
|
187
|
+
export const manifest = {
|
|
188
|
+
name: "up",
|
|
189
|
+
title: "Up Bank",
|
|
190
|
+
iotClass: "cloud_polling",
|
|
191
|
+
pollSeconds: POLL_MS / 1000,
|
|
192
|
+
access: "cloud HTTPS, UP_TOKEN; reads only, writes are `home up` verbs",
|
|
193
|
+
env: [
|
|
194
|
+
{
|
|
195
|
+
name: "UP_TOKEN",
|
|
196
|
+
example: "up:yeah:xxxxxxxx",
|
|
197
|
+
what: "Up personal access token (api.up.com.au/getting_started)",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "UP_ACCOUNT",
|
|
201
|
+
example: "JOINT TRANSACTIONAL",
|
|
202
|
+
optional: true,
|
|
203
|
+
what: "which accounts count, by ownership and type; every account when unset",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "UP_HOUSEHOLD",
|
|
207
|
+
example: "home tag:home",
|
|
208
|
+
optional: true,
|
|
209
|
+
what: "what counts as the household's: Up category ids and tag:labels; everything when unset",
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
lines: ({ state }) => state ? [upText(state), ...categoryLines(state.thisMonth)] : [],
|
|
213
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Category, Transaction } from "./client.ts";
|
|
2
|
+
/** Why a transaction is not spend, or undefined when it is. The one place the rule lives. */
|
|
3
|
+
export declare function notSpend(t: Transaction): string | undefined;
|
|
4
|
+
/** Settled money out to someone else: not held, not a transfer, cover, forward or round-up between your own accounts. */
|
|
5
|
+
export declare const isSpend: (t: Transaction) => boolean;
|
|
6
|
+
/** A window of time by the transaction's creation, `[since, until)` as ISO strings; either end open. */
|
|
7
|
+
export type Window = {
|
|
8
|
+
since?: string;
|
|
9
|
+
until?: string;
|
|
10
|
+
};
|
|
11
|
+
export type CategorySpend = {
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
cents: number;
|
|
15
|
+
count: number;
|
|
16
|
+
children: {
|
|
17
|
+
id: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
cents: number;
|
|
20
|
+
count: number;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
export type SpendSummary = {
|
|
24
|
+
cents: number;
|
|
25
|
+
count: number;
|
|
26
|
+
excluded: {
|
|
27
|
+
count: number;
|
|
28
|
+
cents: number;
|
|
29
|
+
};
|
|
30
|
+
uncategorisedCents: number;
|
|
31
|
+
byCategory: CategorySpend[];
|
|
32
|
+
};
|
|
33
|
+
/** Spend in a window grouped by Up's parent and child categories, with what was excluded counted and summed. */
|
|
34
|
+
export declare function spendByCategory(txs: Transaction[], categories: Category[], window?: Window): SpendSummary;
|
|
35
|
+
/** 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. */
|
|
36
|
+
export declare const merchantKey: (description?: string) => string;
|
|
37
|
+
export type MerchantSpend = {
|
|
38
|
+
merchant: string;
|
|
39
|
+
cents: number;
|
|
40
|
+
count: number;
|
|
41
|
+
};
|
|
42
|
+
/** Spend per merchant, largest first; the merchant shown as its latest description. */
|
|
43
|
+
export declare function spendByMerchant(txs: Transaction[], window?: Window): MerchantSpend[];
|
|
44
|
+
/** What the household shares: Up category ids (a parent takes in its children) and tag labels. */
|
|
45
|
+
export type Household = {
|
|
46
|
+
categories?: string[];
|
|
47
|
+
tags?: string[];
|
|
48
|
+
};
|
|
49
|
+
/** 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. */
|
|
50
|
+
export declare function inHousehold(t: Transaction, h: Household | undefined): boolean;
|
|
51
|
+
/** Household categories Up does not have, said in words: a typo would otherwise count nothing, silently. */
|
|
52
|
+
export declare const unknownCategories: (h: Household | undefined, categories: Category[]) => string[];
|
|
53
|
+
/** Spend Up lets you categorise that has no category yet. Money in (salary, a payment received) is categorisable too, but is not spend. */
|
|
54
|
+
export declare const uncategorised: (txs: Transaction[]) => Transaction[];
|
|
55
|
+
export type Recurring = {
|
|
56
|
+
merchant: string;
|
|
57
|
+
months: number;
|
|
58
|
+
medianCents: number;
|
|
59
|
+
lastAt?: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* A heuristic, since Up has no subscription endpoint: the same merchant in three or more distinct months, every amount
|
|
63
|
+
* within 20% of that merchant's median.
|
|
64
|
+
*/
|
|
65
|
+
export declare function recurring(txs: Transaction[], timeZone?: string): Recurring[];
|
|
66
|
+
export type Unusual = {
|
|
67
|
+
id: string;
|
|
68
|
+
merchant: string;
|
|
69
|
+
cents: number;
|
|
70
|
+
medianCents: number;
|
|
71
|
+
at?: string;
|
|
72
|
+
};
|
|
73
|
+
/** Spend well above that merchant's usual: more than twice the median of at least three earlier purchases there. */
|
|
74
|
+
export declare function unusual(txs: Transaction[]): Unusual[];
|
|
75
|
+
/** Readings that look wrong, said in words: never fixed here. */
|
|
76
|
+
export declare function anomalies(txs: Transaction[]): string[];
|
|
77
|
+
/** Cents as dollars for reading: "$1,234" (whole dollars) or "$12.50". Display only. */
|
|
78
|
+
export declare const dollars: (cents: number, whole?: boolean) => string;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Pure helpers over transactions, one owner per number. Amounts are cents; spend is reported as a positive number.
|
|
2
|
+
/**
|
|
3
|
+
* Up's own movements between your accounts that carry no `transferAccount` (unconfirmed: matched by description, the
|
|
4
|
+
* way up-finance-skills does). Most movements do carry it and are caught by that field.
|
|
5
|
+
*/
|
|
6
|
+
const OWN_MOVEMENT = [
|
|
7
|
+
/^cover from /i,
|
|
8
|
+
/^forward to /i,
|
|
9
|
+
/^round up\b/i,
|
|
10
|
+
/^quick save transfer/i,
|
|
11
|
+
]; // not "Transfer to …": paying someone else is spend
|
|
12
|
+
/** Why a transaction is not spend, or undefined when it is. The one place the rule lives. */
|
|
13
|
+
export function notSpend(t) {
|
|
14
|
+
if (t.status !== "SETTLED")
|
|
15
|
+
return "held";
|
|
16
|
+
if (t.amountCents === undefined)
|
|
17
|
+
return "no amount";
|
|
18
|
+
if (t.amountCents >= 0)
|
|
19
|
+
return "money in";
|
|
20
|
+
if (t.transferAccountId)
|
|
21
|
+
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
|
+
/** Settled money out to someone else: not held, not a transfer, cover, forward or round-up between your own accounts. */
|
|
27
|
+
export const isSpend = (t) => notSpend(t) === undefined;
|
|
28
|
+
/** Money spent, as a positive number of cents. */
|
|
29
|
+
const spent = (t) => -(t.amountCents ?? 0);
|
|
30
|
+
const inWindow = (t, w = {}) => {
|
|
31
|
+
if (!w.since && !w.until)
|
|
32
|
+
return true;
|
|
33
|
+
const at = Date.parse(t.createdAt ?? ""); // Up's times carry an offset (+11:00); compare instants, not text
|
|
34
|
+
return ((!w.since || at >= Date.parse(w.since)) &&
|
|
35
|
+
(!w.until || at < Date.parse(w.until)));
|
|
36
|
+
};
|
|
37
|
+
/** Money out that the rule leaves out (a transfer, cover, forward or round-up), counted so a total can be checked. */
|
|
38
|
+
const MOVED = new Set([
|
|
39
|
+
"transfer between your accounts",
|
|
40
|
+
"cover, forward or round-up",
|
|
41
|
+
]);
|
|
42
|
+
/** Spend in a window grouped by Up's parent and child categories, with what was excluded counted and summed. */
|
|
43
|
+
export function spendByCategory(txs, categories, window) {
|
|
44
|
+
const name = new Map(categories.map((c) => [c.id, c.name]));
|
|
45
|
+
const parentOf = new Map(categories.map((c) => [c.id, c.parentId]));
|
|
46
|
+
const parents = new Map();
|
|
47
|
+
const summary = {
|
|
48
|
+
cents: 0,
|
|
49
|
+
count: 0,
|
|
50
|
+
excluded: { count: 0, cents: 0 },
|
|
51
|
+
uncategorisedCents: 0,
|
|
52
|
+
byCategory: [],
|
|
53
|
+
};
|
|
54
|
+
for (const t of txs) {
|
|
55
|
+
if (!inWindow(t, window))
|
|
56
|
+
continue;
|
|
57
|
+
const why = notSpend(t);
|
|
58
|
+
if (why) {
|
|
59
|
+
if (MOVED.has(why))
|
|
60
|
+
(summary.excluded.count++, (summary.excluded.cents += spent(t)));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
summary.cents += spent(t);
|
|
64
|
+
summary.count++;
|
|
65
|
+
if (!t.categoryId) {
|
|
66
|
+
summary.uncategorisedCents += spent(t);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const parentId = t.parentCategoryId ?? parentOf.get(t.categoryId) ?? t.categoryId;
|
|
70
|
+
const p = parents.get(parentId) ?? {
|
|
71
|
+
id: parentId,
|
|
72
|
+
name: name.get(parentId),
|
|
73
|
+
cents: 0,
|
|
74
|
+
count: 0,
|
|
75
|
+
children: [],
|
|
76
|
+
};
|
|
77
|
+
parents.set(parentId, p);
|
|
78
|
+
p.cents += spent(t);
|
|
79
|
+
p.count++;
|
|
80
|
+
if (t.categoryId !== parentId) {
|
|
81
|
+
let c = p.children.find((x) => x.id === t.categoryId);
|
|
82
|
+
if (!c)
|
|
83
|
+
p.children.push((c = {
|
|
84
|
+
id: t.categoryId,
|
|
85
|
+
name: name.get(t.categoryId),
|
|
86
|
+
cents: 0,
|
|
87
|
+
count: 0,
|
|
88
|
+
}));
|
|
89
|
+
c.cents += spent(t);
|
|
90
|
+
c.count++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
summary.byCategory = [...parents.values()].sort((a, b) => b.cents - a.cents);
|
|
94
|
+
for (const p of summary.byCategory)
|
|
95
|
+
p.children.sort((a, b) => b.cents - a.cents);
|
|
96
|
+
return summary;
|
|
97
|
+
}
|
|
98
|
+
/** 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. */
|
|
99
|
+
export const merchantKey = (description = "") => description
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^\p{L} ]+/gu, " ")
|
|
102
|
+
.replace(/\s+/g, " ")
|
|
103
|
+
.trim() || description;
|
|
104
|
+
/** Spend per merchant, largest first; the merchant shown as its latest description. */
|
|
105
|
+
export function spendByMerchant(txs, window) {
|
|
106
|
+
const by = new Map();
|
|
107
|
+
for (const t of txs) {
|
|
108
|
+
if (!inWindow(t, window) || !isSpend(t))
|
|
109
|
+
continue;
|
|
110
|
+
const key = merchantKey(t.description);
|
|
111
|
+
const m = by.get(key) ?? {
|
|
112
|
+
merchant: t.description ?? "?",
|
|
113
|
+
cents: 0,
|
|
114
|
+
count: 0,
|
|
115
|
+
};
|
|
116
|
+
by.set(key, m);
|
|
117
|
+
m.cents += spent(t);
|
|
118
|
+
m.count++;
|
|
119
|
+
}
|
|
120
|
+
return [...by.values()].sort((a, b) => b.cents - a.cents);
|
|
121
|
+
}
|
|
122
|
+
/** 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. */
|
|
123
|
+
export function inHousehold(t, h) {
|
|
124
|
+
if (!h?.categories?.length && !h?.tags?.length)
|
|
125
|
+
return true;
|
|
126
|
+
const byCategory = h.categories?.some((c) => c === t.categoryId || c === t.parentCategoryId) ??
|
|
127
|
+
false;
|
|
128
|
+
return byCategory || (t.tags?.some((tag) => h.tags?.includes(tag)) ?? false);
|
|
129
|
+
}
|
|
130
|
+
/** Household categories Up does not have, said in words: a typo would otherwise count nothing, silently. */
|
|
131
|
+
export const unknownCategories = (h, categories) => (h?.categories ?? [])
|
|
132
|
+
.filter((c) => !categories.some((k) => k.id === c))
|
|
133
|
+
.map((c) => `household category ${c} is not an Up category`);
|
|
134
|
+
/** Spend Up lets you categorise that has no category yet. Money in (salary, a payment received) is categorisable too, but is not spend. */
|
|
135
|
+
export const uncategorised = (txs) => txs.filter((t) => t.isCategorizable && !t.categoryId && isSpend(t));
|
|
136
|
+
const median = (xs) => {
|
|
137
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
138
|
+
const m = Math.floor(s.length / 2);
|
|
139
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* A heuristic, since Up has no subscription endpoint: the same merchant in three or more distinct months, every amount
|
|
143
|
+
* within 20% of that merchant's median.
|
|
144
|
+
*/
|
|
145
|
+
export function recurring(txs, timeZone) {
|
|
146
|
+
const month = new Intl.DateTimeFormat("en-CA", {
|
|
147
|
+
timeZone,
|
|
148
|
+
year: "numeric",
|
|
149
|
+
month: "2-digit",
|
|
150
|
+
});
|
|
151
|
+
const by = new Map();
|
|
152
|
+
for (const t of txs) {
|
|
153
|
+
if (!isSpend(t) || !t.createdAt)
|
|
154
|
+
continue;
|
|
155
|
+
const key = merchantKey(t.description);
|
|
156
|
+
by.set(key, [...(by.get(key) ?? []), t]);
|
|
157
|
+
}
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const list of by.values()) {
|
|
160
|
+
const months = new Set(list.map((t) => month.format(new Date(t.createdAt))));
|
|
161
|
+
if (months.size < 3)
|
|
162
|
+
continue;
|
|
163
|
+
const mid = median(list.map(spent));
|
|
164
|
+
if (!list.every((t) => Math.abs(spent(t) - mid) <= mid * 0.2))
|
|
165
|
+
continue;
|
|
166
|
+
const latest = list.reduce((a, b) => (a.createdAt > b.createdAt ? a : b));
|
|
167
|
+
out.push({
|
|
168
|
+
merchant: latest.description ?? "?",
|
|
169
|
+
months: months.size,
|
|
170
|
+
medianCents: mid,
|
|
171
|
+
lastAt: latest.createdAt,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return out.sort((a, b) => b.medianCents - a.medianCents);
|
|
175
|
+
}
|
|
176
|
+
/** Spend well above that merchant's usual: more than twice the median of at least three earlier purchases there. */
|
|
177
|
+
export function unusual(txs) {
|
|
178
|
+
const sorted = txs
|
|
179
|
+
.filter((t) => isSpend(t) && t.createdAt)
|
|
180
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
|
|
181
|
+
const seen = new Map();
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const t of sorted) {
|
|
184
|
+
const key = merchantKey(t.description);
|
|
185
|
+
const prior = seen.get(key) ?? [];
|
|
186
|
+
if (prior.length >= 3) {
|
|
187
|
+
const mid = median(prior);
|
|
188
|
+
if (spent(t) > 2 * mid)
|
|
189
|
+
out.push({
|
|
190
|
+
id: t.id,
|
|
191
|
+
merchant: t.description ?? "?",
|
|
192
|
+
cents: spent(t),
|
|
193
|
+
medianCents: mid,
|
|
194
|
+
at: t.createdAt,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
seen.set(key, [...prior, spent(t)]);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/** Readings that look wrong, said in words: never fixed here. */
|
|
202
|
+
export function anomalies(txs) {
|
|
203
|
+
const out = [];
|
|
204
|
+
const noAmount = txs.filter((t) => t.amountCents === undefined).length;
|
|
205
|
+
if (noAmount)
|
|
206
|
+
out.push(`${noAmount} transaction(s) without an amount`);
|
|
207
|
+
const foreign = txs.filter((t) => t.currency !== undefined && t.currency !== "AUD").length;
|
|
208
|
+
if (foreign)
|
|
209
|
+
out.push(`${foreign} transaction(s) not in AUD`);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** Cents as dollars for reading: "$1,234" (whole dollars) or "$12.50". Display only. */
|
|
213
|
+
export const dollars = (cents, whole = true) => `${cents < 0 ? "-" : ""}$${(Math.abs(cents) / 100).toLocaleString("en-AU", { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 })}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The header Up signs each event with: the SHA-256 HMAC of the raw body under the webhook's `secretKey`, hex. */
|
|
2
|
+
export declare const SIGNATURE_HEADER = "X-Up-Authenticity-Signature";
|
|
3
|
+
/** True when `signature` (the header's value) is the HMAC of `rawBody` under `secret`. Compares in constant time. */
|
|
4
|
+
export declare function verifyWebhook(rawBody: string | Uint8Array, signature: string | null | undefined, secret: string): Promise<boolean>;
|
|
5
|
+
/** One webhook event: its type ("TRANSACTION_CREATED", "TRANSACTION_SETTLED", "TRANSACTION_DELETED", "PING") and the transaction it names. */
|
|
6
|
+
export type WebhookEvent = {
|
|
7
|
+
id?: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
createdAt?: string;
|
|
10
|
+
transactionId?: string;
|
|
11
|
+
};
|
|
12
|
+
/** The event in a webhook body. Fields Up leaves out stay undefined; a body that is not JSON throws. */
|
|
13
|
+
export declare function parseWebhookEvent(body: unknown): WebhookEvent;
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
/** The header Up signs each event with: the SHA-256 HMAC of the raw body under the webhook's `secretKey`, hex. */
|
|
4
|
+
export const SIGNATURE_HEADER = "X-Up-Authenticity-Signature";
|
|
5
|
+
const hex = (bytes) => [...new Uint8Array(bytes)]
|
|
6
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
7
|
+
.join("");
|
|
8
|
+
/** True when `signature` (the header's value) is the HMAC of `rawBody` under `secret`. Compares in constant time. */
|
|
9
|
+
export async function verifyWebhook(rawBody, signature, secret) {
|
|
10
|
+
if (!signature)
|
|
11
|
+
return false;
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
14
|
+
const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
|
|
15
|
+
const want = hex(await crypto.subtle.sign("HMAC", key, body));
|
|
16
|
+
const got = signature.trim().toLowerCase();
|
|
17
|
+
let diff = want.length ^ got.length;
|
|
18
|
+
for (let i = 0; i < want.length; i++)
|
|
19
|
+
diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);
|
|
20
|
+
return diff === 0;
|
|
21
|
+
}
|
|
22
|
+
/** The event in a webhook body. Fields Up leaves out stay undefined; a body that is not JSON throws. */
|
|
23
|
+
export function parseWebhookEvent(body) {
|
|
24
|
+
const json = (typeof body === "string" ? JSON.parse(body) : body);
|
|
25
|
+
const d = json?.data;
|
|
26
|
+
return {
|
|
27
|
+
id: d?.id,
|
|
28
|
+
type: d?.attributes?.eventType,
|
|
29
|
+
createdAt: d?.attributes?.createdAt,
|
|
30
|
+
transactionId: d?.relationships?.transaction?.data?.id,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Condition, type WeatherHourly } from "./source.ts";
|
|
2
|
+
/** One part of a day: its name and the local hours it covers, `from` up to `to` (0–24, within one day). */
|
|
3
|
+
export type BlockDef = {
|
|
4
|
+
name: string;
|
|
5
|
+
from: number;
|
|
6
|
+
to: number;
|
|
7
|
+
};
|
|
8
|
+
/** The parts of a day by default, by the local hour each starts in: KO's call. A consumer passes its own to `dayBlocks`. */
|
|
9
|
+
export declare const BLOCKS: readonly BlockDef[];
|
|
10
|
+
/**
|
|
11
|
+
* One part of a day from the hourly forecast: `hours` is how many hours it summarises, fewer than its length when it is under way or
|
|
12
|
+
* the source left hours out. `rainChance` is the highest hour's, `rainMm` the sum (missing if any hour lacks one), `condition` the worst hour's.
|
|
13
|
+
* `late`: it starts after its part of the day does (under way, or the source began later).
|
|
14
|
+
*/
|
|
15
|
+
export type DayBlock = {
|
|
16
|
+
name: string;
|
|
17
|
+
date: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
hours: number;
|
|
21
|
+
late?: boolean;
|
|
22
|
+
minC?: number;
|
|
23
|
+
maxC?: number;
|
|
24
|
+
rainChance?: number;
|
|
25
|
+
rainMm?: number;
|
|
26
|
+
windKmh?: number;
|
|
27
|
+
gustKmh?: number;
|
|
28
|
+
uvIndex?: number;
|
|
29
|
+
condition?: Condition;
|
|
30
|
+
};
|
|
31
|
+
export type DayBlocks = {
|
|
32
|
+
source: string;
|
|
33
|
+
today: DayBlock[];
|
|
34
|
+
tomorrow: DayBlock[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The rest of today and tomorrow in `blocks` (`BLOCKS` by default), from the hourly forecast, in the home's time zone (the
|
|
38
|
+
* runtime's when left out). The one owner: the integration, `home weather` and a consumer with its own clock all call
|
|
39
|
+
* `dayBlocks(state.hourly, now, timeZone)`. An hour counts while it has not ended; a part with no hours left is left out.
|
|
40
|
+
* Throws on a block outside 0–24 or ending before it starts.
|
|
41
|
+
*/
|
|
42
|
+
export declare function dayBlocks(hourly: WeatherHourly | undefined, at: number, timeZone?: string, blocks?: readonly BlockDef[]): DayBlocks | undefined;
|
|
43
|
+
/** Throw on blocks a day cannot hold: whole hours within 0–24, each ending after it starts. */
|
|
44
|
+
export declare function checkBlocks(blocks: readonly BlockDef[]): void;
|
|
45
|
+
/** The UV index from which a block's text names it: 3, where sun protection starts to be advised (WHO's "moderate"). A display rule. */
|
|
46
|
+
export declare const UV_SHOWN = 3;
|
|
47
|
+
/**
|
|
48
|
+
* "afternoon from 16:00 17–19 °C, showers, rain 40% 1.2 mm, wind 25 km/h gusting 40, UV 8". `from` shows when the part is
|
|
49
|
+
* under way or short; UV shows from `UV_SHOWN`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function blockText(b: DayBlock, timeZone?: string): string;
|