@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,798 @@
1
+ /**
2
+ * Fortnox emulator state: the typed entities, the seed input, the default seed, and the
3
+ * account-variance profiles (internal; re-exported by `src/fortnox.ts`).
4
+ *
5
+ * Entity shapes and the default entities follow the synthetic Fortnox conformance fixtures, copied
6
+ * as data (the same customer and document numbers as `fortnoxConformanceFixtureSeeds`), never
7
+ * imported from SDK code. Where no fixture records a value, the default is synthesized and says
8
+ * so below: customer 1002, most invoice rows, and the company information have no fixture.
9
+ *
10
+ * @experimental
11
+ */
12
+ import { Result, Struct } from 'effect'
13
+ import * as Schema from 'effect/Schema'
14
+
15
+ const DiscountType = Schema.Literals(['PERCENT', 'AMOUNT'])
16
+
17
+ const rowFields = {
18
+ RowId: Schema.Int,
19
+ AccountNumber: Schema.NullOr(Schema.Int),
20
+ ArticleNumber: Schema.String,
21
+ ContributionPercent: Schema.String,
22
+ ContributionValue: Schema.String,
23
+ CostCenter: Schema.String,
24
+ DeliveredQuantity: Schema.String,
25
+ Description: Schema.String,
26
+ Discount: Schema.Finite,
27
+ DiscountType,
28
+ HouseWork: Schema.Boolean,
29
+ Price: Schema.Finite,
30
+ PriceExcludingVAT: Schema.Finite,
31
+ Project: Schema.String,
32
+ Total: Schema.Finite,
33
+ TotalExcludingVAT: Schema.Finite,
34
+ Unit: Schema.String,
35
+ VAT: Schema.Finite,
36
+ VATCode: Schema.String
37
+ }
38
+
39
+ /** A stored invoice row, in the single-invoice wire shape. Totals are derived. */
40
+ export const FortnoxEmulatorInvoiceRow = Schema.Struct(rowFields)
41
+
42
+ export type FortnoxEmulatorInvoiceRow = typeof FortnoxEmulatorInvoiceRow.Type
43
+
44
+ const EmailInformation = Schema.Struct({
45
+ EmailAddressFrom: Schema.String,
46
+ EmailAddressTo: Schema.String,
47
+ EmailAddressCC: Schema.String,
48
+ EmailAddressBCC: Schema.String,
49
+ EmailSubject: Schema.String,
50
+ EmailBody: Schema.String
51
+ })
52
+
53
+ const invoiceFields = {
54
+ DocumentNumber: Schema.String,
55
+ CustomerNumber: Schema.String,
56
+ CustomerName: Schema.String,
57
+ Balance: Schema.Finite,
58
+ Booked: Schema.Boolean,
59
+ Cancelled: Schema.Boolean,
60
+ Comments: Schema.String,
61
+ CostCenter: Schema.String,
62
+ Credit: Schema.String,
63
+ Currency: Schema.String,
64
+ CurrencyRate: Schema.Finite,
65
+ CurrencyUnit: Schema.Finite,
66
+ DueDate: Schema.NullOr(Schema.String),
67
+ EmailInformation,
68
+ ExternalInvoiceReference1: Schema.String,
69
+ ExternalInvoiceReference2: Schema.String,
70
+ FinalPayDate: Schema.NullOr(Schema.String),
71
+ Gross: Schema.Finite,
72
+ InvoiceDate: Schema.String,
73
+ InvoiceRows: Schema.Array(FortnoxEmulatorInvoiceRow),
74
+ InvoiceType: Schema.String,
75
+ Net: Schema.Finite,
76
+ NotCompleted: Schema.Boolean,
77
+ NoxFinans: Schema.Boolean,
78
+ OCR: Schema.String,
79
+ OurReference: Schema.String,
80
+ Project: Schema.String,
81
+ Sent: Schema.Boolean,
82
+ TermsOfPayment: Schema.String,
83
+ Total: Schema.Finite,
84
+ TotalToPay: Schema.Finite,
85
+ TotalVAT: Schema.Finite,
86
+ VoucherNumber: Schema.NullOr(Schema.Int),
87
+ VoucherSeries: Schema.NullOr(Schema.String),
88
+ VoucherYear: Schema.NullOr(Schema.Int),
89
+ WayOfDelivery: Schema.String,
90
+ YourReference: Schema.String
91
+ }
92
+
93
+ /** A stored customer invoice with its rows. */
94
+ export const FortnoxEmulatorInvoice = Schema.Struct(invoiceFields)
95
+
96
+ export type FortnoxEmulatorInvoice = typeof FortnoxEmulatorInvoice.Type
97
+
98
+ const customerFields = {
99
+ CustomerNumber: Schema.String,
100
+ Name: Schema.String,
101
+ Active: Schema.Boolean,
102
+ Address1: Schema.String,
103
+ Address2: Schema.String,
104
+ City: Schema.String,
105
+ Comments: Schema.String,
106
+ CostCenter: Schema.String,
107
+ Country: Schema.String,
108
+ CountryCode: Schema.String,
109
+ Currency: Schema.String,
110
+ Email: Schema.String,
111
+ EmailInvoice: Schema.String,
112
+ OrganisationNumber: Schema.String,
113
+ OurReference: Schema.String,
114
+ Phone1: Schema.String,
115
+ Phone2: Schema.String,
116
+ TermsOfPayment: Schema.String,
117
+ Type: Schema.String,
118
+ VATNumber: Schema.String,
119
+ VATType: Schema.String,
120
+ YourReference: Schema.String,
121
+ ZipCode: Schema.String
122
+ }
123
+
124
+ /** A stored customer. `Country` is read-only (derived from `CountryCode`). */
125
+ export const FortnoxEmulatorCustomer = Schema.Struct(customerFields)
126
+
127
+ export type FortnoxEmulatorCustomer = typeof FortnoxEmulatorCustomer.Type
128
+
129
+ const companyFields = {
130
+ CompanyName: Schema.String,
131
+ OrganizationNumber: Schema.String,
132
+ DatabaseNumber: Schema.Int,
133
+ Address: Schema.String,
134
+ City: Schema.String,
135
+ ZipCode: Schema.String,
136
+ CountryCode: Schema.String,
137
+ VisitAddress: Schema.String,
138
+ VisitCity: Schema.String,
139
+ VisitZipCode: Schema.String,
140
+ VisitCountryCode: Schema.String
141
+ }
142
+
143
+ /** The company the credential belongs to (`GET /3/companyinformation`). */
144
+ export const FortnoxEmulatorCompany = Schema.Struct(companyFields)
145
+
146
+ export type FortnoxEmulatorCompany = typeof FortnoxEmulatorCompany.Type
147
+
148
+ /** A recorded (never delivered) invoice email. */
149
+ export const FortnoxEmulatorOutboxEntry = Schema.Struct({
150
+ DocumentNumber: Schema.String,
151
+ EmailAddressTo: Schema.String,
152
+ EmailAddressCC: Schema.String,
153
+ EmailAddressBCC: Schema.String,
154
+ EmailSubject: Schema.String,
155
+ /** ISO timestamp from the emulator clock. */
156
+ sentAt: Schema.String
157
+ })
158
+
159
+ export type FortnoxEmulatorOutboxEntry = typeof FortnoxEmulatorOutboxEntry.Type
160
+
161
+ const Counter = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
162
+
163
+ const Counters = Schema.Struct({
164
+ /** Next RowId handed out; RowIds are regenerated on every row update. */
165
+ nextRowId: Counter,
166
+ /** Next DocumentNumber handed out by `POST /3/invoices`. */
167
+ nextDocumentNumber: Counter
168
+ })
169
+
170
+ /** The whole emulator state (JSON-compatible; what `snapshot()` returns). */
171
+ export const FortnoxEmulatorStateSchema = Schema.Struct({
172
+ company: FortnoxEmulatorCompany,
173
+ customers: Schema.Array(FortnoxEmulatorCustomer),
174
+ invoices: Schema.Array(FortnoxEmulatorInvoice),
175
+ outbox: Schema.Array(FortnoxEmulatorOutboxEntry),
176
+ counters: Counters
177
+ })
178
+
179
+ /**
180
+ * The emulator state. The container is mutable (route handlers replace whole entity lists);
181
+ * every entity is replaced, never edited in place.
182
+ */
183
+ export type FortnoxEmulatorState = {
184
+ company: FortnoxEmulatorCompany
185
+ customers: ReadonlyArray<FortnoxEmulatorCustomer>
186
+ invoices: ReadonlyArray<FortnoxEmulatorInvoice>
187
+ outbox: ReadonlyArray<FortnoxEmulatorOutboxEntry>
188
+ counters: typeof Counters.Type
189
+ }
190
+
191
+ /** A seeded invoice row: the writable row fields; totals and `VATCode` are derived. */
192
+ export const FortnoxEmulatorInvoiceRowSeed = Schema.Struct({
193
+ RowId: Schema.optionalKey(Schema.Int),
194
+ AccountNumber: Schema.optionalKey(Schema.NullOr(Schema.Int)),
195
+ ArticleNumber: Schema.optionalKey(Schema.String),
196
+ CostCenter: Schema.optionalKey(Schema.String),
197
+ DeliveredQuantity: Schema.optionalKey(Schema.String),
198
+ Description: Schema.optionalKey(Schema.String),
199
+ Discount: Schema.optionalKey(Schema.Finite),
200
+ DiscountType: Schema.optionalKey(DiscountType),
201
+ Price: Schema.optionalKey(Schema.Finite),
202
+ Project: Schema.optionalKey(Schema.String),
203
+ Unit: Schema.optionalKey(Schema.String),
204
+ VAT: Schema.optionalKey(Schema.Finite)
205
+ })
206
+
207
+ export type FortnoxEmulatorInvoiceRowSeed = typeof FortnoxEmulatorInvoiceRowSeed.Type
208
+
209
+ const { InvoiceRows: _invoiceRows, ...invoiceScalarFields } = invoiceFields
210
+
211
+ /**
212
+ * A seeded invoice: `DocumentNumber` and `CustomerNumber` are required; everything else has a
213
+ * synthetic default. Totals (`Net`, `Gross`, `TotalVAT`, `Total`, `TotalToPay`) are always
214
+ * computed from the rows; `Balance` defaults to `Total`.
215
+ */
216
+ export const FortnoxEmulatorInvoiceSeed = Schema.Struct({
217
+ ...Struct.mapOmit(invoiceScalarFields, ['DocumentNumber', 'CustomerNumber'], Schema.optionalKey),
218
+ InvoiceRows: Schema.optionalKey(Schema.Array(FortnoxEmulatorInvoiceRowSeed))
219
+ })
220
+
221
+ export type FortnoxEmulatorInvoiceSeed = typeof FortnoxEmulatorInvoiceSeed.Type
222
+
223
+ /** A seeded customer: `CustomerNumber` and `Name` are required; `Country` follows `CountryCode`. */
224
+ export const FortnoxEmulatorCustomerSeed = Schema.Struct(
225
+ Struct.mapOmit(customerFields, ['CustomerNumber', 'Name'], Schema.optionalKey)
226
+ )
227
+
228
+ export type FortnoxEmulatorCustomerSeed = typeof FortnoxEmulatorCustomerSeed.Type
229
+
230
+ /** Account-variance profiles for the default seed. */
231
+ export const FortnoxEmulatorProfile = Schema.Literals([
232
+ 'default',
233
+ 'empty-company',
234
+ 'no-booked-invoices'
235
+ ])
236
+
237
+ export type FortnoxEmulatorProfile = typeof FortnoxEmulatorProfile.Type
238
+
239
+ /**
240
+ * A typed seed. Start from `profile` (default `'default'`, the fixture entities); `company`,
241
+ * `customers`, and `invoices`, when given, replace that part of the profile.
242
+ */
243
+ export const FortnoxEmulatorSeed = Schema.Struct({
244
+ profile: Schema.optionalKey(FortnoxEmulatorProfile),
245
+ company: Schema.optionalKey(FortnoxEmulatorCompany),
246
+ customers: Schema.optionalKey(Schema.Array(FortnoxEmulatorCustomerSeed)),
247
+ invoices: Schema.optionalKey(Schema.Array(FortnoxEmulatorInvoiceSeed))
248
+ })
249
+
250
+ export type FortnoxEmulatorSeed = typeof FortnoxEmulatorSeed.Type
251
+
252
+ const strict = { onExcessProperty: 'error' } as const
253
+
254
+ const decodeSeedInput = Schema.decodeUnknownResult(FortnoxEmulatorSeed, strict)
255
+
256
+ const decodeStateInput = Schema.decodeUnknownResult(FortnoxEmulatorStateSchema, strict)
257
+
258
+ const issueMessage = (issue: Schema.SchemaError['issue']): string =>
259
+ new Schema.SchemaError(issue).message
260
+
261
+ /** `CountryCode` to the Swedish country name Fortnox derives `Country` from (a small table). */
262
+ export const fortnoxEmulatorCountries = new Map([
263
+ ['SE', 'Sverige'],
264
+ ['NO', 'Norge'],
265
+ ['DK', 'Danmark'],
266
+ ['FI', 'Finland'],
267
+ ['DE', 'Tyskland'],
268
+ ['GB', 'Storbritannien'],
269
+ ['US', 'USA']
270
+ ])
271
+
272
+ /** VAT percent to Fortnox sales VAT code. */
273
+ export const fortnoxEmulatorVatCodes = new Map([
274
+ [25, 'MP1'],
275
+ [12, 'MP2'],
276
+ [6, 'MP3'],
277
+ [0, 'MF']
278
+ ])
279
+
280
+ export const round2 = (value: number): number => Math.round(value * 100) / 100
281
+
282
+ /** Row total: `Price × DeliveredQuantity`, less a percent (or amount) discount. */
283
+ export const rowTotal = (
284
+ row: Pick<FortnoxEmulatorInvoiceRow, 'Price' | 'DeliveredQuantity' | 'Discount' | 'DiscountType'>
285
+ ): number => {
286
+ const gross = row.Price * Number(row.DeliveredQuantity)
287
+
288
+ return round2(
289
+ row.DiscountType === 'AMOUNT' ? gross - row.Discount : (gross * (100 - row.Discount)) / 100
290
+ )
291
+ }
292
+
293
+ type RowInput = Pick<
294
+ FortnoxEmulatorInvoiceRow,
295
+ | 'RowId'
296
+ | 'AccountNumber'
297
+ | 'ArticleNumber'
298
+ | 'CostCenter'
299
+ | 'DeliveredQuantity'
300
+ | 'Description'
301
+ | 'Discount'
302
+ | 'DiscountType'
303
+ | 'Price'
304
+ | 'Project'
305
+ | 'Unit'
306
+ | 'VAT'
307
+ >
308
+
309
+ /** A full row (fixture key order) with derived totals and VAT code. */
310
+ export const buildRow = (input: RowInput): FortnoxEmulatorInvoiceRow => {
311
+ const total = rowTotal(input)
312
+
313
+ return {
314
+ AccountNumber: input.AccountNumber,
315
+ ArticleNumber: input.ArticleNumber,
316
+ ContributionPercent: '0',
317
+ ContributionValue: '0',
318
+ CostCenter: input.CostCenter,
319
+ DeliveredQuantity: input.DeliveredQuantity,
320
+ Description: input.Description,
321
+ Discount: input.Discount,
322
+ DiscountType: input.DiscountType,
323
+ HouseWork: false,
324
+ Price: input.Price,
325
+ PriceExcludingVAT: input.Price,
326
+ Project: input.Project,
327
+ RowId: input.RowId,
328
+ Total: total,
329
+ TotalExcludingVAT: total,
330
+ Unit: input.Unit,
331
+ VAT: input.VAT,
332
+ VATCode: fortnoxEmulatorVatCodes.get(input.VAT) ?? ''
333
+ }
334
+ }
335
+
336
+ /** Defaults for a new row (no positional match). */
337
+ export const newRowDefaults: Omit<RowInput, 'RowId'> = {
338
+ AccountNumber: 3001,
339
+ ArticleNumber: '',
340
+ CostCenter: '',
341
+ DeliveredQuantity: '1.00',
342
+ Description: '',
343
+ Discount: 0,
344
+ DiscountType: 'PERCENT',
345
+ Price: 0,
346
+ Project: '',
347
+ Unit: '',
348
+ VAT: 25
349
+ }
350
+
351
+ /** Fortnox-style quantity: two decimals (`"2.00"`). */
352
+ export const formatQuantity = (value: number): string => value.toFixed(2)
353
+
354
+ export type InvoiceTotals = Pick<
355
+ FortnoxEmulatorInvoice,
356
+ 'Net' | 'Gross' | 'TotalVAT' | 'Total' | 'TotalToPay'
357
+ >
358
+
359
+ /**
360
+ * Invoice totals from the rows: `Net = Gross = Σ row totals`, `TotalVAT = Σ row total × VAT%`,
361
+ * and `Total = TotalToPay = Net + TotalVAT` rounded to whole kronor (half up), matching the
362
+ * synthetic row-discount fixture (1150 + 287.5 → 1438).
363
+ */
364
+ export const invoiceTotals = (rows: ReadonlyArray<FortnoxEmulatorInvoiceRow>): InvoiceTotals => {
365
+ const net = round2(rows.reduce((sum, row) => sum + row.Total, 0))
366
+ const vat = round2(rows.reduce((sum, row) => sum + (row.Total * row.VAT) / 100, 0))
367
+ const total = Math.round(net + vat)
368
+
369
+ return { Net: net, Gross: net, TotalVAT: vat, Total: total, TotalToPay: total }
370
+ }
371
+
372
+ // Default entities. Fixture-derived: customer 1001 (empty-string fixture) and the invoice headers
373
+ // of 101-105 (list, payment-filter, row-discount, and email fixtures). Where two fixtures disagree
374
+ // the more specific one wins: invoice 103 (and its two rows) follows the row-discount fixture, not
375
+ // the list fixture (its Balance, DueDate, InvoiceDate, and OCR differ there). Synthesized: customer
376
+ // 1002, the rows of invoices 101, 102, 104, and 105 (chosen so their computed totals equal the
377
+ // fixture totals), the company information (no fixture), and every default `customerFromSeed` and
378
+ // `invoiceFromSeed` fill in for a field the seed omits.
379
+
380
+ const exampleCustomer: FortnoxEmulatorCustomerSeed = {
381
+ CustomerNumber: '1001',
382
+ Name: 'Example Customer AB',
383
+ Active: true,
384
+ Address1: 'Exempelgatan 1',
385
+ Address2: '',
386
+ City: 'Exempelstad',
387
+ Comments: 'Synthetic note: invoice monthly by email.',
388
+ CostCenter: '',
389
+ CountryCode: 'SE',
390
+ Currency: 'SEK',
391
+ Email: 'billing@example.test',
392
+ EmailInvoice: 'billing@example.test',
393
+ OrganisationNumber: '000000-0000',
394
+ OurReference: '',
395
+ Phone1: '',
396
+ Phone2: '',
397
+ TermsOfPayment: '30',
398
+ Type: 'COMPANY',
399
+ VATNumber: '',
400
+ VATType: 'SEVAT',
401
+ YourReference: '',
402
+ ZipCode: '000 00'
403
+ }
404
+
405
+ const exampleTrading: FortnoxEmulatorCustomerSeed = {
406
+ CustomerNumber: '1002',
407
+ Name: 'Example Trading AB',
408
+ Active: true,
409
+ Address1: 'Exempelvägen 2',
410
+ City: 'Exempelstad',
411
+ Comments: '',
412
+ CountryCode: 'SE',
413
+ Currency: 'SEK',
414
+ Email: 'accounts@example.test',
415
+ EmailInvoice: 'accounts@example.test',
416
+ OrganisationNumber: '000000-0001',
417
+ TermsOfPayment: '30',
418
+ Type: 'COMPANY',
419
+ VATType: 'SEVAT',
420
+ ZipCode: '000 01'
421
+ }
422
+
423
+ const singleRow = (description: string, price: number): FortnoxEmulatorInvoiceRowSeed => ({
424
+ AccountNumber: 3001,
425
+ Description: description,
426
+ DeliveredQuantity: '1.00',
427
+ Price: price,
428
+ VAT: 25,
429
+ Discount: 0,
430
+ DiscountType: 'PERCENT'
431
+ })
432
+
433
+ const defaultInvoices: ReadonlyArray<FortnoxEmulatorInvoiceSeed> = [
434
+ {
435
+ DocumentNumber: '101',
436
+ CustomerNumber: '1001',
437
+ Balance: 0,
438
+ Booked: true,
439
+ DueDate: '2026-09-30',
440
+ FinalPayDate: '2026-09-20',
441
+ InvoiceDate: '2026-08-31',
442
+ OCR: '10151',
443
+ Sent: true,
444
+ VoucherNumber: 12,
445
+ VoucherSeries: 'B',
446
+ VoucherYear: 3,
447
+ InvoiceRows: [singleRow('Monthly service (synthetic)', 1000)]
448
+ },
449
+ {
450
+ DocumentNumber: '102',
451
+ CustomerNumber: '1002',
452
+ Balance: 3750,
453
+ Booked: true,
454
+ DueDate: '2026-09-30',
455
+ InvoiceDate: '2026-08-31',
456
+ OCR: '10250',
457
+ Sent: true,
458
+ VoucherNumber: 13,
459
+ VoucherSeries: 'B',
460
+ VoucherYear: 3,
461
+ InvoiceRows: [singleRow('Project delivery (synthetic)', 3000)]
462
+ },
463
+ {
464
+ DocumentNumber: '103',
465
+ CustomerNumber: '1001',
466
+ Booked: false,
467
+ DueDate: '2026-10-31',
468
+ InvoiceDate: '2026-09-30',
469
+ OCR: '10350',
470
+ InvoiceRows: [
471
+ {
472
+ RowId: 1,
473
+ AccountNumber: 3001,
474
+ Description: 'Consulting hours (synthetic)',
475
+ DeliveredQuantity: '2.00',
476
+ Unit: 'h',
477
+ Price: 500,
478
+ VAT: 25,
479
+ Discount: 5,
480
+ DiscountType: 'PERCENT'
481
+ },
482
+ {
483
+ RowId: 2,
484
+ AccountNumber: 3001,
485
+ Description: 'Travel (synthetic)',
486
+ DeliveredQuantity: '1.00',
487
+ Price: 250,
488
+ VAT: 25,
489
+ Discount: 0,
490
+ DiscountType: 'PERCENT'
491
+ }
492
+ ]
493
+ },
494
+ {
495
+ DocumentNumber: '104',
496
+ CustomerNumber: '1001',
497
+ Balance: 625,
498
+ Booked: true,
499
+ DueDate: '2026-10-31',
500
+ InvoiceDate: '2026-09-30',
501
+ VoucherNumber: 14,
502
+ VoucherSeries: 'B',
503
+ VoucherYear: 3,
504
+ EmailInformation: {
505
+ EmailAddressFrom: 'invoices@example.test',
506
+ EmailAddressTo: 'billing@example.test',
507
+ EmailAddressCC: '',
508
+ EmailAddressBCC: '',
509
+ EmailSubject: 'Invoice {no} from Example AB',
510
+ EmailBody: 'Synthetic invoice email body.'
511
+ },
512
+ InvoiceRows: [singleRow('Support hours (synthetic)', 500)]
513
+ },
514
+ {
515
+ DocumentNumber: '105',
516
+ CustomerNumber: '1001',
517
+ Booked: false,
518
+ DueDate: '2026-09-15',
519
+ InvoiceDate: '2026-08-16',
520
+ InvoiceRows: [singleRow('Workshop (synthetic)', 1000)]
521
+ }
522
+ ]
523
+
524
+ const defaultCompany: FortnoxEmulatorCompany = {
525
+ CompanyName: 'Example AB',
526
+ OrganizationNumber: '000000-0002',
527
+ DatabaseNumber: 100001,
528
+ Address: 'Exempelgatan 10',
529
+ City: 'Exempelstad',
530
+ ZipCode: '000 02',
531
+ CountryCode: 'SE',
532
+ VisitAddress: 'Exempelgatan 10',
533
+ VisitCity: 'Exempelstad',
534
+ VisitZipCode: '000 02',
535
+ VisitCountryCode: 'SE'
536
+ }
537
+
538
+ type ProfileEntities = {
539
+ readonly company: FortnoxEmulatorCompany
540
+ readonly customers: ReadonlyArray<FortnoxEmulatorCustomerSeed>
541
+ readonly invoices: ReadonlyArray<FortnoxEmulatorInvoiceSeed>
542
+ }
543
+
544
+ const unbooked = (invoice: FortnoxEmulatorInvoiceSeed): FortnoxEmulatorInvoiceSeed => {
545
+ const { Balance: _balance, FinalPayDate: _finalPayDate, ...rest } = invoice
546
+
547
+ return { ...rest, Booked: false, VoucherNumber: null, VoucherSeries: null, VoucherYear: null }
548
+ }
549
+
550
+ const profileEntities = (profile: FortnoxEmulatorProfile): ProfileEntities => {
551
+ switch (profile) {
552
+ case 'default':
553
+ return {
554
+ company: defaultCompany,
555
+ customers: [exampleCustomer, exampleTrading],
556
+ invoices: defaultInvoices
557
+ }
558
+ case 'empty-company':
559
+ return { company: defaultCompany, customers: [], invoices: [] }
560
+ case 'no-booked-invoices':
561
+ return {
562
+ company: defaultCompany,
563
+ customers: [exampleCustomer, exampleTrading],
564
+ invoices: defaultInvoices.map(unbooked)
565
+ }
566
+ }
567
+ }
568
+
569
+ /** Numeric-aware order for Fortnox numbers (`"999" < "1001"`). */
570
+ export const compareNumbers = (left: string, right: string): number =>
571
+ left.localeCompare(right, 'en', { numeric: true })
572
+
573
+ const customerFromSeed = (seed: FortnoxEmulatorCustomerSeed): FortnoxEmulatorCustomer => {
574
+ const countryCode = seed.CountryCode ?? 'SE'
575
+
576
+ return {
577
+ CustomerNumber: seed.CustomerNumber,
578
+ Name: seed.Name,
579
+ Active: seed.Active ?? true,
580
+ Address1: seed.Address1 ?? '',
581
+ Address2: seed.Address2 ?? '',
582
+ City: seed.City ?? '',
583
+ Comments: seed.Comments ?? '',
584
+ CostCenter: seed.CostCenter ?? '',
585
+ Country: seed.Country ?? fortnoxEmulatorCountries.get(countryCode) ?? '',
586
+ CountryCode: countryCode,
587
+ Currency: seed.Currency ?? 'SEK',
588
+ Email: seed.Email ?? '',
589
+ EmailInvoice: seed.EmailInvoice ?? '',
590
+ OrganisationNumber: seed.OrganisationNumber ?? '',
591
+ OurReference: seed.OurReference ?? '',
592
+ Phone1: seed.Phone1 ?? '',
593
+ Phone2: seed.Phone2 ?? '',
594
+ TermsOfPayment: seed.TermsOfPayment ?? '30',
595
+ Type: seed.Type ?? 'COMPANY',
596
+ VATNumber: seed.VATNumber ?? '',
597
+ VATType: seed.VATType ?? 'SEVAT',
598
+ YourReference: seed.YourReference ?? '',
599
+ ZipCode: seed.ZipCode ?? ''
600
+ }
601
+ }
602
+
603
+ /** Default invoice email for a customer (the email fixture's sender, subject, and body). */
604
+ export const defaultEmailInformation = (
605
+ customer: FortnoxEmulatorCustomer | undefined,
606
+ company: FortnoxEmulatorCompany
607
+ ): FortnoxEmulatorInvoice['EmailInformation'] => ({
608
+ EmailAddressFrom: 'invoices@example.test',
609
+ EmailAddressTo: customer?.EmailInvoice ?? '',
610
+ EmailAddressCC: '',
611
+ EmailAddressBCC: '',
612
+ EmailSubject: `Invoice {no} from ${company.CompanyName}`,
613
+ EmailBody: 'Synthetic invoice email body.'
614
+ })
615
+
616
+ export type RowIdSource = { next: number }
617
+
618
+ const rowFromSeed = (
619
+ seed: FortnoxEmulatorInvoiceRowSeed,
620
+ ids: RowIdSource
621
+ ): FortnoxEmulatorInvoiceRow =>
622
+ buildRow({
623
+ ...newRowDefaults,
624
+ ...seed,
625
+ DeliveredQuantity: formatQuantity(Number(seed.DeliveredQuantity ?? '1')),
626
+ RowId: seed.RowId ?? ids.next++
627
+ })
628
+
629
+ /** Build a stored invoice from a seed; row ids come from `ids` unless the seed names them. */
630
+ export const invoiceFromSeed = (
631
+ seed: FortnoxEmulatorInvoiceSeed,
632
+ customer: FortnoxEmulatorCustomer | undefined,
633
+ company: FortnoxEmulatorCompany,
634
+ ids: RowIdSource
635
+ ): FortnoxEmulatorInvoice => {
636
+ const rows = (seed.InvoiceRows ?? []).map(row => rowFromSeed(row, ids))
637
+ const totals = invoiceTotals(rows)
638
+
639
+ return {
640
+ DocumentNumber: seed.DocumentNumber,
641
+ CustomerNumber: seed.CustomerNumber,
642
+ CustomerName: seed.CustomerName ?? customer?.Name ?? '',
643
+ Balance: seed.Balance ?? totals.Total,
644
+ Booked: seed.Booked ?? false,
645
+ Cancelled: seed.Cancelled ?? false,
646
+ Comments: seed.Comments ?? '',
647
+ CostCenter: seed.CostCenter ?? '',
648
+ Credit: seed.Credit ?? 'false',
649
+ Currency: seed.Currency ?? customer?.Currency ?? 'SEK',
650
+ CurrencyRate: seed.CurrencyRate ?? 1,
651
+ CurrencyUnit: seed.CurrencyUnit ?? 1,
652
+ DueDate: seed.DueDate ?? null,
653
+ EmailInformation: seed.EmailInformation ?? defaultEmailInformation(customer, company),
654
+ ExternalInvoiceReference1: seed.ExternalInvoiceReference1 ?? '',
655
+ ExternalInvoiceReference2: seed.ExternalInvoiceReference2 ?? '',
656
+ FinalPayDate: seed.FinalPayDate ?? null,
657
+ Gross: totals.Gross,
658
+ InvoiceDate: seed.InvoiceDate ?? '2026-09-30',
659
+ InvoiceRows: rows,
660
+ InvoiceType: seed.InvoiceType ?? 'INVOICE',
661
+ Net: totals.Net,
662
+ NotCompleted: seed.NotCompleted ?? false,
663
+ NoxFinans: seed.NoxFinans ?? false,
664
+ OCR: seed.OCR ?? '',
665
+ OurReference: seed.OurReference ?? '',
666
+ Project: seed.Project ?? '',
667
+ Sent: seed.Sent ?? false,
668
+ TermsOfPayment: seed.TermsOfPayment ?? customer?.TermsOfPayment ?? '30',
669
+ Total: totals.Total,
670
+ TotalToPay: totals.TotalToPay,
671
+ TotalVAT: totals.TotalVAT,
672
+ VoucherNumber: seed.VoucherNumber ?? null,
673
+ VoucherSeries: seed.VoucherSeries ?? null,
674
+ VoucherYear: seed.VoucherYear ?? null,
675
+ WayOfDelivery: seed.WayOfDelivery ?? '',
676
+ YourReference: seed.YourReference ?? ''
677
+ }
678
+ }
679
+
680
+ const duplicate = (values: ReadonlyArray<string | number>): string | number | undefined =>
681
+ values.find((value, index) => values.indexOf(value) !== index)
682
+
683
+ const quantityProblem = (rows: ReadonlyArray<FortnoxEmulatorInvoiceRowSeed>) =>
684
+ rows.find(
685
+ row => row.DeliveredQuantity !== undefined && !Number.isFinite(Number(row.DeliveredQuantity))
686
+ )
687
+
688
+ /** Integrity problems a decoded seed can still have (duplicates, dangling references). */
689
+ const seedProblem = (entities: ProfileEntities): string | undefined => {
690
+ const customerNumbers = entities.customers.map(customer => customer.CustomerNumber)
691
+ const documentNumbers = entities.invoices.map(invoice => invoice.DocumentNumber)
692
+
693
+ const rowIds = entities.invoices.flatMap(invoice =>
694
+ (invoice.InvoiceRows ?? []).flatMap(row => (row.RowId === undefined ? [] : [row.RowId]))
695
+ )
696
+
697
+ const duplicateCustomer = duplicate(customerNumbers)
698
+ const duplicateInvoice = duplicate(documentNumbers)
699
+ const duplicateRow = duplicate(rowIds)
700
+
701
+ if (duplicateCustomer !== undefined) return `duplicate CustomerNumber ${duplicateCustomer}`
702
+
703
+ if (duplicateInvoice !== undefined) return `duplicate DocumentNumber ${duplicateInvoice}`
704
+
705
+ if (duplicateRow !== undefined) return `duplicate RowId ${duplicateRow}`
706
+
707
+ const dangling = entities.invoices.find(
708
+ invoice => !customerNumbers.includes(invoice.CustomerNumber)
709
+ )
710
+
711
+ if (dangling !== undefined) {
712
+ return `invoice ${dangling.DocumentNumber} references missing customer ${dangling.CustomerNumber}`
713
+ }
714
+
715
+ const badQuantity = entities.invoices.find(
716
+ invoice => quantityProblem(invoice.InvoiceRows ?? []) !== undefined
717
+ )
718
+
719
+ return badQuantity === undefined
720
+ ? undefined
721
+ : `invoice ${badQuantity.DocumentNumber} has a non-numeric DeliveredQuantity`
722
+ }
723
+
724
+ const nextNumber = (numbers: ReadonlyArray<string>): number =>
725
+ numbers.reduce((max, value) => {
726
+ const parsed = Number(value)
727
+
728
+ return Number.isSafeInteger(parsed) && parsed >= max ? parsed + 1 : max
729
+ }, 1)
730
+
731
+ /** Build the emulator state for a decoded seed; a string is an integrity problem. */
732
+ const stateFromSeed = (seed: FortnoxEmulatorSeed): FortnoxEmulatorState | string => {
733
+ const profile = profileEntities(seed.profile ?? 'default')
734
+
735
+ const entities: ProfileEntities = {
736
+ company: seed.company ?? profile.company,
737
+ customers: seed.customers ?? profile.customers,
738
+ invoices: seed.invoices ?? profile.invoices
739
+ }
740
+
741
+ const problem = seedProblem(entities)
742
+
743
+ if (problem !== undefined) {
744
+ return problem
745
+ }
746
+
747
+ const customers = entities.customers
748
+ .map(customerFromSeed)
749
+ .sort((left, right) => compareNumbers(left.CustomerNumber, right.CustomerNumber))
750
+
751
+ const explicitRowIds = entities.invoices.flatMap(invoice =>
752
+ (invoice.InvoiceRows ?? []).flatMap(row => (row.RowId === undefined ? [] : [row.RowId]))
753
+ )
754
+
755
+ const ids: RowIdSource = { next: Math.max(0, ...explicitRowIds) + 1 }
756
+
757
+ const invoices = entities.invoices
758
+ .map(invoice =>
759
+ invoiceFromSeed(
760
+ invoice,
761
+ customers.find(customer => customer.CustomerNumber === invoice.CustomerNumber),
762
+ entities.company,
763
+ ids
764
+ )
765
+ )
766
+ .sort((left, right) => compareNumbers(left.DocumentNumber, right.DocumentNumber))
767
+
768
+ return {
769
+ company: entities.company,
770
+ customers,
771
+ invoices,
772
+ outbox: [],
773
+ counters: {
774
+ nextRowId: ids.next,
775
+ nextDocumentNumber: nextNumber(invoices.map(invoice => invoice.DocumentNumber))
776
+ }
777
+ }
778
+ }
779
+
780
+ /** Decode and build a seed; a string is the reason it is invalid. */
781
+ export const buildSeedState = (input: unknown): FortnoxEmulatorState | string => {
782
+ const decoded = decodeSeedInput(input)
783
+
784
+ return Result.isFailure(decoded)
785
+ ? issueMessage(decoded.failure.issue)
786
+ : stateFromSeed(decoded.success)
787
+ }
788
+
789
+ /** Decode a full state (a restored snapshot); a string is the reason it is invalid. */
790
+ export const decodeState = (input: unknown): FortnoxEmulatorState | string => {
791
+ const decoded = decodeStateInput(input)
792
+
793
+ if (Result.isFailure(decoded)) {
794
+ return issueMessage(decoded.failure.issue)
795
+ }
796
+
797
+ return { ...decoded.success }
798
+ }