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