@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,750 @@
1
+ import { answeredOutsideCore, handlerFailedResponse } from "../emulator-http.mjs";
2
+ import { buildRow, compareNumbers, defaultEmailInformation, formatQuantity, fortnoxEmulatorCountries, fortnoxEmulatorVatCodes, invoiceFromSeed, invoiceTotals, newRowDefaults } from "./state.mjs";
3
+ import { Predicate, Result } from "effect";
4
+ import * as Schema from "effect/Schema";
5
+ //#region src/fortnox/api.ts
6
+ /**
7
+ * `ErrorInformation` codes the emulator answers with. The two customer-not-found codes are copied
8
+ * from the rejection fixture; the `2999xxx` codes are synthetic emulator codes.
9
+ */
10
+ const fortnoxEmulatorErrorCodes = {
11
+ customerNotFound: 2000204,
12
+ invoiceCustomerNotFound: 2000433,
13
+ unauthorized: 2999001,
14
+ invalidBody: 2999002,
15
+ invalidField: 2999003,
16
+ readOnlyField: 2999004,
17
+ notEditable: 2999005,
18
+ invoiceNotFound: 2999006,
19
+ unknownRoute: 2999007,
20
+ unsupportedQuery: 2999008,
21
+ rateLimited: 2999009,
22
+ upstreamError: 2999010
23
+ };
24
+ /** Fortnox API base path. */
25
+ const fortnoxEmulatorBasePath = "/3";
26
+ const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
27
+ status,
28
+ headers: { "content-type": "application/json" }
29
+ });
30
+ /** A Fortnox `ErrorInformation` response with the lowercase fields of the rejection fixture. */
31
+ const errorInformation = (status, code, message) => jsonResponse(status, { ErrorInformation: {
32
+ error: 1,
33
+ message,
34
+ code
35
+ } });
36
+ const codes = fortnoxEmulatorErrorCodes;
37
+ const customerNotFound = () => errorInformation(404, codes.customerNotFound, "Synthetic placeholder: customer not found.");
38
+ const invoiceCustomerNotFound = () => errorInformation(400, codes.invoiceCustomerNotFound, "Synthetic placeholder: customer not found.");
39
+ const invoiceNotFound = () => errorInformation(404, codes.invoiceNotFound, "Synthetic: invoice not found.");
40
+ const invalidBody = (message) => errorInformation(400, codes.invalidBody, `Synthetic: ${message}`);
41
+ const invalidField = (message) => errorInformation(400, codes.invalidField, `Synthetic: ${message}`);
42
+ const noReferencedEntity = (kind, value) => invalidField(`${kind} ${value} does not exist in the emulated company.`);
43
+ const readOnlyField = (message) => errorInformation(400, codes.readOnlyField, `Synthetic: ${message}`);
44
+ const notEditable = (message) => errorInformation(400, codes.notEditable, `Synthetic: ${message}`);
45
+ const unsupportedQuery = (key) => errorInformation(400, codes.unsupportedQuery, key === "lastmodified" ? "Synthetic: query parameter lastmodified is not emulated (the emulator does not track modification times)." : `Synthetic: query parameter ${key} is not emulated.`);
46
+ const previewPdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFtdIC9Db3VudCAwID4+CmVuZG9iagp0cmFpbGVyCjw8IC9Sb290IDEgMCBSID4+CiUlRU9GCg==";
47
+ const previewPdfBytes = () => Uint8Array.from(atob(previewPdfBase64), (character) => character.charCodeAt(0));
48
+ const isJsonObject = (value) => value !== void 0 && value !== null && Predicate.isObject(value) && !Array.isArray(value);
49
+ const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/;
50
+ /** A real `YYYY-MM-DD` calendar date (no JavaScript rollover such as `2026-02-30`). */
51
+ const isIsoDate = (value) => {
52
+ const parsed = Date.parse(`${value}T00:00:00.000Z`);
53
+ return isoDatePattern.test(value) && !Number.isNaN(parsed) && new Date(parsed).toISOString().slice(0, 10) === value;
54
+ };
55
+ const today = (env) => new Date(env.now()).toISOString().slice(0, 10);
56
+ /** `date` plus `days`, or `undefined` when the result is not a representable `YYYY-MM-DD` date. */
57
+ const addDays = (date, days) => {
58
+ const result = new Date(Date.parse(`${date}T00:00:00.000Z`) + days * 24 * 60 * 60 * 1e3);
59
+ if (Number.isNaN(result.getTime())) return void 0;
60
+ const iso = result.toISOString().slice(0, 10);
61
+ return isIsoDate(iso) ? iso : void 0;
62
+ };
63
+ const maxTermsOfPaymentDays = 365;
64
+ /**
65
+ * `TermsOfPayment` as a whole number of days (`0`–`365`, no sign or leading zeros), or
66
+ * `undefined` when the value is not emulated. Fortnox also knows named terms (such as `K`); the
67
+ * emulated company has day terms only.
68
+ */
69
+ const termsOfPaymentDays = (value) => {
70
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(value)) return void 0;
71
+ const days = Number(value);
72
+ return days <= maxTermsOfPaymentDays ? days : void 0;
73
+ };
74
+ const unsupportedTermsOfPayment = (value) => invalidField(`TermsOfPayment ${value} is not emulated (whole days from 0 to ${maxTermsOfPaymentDays} only).`);
75
+ /**
76
+ * The customer categorical values the emulator deliberately models. This is the emulated subset,
77
+ * not Fortnox's full enum: other values (for example export or EU reverse-charge VAT) would change
78
+ * invoice VAT, which the emulator does not model, so they are rejected.
79
+ */
80
+ const emulatedCustomerVatTypes = ["SEVAT"];
81
+ const emulatedCustomerTypes = ["COMPANY", "PRIVATE"];
82
+ const unsupportedVatType = (value) => invalidField(`VATType ${value} is not emulated (supported: ${emulatedCustomerVatTypes.join(", ")}).`);
83
+ const customerUrl = (env, customerNumber) => `${env.linkOrigin}/3/customers/${encodeURIComponent(customerNumber)}`;
84
+ const invoiceUrl = (env, documentNumber) => `${env.linkOrigin}/3/invoices/${encodeURIComponent(documentNumber)}`;
85
+ const renderCustomer = (env, customer) => ({
86
+ "@url": customerUrl(env, customer.CustomerNumber),
87
+ ...customer
88
+ });
89
+ const renderCustomerRow = (env, customer) => ({
90
+ "@url": customerUrl(env, customer.CustomerNumber),
91
+ Address1: customer.Address1,
92
+ Address2: customer.Address2,
93
+ City: customer.City,
94
+ CustomerNumber: customer.CustomerNumber,
95
+ Email: customer.Email,
96
+ Name: customer.Name,
97
+ OrganisationNumber: customer.OrganisationNumber,
98
+ Phone: customer.Phone1,
99
+ ZipCode: customer.ZipCode
100
+ });
101
+ /**
102
+ * A single-invoice read: JSON-number amounts. `dropEmptyCopies` leaves empty CC/BCC addresses out
103
+ * of `EmailInformation`, as the email-send fixture response does.
104
+ */
105
+ const renderInvoice = (env, invoice, dropEmptyCopies = false) => {
106
+ const source = invoice.EmailInformation;
107
+ const email = {
108
+ EmailAddressFrom: source.EmailAddressFrom,
109
+ EmailAddressTo: source.EmailAddressTo
110
+ };
111
+ if (!dropEmptyCopies || source.EmailAddressCC !== "") email.EmailAddressCC = source.EmailAddressCC;
112
+ if (!dropEmptyCopies || source.EmailAddressBCC !== "") email.EmailAddressBCC = source.EmailAddressBCC;
113
+ email.EmailSubject = source.EmailSubject;
114
+ email.EmailBody = source.EmailBody;
115
+ return {
116
+ "@url": invoiceUrl(env, invoice.DocumentNumber),
117
+ ...invoice,
118
+ EmailInformation: email
119
+ };
120
+ };
121
+ /** An invoice list row, in the list fixture's shape: `CurrencyRate` is a numeric string. */
122
+ const renderInvoiceRow = (env, invoice) => ({
123
+ "@url": invoiceUrl(env, invoice.DocumentNumber),
124
+ Balance: invoice.Balance,
125
+ Booked: invoice.Booked,
126
+ Cancelled: invoice.Cancelled,
127
+ CostCenter: invoice.CostCenter,
128
+ Currency: invoice.Currency,
129
+ CurrencyRate: String(invoice.CurrencyRate),
130
+ CurrencyUnit: invoice.CurrencyUnit,
131
+ CustomerName: invoice.CustomerName,
132
+ CustomerNumber: invoice.CustomerNumber,
133
+ DocumentNumber: invoice.DocumentNumber,
134
+ DueDate: invoice.DueDate,
135
+ ExternalInvoiceReference1: invoice.ExternalInvoiceReference1,
136
+ ExternalInvoiceReference2: invoice.ExternalInvoiceReference2,
137
+ FinalPayDate: invoice.FinalPayDate,
138
+ InvoiceDate: invoice.InvoiceDate,
139
+ InvoiceType: invoice.InvoiceType,
140
+ NoxFinans: invoice.NoxFinans,
141
+ OCR: invoice.OCR,
142
+ VoucherNumber: invoice.VoucherNumber,
143
+ VoucherSeries: invoice.VoucherSeries,
144
+ VoucherYear: invoice.VoucherYear,
145
+ WayOfDelivery: invoice.WayOfDelivery,
146
+ TermsOfPayment: invoice.TermsOfPayment,
147
+ Project: invoice.Project,
148
+ Sent: invoice.Sent,
149
+ Total: invoice.Total
150
+ });
151
+ /** The 400 for the first query key the route does not emulate, or `undefined`. */
152
+ const unsupportedQueryKey = (query, allowed) => {
153
+ const key = [...query.keys()].find((candidate) => !allowed.includes(candidate));
154
+ return key === void 0 ? void 0 : unsupportedQuery(key);
155
+ };
156
+ const positiveInt = (query, key, fallback, maximum) => {
157
+ const raw = query.get(key);
158
+ if (raw === null) return fallback;
159
+ const value = Number(raw);
160
+ return /^\d+$/.test(raw) && value >= 1 && value <= maximum ? value : invalidField(`query parameter ${key} must be an integer from 1 to ${maximum}.`);
161
+ };
162
+ /** Fortnox default and maximum page sizes. */
163
+ const defaultLimit = 100;
164
+ const maxLimit = 500;
165
+ /** One page plus `MetaInformation` (`@TotalPages` is at least 1, as the empty-list fixture shows). */
166
+ const paginate = (items, query) => {
167
+ const limit = positiveInt(query, "limit", defaultLimit, maxLimit);
168
+ const page = positiveInt(query, "page", 1, Number.MAX_SAFE_INTEGER);
169
+ if (limit instanceof Response) return limit;
170
+ if (page instanceof Response) return page;
171
+ return {
172
+ items: items.slice((page - 1) * limit, page * limit),
173
+ meta: {
174
+ "@CurrentPage": page,
175
+ "@TotalPages": Math.max(1, Math.ceil(items.length / limit)),
176
+ "@TotalResources": items.length
177
+ }
178
+ };
179
+ };
180
+ const contains = (value, search) => value.toLowerCase().includes(search.toLowerCase());
181
+ const customerSearch = {
182
+ customernumber: (customer, value) => customer.CustomerNumber === value,
183
+ name: (customer, value) => contains(customer.Name, value),
184
+ organisationnumber: (customer, value) => contains(customer.OrganisationNumber, value),
185
+ email: (customer, value) => contains(customer.Email, value),
186
+ city: (customer, value) => contains(customer.City, value),
187
+ phone: (customer, value) => contains(customer.Phone1, value) || contains(customer.Phone2, value)
188
+ };
189
+ const customerListQueryKeys = [
190
+ "page",
191
+ "limit",
192
+ "filter",
193
+ ...Object.keys(customerSearch)
194
+ ];
195
+ const listCustomers = (state, { query }, env) => {
196
+ const filter = query.get("filter");
197
+ if (filter !== null && filter !== "active" && filter !== "inactive") return invalidField(`unknown customer filter ${filter}.`);
198
+ const page = paginate(state.customers.filter((customer) => (filter === null || customer.Active === (filter === "active")) && Object.entries(customerSearch).every(([key, matches]) => {
199
+ const value = query.get(key);
200
+ return value === null || matches(customer, value);
201
+ })), query);
202
+ if (page instanceof Response) return page;
203
+ return jsonResponse(200, {
204
+ Customers: page.items.map((customer) => renderCustomerRow(env, customer)),
205
+ MetaInformation: page.meta
206
+ });
207
+ };
208
+ const findCustomer = (state, customerNumber) => state.customers.find((customer) => customer.CustomerNumber === customerNumber);
209
+ const findInvoice = (state, documentNumber) => state.invoices.find((invoice) => invoice.DocumentNumber === documentNumber);
210
+ const getCustomer = (state, { params }, env) => {
211
+ const customer = findCustomer(state, params.CustomerNumber ?? "");
212
+ return customer === void 0 ? customerNotFound() : jsonResponse(200, { Customer: renderCustomer(env, customer) });
213
+ };
214
+ /** The single resource object of a `{ <envelope>: { ... } }` write body. */
215
+ const envelopeObject = (body, envelope) => {
216
+ const value = isJsonObject(body) ? body[envelope] : void 0;
217
+ return isJsonObject(body) && Object.keys(body).every((key) => key === envelope) && isJsonObject(value) ? value : invalidBody(`the request body must be { "${envelope}": { ... } }.`);
218
+ };
219
+ const writableCustomerStrings = [
220
+ "Name",
221
+ "OrganisationNumber",
222
+ "Email",
223
+ "Phone1",
224
+ "Phone2",
225
+ "Address1",
226
+ "Address2",
227
+ "City",
228
+ "ZipCode",
229
+ "CountryCode",
230
+ "Currency",
231
+ "VATNumber",
232
+ "VATType",
233
+ "TermsOfPayment",
234
+ "OurReference",
235
+ "YourReference",
236
+ "Comments",
237
+ "EmailInvoice",
238
+ "Type",
239
+ "CostCenter"
240
+ ];
241
+ const isCustomerStringField = (key) => writableCustomerStrings.some((field) => field === key);
242
+ /**
243
+ * Why a customer field value is not emulated (fail closed), or `undefined`. The emulated company
244
+ * has only SEK, no cost centers, terms of payment in whole days (0–365), `VATType` `SEVAT`, and
245
+ * `Type` `COMPANY` or `PRIVATE`.
246
+ */
247
+ const unsupportedCustomerValue = (key, value) => {
248
+ switch (key) {
249
+ case "Currency": return value === "SEK" ? void 0 : noReferencedEntity("Currency", value);
250
+ case "CostCenter": return value === "" ? void 0 : noReferencedEntity("CostCenter", value);
251
+ case "TermsOfPayment": return termsOfPaymentDays(value) === void 0 ? unsupportedTermsOfPayment(value) : void 0;
252
+ case "VATType": return emulatedCustomerVatTypes.includes(value) ? void 0 : unsupportedVatType(value);
253
+ case "Type": return emulatedCustomerTypes.includes(value) ? void 0 : invalidField(`Type ${value} is not emulated (supported: ${emulatedCustomerTypes.join(", ")}).`);
254
+ default: return;
255
+ }
256
+ };
257
+ /**
258
+ * Quirk 2: an empty string keeps the stored value (unless the `emptyStringClears` drill knob is
259
+ * set); omitted fields keep theirs. Quirk 5: `Country` is read-only. Unknown fields, wrong types,
260
+ * and values the emulator does not support (non-SEK `Currency`, a `CostCenter`, `TermsOfPayment`
261
+ * other than 0–365 days, `VATType` other than `SEVAT`, `Type` other than `COMPANY`/`PRIVATE`) are
262
+ * rejected; the update is atomic.
263
+ */
264
+ const customerUpdates = (fields, customer, env) => {
265
+ const updates = {};
266
+ for (const [key, value] of Object.entries(fields)) {
267
+ if (key === "Country") return readOnlyField("Country is read-only; set CountryCode instead.");
268
+ if (key === "CustomerNumber") {
269
+ if (value !== customer.CustomerNumber) return invalidField("CustomerNumber cannot be changed.");
270
+ continue;
271
+ }
272
+ if (key === "Active") {
273
+ if (!Predicate.isBoolean(value)) return invalidField("Active must be a boolean.");
274
+ updates.Active = value;
275
+ continue;
276
+ }
277
+ if (!isCustomerStringField(key)) return invalidField(`unknown customer field ${key}.`);
278
+ if (!Predicate.isString(value)) return invalidField(`${key} must be a string.`);
279
+ if (value === "" && !env.quirks.emptyStringClears) continue;
280
+ const unsupported = unsupportedCustomerValue(key, value);
281
+ if (unsupported !== void 0) return unsupported;
282
+ if (key === "CountryCode" && value !== "") {
283
+ const country = fortnoxEmulatorCountries.get(value);
284
+ if (country === void 0) return invalidField(`unknown CountryCode ${value}.`);
285
+ updates.Country = country;
286
+ }
287
+ updates[key] = value;
288
+ }
289
+ return updates;
290
+ };
291
+ const updateCustomer = (state, { params, body }, env) => {
292
+ const customer = findCustomer(state, params.CustomerNumber ?? "");
293
+ if (customer === void 0) return customerNotFound();
294
+ const fields = envelopeObject(body, "Customer");
295
+ if (fields instanceof Response) return fields;
296
+ const updates = customerUpdates(fields, customer, env);
297
+ if (updates instanceof Response) return updates;
298
+ const updated = {
299
+ ...customer,
300
+ ...updates
301
+ };
302
+ state.customers = state.customers.map((existing) => existing.CustomerNumber === updated.CustomerNumber ? updated : existing);
303
+ return jsonResponse(200, { Customer: renderCustomer(env, updated) });
304
+ };
305
+ const invoiceFilters = [
306
+ "cancelled",
307
+ "fullypaid",
308
+ "unpaid",
309
+ "unpaidoverdue",
310
+ "unbooked"
311
+ ];
312
+ const isInvoiceFilter = (value) => invoiceFilters.some((filter) => filter === value);
313
+ /**
314
+ * Quirk 3: the payment-status filters consider booked invoices only (unless the
315
+ * `paymentFiltersIncludeUnbooked` drill knob is set); `unbooked` returns unbooked, uncancelled
316
+ * invoices; `unpaidoverdue` needs a `DueDate` before today (UTC, from the emulator clock).
317
+ */
318
+ const matchesInvoiceFilter = (invoice, filter, env) => {
319
+ const considered = (invoice.Booked || env.quirks.paymentFiltersIncludeUnbooked) && !invoice.Cancelled;
320
+ switch (filter) {
321
+ case "cancelled": return invoice.Cancelled;
322
+ case "unbooked": return !invoice.Booked && !invoice.Cancelled;
323
+ case "unpaid": return considered && invoice.Balance > 0;
324
+ case "unpaidoverdue": return considered && invoice.Balance > 0 && invoice.DueDate !== null && isIsoDate(invoice.DueDate) && invoice.DueDate < today(env);
325
+ case "fullypaid": return considered && invoice.Balance === 0;
326
+ }
327
+ };
328
+ const invoiceSearch = {
329
+ customernumber: (invoice, value) => invoice.CustomerNumber === value,
330
+ customername: (invoice, value) => contains(invoice.CustomerName, value),
331
+ documentnumber: (invoice, value) => invoice.DocumentNumber === value,
332
+ ocr: (invoice, value) => invoice.OCR === value
333
+ };
334
+ const invoiceListQueryKeys = [
335
+ "page",
336
+ "limit",
337
+ "filter",
338
+ "fromdate",
339
+ "todate",
340
+ ...Object.keys(invoiceSearch)
341
+ ];
342
+ const listInvoices = (state, { query }, env) => {
343
+ const filter = query.get("filter");
344
+ if (filter !== null && !isInvoiceFilter(filter)) return invalidField(`unknown invoice filter ${filter}.`);
345
+ const fromDate = query.get("fromdate");
346
+ const toDate = query.get("todate");
347
+ for (const [key, value] of [["fromdate", fromDate], ["todate", toDate]]) if (value !== null && !isIsoDate(value)) return invalidField(`query parameter ${key} must be a YYYY-MM-DD date.`);
348
+ const page = paginate(state.invoices.filter((invoice) => (filter === null || matchesInvoiceFilter(invoice, filter, env)) && (fromDate === null || invoice.InvoiceDate >= fromDate) && (toDate === null || invoice.InvoiceDate <= toDate) && Object.entries(invoiceSearch).every(([key, matches]) => {
349
+ const value = query.get(key);
350
+ return value === null || matches(invoice, value);
351
+ })), query);
352
+ if (page instanceof Response) return page;
353
+ return jsonResponse(200, {
354
+ Invoices: page.items.map((invoice) => renderInvoiceRow(env, invoice)),
355
+ MetaInformation: page.meta
356
+ });
357
+ };
358
+ const getInvoice = (state, { params }, env) => {
359
+ const invoice = findInvoice(state, params.DocumentNumber ?? "");
360
+ return invoice === void 0 ? invoiceNotFound() : jsonResponse(200, { Invoice: renderInvoice(env, invoice) });
361
+ };
362
+ const vatRates = [...fortnoxEmulatorVatCodes.keys()];
363
+ /**
364
+ * One row of an `InvoiceRows` write. `null` means omitted. Referenced articles, cost centers, and
365
+ * projects must exist (the emulated company has none); accounts must be in 1000-8999.
366
+ */
367
+ const rowPatch = (value, index) => {
368
+ if (!isJsonObject(value)) return invalidField(`InvoiceRows[${index}] must be an object.`);
369
+ const patch = {};
370
+ const label = (key) => `InvoiceRows[${index}].${key}`;
371
+ for (const [key, field] of Object.entries(value)) {
372
+ if (field === null) continue;
373
+ switch (key) {
374
+ case "RowId":
375
+ if (!Predicate.isNumber(field) || !Number.isSafeInteger(field) || field < 1) return invalidField(`${label(key)} must be a positive integer.`);
376
+ patch.RowId = field;
377
+ break;
378
+ case "AccountNumber":
379
+ if (!Predicate.isNumber(field) || !Number.isInteger(field) || field < 1e3 || field > 8999) return invalidField(`${label(key)} must be an account number from 1000 to 8999.`);
380
+ patch.AccountNumber = field;
381
+ break;
382
+ case "ArticleNumber":
383
+ case "CostCenter":
384
+ case "Project":
385
+ if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`);
386
+ if (field !== "") return noReferencedEntity(key, field);
387
+ patch[key] = field;
388
+ break;
389
+ case "Description":
390
+ case "Unit":
391
+ if (!Predicate.isString(field)) return invalidField(`${label(key)} must be a string.`);
392
+ patch[key] = field;
393
+ break;
394
+ case "DeliveredQuantity": {
395
+ const quantity = Predicate.isString(field) ? Number(field.trim()) : field;
396
+ if (!Predicate.isNumber(quantity) || !Number.isFinite(quantity) || quantity < 0 || Predicate.isString(field) && field.trim() === "") return invalidField(`${label(key)} must be a non-negative number.`);
397
+ patch.DeliveredQuantity = formatQuantity(quantity);
398
+ break;
399
+ }
400
+ case "Price":
401
+ if (!Predicate.isNumber(field) || !Number.isFinite(field)) return invalidField(`${label(key)} must be a finite number.`);
402
+ patch.Price = field;
403
+ break;
404
+ case "Discount":
405
+ if (!Predicate.isNumber(field) || !Number.isFinite(field) || field < 0) return invalidField(`${label(key)} must be a non-negative number.`);
406
+ patch.Discount = field;
407
+ break;
408
+ case "DiscountType":
409
+ if (field !== "PERCENT" && field !== "AMOUNT") return invalidField(`${label(key)} must be PERCENT or AMOUNT.`);
410
+ patch.DiscountType = field;
411
+ break;
412
+ case "VAT":
413
+ if (!Predicate.isNumber(field) || !vatRates.includes(field)) return invalidField(`${label(key)} must be one of ${vatRates.join(", ")}.`);
414
+ patch.VAT = field;
415
+ break;
416
+ default: return invalidField(`${label(key)} is not a writable invoice row field.`);
417
+ }
418
+ }
419
+ return patch;
420
+ };
421
+ const rowPatches = (value) => {
422
+ if (!Array.isArray(value)) return invalidField("InvoiceRows must be an array.");
423
+ const patches = [];
424
+ for (const [index, row] of value.entries()) {
425
+ const patch = rowPatch(row, index);
426
+ if (patch instanceof Response) return patch;
427
+ patches.push(patch);
428
+ }
429
+ return patches;
430
+ };
431
+ /**
432
+ * Quirk 1: `InvoiceRows` replaces the row list. Without any `RowId`, rows match existing rows by
433
+ * position; with a `RowId`, that row is updated and rows without one are added. A matched row
434
+ * keeps every omitted field, including `Discount`/`DiscountType` (unless the `stickyRowDiscount`
435
+ * drill knob is off, which resets them); `Discount: 0` clears. RowIds are regenerated for every
436
+ * row, and totals follow from the fields.
437
+ */
438
+ const mergeRows = (existing, patches, ids, env) => {
439
+ const byRowId = patches.some((patch) => patch.RowId !== void 0);
440
+ const rows = [];
441
+ for (const [index, patch] of patches.entries()) {
442
+ const base = byRowId ? existing.find((row) => patch.RowId !== void 0 && row.RowId === patch.RowId) : existing[index];
443
+ if (byRowId && patch.RowId !== void 0 && base === void 0) return invalidField(`InvoiceRows[${index}].RowId ${patch.RowId} does not exist.`);
444
+ const start = {
445
+ ...newRowDefaults,
446
+ ...base
447
+ };
448
+ if (base !== void 0 && !env.quirks.stickyRowDiscount) {
449
+ start.Discount = 0;
450
+ start.DiscountType = "PERCENT";
451
+ }
452
+ rows.push(buildRow({
453
+ ...start,
454
+ ...patch,
455
+ RowId: ids.next++
456
+ }));
457
+ }
458
+ return rows;
459
+ };
460
+ const invoiceTextFields = [
461
+ "Comments",
462
+ "OurReference",
463
+ "YourReference",
464
+ "OCR"
465
+ ];
466
+ const isInvoiceTextField = (key) => invoiceTextFields.some((field) => field === key);
467
+ const invoiceReadOnlyFields = [
468
+ "Balance",
469
+ "Booked",
470
+ "Cancelled",
471
+ "CustomerName",
472
+ "FinalPayDate",
473
+ "Gross",
474
+ "Net",
475
+ "Sent",
476
+ "Total",
477
+ "TotalToPay",
478
+ "TotalVAT",
479
+ "VoucherNumber",
480
+ "VoucherSeries",
481
+ "VoucherYear"
482
+ ];
483
+ /**
484
+ * The writable invoice fields of a create or update body (the connector's invoice mutation
485
+ * fields). Quirk 4: an unknown `CustomerNumber` is rejected with the rejection fixture's 400
486
+ * `ErrorInformation`; other invalid or read-only fields are rejected with 400 too.
487
+ */
488
+ const invoiceUpdates = (fields, state, existing) => {
489
+ const scalars = {};
490
+ let customer;
491
+ let rows;
492
+ for (const [key, value] of Object.entries(fields)) {
493
+ if (key === "DocumentNumber") {
494
+ if (existing === void 0 || value !== existing.DocumentNumber) return readOnlyField("DocumentNumber is assigned by Fortnox and cannot be changed.");
495
+ continue;
496
+ }
497
+ if (key === "InvoiceRows") {
498
+ const patches = rowPatches(value);
499
+ if (patches instanceof Response) return patches;
500
+ if (existing === void 0 && patches.some((patch) => patch.RowId !== void 0)) return readOnlyField("RowId is assigned by Fortnox.");
501
+ rows = patches;
502
+ continue;
503
+ }
504
+ if (invoiceReadOnlyFields.includes(key)) return readOnlyField(`${key} is read-only.`);
505
+ if (!Predicate.isString(value)) return invalidField(key === "CustomerNumber" || key === "Currency" || isInvoiceTextField(key) ? `${key} must be a string.` : `unknown or unsupported invoice field ${key}.`);
506
+ switch (key) {
507
+ case "CustomerNumber":
508
+ customer = findCustomer(state, value);
509
+ if (customer === void 0) return invoiceCustomerNotFound();
510
+ scalars.CustomerNumber = value;
511
+ break;
512
+ case "Currency":
513
+ if (value !== "SEK") return noReferencedEntity("Currency", value);
514
+ scalars.Currency = value;
515
+ break;
516
+ case "InvoiceDate":
517
+ case "DueDate":
518
+ if (!isIsoDate(value)) return invalidField(`${key} must be a YYYY-MM-DD date.`);
519
+ scalars[key] = value;
520
+ break;
521
+ case "CostCenter":
522
+ case "Project":
523
+ if (value !== "") return noReferencedEntity(key, value);
524
+ scalars[key] = value;
525
+ break;
526
+ default:
527
+ if (!isInvoiceTextField(key)) return invalidField(`unknown or unsupported invoice field ${key}.`);
528
+ scalars[key] = value;
529
+ }
530
+ }
531
+ return {
532
+ scalars,
533
+ customer,
534
+ rows
535
+ };
536
+ };
537
+ const replaceInvoice = (state, updated) => {
538
+ state.invoices = state.invoices.map((invoice) => invoice.DocumentNumber === updated.DocumentNumber ? updated : invoice);
539
+ };
540
+ const updateInvoice = (state, { params, body }, env) => {
541
+ const invoice = findInvoice(state, params.DocumentNumber ?? "");
542
+ if (invoice === void 0) return invoiceNotFound();
543
+ const fields = envelopeObject(body, "Invoice");
544
+ if (fields instanceof Response) return fields;
545
+ if (invoice.Booked) return notEditable("a booked invoice cannot be updated.");
546
+ if (invoice.Cancelled) return notEditable("a cancelled invoice cannot be updated.");
547
+ const updates = invoiceUpdates(fields, state, invoice);
548
+ if (updates instanceof Response) return updates;
549
+ let updated = {
550
+ ...invoice,
551
+ ...updates.scalars,
552
+ CustomerName: updates.customer?.Name ?? invoice.CustomerName
553
+ };
554
+ if (updates.rows !== void 0) {
555
+ const ids = { next: state.counters.nextRowId };
556
+ const rows = mergeRows(invoice.InvoiceRows, updates.rows, ids, env);
557
+ if (rows instanceof Response) return rows;
558
+ const totals = invoiceTotals(rows);
559
+ updated = {
560
+ ...updated,
561
+ InvoiceRows: rows,
562
+ ...totals,
563
+ Balance: totals.Total
564
+ };
565
+ state.counters = {
566
+ ...state.counters,
567
+ nextRowId: ids.next
568
+ };
569
+ }
570
+ replaceInvoice(state, updated);
571
+ return jsonResponse(200, { Invoice: renderInvoice(env, updated) });
572
+ };
573
+ const createInvoice = (state, { body }, env) => {
574
+ const fields = envelopeObject(body, "Invoice");
575
+ if (fields instanceof Response) return fields;
576
+ const updates = invoiceUpdates(fields, state, void 0);
577
+ if (updates instanceof Response) return updates;
578
+ const { customer } = updates;
579
+ if (customer === void 0) return invalidField("CustomerNumber is required.");
580
+ const currency = updates.scalars.Currency ?? customer.Currency;
581
+ if (currency !== "SEK") return noReferencedEntity("Currency", currency);
582
+ if (!emulatedCustomerVatTypes.includes(customer.VATType)) return unsupportedVatType(customer.VATType);
583
+ const terms = termsOfPaymentDays(customer.TermsOfPayment);
584
+ if (terms === void 0) return unsupportedTermsOfPayment(customer.TermsOfPayment);
585
+ const invoiceDate = updates.scalars.InvoiceDate ?? today(env);
586
+ const dueDate = updates.scalars.DueDate ?? addDays(invoiceDate, terms);
587
+ if (dueDate === void 0) return invalidField(`the due date ${invoiceDate} + ${terms} days is not a representable YYYY-MM-DD date.`);
588
+ const ids = { next: state.counters.nextRowId };
589
+ const invoice = invoiceFromSeed({
590
+ ...updates.scalars,
591
+ DocumentNumber: String(state.counters.nextDocumentNumber),
592
+ CustomerNumber: customer.CustomerNumber,
593
+ InvoiceDate: invoiceDate,
594
+ DueDate: dueDate,
595
+ EmailInformation: defaultEmailInformation(customer, state.company),
596
+ InvoiceRows: updates.rows ?? []
597
+ }, customer, state.company, ids);
598
+ state.invoices = [...state.invoices, invoice].sort((left, right) => compareNumbers(left.DocumentNumber, right.DocumentNumber));
599
+ state.counters = {
600
+ nextRowId: ids.next,
601
+ nextDocumentNumber: state.counters.nextDocumentNumber + 1
602
+ };
603
+ return jsonResponse(201, { Invoice: renderInvoice(env, invoice) });
604
+ };
605
+ const previewInvoice = (state, { params }) => findInvoice(state, params.DocumentNumber ?? "") === void 0 ? invoiceNotFound() : new Response(previewPdfBytes(), {
606
+ status: 200,
607
+ headers: { "content-type": "application/pdf" }
608
+ });
609
+ /** Marks the invoice `Sent` and records an outbox entry. Nothing is ever delivered. */
610
+ const emailInvoice = (state, { params }, env) => {
611
+ const invoice = findInvoice(state, params.DocumentNumber ?? "");
612
+ if (invoice === void 0) return invoiceNotFound();
613
+ if (invoice.Cancelled) return notEditable("a cancelled invoice cannot be sent.");
614
+ const email = invoice.EmailInformation;
615
+ if (email.EmailAddressTo.trim() === "") return invalidField("the invoice has no EmailInformation.EmailAddressTo.");
616
+ const updated = {
617
+ ...invoice,
618
+ Sent: true
619
+ };
620
+ replaceInvoice(state, updated);
621
+ state.outbox = [...state.outbox, {
622
+ DocumentNumber: invoice.DocumentNumber,
623
+ EmailAddressTo: email.EmailAddressTo,
624
+ EmailAddressCC: email.EmailAddressCC,
625
+ EmailAddressBCC: email.EmailAddressBCC,
626
+ EmailSubject: email.EmailSubject.replaceAll("{no}", invoice.DocumentNumber),
627
+ sentAt: new Date(env.now()).toISOString()
628
+ }];
629
+ return jsonResponse(200, { Invoice: renderInvoice(env, updated, true) });
630
+ };
631
+ const companyInformation = (state) => jsonResponse(200, { CompanyInformation: state.company });
632
+ const connectorRoute = (method, path, write, caseIds, handler, queryKeys = []) => ({
633
+ method,
634
+ path: `/3${path}`,
635
+ kind: "connector",
636
+ write,
637
+ caseIds,
638
+ evidence: "unverified",
639
+ queryKeys,
640
+ handler
641
+ });
642
+ const listCase = "fortnox.invoice.list-populated";
643
+ const previewCase = "fortnox.invoice.preview-pdf";
644
+ const filtersCase = "fortnox.invoice.payment-filters-exclude-unbooked";
645
+ const discountCase = "fortnox.invoice.row-discount-sticky";
646
+ const emptyStringCase = "fortnox.customer.empty-string-keeps-value";
647
+ const rejectionCase = "fortnox.write.rejection-error-information";
648
+ const emailCase = "fortnox.invoice.send-email";
649
+ /** The route table: evidence plus handler. `fortnoxEmulatorRoutes` is its evidence part. */
650
+ const fortnoxApiRoutes = [
651
+ connectorRoute("GET", "/companyinformation", false, [], companyInformation),
652
+ connectorRoute("GET", "/customers", false, [], listCustomers, customerListQueryKeys),
653
+ connectorRoute("GET", "/customers/{CustomerNumber}", false, [emptyStringCase, rejectionCase], getCustomer),
654
+ connectorRoute("PUT", "/customers/{CustomerNumber}", true, [emptyStringCase], updateCustomer),
655
+ connectorRoute("GET", "/invoices", false, [listCase, filtersCase], listInvoices, invoiceListQueryKeys),
656
+ connectorRoute("POST", "/invoices", true, [rejectionCase], createInvoice),
657
+ connectorRoute("GET", "/invoices/{DocumentNumber}", false, [discountCase, emailCase], getInvoice),
658
+ connectorRoute("PUT", "/invoices/{DocumentNumber}", true, [discountCase], updateInvoice),
659
+ connectorRoute("GET", "/invoices/{DocumentNumber}/preview", false, [previewCase], previewInvoice),
660
+ connectorRoute("GET", "/invoices/{DocumentNumber}/email", true, [emailCase], emailInvoice)
661
+ ];
662
+ /** The observed quirks, each tied to its conformance case id. */
663
+ const fortnoxEmulatorQuirks = [
664
+ {
665
+ id: "row-discount-sticky",
666
+ route: "PUT /3/invoices/{DocumentNumber}",
667
+ caseId: discountCase,
668
+ knob: "stickyRowDiscount",
669
+ summary: "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."
670
+ },
671
+ {
672
+ id: "empty-string-keeps-value",
673
+ route: "PUT /3/customers/{CustomerNumber}",
674
+ caseId: emptyStringCase,
675
+ knob: "emptyStringClears",
676
+ summary: "A customer update with \"\" for a string field keeps the stored value."
677
+ },
678
+ {
679
+ id: "payment-filters-exclude-unbooked",
680
+ route: "GET /3/invoices",
681
+ caseId: filtersCase,
682
+ knob: "paymentFiltersIncludeUnbooked",
683
+ summary: "unpaid, unpaidoverdue, and fullypaid consider booked invoices only; unbooked lists unbooked, uncancelled invoices; unpaidoverdue needs DueDate before today."
684
+ },
685
+ {
686
+ id: "rejection-error-information",
687
+ route: "POST /3/invoices",
688
+ caseId: rejectionCase,
689
+ knob: void 0,
690
+ summary: "Writes for an unknown customer, or with invalid fields, answer 400 with a lowercase ErrorInformation { error, message, code }."
691
+ },
692
+ {
693
+ id: "customer-country-read-only",
694
+ route: "PUT /3/customers/{CustomerNumber}",
695
+ caseId: void 0,
696
+ knob: void 0,
697
+ summary: "Customer Country is read-only (derived from CountryCode); sending it answers 400 ErrorInformation. No conformance case covers it yet."
698
+ }
699
+ ];
700
+ const templatePattern = (template) => new RegExp(`^${template.split(/(\{[A-Za-z]+\})/).map((part) => /^\{[A-Za-z]+\}$/.test(part) ? "([^/]+)" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}$`);
701
+ const compiledRoutes = fortnoxApiRoutes.map((route) => ({
702
+ route,
703
+ pattern: templatePattern(route.path)
704
+ }));
705
+ /** The route answering `method` + concrete `path`, or `undefined` (fail closed). */
706
+ const matchFortnoxRoute = (method, path) => compiledRoutes.find((candidate) => candidate.route.method === method.toUpperCase() && candidate.pattern.test(path))?.route;
707
+ const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json));
708
+ /** Parsed JSON, or `undefined` for invalid JSON. */
709
+ const parseJsonText = (text) => {
710
+ const result = decodeJsonText(text);
711
+ return Result.isSuccess(result) ? result.success : void 0;
712
+ };
713
+ /** `{Name}` path templates become `:Name` core route parameters. */
714
+ const corePath = (template) => template.replace(/\{([A-Za-z]+)\}/g, ":$1");
715
+ /**
716
+ * Register every route of the table on the core app, over the generation's state. Each request is
717
+ * planned, then faulted, then committed: the handler runs on a draft of the state (handlers replace
718
+ * whole lists and counters, never edit them in place, so a shallow copy keeps every write off the
719
+ * state); a refusal (any answer that is not 2xx) is sent as is and uses up no fault; a successful
720
+ * answer asks `decideFault`, and only an unfaulted one commits the draft. The three steps run
721
+ * synchronously together, so no other request interleaves. A handler that throws answers
722
+ * `handlerFailedResponse()` (nothing written), which the wrapper turns into its `ErrorInformation`
723
+ * 500 with `responseError` in the ledger.
724
+ */
725
+ const registerFortnoxApi = (app, state, env, decideFault) => {
726
+ for (const route of fortnoxApiRoutes) app.on(route.method, corePath(route.path), async (context) => {
727
+ try {
728
+ const query = new URL(context.req.url).searchParams;
729
+ const unsupported = unsupportedQueryKey(query, route.queryKeys);
730
+ if (unsupported !== void 0) return unsupported;
731
+ const text = await context.req.text();
732
+ const draft = { ...state };
733
+ const response = route.handler(draft, {
734
+ params: context.req.param(),
735
+ query,
736
+ body: text === "" ? void 0 : parseJsonText(text)
737
+ }, env);
738
+ if (!response.ok) return response;
739
+ if (decideFault(context.req.header("x-emulator-job-id"))) return answeredOutsideCore();
740
+ Object.assign(state, draft);
741
+ return response;
742
+ } catch {
743
+ return handlerFailedResponse();
744
+ }
745
+ });
746
+ };
747
+ //#endregion
748
+ export { errorInformation, fortnoxApiRoutes, fortnoxEmulatorBasePath, fortnoxEmulatorErrorCodes, fortnoxEmulatorQuirks, matchFortnoxRoute, parseJsonText, registerFortnoxApi };
749
+
750
+ //# sourceMappingURL=api.mjs.map