@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,1352 @@
1
+ /**
2
+ * Fortnox emulator API: the route table (with its evidence) and the stateful route handlers
3
+ * registered on the `@emulators/core` app (internal; re-exported by `src/fortnox.ts`).
4
+ *
5
+ * Wire shapes follow the synthetic Fortnox conformance fixtures, copied as data. Each route lists
6
+ * the conformance cases whose claims it follows; the observed quirks are implemented here.
7
+ *
8
+ * @experimental
9
+ */
10
+ import type { Hono } from '@emulators/core'
11
+ import { Predicate, Result } from 'effect'
12
+ import * as Schema from 'effect/Schema'
13
+ import { answeredOutsideCore, emulatorJobHeader, handlerFailedResponse } from '../emulator-http.ts'
14
+ import type { EmulatorRouteEvidence } from '../route-evidence.ts'
15
+ import {
16
+ buildRow,
17
+ compareNumbers,
18
+ defaultEmailInformation,
19
+ formatQuantity,
20
+ fortnoxEmulatorCountries,
21
+ fortnoxEmulatorVatCodes,
22
+ invoiceFromSeed,
23
+ invoiceTotals,
24
+ newRowDefaults,
25
+ type FortnoxEmulatorCustomer,
26
+ type FortnoxEmulatorInvoice,
27
+ type FortnoxEmulatorInvoiceRow,
28
+ type FortnoxEmulatorInvoiceRowSeed,
29
+ type FortnoxEmulatorInvoiceSeed,
30
+ type FortnoxEmulatorState
31
+ } from './state.ts'
32
+
33
+ /**
34
+ * Drill knobs: each flips one observed quirk to the documented-but-wrong behavior, ONLY to prove
35
+ * that the conformance cases catch a disagreement. Defaults follow the observed behavior.
36
+ */
37
+ export type FortnoxEmulatorQuirks = {
38
+ /** `false`: a positionally matched row that omits `Discount`/`DiscountType` resets them. */
39
+ readonly stickyRowDiscount?: boolean
40
+ /** `true`: a customer update with `""` clears the stored string instead of keeping it. */
41
+ readonly emptyStringClears?: boolean
42
+ /** `true`: `unpaid`/`unpaidoverdue`/`fullypaid` also match unbooked invoices. */
43
+ readonly paymentFiltersIncludeUnbooked?: boolean
44
+ }
45
+
46
+ export type FortnoxApiEnv = {
47
+ /** Clock in epoch milliseconds (the `unpaidoverdue` "today", new invoice dates, the outbox). */
48
+ readonly now: () => number
49
+ /** Origin used in `@url` links, for example `https://api.fortnox.se`. */
50
+ readonly linkOrigin: string
51
+ readonly quirks: Required<FortnoxEmulatorQuirks>
52
+ }
53
+
54
+ /**
55
+ * `ErrorInformation` codes the emulator answers with. The two customer-not-found codes are copied
56
+ * from the rejection fixture; the `2999xxx` codes are synthetic emulator codes.
57
+ */
58
+ export const fortnoxEmulatorErrorCodes = {
59
+ customerNotFound: 2000204,
60
+ invoiceCustomerNotFound: 2000433,
61
+ unauthorized: 2999001,
62
+ invalidBody: 2999002,
63
+ invalidField: 2999003,
64
+ readOnlyField: 2999004,
65
+ notEditable: 2999005,
66
+ invoiceNotFound: 2999006,
67
+ unknownRoute: 2999007,
68
+ unsupportedQuery: 2999008,
69
+ rateLimited: 2999009,
70
+ upstreamError: 2999010
71
+ } as const
72
+
73
+ /** Fortnox API base path. */
74
+ export const fortnoxEmulatorBasePath = '/3'
75
+
76
+ const jsonResponse = (status: number, body: unknown): Response =>
77
+ new Response(JSON.stringify(body), {
78
+ status,
79
+ headers: { 'content-type': 'application/json' }
80
+ })
81
+
82
+ /** A Fortnox `ErrorInformation` response with the lowercase fields of the rejection fixture. */
83
+ export const errorInformation = (status: number, code: number, message: string): Response =>
84
+ jsonResponse(status, { ErrorInformation: { error: 1, message, code } })
85
+
86
+ const codes = fortnoxEmulatorErrorCodes
87
+
88
+ // Messages copied from the rejection fixture.
89
+ const customerNotFound = (): Response =>
90
+ errorInformation(404, codes.customerNotFound, 'Synthetic placeholder: customer not found.')
91
+
92
+ const invoiceCustomerNotFound = (): Response =>
93
+ errorInformation(400, codes.invoiceCustomerNotFound, 'Synthetic placeholder: customer not found.')
94
+
95
+ const invoiceNotFound = (): Response =>
96
+ errorInformation(404, codes.invoiceNotFound, 'Synthetic: invoice not found.')
97
+
98
+ const invalidBody = (message: string): Response =>
99
+ errorInformation(400, codes.invalidBody, `Synthetic: ${message}`)
100
+
101
+ const invalidField = (message: string): Response =>
102
+ errorInformation(400, codes.invalidField, `Synthetic: ${message}`)
103
+
104
+ const noReferencedEntity = (kind: string, value: string): Response =>
105
+ invalidField(`${kind} ${value} does not exist in the emulated company.`)
106
+
107
+ const readOnlyField = (message: string): Response =>
108
+ errorInformation(400, codes.readOnlyField, `Synthetic: ${message}`)
109
+
110
+ const notEditable = (message: string): Response =>
111
+ errorInformation(400, codes.notEditable, `Synthetic: ${message}`)
112
+
113
+ const unsupportedQuery = (key: string): Response =>
114
+ errorInformation(
115
+ 400,
116
+ codes.unsupportedQuery,
117
+ key === 'lastmodified'
118
+ ? 'Synthetic: query parameter lastmodified is not emulated (the emulator does not track modification times).'
119
+ : `Synthetic: query parameter ${key} is not emulated.`
120
+ )
121
+
122
+ // The synthetic invoice preview PDF from the preview fixture (`bodyBase64`), copied as data.
123
+ const previewPdfBase64 =
124
+ 'JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFtdIC9Db3VudCAwID4+CmVuZG9iagp0cmFpbGVyCjw8IC9Sb290IDEgMCBSID4+CiUlRU9GCg=='
125
+
126
+ const previewPdfBytes = (): Uint8Array<ArrayBuffer> =>
127
+ Uint8Array.from(atob(previewPdfBase64), character => character.charCodeAt(0))
128
+
129
+ type RouteRequest = {
130
+ readonly params: Readonly<Record<string, string>>
131
+ readonly query: URLSearchParams
132
+ /** Parsed JSON body; `undefined` when absent or not JSON. */
133
+ readonly body: Schema.Json | undefined
134
+ }
135
+
136
+ const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
137
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
138
+
139
+ type RouteHandler = (
140
+ state: FortnoxEmulatorState,
141
+ request: RouteRequest,
142
+ env: FortnoxApiEnv
143
+ ) => Response
144
+
145
+ type FortnoxApiRoute = EmulatorRouteEvidence & {
146
+ /**
147
+ * Query parameters the route emulates; any other key answers 400 before the handler runs, so
148
+ * a rejected request never writes. Empty by default.
149
+ */
150
+ readonly queryKeys: ReadonlyArray<string>
151
+ readonly handler: RouteHandler
152
+ }
153
+
154
+ const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/
155
+
156
+ /** A real `YYYY-MM-DD` calendar date (no JavaScript rollover such as `2026-02-30`). */
157
+ const isIsoDate = (value: string): boolean => {
158
+ const parsed = Date.parse(`${value}T00:00:00.000Z`)
159
+
160
+ return (
161
+ isoDatePattern.test(value) &&
162
+ !Number.isNaN(parsed) &&
163
+ new Date(parsed).toISOString().slice(0, 10) === value
164
+ )
165
+ }
166
+
167
+ const today = (env: FortnoxApiEnv): string => new Date(env.now()).toISOString().slice(0, 10)
168
+
169
+ /** `date` plus `days`, or `undefined` when the result is not a representable `YYYY-MM-DD` date. */
170
+ const addDays = (date: string, days: number): string | undefined => {
171
+ const result = new Date(Date.parse(`${date}T00:00:00.000Z`) + days * 24 * 60 * 60 * 1000)
172
+
173
+ if (Number.isNaN(result.getTime())) return undefined
174
+
175
+ const iso = result.toISOString().slice(0, 10)
176
+
177
+ return isIsoDate(iso) ? iso : undefined
178
+ }
179
+
180
+ const maxTermsOfPaymentDays = 365
181
+
182
+ /**
183
+ * `TermsOfPayment` as a whole number of days (`0`–`365`, no sign or leading zeros), or
184
+ * `undefined` when the value is not emulated. Fortnox also knows named terms (such as `K`); the
185
+ * emulated company has day terms only.
186
+ */
187
+ const termsOfPaymentDays = (value: string): number | undefined => {
188
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(value)) return undefined
189
+
190
+ const days = Number(value)
191
+
192
+ return days <= maxTermsOfPaymentDays ? days : undefined
193
+ }
194
+
195
+ const unsupportedTermsOfPayment = (value: string): Response =>
196
+ invalidField(
197
+ `TermsOfPayment ${value} is not emulated (whole days from 0 to ${maxTermsOfPaymentDays} only).`
198
+ )
199
+
200
+ /**
201
+ * The customer categorical values the emulator deliberately models. This is the emulated subset,
202
+ * not Fortnox's full enum: other values (for example export or EU reverse-charge VAT) would change
203
+ * invoice VAT, which the emulator does not model, so they are rejected.
204
+ */
205
+ const emulatedCustomerVatTypes: ReadonlyArray<string> = ['SEVAT']
206
+
207
+ const emulatedCustomerTypes: ReadonlyArray<string> = ['COMPANY', 'PRIVATE']
208
+
209
+ const unsupportedVatType = (value: string): Response =>
210
+ invalidField(
211
+ `VATType ${value} is not emulated (supported: ${emulatedCustomerVatTypes.join(', ')}).`
212
+ )
213
+
214
+ const customerUrl = (env: FortnoxApiEnv, customerNumber: string): string =>
215
+ `${env.linkOrigin}${fortnoxEmulatorBasePath}/customers/${encodeURIComponent(customerNumber)}`
216
+
217
+ const invoiceUrl = (env: FortnoxApiEnv, documentNumber: string): string =>
218
+ `${env.linkOrigin}${fortnoxEmulatorBasePath}/invoices/${encodeURIComponent(documentNumber)}`
219
+
220
+ const renderCustomer = (env: FortnoxApiEnv, customer: FortnoxEmulatorCustomer) => ({
221
+ '@url': customerUrl(env, customer.CustomerNumber),
222
+ ...customer
223
+ })
224
+
225
+ // No fixture records the customer list: rows carry the list-only `Phone` and a subset of fields.
226
+ const renderCustomerRow = (env: FortnoxApiEnv, customer: FortnoxEmulatorCustomer) => ({
227
+ '@url': customerUrl(env, customer.CustomerNumber),
228
+ Address1: customer.Address1,
229
+ Address2: customer.Address2,
230
+ City: customer.City,
231
+ CustomerNumber: customer.CustomerNumber,
232
+ Email: customer.Email,
233
+ Name: customer.Name,
234
+ OrganisationNumber: customer.OrganisationNumber,
235
+ Phone: customer.Phone1,
236
+ ZipCode: customer.ZipCode
237
+ })
238
+
239
+ type EmailInformationFields = {
240
+ -readonly [
241
+ K in keyof FortnoxEmulatorInvoice['EmailInformation']
242
+ ]: FortnoxEmulatorInvoice['EmailInformation'][K]
243
+ }
244
+
245
+ /**
246
+ * A single-invoice read: JSON-number amounts. `dropEmptyCopies` leaves empty CC/BCC addresses out
247
+ * of `EmailInformation`, as the email-send fixture response does.
248
+ */
249
+ const renderInvoice = (
250
+ env: FortnoxApiEnv,
251
+ invoice: FortnoxEmulatorInvoice,
252
+ dropEmptyCopies = false
253
+ ) => {
254
+ const source = invoice.EmailInformation
255
+
256
+ const email: Partial<EmailInformationFields> = {
257
+ EmailAddressFrom: source.EmailAddressFrom,
258
+ EmailAddressTo: source.EmailAddressTo
259
+ }
260
+
261
+ if (!dropEmptyCopies || source.EmailAddressCC !== '') {
262
+ email.EmailAddressCC = source.EmailAddressCC
263
+ }
264
+
265
+ if (!dropEmptyCopies || source.EmailAddressBCC !== '') {
266
+ email.EmailAddressBCC = source.EmailAddressBCC
267
+ }
268
+
269
+ email.EmailSubject = source.EmailSubject
270
+ email.EmailBody = source.EmailBody
271
+
272
+ return {
273
+ '@url': invoiceUrl(env, invoice.DocumentNumber),
274
+ ...invoice,
275
+ EmailInformation: email
276
+ }
277
+ }
278
+
279
+ /** An invoice list row, in the list fixture's shape: `CurrencyRate` is a numeric string. */
280
+ const renderInvoiceRow = (env: FortnoxApiEnv, invoice: FortnoxEmulatorInvoice) => ({
281
+ '@url': invoiceUrl(env, invoice.DocumentNumber),
282
+ Balance: invoice.Balance,
283
+ Booked: invoice.Booked,
284
+ Cancelled: invoice.Cancelled,
285
+ CostCenter: invoice.CostCenter,
286
+ Currency: invoice.Currency,
287
+ CurrencyRate: String(invoice.CurrencyRate),
288
+ CurrencyUnit: invoice.CurrencyUnit,
289
+ CustomerName: invoice.CustomerName,
290
+ CustomerNumber: invoice.CustomerNumber,
291
+ DocumentNumber: invoice.DocumentNumber,
292
+ DueDate: invoice.DueDate,
293
+ ExternalInvoiceReference1: invoice.ExternalInvoiceReference1,
294
+ ExternalInvoiceReference2: invoice.ExternalInvoiceReference2,
295
+ FinalPayDate: invoice.FinalPayDate,
296
+ InvoiceDate: invoice.InvoiceDate,
297
+ InvoiceType: invoice.InvoiceType,
298
+ NoxFinans: invoice.NoxFinans,
299
+ OCR: invoice.OCR,
300
+ VoucherNumber: invoice.VoucherNumber,
301
+ VoucherSeries: invoice.VoucherSeries,
302
+ VoucherYear: invoice.VoucherYear,
303
+ WayOfDelivery: invoice.WayOfDelivery,
304
+ TermsOfPayment: invoice.TermsOfPayment,
305
+ Project: invoice.Project,
306
+ Sent: invoice.Sent,
307
+ Total: invoice.Total
308
+ })
309
+
310
+ /** The 400 for the first query key the route does not emulate, or `undefined`. */
311
+ const unsupportedQueryKey = (
312
+ query: URLSearchParams,
313
+ allowed: ReadonlyArray<string>
314
+ ): Response | undefined => {
315
+ const key = [...query.keys()].find(candidate => !allowed.includes(candidate))
316
+
317
+ return key === undefined ? undefined : unsupportedQuery(key)
318
+ }
319
+
320
+ const positiveInt = (
321
+ query: URLSearchParams,
322
+ key: string,
323
+ fallback: number,
324
+ maximum: number
325
+ ): number | Response => {
326
+ const raw = query.get(key)
327
+
328
+ if (raw === null) {
329
+ return fallback
330
+ }
331
+
332
+ const value = Number(raw)
333
+
334
+ return /^\d+$/.test(raw) && value >= 1 && value <= maximum
335
+ ? value
336
+ : invalidField(`query parameter ${key} must be an integer from 1 to ${maximum}.`)
337
+ }
338
+
339
+ /** Fortnox default and maximum page sizes. */
340
+ const defaultLimit = 100
341
+
342
+ const maxLimit = 500
343
+
344
+ /** One page plus `MetaInformation` (`@TotalPages` is at least 1, as the empty-list fixture shows). */
345
+ const paginate = <A>(
346
+ items: ReadonlyArray<A>,
347
+ query: URLSearchParams
348
+ ): { readonly items: ReadonlyArray<A>; readonly meta: Record<string, number> } | Response => {
349
+ const limit = positiveInt(query, 'limit', defaultLimit, maxLimit)
350
+ const page = positiveInt(query, 'page', 1, Number.MAX_SAFE_INTEGER)
351
+
352
+ if (limit instanceof Response) return limit
353
+
354
+ if (page instanceof Response) return page
355
+
356
+ return {
357
+ items: items.slice((page - 1) * limit, page * limit),
358
+ meta: {
359
+ '@CurrentPage': page,
360
+ '@TotalPages': Math.max(1, Math.ceil(items.length / limit)),
361
+ '@TotalResources': items.length
362
+ }
363
+ }
364
+ }
365
+
366
+ const contains = (value: string, search: string): boolean =>
367
+ value.toLowerCase().includes(search.toLowerCase())
368
+
369
+ type CustomerSearch = (customer: FortnoxEmulatorCustomer, value: string) => boolean
370
+
371
+ const customerSearch = {
372
+ customernumber: (customer, value) => customer.CustomerNumber === value,
373
+ name: (customer, value) => contains(customer.Name, value),
374
+ organisationnumber: (customer, value) => contains(customer.OrganisationNumber, value),
375
+ email: (customer, value) => contains(customer.Email, value),
376
+ city: (customer, value) => contains(customer.City, value),
377
+ phone: (customer, value) => contains(customer.Phone1, value) || contains(customer.Phone2, value)
378
+ } satisfies Record<string, CustomerSearch>
379
+
380
+ const customerListQueryKeys: ReadonlyArray<string> = [
381
+ 'page',
382
+ 'limit',
383
+ 'filter',
384
+ ...Object.keys(customerSearch)
385
+ ]
386
+
387
+ const listCustomers: RouteHandler = (state, { query }, env) => {
388
+ const filter = query.get('filter')
389
+
390
+ if (filter !== null && filter !== 'active' && filter !== 'inactive') {
391
+ return invalidField(`unknown customer filter ${filter}.`)
392
+ }
393
+
394
+ const matching = state.customers.filter(
395
+ customer =>
396
+ (filter === null || customer.Active === (filter === 'active')) &&
397
+ Object.entries(customerSearch).every(([key, matches]) => {
398
+ const value = query.get(key)
399
+
400
+ return value === null || matches(customer, value)
401
+ })
402
+ )
403
+
404
+ const page = paginate(matching, query)
405
+
406
+ if (page instanceof Response) return page
407
+
408
+ return jsonResponse(200, {
409
+ Customers: page.items.map(customer => renderCustomerRow(env, customer)),
410
+ MetaInformation: page.meta
411
+ })
412
+ }
413
+
414
+ const findCustomer = (state: FortnoxEmulatorState, customerNumber: string) =>
415
+ state.customers.find(customer => customer.CustomerNumber === customerNumber)
416
+
417
+ const findInvoice = (state: FortnoxEmulatorState, documentNumber: string) =>
418
+ state.invoices.find(invoice => invoice.DocumentNumber === documentNumber)
419
+
420
+ const getCustomer: RouteHandler = (state, { params }, env) => {
421
+ const customer = findCustomer(state, params.CustomerNumber ?? '')
422
+
423
+ return customer === undefined
424
+ ? customerNotFound()
425
+ : jsonResponse(200, { Customer: renderCustomer(env, customer) })
426
+ }
427
+
428
+ /** The single resource object of a `{ <envelope>: { ... } }` write body. */
429
+ const envelopeObject = (
430
+ body: Schema.Json | undefined,
431
+ envelope: string
432
+ ): Schema.JsonObject | Response => {
433
+ const value = isJsonObject(body) ? body[envelope] : undefined
434
+
435
+ return isJsonObject(body) &&
436
+ Object.keys(body).every(key => key === envelope) &&
437
+ isJsonObject(value)
438
+ ? value
439
+ : invalidBody(`the request body must be { "${envelope}": { ... } }.`)
440
+ }
441
+
442
+ const writableCustomerStrings = [
443
+ 'Name',
444
+ 'OrganisationNumber',
445
+ 'Email',
446
+ 'Phone1',
447
+ 'Phone2',
448
+ 'Address1',
449
+ 'Address2',
450
+ 'City',
451
+ 'ZipCode',
452
+ 'CountryCode',
453
+ 'Currency',
454
+ 'VATNumber',
455
+ 'VATType',
456
+ 'TermsOfPayment',
457
+ 'OurReference',
458
+ 'YourReference',
459
+ 'Comments',
460
+ 'EmailInvoice',
461
+ 'Type',
462
+ 'CostCenter'
463
+ ] as const
464
+
465
+ type CustomerStringField = (typeof writableCustomerStrings)[number]
466
+
467
+ const isCustomerStringField = (key: string): key is CustomerStringField =>
468
+ writableCustomerStrings.some(field => field === key)
469
+
470
+ type CustomerUpdates = {
471
+ -readonly [K in keyof FortnoxEmulatorCustomer]?: FortnoxEmulatorCustomer[K]
472
+ }
473
+
474
+ /**
475
+ * Why a customer field value is not emulated (fail closed), or `undefined`. The emulated company
476
+ * has only SEK, no cost centers, terms of payment in whole days (0–365), `VATType` `SEVAT`, and
477
+ * `Type` `COMPANY` or `PRIVATE`.
478
+ */
479
+ const unsupportedCustomerValue = (
480
+ key: CustomerStringField,
481
+ value: string
482
+ ): Response | undefined => {
483
+ switch (key) {
484
+ case 'Currency':
485
+ return value === 'SEK' ? undefined : noReferencedEntity('Currency', value)
486
+ case 'CostCenter':
487
+ return value === '' ? undefined : noReferencedEntity('CostCenter', value)
488
+ case 'TermsOfPayment':
489
+ return termsOfPaymentDays(value) === undefined ? unsupportedTermsOfPayment(value) : undefined
490
+ case 'VATType':
491
+ return emulatedCustomerVatTypes.includes(value) ? undefined : unsupportedVatType(value)
492
+ case 'Type':
493
+ return emulatedCustomerTypes.includes(value)
494
+ ? undefined
495
+ : invalidField(
496
+ `Type ${value} is not emulated (supported: ${emulatedCustomerTypes.join(', ')}).`
497
+ )
498
+ default:
499
+ return undefined
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Quirk 2: an empty string keeps the stored value (unless the `emptyStringClears` drill knob is
505
+ * set); omitted fields keep theirs. Quirk 5: `Country` is read-only. Unknown fields, wrong types,
506
+ * and values the emulator does not support (non-SEK `Currency`, a `CostCenter`, `TermsOfPayment`
507
+ * other than 0–365 days, `VATType` other than `SEVAT`, `Type` other than `COMPANY`/`PRIVATE`) are
508
+ * rejected; the update is atomic.
509
+ */
510
+ const customerUpdates = (
511
+ fields: Schema.JsonObject,
512
+ customer: FortnoxEmulatorCustomer,
513
+ env: FortnoxApiEnv
514
+ ): CustomerUpdates | Response => {
515
+ const updates: CustomerUpdates = {}
516
+
517
+ for (const [key, value] of Object.entries(fields)) {
518
+ if (key === 'Country') {
519
+ return readOnlyField('Country is read-only; set CountryCode instead.')
520
+ }
521
+
522
+ if (key === 'CustomerNumber') {
523
+ if (value !== customer.CustomerNumber) {
524
+ return invalidField('CustomerNumber cannot be changed.')
525
+ }
526
+
527
+ continue
528
+ }
529
+
530
+ if (key === 'Active') {
531
+ if (!Predicate.isBoolean(value)) return invalidField('Active must be a boolean.')
532
+
533
+ updates.Active = value
534
+
535
+ continue
536
+ }
537
+
538
+ if (!isCustomerStringField(key)) {
539
+ return invalidField(`unknown customer field ${key}.`)
540
+ }
541
+
542
+ if (!Predicate.isString(value)) {
543
+ return invalidField(`${key} must be a string.`)
544
+ }
545
+
546
+ if (value === '' && !env.quirks.emptyStringClears) {
547
+ continue
548
+ }
549
+
550
+ const unsupported = unsupportedCustomerValue(key, value)
551
+
552
+ if (unsupported !== undefined) return unsupported
553
+
554
+ if (key === 'CountryCode' && value !== '') {
555
+ const country = fortnoxEmulatorCountries.get(value)
556
+
557
+ if (country === undefined) return invalidField(`unknown CountryCode ${value}.`)
558
+
559
+ updates.Country = country
560
+ }
561
+
562
+ updates[key] = value
563
+ }
564
+
565
+ return updates
566
+ }
567
+
568
+ const updateCustomer: RouteHandler = (state, { params, body }, env) => {
569
+ const customer = findCustomer(state, params.CustomerNumber ?? '')
570
+
571
+ if (customer === undefined) return customerNotFound()
572
+
573
+ const fields = envelopeObject(body, 'Customer')
574
+
575
+ if (fields instanceof Response) return fields
576
+
577
+ const updates = customerUpdates(fields, customer, env)
578
+
579
+ if (updates instanceof Response) return updates
580
+
581
+ const updated: FortnoxEmulatorCustomer = { ...customer, ...updates }
582
+
583
+ state.customers = state.customers.map(existing =>
584
+ existing.CustomerNumber === updated.CustomerNumber ? updated : existing
585
+ )
586
+
587
+ return jsonResponse(200, { Customer: renderCustomer(env, updated) })
588
+ }
589
+
590
+ type InvoiceFilter = 'cancelled' | 'fullypaid' | 'unpaid' | 'unpaidoverdue' | 'unbooked'
591
+
592
+ const invoiceFilters: ReadonlyArray<InvoiceFilter> = [
593
+ 'cancelled',
594
+ 'fullypaid',
595
+ 'unpaid',
596
+ 'unpaidoverdue',
597
+ 'unbooked'
598
+ ]
599
+
600
+ const isInvoiceFilter = (value: string): value is InvoiceFilter =>
601
+ invoiceFilters.some(filter => filter === value)
602
+
603
+ /**
604
+ * Quirk 3: the payment-status filters consider booked invoices only (unless the
605
+ * `paymentFiltersIncludeUnbooked` drill knob is set); `unbooked` returns unbooked, uncancelled
606
+ * invoices; `unpaidoverdue` needs a `DueDate` before today (UTC, from the emulator clock).
607
+ */
608
+ const matchesInvoiceFilter = (
609
+ invoice: FortnoxEmulatorInvoice,
610
+ filter: InvoiceFilter,
611
+ env: FortnoxApiEnv
612
+ ): boolean => {
613
+ const considered =
614
+ (invoice.Booked || env.quirks.paymentFiltersIncludeUnbooked) && !invoice.Cancelled
615
+
616
+ switch (filter) {
617
+ case 'cancelled':
618
+ return invoice.Cancelled
619
+ case 'unbooked':
620
+ return !invoice.Booked && !invoice.Cancelled
621
+ case 'unpaid':
622
+ return considered && invoice.Balance > 0
623
+ case 'unpaidoverdue':
624
+ return (
625
+ considered &&
626
+ invoice.Balance > 0 &&
627
+ invoice.DueDate !== null &&
628
+ isIsoDate(invoice.DueDate) &&
629
+ invoice.DueDate < today(env)
630
+ )
631
+ case 'fullypaid':
632
+ return considered && invoice.Balance === 0
633
+ }
634
+ }
635
+
636
+ type InvoiceSearch = (invoice: FortnoxEmulatorInvoice, value: string) => boolean
637
+
638
+ const invoiceSearch = {
639
+ customernumber: (invoice, value) => invoice.CustomerNumber === value,
640
+ customername: (invoice, value) => contains(invoice.CustomerName, value),
641
+ documentnumber: (invoice, value) => invoice.DocumentNumber === value,
642
+ ocr: (invoice, value) => invoice.OCR === value
643
+ } satisfies Record<string, InvoiceSearch>
644
+
645
+ const invoiceListQueryKeys: ReadonlyArray<string> = [
646
+ 'page',
647
+ 'limit',
648
+ 'filter',
649
+ 'fromdate',
650
+ 'todate',
651
+ ...Object.keys(invoiceSearch)
652
+ ]
653
+
654
+ const listInvoices: RouteHandler = (state, { query }, env) => {
655
+ const filter = query.get('filter')
656
+
657
+ if (filter !== null && !isInvoiceFilter(filter)) {
658
+ return invalidField(`unknown invoice filter ${filter}.`)
659
+ }
660
+
661
+ const fromDate = query.get('fromdate')
662
+ const toDate = query.get('todate')
663
+
664
+ for (const [key, value] of [
665
+ ['fromdate', fromDate],
666
+ ['todate', toDate]
667
+ ] as const) {
668
+ if (value !== null && !isIsoDate(value)) {
669
+ return invalidField(`query parameter ${key} must be a YYYY-MM-DD date.`)
670
+ }
671
+ }
672
+
673
+ const matching = state.invoices.filter(
674
+ invoice =>
675
+ (filter === null || matchesInvoiceFilter(invoice, filter, env)) &&
676
+ (fromDate === null || invoice.InvoiceDate >= fromDate) &&
677
+ (toDate === null || invoice.InvoiceDate <= toDate) &&
678
+ Object.entries(invoiceSearch).every(([key, matches]) => {
679
+ const value = query.get(key)
680
+
681
+ return value === null || matches(invoice, value)
682
+ })
683
+ )
684
+
685
+ const page = paginate(matching, query)
686
+
687
+ if (page instanceof Response) return page
688
+
689
+ return jsonResponse(200, {
690
+ Invoices: page.items.map(invoice => renderInvoiceRow(env, invoice)),
691
+ MetaInformation: page.meta
692
+ })
693
+ }
694
+
695
+ const getInvoice: RouteHandler = (state, { params }, env) => {
696
+ const invoice = findInvoice(state, params.DocumentNumber ?? '')
697
+
698
+ return invoice === undefined
699
+ ? invoiceNotFound()
700
+ : jsonResponse(200, { Invoice: renderInvoice(env, invoice) })
701
+ }
702
+
703
+ type RowPatch = {
704
+ -readonly [K in keyof FortnoxEmulatorInvoiceRowSeed]?: FortnoxEmulatorInvoiceRowSeed[K]
705
+ }
706
+
707
+ const vatRates: ReadonlyArray<number> = [...fortnoxEmulatorVatCodes.keys()]
708
+
709
+ /**
710
+ * One row of an `InvoiceRows` write. `null` means omitted. Referenced articles, cost centers, and
711
+ * projects must exist (the emulated company has none); accounts must be in 1000-8999.
712
+ */
713
+ const rowPatch = (value: Schema.Json, index: number): RowPatch | Response => {
714
+ if (!isJsonObject(value)) {
715
+ return invalidField(`InvoiceRows[${index}] must be an object.`)
716
+ }
717
+
718
+ const patch: RowPatch = {}
719
+ const label = (key: string) => `InvoiceRows[${index}].${key}`
720
+
721
+ for (const [key, field] of Object.entries(value)) {
722
+ if (field === null) continue
723
+
724
+ switch (key) {
725
+ case 'RowId':
726
+ if (!Predicate.isNumber(field) || !Number.isSafeInteger(field) || field < 1) {
727
+ return invalidField(`${label(key)} must be a positive integer.`)
728
+ }
729
+
730
+ patch.RowId = field
731
+ break
732
+ case 'AccountNumber':
733
+ if (
734
+ !Predicate.isNumber(field) ||
735
+ !Number.isInteger(field) ||
736
+ field < 1000 ||
737
+ field > 8999
738
+ ) {
739
+ return invalidField(`${label(key)} must be an account number from 1000 to 8999.`)
740
+ }
741
+
742
+ patch.AccountNumber = field
743
+ break
744
+ case 'ArticleNumber':
745
+ case 'CostCenter':
746
+ case 'Project':
747
+ if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`)
748
+
749
+ if (field !== '') return noReferencedEntity(key, field)
750
+
751
+ patch[key] = field
752
+ break
753
+ case 'Description':
754
+ case 'Unit':
755
+ if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`)
756
+
757
+ patch[key] = field
758
+ break
759
+ case 'DeliveredQuantity': {
760
+ const quantity = Predicate.isString(field) ? Number(field.trim()) : field
761
+
762
+ if (
763
+ !Predicate.isNumber(quantity) ||
764
+ !Number.isFinite(quantity) ||
765
+ quantity < 0 ||
766
+ (Predicate.isString(field) && field.trim() === '')
767
+ ) {
768
+ return invalidField(`${label(key)} must be a non-negative number.`)
769
+ }
770
+
771
+ patch.DeliveredQuantity = formatQuantity(quantity)
772
+ break
773
+ }
774
+
775
+ case 'Price':
776
+ if (!Predicate.isNumber(field) || !Number.isFinite(field)) {
777
+ return invalidField(`${label(key)} must be a finite number.`)
778
+ }
779
+
780
+ patch.Price = field
781
+ break
782
+ case 'Discount':
783
+ if (!Predicate.isNumber(field) || !Number.isFinite(field) || field < 0) {
784
+ return invalidField(`${label(key)} must be a non-negative number.`)
785
+ }
786
+
787
+ patch.Discount = field
788
+ break
789
+ case 'DiscountType':
790
+ if (field !== 'PERCENT' && field !== 'AMOUNT') {
791
+ return invalidField(`${label(key)} must be PERCENT or AMOUNT.`)
792
+ }
793
+
794
+ patch.DiscountType = field
795
+ break
796
+ case 'VAT':
797
+ if (!Predicate.isNumber(field) || !vatRates.includes(field)) {
798
+ return invalidField(`${label(key)} must be one of ${vatRates.join(', ')}.`)
799
+ }
800
+
801
+ patch.VAT = field
802
+ break
803
+ default:
804
+ return invalidField(`${label(key)} is not a writable invoice row field.`)
805
+ }
806
+ }
807
+
808
+ return patch
809
+ }
810
+
811
+ const rowPatches = (value: Schema.Json): ReadonlyArray<RowPatch> | Response => {
812
+ if (!Array.isArray(value)) {
813
+ return invalidField('InvoiceRows must be an array.')
814
+ }
815
+
816
+ const patches: Array<RowPatch> = []
817
+
818
+ for (const [index, row] of value.entries()) {
819
+ const patch = rowPatch(row, index)
820
+
821
+ if (patch instanceof Response) return patch
822
+
823
+ patches.push(patch)
824
+ }
825
+
826
+ return patches
827
+ }
828
+
829
+ type RowIds = { next: number }
830
+
831
+ /**
832
+ * Quirk 1: `InvoiceRows` replaces the row list. Without any `RowId`, rows match existing rows by
833
+ * position; with a `RowId`, that row is updated and rows without one are added. A matched row
834
+ * keeps every omitted field, including `Discount`/`DiscountType` (unless the `stickyRowDiscount`
835
+ * drill knob is off, which resets them); `Discount: 0` clears. RowIds are regenerated for every
836
+ * row, and totals follow from the fields.
837
+ */
838
+ const mergeRows = (
839
+ existing: ReadonlyArray<FortnoxEmulatorInvoiceRow>,
840
+ patches: ReadonlyArray<RowPatch>,
841
+ ids: RowIds,
842
+ env: FortnoxApiEnv
843
+ ): ReadonlyArray<FortnoxEmulatorInvoiceRow> | Response => {
844
+ const byRowId = patches.some(patch => patch.RowId !== undefined)
845
+ const rows: Array<FortnoxEmulatorInvoiceRow> = []
846
+
847
+ for (const [index, patch] of patches.entries()) {
848
+ const base = byRowId
849
+ ? existing.find(row => patch.RowId !== undefined && row.RowId === patch.RowId)
850
+ : existing[index]
851
+
852
+ if (byRowId && patch.RowId !== undefined && base === undefined) {
853
+ return invalidField(`InvoiceRows[${index}].RowId ${patch.RowId} does not exist.`)
854
+ }
855
+
856
+ const start = { ...newRowDefaults, ...base }
857
+
858
+ // Drill knob only: forget the matched row's discount instead of keeping it.
859
+ if (base !== undefined && !env.quirks.stickyRowDiscount) {
860
+ start.Discount = 0
861
+ start.DiscountType = 'PERCENT'
862
+ }
863
+
864
+ rows.push(buildRow({ ...start, ...patch, RowId: ids.next++ }))
865
+ }
866
+
867
+ return rows
868
+ }
869
+
870
+ const invoiceTextFields = ['Comments', 'OurReference', 'YourReference', 'OCR'] as const
871
+
872
+ type InvoiceTextField = (typeof invoiceTextFields)[number]
873
+
874
+ const isInvoiceTextField = (key: string): key is InvoiceTextField =>
875
+ invoiceTextFields.some(field => field === key)
876
+
877
+ const invoiceReadOnlyFields: ReadonlyArray<string> = [
878
+ 'Balance',
879
+ 'Booked',
880
+ 'Cancelled',
881
+ 'CustomerName',
882
+ 'FinalPayDate',
883
+ 'Gross',
884
+ 'Net',
885
+ 'Sent',
886
+ 'Total',
887
+ 'TotalToPay',
888
+ 'TotalVAT',
889
+ 'VoucherNumber',
890
+ 'VoucherSeries',
891
+ 'VoucherYear'
892
+ ]
893
+
894
+ type InvoiceScalars = {
895
+ CustomerNumber?: string
896
+ Currency?: string
897
+ InvoiceDate?: string
898
+ DueDate?: string
899
+ CostCenter?: string
900
+ Project?: string
901
+ } & { -readonly [K in InvoiceTextField]?: string }
902
+
903
+ type InvoiceUpdates = {
904
+ readonly scalars: InvoiceScalars
905
+ readonly customer: FortnoxEmulatorCustomer | undefined
906
+ readonly rows: ReadonlyArray<RowPatch> | undefined
907
+ }
908
+
909
+ /**
910
+ * The writable invoice fields of a create or update body (the connector's invoice mutation
911
+ * fields). Quirk 4: an unknown `CustomerNumber` is rejected with the rejection fixture's 400
912
+ * `ErrorInformation`; other invalid or read-only fields are rejected with 400 too.
913
+ */
914
+ const invoiceUpdates = (
915
+ fields: Schema.JsonObject,
916
+ state: FortnoxEmulatorState,
917
+ existing: FortnoxEmulatorInvoice | undefined
918
+ ): InvoiceUpdates | Response => {
919
+ const scalars: InvoiceScalars = {}
920
+ let customer: FortnoxEmulatorCustomer | undefined
921
+ let rows: ReadonlyArray<RowPatch> | undefined
922
+
923
+ for (const [key, value] of Object.entries(fields)) {
924
+ if (key === 'DocumentNumber') {
925
+ if (existing === undefined || value !== existing.DocumentNumber) {
926
+ return readOnlyField('DocumentNumber is assigned by Fortnox and cannot be changed.')
927
+ }
928
+
929
+ continue
930
+ }
931
+
932
+ if (key === 'InvoiceRows') {
933
+ const patches = rowPatches(value)
934
+
935
+ if (patches instanceof Response) return patches
936
+
937
+ if (existing === undefined && patches.some(patch => patch.RowId !== undefined)) {
938
+ return readOnlyField('RowId is assigned by Fortnox.')
939
+ }
940
+
941
+ rows = patches
942
+
943
+ continue
944
+ }
945
+
946
+ if (invoiceReadOnlyFields.includes(key)) {
947
+ return readOnlyField(`${key} is read-only.`)
948
+ }
949
+
950
+ if (!Predicate.isString(value)) {
951
+ return invalidField(
952
+ key === 'CustomerNumber' || key === 'Currency' || isInvoiceTextField(key)
953
+ ? `${key} must be a string.`
954
+ : `unknown or unsupported invoice field ${key}.`
955
+ )
956
+ }
957
+
958
+ switch (key) {
959
+ case 'CustomerNumber':
960
+ customer = findCustomer(state, value)
961
+
962
+ if (customer === undefined) return invoiceCustomerNotFound()
963
+
964
+ scalars.CustomerNumber = value
965
+ break
966
+ case 'Currency':
967
+ if (value !== 'SEK') return noReferencedEntity('Currency', value)
968
+
969
+ scalars.Currency = value
970
+ break
971
+ case 'InvoiceDate':
972
+ case 'DueDate':
973
+ if (!isIsoDate(value)) return invalidField(`${key} must be a YYYY-MM-DD date.`)
974
+
975
+ scalars[key] = value
976
+ break
977
+ case 'CostCenter':
978
+ case 'Project':
979
+ if (value !== '') return noReferencedEntity(key, value)
980
+
981
+ scalars[key] = value
982
+ break
983
+ default:
984
+ if (!isInvoiceTextField(key)) {
985
+ return invalidField(`unknown or unsupported invoice field ${key}.`)
986
+ }
987
+
988
+ scalars[key] = value
989
+ }
990
+ }
991
+
992
+ return { scalars, customer, rows }
993
+ }
994
+
995
+ const replaceInvoice = (state: FortnoxEmulatorState, updated: FortnoxEmulatorInvoice): void => {
996
+ state.invoices = state.invoices.map(invoice =>
997
+ invoice.DocumentNumber === updated.DocumentNumber ? updated : invoice
998
+ )
999
+ }
1000
+
1001
+ const updateInvoice: RouteHandler = (state, { params, body }, env) => {
1002
+ const invoice = findInvoice(state, params.DocumentNumber ?? '')
1003
+
1004
+ if (invoice === undefined) return invoiceNotFound()
1005
+
1006
+ const fields = envelopeObject(body, 'Invoice')
1007
+
1008
+ if (fields instanceof Response) return fields
1009
+
1010
+ if (invoice.Booked) return notEditable('a booked invoice cannot be updated.')
1011
+
1012
+ if (invoice.Cancelled) return notEditable('a cancelled invoice cannot be updated.')
1013
+
1014
+ const updates = invoiceUpdates(fields, state, invoice)
1015
+
1016
+ if (updates instanceof Response) return updates
1017
+
1018
+ let updated: FortnoxEmulatorInvoice = {
1019
+ ...invoice,
1020
+ ...updates.scalars,
1021
+ CustomerName: updates.customer?.Name ?? invoice.CustomerName
1022
+ }
1023
+
1024
+ if (updates.rows !== undefined) {
1025
+ const ids: RowIds = { next: state.counters.nextRowId }
1026
+ const rows = mergeRows(invoice.InvoiceRows, updates.rows, ids, env)
1027
+
1028
+ if (rows instanceof Response) return rows
1029
+
1030
+ const totals = invoiceTotals(rows)
1031
+
1032
+ updated = { ...updated, InvoiceRows: rows, ...totals, Balance: totals.Total }
1033
+ state.counters = { ...state.counters, nextRowId: ids.next }
1034
+ }
1035
+
1036
+ replaceInvoice(state, updated)
1037
+
1038
+ return jsonResponse(200, { Invoice: renderInvoice(env, updated) })
1039
+ }
1040
+
1041
+ const createInvoice: RouteHandler = (state, { body }, env) => {
1042
+ const fields = envelopeObject(body, 'Invoice')
1043
+
1044
+ if (fields instanceof Response) return fields
1045
+
1046
+ const updates = invoiceUpdates(fields, state, undefined)
1047
+
1048
+ if (updates instanceof Response) return updates
1049
+
1050
+ const { customer } = updates
1051
+
1052
+ if (customer === undefined) return invalidField('CustomerNumber is required.')
1053
+
1054
+ // The invoice inherits the customer's currency when it names none; check the effective value
1055
+ // (a seeded customer can carry any currency) before anything is committed.
1056
+ const currency = updates.scalars.Currency ?? customer.Currency
1057
+
1058
+ if (currency !== 'SEK') return noReferencedEntity('Currency', currency)
1059
+
1060
+ // Likewise the inherited VAT type (invoice VAT comes from the rows only) and payment terms,
1061
+ // which the invoice records and derives its due date from.
1062
+ if (!emulatedCustomerVatTypes.includes(customer.VATType)) {
1063
+ return unsupportedVatType(customer.VATType)
1064
+ }
1065
+
1066
+ const terms = termsOfPaymentDays(customer.TermsOfPayment)
1067
+
1068
+ if (terms === undefined) return unsupportedTermsOfPayment(customer.TermsOfPayment)
1069
+
1070
+ const invoiceDate = updates.scalars.InvoiceDate ?? today(env)
1071
+ const dueDate = updates.scalars.DueDate ?? addDays(invoiceDate, terms)
1072
+
1073
+ if (dueDate === undefined) {
1074
+ return invalidField(
1075
+ `the due date ${invoiceDate} + ${terms} days is not a representable YYYY-MM-DD date.`
1076
+ )
1077
+ }
1078
+
1079
+ const ids: RowIds = { next: state.counters.nextRowId }
1080
+
1081
+ const seed: FortnoxEmulatorInvoiceSeed = {
1082
+ ...updates.scalars,
1083
+ DocumentNumber: String(state.counters.nextDocumentNumber),
1084
+ CustomerNumber: customer.CustomerNumber,
1085
+ InvoiceDate: invoiceDate,
1086
+ DueDate: dueDate,
1087
+ EmailInformation: defaultEmailInformation(customer, state.company),
1088
+ InvoiceRows: updates.rows ?? []
1089
+ }
1090
+
1091
+ const invoice = invoiceFromSeed(seed, customer, state.company, ids)
1092
+
1093
+ state.invoices = [...state.invoices, invoice].sort((left, right) =>
1094
+ compareNumbers(left.DocumentNumber, right.DocumentNumber)
1095
+ )
1096
+ state.counters = {
1097
+ nextRowId: ids.next,
1098
+ nextDocumentNumber: state.counters.nextDocumentNumber + 1
1099
+ }
1100
+
1101
+ return jsonResponse(201, { Invoice: renderInvoice(env, invoice) })
1102
+ }
1103
+
1104
+ const previewInvoice: RouteHandler = (state, { params }) =>
1105
+ findInvoice(state, params.DocumentNumber ?? '') === undefined
1106
+ ? invoiceNotFound()
1107
+ : new Response(previewPdfBytes(), {
1108
+ status: 200,
1109
+ headers: { 'content-type': 'application/pdf' }
1110
+ })
1111
+
1112
+ /** Marks the invoice `Sent` and records an outbox entry. Nothing is ever delivered. */
1113
+ const emailInvoice: RouteHandler = (state, { params }, env) => {
1114
+ const invoice = findInvoice(state, params.DocumentNumber ?? '')
1115
+
1116
+ if (invoice === undefined) return invoiceNotFound()
1117
+
1118
+ if (invoice.Cancelled) return notEditable('a cancelled invoice cannot be sent.')
1119
+
1120
+ const email = invoice.EmailInformation
1121
+
1122
+ if (email.EmailAddressTo.trim() === '') {
1123
+ return invalidField('the invoice has no EmailInformation.EmailAddressTo.')
1124
+ }
1125
+
1126
+ const updated: FortnoxEmulatorInvoice = { ...invoice, Sent: true }
1127
+
1128
+ replaceInvoice(state, updated)
1129
+ state.outbox = [
1130
+ ...state.outbox,
1131
+ {
1132
+ DocumentNumber: invoice.DocumentNumber,
1133
+ EmailAddressTo: email.EmailAddressTo,
1134
+ EmailAddressCC: email.EmailAddressCC,
1135
+ EmailAddressBCC: email.EmailAddressBCC,
1136
+ EmailSubject: email.EmailSubject.replaceAll('{no}', invoice.DocumentNumber),
1137
+ sentAt: new Date(env.now()).toISOString()
1138
+ }
1139
+ ]
1140
+
1141
+ return jsonResponse(200, { Invoice: renderInvoice(env, updated, true) })
1142
+ }
1143
+
1144
+ const companyInformation: RouteHandler = state =>
1145
+ jsonResponse(200, { CompanyInformation: state.company })
1146
+
1147
+ const connectorRoute = (
1148
+ method: string,
1149
+ path: string,
1150
+ write: boolean,
1151
+ caseIds: ReadonlyArray<string>,
1152
+ handler: RouteHandler,
1153
+ queryKeys: ReadonlyArray<string> = []
1154
+ ): FortnoxApiRoute => ({
1155
+ method,
1156
+ path: `${fortnoxEmulatorBasePath}${path}`,
1157
+ kind: 'connector',
1158
+ write,
1159
+ caseIds,
1160
+ evidence: 'unverified',
1161
+ queryKeys,
1162
+ handler
1163
+ })
1164
+
1165
+ const listCase = 'fortnox.invoice.list-populated'
1166
+
1167
+ const previewCase = 'fortnox.invoice.preview-pdf'
1168
+
1169
+ const filtersCase = 'fortnox.invoice.payment-filters-exclude-unbooked'
1170
+
1171
+ const discountCase = 'fortnox.invoice.row-discount-sticky'
1172
+
1173
+ const emptyStringCase = 'fortnox.customer.empty-string-keeps-value'
1174
+
1175
+ const rejectionCase = 'fortnox.write.rejection-error-information'
1176
+
1177
+ const emailCase = 'fortnox.invoice.send-email'
1178
+
1179
+ /** The route table: evidence plus handler. `fortnoxEmulatorRoutes` is its evidence part. */
1180
+ export const fortnoxApiRoutes: ReadonlyArray<FortnoxApiRoute> = [
1181
+ connectorRoute('GET', '/companyinformation', false, [], companyInformation),
1182
+ connectorRoute('GET', '/customers', false, [], listCustomers, customerListQueryKeys),
1183
+ connectorRoute(
1184
+ 'GET',
1185
+ '/customers/{CustomerNumber}',
1186
+ false,
1187
+ [emptyStringCase, rejectionCase],
1188
+ getCustomer
1189
+ ),
1190
+ connectorRoute('PUT', '/customers/{CustomerNumber}', true, [emptyStringCase], updateCustomer),
1191
+ connectorRoute(
1192
+ 'GET',
1193
+ '/invoices',
1194
+ false,
1195
+ [listCase, filtersCase],
1196
+ listInvoices,
1197
+ invoiceListQueryKeys
1198
+ ),
1199
+ connectorRoute('POST', '/invoices', true, [rejectionCase], createInvoice),
1200
+ connectorRoute('GET', '/invoices/{DocumentNumber}', false, [discountCase, emailCase], getInvoice),
1201
+ connectorRoute('PUT', '/invoices/{DocumentNumber}', true, [discountCase], updateInvoice),
1202
+ connectorRoute('GET', '/invoices/{DocumentNumber}/preview', false, [previewCase], previewInvoice),
1203
+ connectorRoute('GET', '/invoices/{DocumentNumber}/email', true, [emailCase], emailInvoice)
1204
+ ]
1205
+
1206
+ /** One observed Fortnox quirk the emulator implements, and the case that claims it. */
1207
+ export type FortnoxEmulatorQuirk = {
1208
+ readonly id: string
1209
+ /** `METHOD path` of the route that implements it. */
1210
+ readonly route: string
1211
+ /** The conformance case whose claim it follows; `undefined` when no case covers it yet. */
1212
+ readonly caseId: string | undefined
1213
+ /** The drill knob that flips it, if any. */
1214
+ readonly knob: keyof FortnoxEmulatorQuirks | undefined
1215
+ readonly summary: string
1216
+ }
1217
+
1218
+ /** The observed quirks, each tied to its conformance case id. */
1219
+ export const fortnoxEmulatorQuirks: ReadonlyArray<FortnoxEmulatorQuirk> = [
1220
+ {
1221
+ id: 'row-discount-sticky',
1222
+ route: 'PUT /3/invoices/{DocumentNumber}',
1223
+ caseId: discountCase,
1224
+ knob: 'stickyRowDiscount',
1225
+ summary:
1226
+ 'InvoiceRows replaces the rows; rows without RowId match by position; an omitted Discount/DiscountType keeps its value; Discount 0 clears; RowIds regenerate; totals are recomputed.'
1227
+ },
1228
+ {
1229
+ id: 'empty-string-keeps-value',
1230
+ route: 'PUT /3/customers/{CustomerNumber}',
1231
+ caseId: emptyStringCase,
1232
+ knob: 'emptyStringClears',
1233
+ summary: 'A customer update with "" for a string field keeps the stored value.'
1234
+ },
1235
+ {
1236
+ id: 'payment-filters-exclude-unbooked',
1237
+ route: 'GET /3/invoices',
1238
+ caseId: filtersCase,
1239
+ knob: 'paymentFiltersIncludeUnbooked',
1240
+ summary:
1241
+ 'unpaid, unpaidoverdue, and fullypaid consider booked invoices only; unbooked lists unbooked, uncancelled invoices; unpaidoverdue needs DueDate before today.'
1242
+ },
1243
+ {
1244
+ id: 'rejection-error-information',
1245
+ route: 'POST /3/invoices',
1246
+ caseId: rejectionCase,
1247
+ knob: undefined,
1248
+ summary:
1249
+ 'Writes for an unknown customer, or with invalid fields, answer 400 with a lowercase ErrorInformation { error, message, code }.'
1250
+ },
1251
+ {
1252
+ id: 'customer-country-read-only',
1253
+ route: 'PUT /3/customers/{CustomerNumber}',
1254
+ caseId: undefined,
1255
+ knob: undefined,
1256
+ summary:
1257
+ 'Customer Country is read-only (derived from CountryCode); sending it answers 400 ErrorInformation. No conformance case covers it yet.'
1258
+ }
1259
+ ]
1260
+
1261
+ const templatePattern = (template: string): RegExp =>
1262
+ new RegExp(
1263
+ `^${template
1264
+ .split(/(\{[A-Za-z]+\})/)
1265
+ .map(part =>
1266
+ /^\{[A-Za-z]+\}$/.test(part) ? '([^/]+)' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1267
+ )
1268
+ .join('')}$`
1269
+ )
1270
+
1271
+ const compiledRoutes = fortnoxApiRoutes.map(route => ({
1272
+ route,
1273
+ pattern: templatePattern(route.path)
1274
+ }))
1275
+
1276
+ /** The route answering `method` + concrete `path`, or `undefined` (fail closed). */
1277
+ export const matchFortnoxRoute = (method: string, path: string): FortnoxApiRoute | undefined =>
1278
+ compiledRoutes.find(
1279
+ candidate => candidate.route.method === method.toUpperCase() && candidate.pattern.test(path)
1280
+ )?.route
1281
+
1282
+ const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json))
1283
+
1284
+ /** Parsed JSON, or `undefined` for invalid JSON. */
1285
+ export const parseJsonText = (text: string): Schema.Json | undefined => {
1286
+ const result = decodeJsonText(text)
1287
+
1288
+ return Result.isSuccess(result) ? result.success : undefined
1289
+ }
1290
+
1291
+ /** `{Name}` path templates become `:Name` core route parameters. */
1292
+ const corePath = (template: string): string => template.replace(/\{([A-Za-z]+)\}/g, ':$1')
1293
+
1294
+ /**
1295
+ * The wrapper's fault decision for the core request of job `jobId` (its `emulatorJobHeader`),
1296
+ * asked only once the route would answer successfully: `true` when a fault answers instead (it is
1297
+ * used up, and the wrapper sends its answer), `false` when none applies.
1298
+ */
1299
+ export type FortnoxFaultDecision = (jobId: string | undefined) => boolean
1300
+
1301
+ /**
1302
+ * Register every route of the table on the core app, over the generation's state. Each request is
1303
+ * planned, then faulted, then committed: the handler runs on a draft of the state (handlers replace
1304
+ * whole lists and counters, never edit them in place, so a shallow copy keeps every write off the
1305
+ * state); a refusal (any answer that is not 2xx) is sent as is and uses up no fault; a successful
1306
+ * answer asks `decideFault`, and only an unfaulted one commits the draft. The three steps run
1307
+ * synchronously together, so no other request interleaves. A handler that throws answers
1308
+ * `handlerFailedResponse()` (nothing written), which the wrapper turns into its `ErrorInformation`
1309
+ * 500 with `responseError` in the ledger.
1310
+ */
1311
+ export const registerFortnoxApi = (
1312
+ app: Hono,
1313
+ state: FortnoxEmulatorState,
1314
+ env: FortnoxApiEnv,
1315
+ decideFault: FortnoxFaultDecision
1316
+ ): void => {
1317
+ for (const route of fortnoxApiRoutes) {
1318
+ app.on(route.method, corePath(route.path), async context => {
1319
+ try {
1320
+ const query = new URL(context.req.url).searchParams
1321
+
1322
+ // Fail closed on query parameters the route does not emulate, before the handler can write.
1323
+ const unsupported = unsupportedQueryKey(query, route.queryKeys)
1324
+
1325
+ if (unsupported !== undefined) return unsupported
1326
+
1327
+ const text = await context.req.text()
1328
+ const draft: FortnoxEmulatorState = { ...state }
1329
+
1330
+ const response = route.handler(
1331
+ draft,
1332
+ {
1333
+ params: context.req.param(),
1334
+ query,
1335
+ body: text === '' ? undefined : parseJsonText(text)
1336
+ },
1337
+ env
1338
+ )
1339
+
1340
+ if (!response.ok) return response
1341
+
1342
+ if (decideFault(context.req.header(emulatorJobHeader))) return answeredOutsideCore()
1343
+
1344
+ Object.assign(state, draft)
1345
+
1346
+ return response
1347
+ } catch {
1348
+ return handlerFailedResponse()
1349
+ }
1350
+ })
1351
+ }
1352
+ }