@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,35 @@
1
+ # Up Bank
2
+
3
+ Reads the household's spending from Up Bank's API: two months of transactions, spend by Up's own categories, top merchants, what still needs a category. `iot_class`: **cloud_polling**, every 15 minutes. Built from KO's design in [docs/up.md](../../../docs/up.md); one live read on 2026-10-16. The writes (a category, a tag, webhooks) are built and untried.
4
+
5
+ ## Built
6
+
7
+ - **`client.ts`**: `upClient({ token, fetch })`. Reads: `ping`, `accounts(filter)`, `transactions(query)` following cursors to `limit` (default 1000) and `pageCap` (20) and returning `{ transactions, truncated }` so a cut-off list never looks complete, `transaction(id)`, `categories`, `tags`, `webhooks`, `webhook(id)`, `webhookLogs(id)`. Writes: `setCategory(id, categoryId | null)`, `addTags(id, labels)`, `removeTags(id, labels)`, each re-reading and returning the transaction as Up now holds it; `createWebhook`, `deleteWebhook`, `pingWebhook`. A transaction is flattened to cents (Up's `valueInBaseUnits`) and ids.
8
+ - **`spend.ts`**, pure, one owner per number. `notSpend(t)` is the rule and says why: held, money in, `transferAccount` set (a movement between your own accounts), or a description Up gives its own movements ("Cover from", "Forward to", "Round Up", "Quick save transfer"). "Transfer to …" is not on the list: paying someone else is spend. `isSpend`, `spendByCategory` (parents and children, excluded movements counted and summed so a total can be checked), `spendByMerchant`, `uncategorised` (spend with no category; money in is categorisable too but left out), `recurring` (a heuristic: three or more months within 20% of the median), `unusual` (over twice the median of three or more earlier purchases at that merchant), `dollars` for display.
9
+ - **`webhook.ts`**: `verifyWebhook(rawBody, signature, secret)`, the SHA-256 HMAC of the raw body in hex, Web Crypto, compared in constant time; `parseWebhookEvent(body)` gives the event type and the transaction id. A consumer's handler verifies, then calls `home.refresh("up")`.
10
+ - **The integration** (`up({ token, account?, timeZone?, fetch })`): every 15 minutes it reads the categories, the accounts the filter names, and 63 days of transactions, then cuts this month and last in the home's time zone. The snapshot holds each month's spend by category and top five merchants, the ids still to categorise, and the latest 20 purchases trimmed to id, time, description, amount, category, status and tags (movements between your own accounts are left out: their descriptions name the accounts). Never kept: raw text, message, note, who spent (`performingCustomer`), account ids, balances. A 401 is `unavailable`; a read that hit its page cap is an `anomaly`, as is an amount missing or not in AUD. It never writes.
11
+ - **Config**: `{ connector: "up", key: "UP_TOKEN", account: { ownershipType: "JOINT", accountType: "TRANSACTIONAL" } }`. Accounts are chosen by type and ownership, never by id. Without `account`, every account is read; transfers between them are excluded by `transferAccount`.
12
+ - **CLI**: `home up` (this month, last month, to categorise, top five categories), `home up transactions [--since] [--until] [--category] [--tag] [--status] [--limit] [--json|--csv]`, `categories`, `uncategorised`, `spend [--by category|merchant|recurring]`, and the writes `categorise <id> <category|none>`, `tag <id> <label> [--remove]`, `webhooks [get|logs|ping|create|delete]`. `UP_ACCOUNT` ("JOINT TRANSACTIONAL") is the filter's env form.
13
+ - **Household filter** (`household: { categories, tags }` in the config, `UP_HOUSEHOLD="home tag:home"` in env): the totals, top merchants and recent rows count only purchases in a listed category or its parent, or with a listed tag, so a family dashboard shows groceries and utilities, not personal spending. Tag anything else `home` in the Up app to bring it in. "To categorise" still looks at every purchase, since a category is how one joins. A listed category Up does not have is an `anomaly`, not a silent zero. `inHousehold` is the one owner of the rule; `home up spend` applies it too. `webhooks create` writes the secret Up returns once to `~/.config/home/up-webhook-<id>.secret` (0600); it is never printed.
14
+
15
+ ## The API
16
+
17
+ `https://api.up.com.au/api/v1`, a bearer token, JSON:API shapes. Only child categories can be set; `data: null` clears one. At most six tags per transaction, by label. Pages of up to 100, `links.next` an absolute URL. No published rate limit: `X-RateLimit-Remaining` answered 997 on the first read. Webhook events: `TRANSACTION_CREATED`, `TRANSACTION_SETTLED`, `TRANSACTION_DELETED`, `PING`, signed in `X-Up-Authenticity-Signature`.
18
+
19
+ ## Unverified
20
+
21
+ - Whether a category set through the API teaches Up about that merchant. The first live write, on one transaction, then the next purchase there, answers it.
22
+ - Covers, forwards and round-ups without a `transferAccount` are matched by description, which Up could change.
23
+ - Every write, the webhook calls and `verifyWebhook` against a real Up signature (tested against Node's `createHmac`).
24
+
25
+ ## Live, 2026-10-16 (Bun 1.4.3)
26
+
27
+ - 44 categories under 4 parents: `good-life`, `personal`, `home`, `transport`. `home` holds `groceries`, `utilities`, `internet`, `rent-and-mortgage` and four more.
28
+ - Fields beyond the spec: `transactionType` ("Purchase", "Payment", "International Purchase", "Salary", "Osko Payment Received"), `note`, `performingCustomer.displayName`, `deepLinkURL`. A transfer between own accounts has `transferAccount` set and no category.
29
+ - Accounts come as TRANSACTIONAL or SAVER, INDIVIDUAL or JOINT.
30
+ - One integration read of two months: 4.0 s for one account, 6.2 s for every account, under the page cap. Salary and payments received are categorisable with no category, which is why "to categorise" counts spend only.
31
+
32
+ ## Sources
33
+
34
+ - [Up API docs](https://developer.up.com.au/) and [OpenAPI spec](https://github.com/up-banking/api/blob/master/v1/openapi.json) (Up's own).
35
+ - [fane-ye/up-bank-mcp](https://github.com/fane-ye/up-bank-mcp) (no licence, read for the endpoint list), [xaviershay/up-finance-skills](https://github.com/xaviershay/up-finance-skills) (the spend exclusions), [edkranz/up-mcp](https://github.com/edkranz/up-mcp) (MIT). No code copied.
@@ -0,0 +1,518 @@
1
+ import { z } from "zod";
2
+ import { ConnectorError, fetchTimeout, getJson } from "../../connector.ts";
3
+
4
+ // Up Bank's API v1 (https://developer.up.com.au/, spec github.com/up-banking/api, v1/openapi.json). Field names checked
5
+ // against one live read, 2026-10-16. Every field optional so a change degrades a reading rather than throwing.
6
+
7
+ const BASE = "https://api.up.com.au/api/v1";
8
+
9
+ /** A failed Up call. `status` 401 means the token is wrong or revoked; 429 the rate limit (unpublished: read `X-RateLimit-Remaining`). */
10
+ export class UpError extends ConnectorError {}
11
+
12
+ const Money = z.looseObject({
13
+ currencyCode: z.string().optional(),
14
+ value: z.string().optional(),
15
+ valueInBaseUnits: z.number().optional(),
16
+ });
17
+ const Ref = z.looseObject({
18
+ data: z.looseObject({ id: z.string() }).nullable().optional(),
19
+ });
20
+
21
+ const TransactionResource = z.looseObject({
22
+ id: z.string(),
23
+ attributes: z
24
+ .looseObject({
25
+ status: z.string().optional(), // "HELD" | "SETTLED"
26
+ rawText: z.string().nullable().optional(),
27
+ description: z.string().optional(),
28
+ message: z.string().nullable().optional(),
29
+ isCategorizable: z.boolean().optional(),
30
+ holdInfo: z
31
+ .looseObject({ amount: Money.optional() })
32
+ .nullable()
33
+ .optional(),
34
+ roundUp: z
35
+ .looseObject({ amount: Money.optional() })
36
+ .nullable()
37
+ .optional(),
38
+ cashback: z
39
+ .looseObject({ amount: Money.optional() })
40
+ .nullable()
41
+ .optional(),
42
+ amount: Money.optional(),
43
+ foreignAmount: Money.nullable().optional(),
44
+ settledAt: z.string().nullable().optional(),
45
+ createdAt: z.string().optional(),
46
+ transactionType: z.string().nullable().optional(),
47
+ note: z
48
+ .looseObject({ text: z.string().optional() })
49
+ .nullable()
50
+ .optional(),
51
+ performingCustomer: z
52
+ .looseObject({ displayName: z.string().optional() })
53
+ .nullable()
54
+ .optional(),
55
+ deepLinkURL: z.string().optional(),
56
+ })
57
+ .optional(),
58
+ relationships: z
59
+ .looseObject({
60
+ account: Ref.optional(),
61
+ transferAccount: Ref.optional(),
62
+ category: Ref.optional(),
63
+ parentCategory: Ref.optional(),
64
+ tags: z
65
+ .looseObject({
66
+ data: z.array(z.looseObject({ id: z.string() })).optional(),
67
+ })
68
+ .optional(),
69
+ })
70
+ .optional(),
71
+ });
72
+
73
+ const Page = <T extends z.ZodType>(item: T) =>
74
+ z.looseObject({
75
+ data: z.array(item),
76
+ links: z.looseObject({ next: z.string().nullable().optional() }).optional(),
77
+ });
78
+ const One = <T extends z.ZodType>(item: T) => z.looseObject({ data: item });
79
+
80
+ const CategoryResource = z.looseObject({
81
+ id: z.string(),
82
+ attributes: z.looseObject({ name: z.string().optional() }).optional(),
83
+ relationships: z.looseObject({ parent: Ref.optional() }).optional(),
84
+ });
85
+ const AccountResource = z.looseObject({
86
+ id: z.string(),
87
+ attributes: z
88
+ .looseObject({
89
+ displayName: z.string().optional(),
90
+ accountType: z.string().optional(),
91
+ ownershipType: z.string().optional(),
92
+ balance: Money.optional(),
93
+ })
94
+ .optional(),
95
+ });
96
+ const WebhookResource = z.looseObject({
97
+ id: z.string(),
98
+ attributes: z
99
+ .looseObject({
100
+ url: z.string().optional(),
101
+ description: z.string().nullable().optional(),
102
+ secretKey: z.string().optional(),
103
+ createdAt: z.string().optional(),
104
+ })
105
+ .optional(),
106
+ });
107
+ const LogResource = z.looseObject({
108
+ id: z.string(),
109
+ attributes: z
110
+ .looseObject({
111
+ deliveryStatus: z.string().optional(),
112
+ createdAt: z.string().optional(),
113
+ response: z
114
+ .looseObject({ statusCode: z.number().optional() })
115
+ .nullable()
116
+ .optional(),
117
+ })
118
+ .optional(),
119
+ });
120
+
121
+ /** One transaction as the library keeps it: amounts in cents (Up's base units), ids flattened, nothing derived. */
122
+ export type Transaction = {
123
+ id: string;
124
+ status?: string; // "HELD" | "SETTLED"
125
+ description?: string; // usually the merchant
126
+ rawText?: string;
127
+ message?: string;
128
+ amountCents?: number; // negative is money out
129
+ currency?: string;
130
+ foreignCents?: number;
131
+ foreignCurrency?: string;
132
+ heldCents?: number; // the amount while held, kept after it settles
133
+ roundUpCents?: number; // the round-up this purchase triggered (negative), a separate movement
134
+ cashbackCents?: number;
135
+ isCategorizable?: boolean;
136
+ createdAt?: string;
137
+ settledAt?: string;
138
+ transactionType?: string;
139
+ note?: string;
140
+ performedBy?: string; // performingCustomer.displayName: who spent, on a joint account
141
+ deepLink?: string;
142
+ accountId?: string;
143
+ transferAccountId?: string; // set on a movement between your own accounts
144
+ categoryId?: string;
145
+ parentCategoryId?: string;
146
+ tags: string[];
147
+ };
148
+
149
+ export type Category = { id: string; name?: string; parentId?: string };
150
+ export type Account = {
151
+ id: string;
152
+ displayName?: string;
153
+ accountType?: string;
154
+ ownershipType?: string;
155
+ balanceCents?: number;
156
+ };
157
+ export type Webhook = {
158
+ id: string;
159
+ url?: string;
160
+ description?: string;
161
+ createdAt?: string;
162
+ secretKey?: string;
163
+ };
164
+ export type WebhookLog = {
165
+ id: string;
166
+ deliveryStatus?: string;
167
+ createdAt?: string;
168
+ statusCode?: number;
169
+ };
170
+
171
+ const id = (r?: z.infer<typeof Ref>) => r?.data?.id ?? undefined;
172
+ const nul = <T>(v: T | null | undefined) => v ?? undefined;
173
+
174
+ /** Up's transaction resource to the library's shape. */
175
+ export function toTransaction(
176
+ r: z.infer<typeof TransactionResource>,
177
+ ): Transaction {
178
+ const a = r.attributes ?? {};
179
+ const rel = r.relationships ?? {};
180
+ return {
181
+ id: r.id,
182
+ status: a.status,
183
+ description: a.description,
184
+ rawText: nul(a.rawText),
185
+ message: nul(a.message),
186
+ amountCents: a.amount?.valueInBaseUnits,
187
+ currency: a.amount?.currencyCode,
188
+ foreignCents: nul(a.foreignAmount)?.valueInBaseUnits,
189
+ foreignCurrency: nul(a.foreignAmount)?.currencyCode,
190
+ heldCents: nul(a.holdInfo)?.amount?.valueInBaseUnits,
191
+ roundUpCents: nul(a.roundUp)?.amount?.valueInBaseUnits,
192
+ cashbackCents: nul(a.cashback)?.amount?.valueInBaseUnits,
193
+ isCategorizable: a.isCategorizable,
194
+ createdAt: a.createdAt,
195
+ settledAt: nul(a.settledAt),
196
+ transactionType: nul(a.transactionType),
197
+ note: nul(a.note)?.text,
198
+ performedBy: nul(a.performingCustomer)?.displayName,
199
+ deepLink: a.deepLinkURL,
200
+ accountId: id(rel.account),
201
+ transferAccountId: id(rel.transferAccount),
202
+ categoryId: id(rel.category),
203
+ parentCategoryId: id(rel.parentCategory),
204
+ tags: rel.tags?.data?.map((t) => t.id) ?? [],
205
+ };
206
+ }
207
+
208
+ export type TransactionQuery = {
209
+ since?: string; // ISO date-time
210
+ until?: string;
211
+ status?: "HELD" | "SETTLED";
212
+ category?: string; // a parent includes its children
213
+ tag?: string;
214
+ accountId?: string;
215
+ limit?: number; // most transactions to return; default 1000
216
+ pageCap?: number; // most pages to follow; default 20
217
+ };
218
+
219
+ /** The Up client: the token comes in, nothing is stored. Reads are GETs; the writes are the three Up allows, each re-reading what Up now holds. */
220
+ export function upClient(options: { token: string; fetch: typeof fetch }) {
221
+ const headers = {
222
+ authorization: `Bearer ${options.token}`,
223
+ accept: "application/json",
224
+ };
225
+ const url = (path: string) =>
226
+ path.startsWith("https://") ? path : `${BASE}${path}`;
227
+ const get = <T>(path: string, schema: z.ZodType<T>, signal?: AbortSignal) =>
228
+ getJson(options.fetch, url(path), {
229
+ init: { headers },
230
+ signal,
231
+ schema,
232
+ error: UpError,
233
+ });
234
+ /** A write: JSON in, the status checked, the body (if any) returned. Up answers 204 to category and tag writes. */
235
+ async function send(
236
+ method: string,
237
+ path: string,
238
+ body?: unknown,
239
+ signal?: AbortSignal,
240
+ ): Promise<unknown> {
241
+ const init = {
242
+ method,
243
+ headers: {
244
+ ...headers,
245
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
246
+ },
247
+ body: body === undefined ? undefined : JSON.stringify(body),
248
+ };
249
+ const res = await fetchTimeout(options.fetch, url(path), init, { signal });
250
+ if (!res.ok)
251
+ throw new UpError(
252
+ `HTTP ${res.status} on ${method} ${new URL(url(path)).pathname}`,
253
+ { status: res.status },
254
+ );
255
+ return res.status === 204 ? undefined : res.json();
256
+ }
257
+ /** Follow `links.next` until `limit` items or `pageCap` pages; `truncated` says more were there. */
258
+ async function pages<T extends z.ZodType>(
259
+ path: string,
260
+ item: T,
261
+ limit: number,
262
+ pageCap: number,
263
+ signal?: AbortSignal,
264
+ ) {
265
+ const out: z.infer<T>[] = [];
266
+ let next: string | undefined = path;
267
+ let n = 0;
268
+ while (next && out.length < limit && n < pageCap) {
269
+ const page: { data: z.infer<T>[]; links?: { next?: string | null } } =
270
+ await get(next, Page(item), signal);
271
+ out.push(...page.data);
272
+ next = page.links?.next ?? undefined;
273
+ n++;
274
+ }
275
+ return {
276
+ items: out.slice(0, limit),
277
+ truncated: next !== undefined || out.length > limit,
278
+ };
279
+ }
280
+ const transaction = async (txId: string, signal?: AbortSignal) =>
281
+ toTransaction(
282
+ (
283
+ await get(
284
+ `/transactions/${encodeURIComponent(txId)}`,
285
+ One(TransactionResource),
286
+ signal,
287
+ )
288
+ ).data,
289
+ );
290
+ const tagBody = (labels: string[]) => ({
291
+ data: labels.map((l) => ({ type: "tags", id: l })),
292
+ });
293
+
294
+ return {
295
+ /** 200 when the token is good. */
296
+ async ping(signal?: AbortSignal): Promise<void> {
297
+ await get("/util/ping", z.unknown(), signal);
298
+ },
299
+ async accounts(
300
+ filter: { accountType?: string; ownershipType?: string } = {},
301
+ signal?: AbortSignal,
302
+ ): Promise<Account[]> {
303
+ const q = new URLSearchParams({ "page[size]": "100" });
304
+ if (filter.accountType) q.set("filter[accountType]", filter.accountType);
305
+ if (filter.ownershipType)
306
+ q.set("filter[ownershipType]", filter.ownershipType);
307
+ const { items } = await pages(
308
+ `/accounts?${q}`,
309
+ AccountResource,
310
+ 1000,
311
+ 10,
312
+ signal,
313
+ );
314
+ return items.map((r) => ({
315
+ id: r.id,
316
+ displayName: r.attributes?.displayName,
317
+ accountType: r.attributes?.accountType,
318
+ ownershipType: r.attributes?.ownershipType,
319
+ balanceCents: r.attributes?.balance?.valueInBaseUnits,
320
+ }));
321
+ },
322
+ /** Transactions newest first, following cursors. A cut-off result says so in `truncated`, so it never looks complete. */
323
+ async transactions(
324
+ query: TransactionQuery = {},
325
+ signal?: AbortSignal,
326
+ ): Promise<{ transactions: Transaction[]; truncated: boolean }> {
327
+ const limit = query.limit ?? 1000;
328
+ const q = new URLSearchParams({
329
+ "page[size]": String(Math.min(100, limit)),
330
+ });
331
+ for (const [k, v] of [
332
+ ["since", query.since],
333
+ ["until", query.until],
334
+ ["status", query.status],
335
+ ["category", query.category],
336
+ ["tag", query.tag],
337
+ ] as const)
338
+ if (v) q.set(`filter[${k}]`, v);
339
+ const base = query.accountId
340
+ ? `/accounts/${encodeURIComponent(query.accountId)}/transactions`
341
+ : "/transactions";
342
+ const { items, truncated } = await pages(
343
+ `${base}?${q}`,
344
+ TransactionResource,
345
+ limit,
346
+ query.pageCap ?? 20,
347
+ signal,
348
+ );
349
+ return { transactions: items.map(toTransaction), truncated };
350
+ },
351
+ transaction,
352
+ async categories(signal?: AbortSignal): Promise<Category[]> {
353
+ const { data } = await get(
354
+ "/categories",
355
+ z.looseObject({ data: z.array(CategoryResource) }),
356
+ signal,
357
+ );
358
+ return data.map((c) => ({
359
+ id: c.id,
360
+ name: c.attributes?.name,
361
+ parentId: id(c.relationships?.parent),
362
+ }));
363
+ },
364
+ async tags(signal?: AbortSignal): Promise<string[]> {
365
+ return (
366
+ await pages(
367
+ "/tags?page[size]=100",
368
+ z.looseObject({ id: z.string() }),
369
+ 1000,
370
+ 10,
371
+ signal,
372
+ )
373
+ ).items.map((t) => t.id);
374
+ },
375
+
376
+ // Writes. The integration never calls these; `home up categorise|tag` and a consumer's own code do.
377
+
378
+ /** Set a child category, or `null` to clear it. Returns the transaction as Up now holds it. */
379
+ async setCategory(
380
+ txId: string,
381
+ categoryId: string | null,
382
+ signal?: AbortSignal,
383
+ ): Promise<Transaction> {
384
+ await send(
385
+ "PATCH",
386
+ `/transactions/${encodeURIComponent(txId)}/relationships/category`,
387
+ {
388
+ data:
389
+ categoryId === null ? null : { type: "categories", id: categoryId },
390
+ },
391
+ signal,
392
+ );
393
+ return transaction(txId, signal);
394
+ },
395
+ /** Add tags by label (at most 6 per transaction; a duplicate is ignored). Returns the transaction as Up now holds it. */
396
+ async addTags(
397
+ txId: string,
398
+ labels: string[],
399
+ signal?: AbortSignal,
400
+ ): Promise<Transaction> {
401
+ await send(
402
+ "POST",
403
+ `/transactions/${encodeURIComponent(txId)}/relationships/tags`,
404
+ tagBody(labels),
405
+ signal,
406
+ );
407
+ return transaction(txId, signal);
408
+ },
409
+ /** Remove tags by label (an absent one is ignored). Returns the transaction as Up now holds it. */
410
+ async removeTags(
411
+ txId: string,
412
+ labels: string[],
413
+ signal?: AbortSignal,
414
+ ): Promise<Transaction> {
415
+ await send(
416
+ "DELETE",
417
+ `/transactions/${encodeURIComponent(txId)}/relationships/tags`,
418
+ tagBody(labels),
419
+ signal,
420
+ );
421
+ return transaction(txId, signal);
422
+ },
423
+
424
+ // Webhooks: reads, then writes behind the same gate.
425
+
426
+ async webhooks(signal?: AbortSignal): Promise<Webhook[]> {
427
+ return (
428
+ await pages(
429
+ "/webhooks?page[size]=100",
430
+ WebhookResource,
431
+ 1000,
432
+ 10,
433
+ signal,
434
+ )
435
+ ).items.map(toWebhook);
436
+ },
437
+ async webhook(webhookId: string, signal?: AbortSignal): Promise<Webhook> {
438
+ return toWebhook(
439
+ (
440
+ await get(
441
+ `/webhooks/${encodeURIComponent(webhookId)}`,
442
+ One(WebhookResource),
443
+ signal,
444
+ )
445
+ ).data,
446
+ );
447
+ },
448
+ async webhookLogs(
449
+ webhookId: string,
450
+ signal?: AbortSignal,
451
+ ): Promise<WebhookLog[]> {
452
+ const { items } = await pages(
453
+ `/webhooks/${encodeURIComponent(webhookId)}/logs?page[size]=30`,
454
+ LogResource,
455
+ 30,
456
+ 1,
457
+ signal,
458
+ );
459
+ return items.map((l) => ({
460
+ id: l.id,
461
+ deliveryStatus: l.attributes?.deliveryStatus,
462
+ createdAt: l.attributes?.createdAt,
463
+ statusCode: nul(l.attributes?.response)?.statusCode,
464
+ }));
465
+ },
466
+ /** Create a webhook. Its `secretKey` comes back this once: the caller keeps it; this client never stores or logs it. */
467
+ async createWebhook(
468
+ hookUrl: string,
469
+ description?: string,
470
+ signal?: AbortSignal,
471
+ ): Promise<Webhook> {
472
+ const body = await send(
473
+ "POST",
474
+ "/webhooks",
475
+ {
476
+ data: {
477
+ attributes: {
478
+ url: hookUrl,
479
+ ...(description ? { description } : {}),
480
+ },
481
+ },
482
+ },
483
+ signal,
484
+ );
485
+ return toWebhook(One(WebhookResource).parse(body).data);
486
+ },
487
+ async deleteWebhook(
488
+ webhookId: string,
489
+ signal?: AbortSignal,
490
+ ): Promise<void> {
491
+ await send(
492
+ "DELETE",
493
+ `/webhooks/${encodeURIComponent(webhookId)}`,
494
+ undefined,
495
+ signal,
496
+ );
497
+ },
498
+ /** Ask Up to send a PING event to the webhook. */
499
+ async pingWebhook(webhookId: string, signal?: AbortSignal): Promise<void> {
500
+ await send(
501
+ "POST",
502
+ `/webhooks/${encodeURIComponent(webhookId)}/ping`,
503
+ undefined,
504
+ signal,
505
+ );
506
+ },
507
+ };
508
+ }
509
+
510
+ export type UpClient = ReturnType<typeof upClient>;
511
+
512
+ const toWebhook = (w: z.infer<typeof WebhookResource>): Webhook => ({
513
+ id: w.id,
514
+ url: w.attributes?.url,
515
+ description: nul(w.attributes?.description),
516
+ createdAt: w.attributes?.createdAt,
517
+ secretKey: w.attributes?.secretKey,
518
+ });