@substrat-run/connector-fortnox 0.2.0
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/README.md +204 -0
- package/dist/aggregate.d.ts +69 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +101 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/api.d.ts +154 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +276 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +260 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/dist/mock.d.ts +59 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +111 -0
- package/dist/mock.js.map +1 -0
- package/dist/sie4.d.ts +132 -0
- package/dist/sie4.d.ts.map +1 -0
- package/dist/sie4.js +265 -0
- package/dist/sie4.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { connectionActivity, connectionId as connectionIdSchema, instant, money, scopeId as scopeIdSchema, tenantId as tenantIdSchema, } from '@substrat-run/contracts';
|
|
3
|
+
import { FortnoxApi, FortnoxApiError, FORTNOX_API_BASE, FORTNOX_OAUTH_BASE, fortnoxSecret, } from './api.js';
|
|
4
|
+
import { financialYearFor, summarizeLedger } from './aggregate.js';
|
|
5
|
+
import { parseSie4 } from './sie4.js';
|
|
6
|
+
export { FortnoxApi, FortnoxApiError, FORTNOX_API_BASE, FORTNOX_OAUTH_BASE, fortnoxConsentUrl, fortnoxSecret, } from './api.js';
|
|
7
|
+
export { parseSie4, splitSieLine, sieAmount, sieDate, } from './sie4.js';
|
|
8
|
+
export { COST_CENTRE_DIMENSION, financialYearFor, summarizeLedger, } from './aggregate.js';
|
|
9
|
+
export { FortnoxMock } from './mock.js';
|
|
10
|
+
/**
|
|
11
|
+
* The Fortnox connector — the INBOUND half of accounting integration.
|
|
12
|
+
*
|
|
13
|
+
* ## Poll-only, and that is a design fact rather than an omission
|
|
14
|
+
*
|
|
15
|
+
* `connector-scrive` has two halves: a dispatch handler driven by an engine event, and
|
|
16
|
+
* a sweep that polls the provider. This connector has **only the sweep**, and registers
|
|
17
|
+
* no event handler at all, because nothing inside a scope initiates the work. A
|
|
18
|
+
* vertical does not *ask* for last month's bookkeeping the way it asks for a signature;
|
|
19
|
+
* the bookkeeping simply changes at Fortnox, and the platform finds out by looking.
|
|
20
|
+
*
|
|
21
|
+
* So there is no `registerFortnoxConnector`. `sweepFortnoxLedger` is a
|
|
22
|
+
* {@link ConnectorSweeper}, the deployment binds it into the platform sweeper exactly
|
|
23
|
+
* as it binds Scrive's, and that is the whole trigger surface.
|
|
24
|
+
*
|
|
25
|
+
* ## Where the data lands, and why the connector does not decide
|
|
26
|
+
*
|
|
27
|
+
* A sweep has no delivered event, so it has neither a scope to write to nor authority
|
|
28
|
+
* to write with. Scrive gets both from its dispatch ledger — the row it wrote when the
|
|
29
|
+
* event arrived. This connector has no such moment, so the scope is declared **once**,
|
|
30
|
+
* explicitly, by {@link bindFortnoxScope}: which scope, which operation to land the
|
|
31
|
+
* ledger through, and which permission that operation checks.
|
|
32
|
+
*
|
|
33
|
+
* The operation is the *consumer's*, not this connector's, and that is deliberate. What
|
|
34
|
+
* comes out of Fortnox is neutral accounting fact — accounts, cost centres, months,
|
|
35
|
+
* debit-positive sums. What a business *means* by them (which account is
|
|
36
|
+
* `lokal_grundhyra`, which sign normalizes it, which group it rolls into) is vocabulary,
|
|
37
|
+
* and vocabulary is the vertical's layer. A connector that mapped accounts to row keys
|
|
38
|
+
* would be a vertical wearing a connector's clothes, and the second customer with a
|
|
39
|
+
* different chart of accounts would have to fork it.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The standing grants this connector requires — deliberately EMPTY, with a mechanism
|
|
43
|
+
* in place of a declaration.
|
|
44
|
+
*
|
|
45
|
+
* Every other connector names its return-path permissions here so `lint:connector-grants`
|
|
46
|
+
* can prove the dashboard's door is able to grant them. This one genuinely cannot: the
|
|
47
|
+
* permission it needs is whatever the *consumer's* landing operation checks, which
|
|
48
|
+
* differs per vertical and is unknown at this package's build time.
|
|
49
|
+
*
|
|
50
|
+
* That would be an invisible hole — the exact shape of #841, where a connector needed a
|
|
51
|
+
* grant no door could give and the way it surfaced was a failure months later. So the
|
|
52
|
+
* check moves from build time to bind time: {@link bindFortnoxScope} verifies the
|
|
53
|
+
* connection actually holds the named permission in the named scope and **refuses the
|
|
54
|
+
* binding otherwise**, naming what is missing. A sweep can therefore never be configured
|
|
55
|
+
* into a state where it fetches a year of bookkeeping and cannot write it down.
|
|
56
|
+
*/
|
|
57
|
+
export const FORTNOX_CONNECTION_GRANTS = [];
|
|
58
|
+
/** The connector-state key prefix every binding lives under — what the sweep enumerates. */
|
|
59
|
+
const BINDING_PREFIX = 'fortnox:binding:';
|
|
60
|
+
const bindingKey = (scope) => `${BINDING_PREFIX}${scope}`;
|
|
61
|
+
/**
|
|
62
|
+
* One page of parsed bookkeeping, as it crosses into a scope.
|
|
63
|
+
*
|
|
64
|
+
* Parsed with this schema on the way OUT, before every `invoke`. The engine-seam rule
|
|
65
|
+
* (`returns()`) exists because a value crossing a version boundary must be pinned to a
|
|
66
|
+
* published shape rather than to whatever the code currently produces, and a connector
|
|
67
|
+
* seam is the same boundary with a network in the middle: a vertical compiled against
|
|
68
|
+
* one version of this package and running against another must get a throw, never a
|
|
69
|
+
* silently-reshaped ledger on a screen.
|
|
70
|
+
*/
|
|
71
|
+
export const fortnoxLedgerPage = z.object({
|
|
72
|
+
/**
|
|
73
|
+
* Identifies this sync RUN, and it is the content hash rather than a ULID on purpose:
|
|
74
|
+
* two syncs of unchanged books produce the same `syncId`, so a consumer's upsert is
|
|
75
|
+
* naturally idempotent and a redelivered page cannot double a balance.
|
|
76
|
+
*/
|
|
77
|
+
syncId: z.string().min(1),
|
|
78
|
+
connectionId: z.string().min(1),
|
|
79
|
+
company: z.object({
|
|
80
|
+
name: z.string(),
|
|
81
|
+
organizationNumber: z.string(),
|
|
82
|
+
}),
|
|
83
|
+
financialYear: z.object({
|
|
84
|
+
id: z.number(),
|
|
85
|
+
from: z.string(),
|
|
86
|
+
to: z.string(),
|
|
87
|
+
}),
|
|
88
|
+
page: z.number().int().nonnegative(),
|
|
89
|
+
pageCount: z.number().int().positive(),
|
|
90
|
+
/** True on the last page — the signal a consumer commits or swaps on. */
|
|
91
|
+
final: z.boolean(),
|
|
92
|
+
/** Present on page 0 only; the labels every later page's codes refer to. */
|
|
93
|
+
accounts: z.array(z.object({ number: z.string(), name: z.string() })).default([]),
|
|
94
|
+
costCentres: z.array(z.object({ code: z.string(), name: z.string() })).default([]),
|
|
95
|
+
balances: z.array(z.object({
|
|
96
|
+
account: z.string(),
|
|
97
|
+
costCentre: z.string().nullable(),
|
|
98
|
+
month: z.string(),
|
|
99
|
+
/** Debit-positive, as SIE states it. The consumer applies its own sign. */
|
|
100
|
+
amount: money,
|
|
101
|
+
})),
|
|
102
|
+
});
|
|
103
|
+
/** How many balance rows ride one `invoke`. */
|
|
104
|
+
const PAGE_SIZE = 500;
|
|
105
|
+
/**
|
|
106
|
+
* Declare that a connection should sync one scope — the one-time setup a poll-only
|
|
107
|
+
* connector needs in place of a dispatch.
|
|
108
|
+
*
|
|
109
|
+
* **Refuses a binding whose grant is missing**, which is the whole reason this is a
|
|
110
|
+
* function rather than a config object. The alternative — write the binding, discover
|
|
111
|
+
* at sweep time that the connection cannot invoke the operation — fails in the worst
|
|
112
|
+
* possible place: after a year of bookkeeping has been fetched, in a background timer
|
|
113
|
+
* nobody is watching, with a provider round trip already spent. Here it fails in the
|
|
114
|
+
* operator's hands, naming the permission to grant.
|
|
115
|
+
*/
|
|
116
|
+
export async function bindFortnoxScope(host, input) {
|
|
117
|
+
const tenant = tenantIdSchema.parse(input.tenantId);
|
|
118
|
+
const scope = scopeIdSchema.parse(input.scopeId);
|
|
119
|
+
// On the host, not `admin`: this is the same projection the permission checker reads,
|
|
120
|
+
// so a binding is verified against the tuples that will actually gate the invoke —
|
|
121
|
+
// not against a directory row that may not have reached the scope yet.
|
|
122
|
+
const granted = await host.connectionGrantsInScope(tenant, scope);
|
|
123
|
+
const held = granted.some((g) => g.connectionId === input.connectionId && g.permission === input.permission);
|
|
124
|
+
if (!held) {
|
|
125
|
+
throw new Error(`connection ${input.connectionId} does not hold '${input.permission}' on scope ${input.scopeId} — ` +
|
|
126
|
+
`a sweep would fetch the ledger and then fail to land it. Grant it first ` +
|
|
127
|
+
`(grantToConnection), then bind.`);
|
|
128
|
+
}
|
|
129
|
+
const binding = {
|
|
130
|
+
scopeId: input.scopeId,
|
|
131
|
+
tenantId: input.tenantId,
|
|
132
|
+
vertical: input.vertical,
|
|
133
|
+
operation: input.operation,
|
|
134
|
+
permission: input.permission,
|
|
135
|
+
boundAt: new Date(input.now?.() ?? Date.now()).toISOString(),
|
|
136
|
+
};
|
|
137
|
+
await host.admin.putConnectorState(input.connectionId, bindingKey(input.scopeId), binding);
|
|
138
|
+
return binding;
|
|
139
|
+
}
|
|
140
|
+
/** Every scope this connection syncs into. */
|
|
141
|
+
export async function listFortnoxBindings(host, connectionId) {
|
|
142
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
143
|
+
// Tombstones filtered, exactly as the sweep and the activity projection do.
|
|
144
|
+
// `unbindFortnoxScope` writes `null` under the same key, and casting that to
|
|
145
|
+
// `FortnoxBinding` hands a caller a typed value that throws on the first property
|
|
146
|
+
// read — a lie the type system cannot catch.
|
|
147
|
+
return rows
|
|
148
|
+
.filter((r) => r.value !== null && typeof r.value === 'object')
|
|
149
|
+
.map((r) => r.value);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Stop syncing one scope. The binding row is replaced with a tombstone rather than
|
|
153
|
+
* removed, because `putConnectorState` is the only verb this surface has — and an
|
|
154
|
+
* unbound scope that a later sweep silently re-adopts would be worse than a visible
|
|
155
|
+
* dead row.
|
|
156
|
+
*/
|
|
157
|
+
export async function unbindFortnoxScope(host, connectionId, scopeId) {
|
|
158
|
+
await host.admin.putConnectorState(connectionId, bindingKey(scopeId), null);
|
|
159
|
+
}
|
|
160
|
+
const sha256Hex = async (text) => {
|
|
161
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
162
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Sync ONE bound scope: fetch the year that covers `period`, parse it, sum it, and land
|
|
166
|
+
* it through the binding's operation as the connection itself (#97).
|
|
167
|
+
*
|
|
168
|
+
* Idempotent and cheap on a no-op. The SIE payload is hashed before anything is landed,
|
|
169
|
+
* and an unchanged hash returns `changed: false` without a single `invoke` — which
|
|
170
|
+
* matters because a sweep runs on a timer and most passes find books nobody has touched.
|
|
171
|
+
*/
|
|
172
|
+
export async function syncFortnoxScope(host, connectionId, binding, options) {
|
|
173
|
+
const admin = host.admin;
|
|
174
|
+
const conn = await openFortnoxConnection(admin, options.fetch, binding.tenantId, binding.vertical, options.timeoutMs ?? 30_000);
|
|
175
|
+
const api = new FortnoxApi(conn, {
|
|
176
|
+
apiBase: options.apiBase,
|
|
177
|
+
oauthBase: options.oauthBase,
|
|
178
|
+
now: options.now,
|
|
179
|
+
});
|
|
180
|
+
// The period defaults to the calendar year containing "now", which is the sane
|
|
181
|
+
// default for a recurring sync; a caller back-filling names its own.
|
|
182
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
183
|
+
const year = new Date(nowMs).toISOString().slice(0, 4);
|
|
184
|
+
const period = options.period ?? { from: `${year}-01-01`, to: `${year}-12-31` };
|
|
185
|
+
const years = await api.financialYears();
|
|
186
|
+
// Overlap, never containment — a first financial year can start mid-month. See
|
|
187
|
+
// `financialYearFor`.
|
|
188
|
+
const financialYear = financialYearFor(years, period);
|
|
189
|
+
if (!financialYear) {
|
|
190
|
+
return {
|
|
191
|
+
scopeId: binding.scopeId,
|
|
192
|
+
changed: false,
|
|
193
|
+
syncId: '',
|
|
194
|
+
financialYearId: null,
|
|
195
|
+
balances: 0,
|
|
196
|
+
pages: 0,
|
|
197
|
+
outOfRange: 0,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const sie = await api.sieFile(financialYear.Id);
|
|
201
|
+
// The WINDOW is part of the sync's identity, not just the payload.
|
|
202
|
+
//
|
|
203
|
+
// What lands is `summarizeLedger(ledger, period)` — the payload filtered to the
|
|
204
|
+
// window — so hashing the payload alone makes two genuinely different results share
|
|
205
|
+
// an identity, and the skip below then drops the second one. A broken financial year
|
|
206
|
+
// is all it takes: with `#RAR` running 2026-07-01..2027-06-30, a December sweep uses
|
|
207
|
+
// the default period 2026-01-01..2026-12-31 and lands only the 2026 months; a January
|
|
208
|
+
// sweep selects the SAME year by overlap, downloads the SAME unchanged payload, and
|
|
209
|
+
// returns `changed: false` — so the 2027 months never land until the books happen to
|
|
210
|
+
// change. Non-calendar financial years are ordinary in Sweden, so this was reachable
|
|
211
|
+
// rather than theoretical. An explicit back-fill over a different window inside an
|
|
212
|
+
// already-synced year was a silent no-op for the same reason.
|
|
213
|
+
//
|
|
214
|
+
// Folding the window in fixes the second half too: `syncId` IS this hash, and it is
|
|
215
|
+
// the consumer's idempotency key. Two windows that land different rows must never
|
|
216
|
+
// present the same key to an upsert.
|
|
217
|
+
const contentHash = await sha256Hex(`${financialYear.Id}\n${period.from}\n${period.to}\n${sie}`);
|
|
218
|
+
if (binding.lastSync?.contentHash === contentHash) {
|
|
219
|
+
return {
|
|
220
|
+
scopeId: binding.scopeId,
|
|
221
|
+
changed: false,
|
|
222
|
+
syncId: contentHash,
|
|
223
|
+
financialYearId: financialYear.Id,
|
|
224
|
+
balances: binding.lastSync.balances,
|
|
225
|
+
pages: 0,
|
|
226
|
+
outOfRange: 0,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const ledger = parseSie4(sie);
|
|
230
|
+
const summary = summarizeLedger(ledger, period);
|
|
231
|
+
// The connection acting as itself (#97). Refuses a scope in another tenant or running
|
|
232
|
+
// another vertical by construction, and the invoke below is gated on the connection's
|
|
233
|
+
// own grant — the one `bindFortnoxScope` verified.
|
|
234
|
+
const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
|
|
235
|
+
const pageCount = Math.max(1, Math.ceil(summary.balances.length / PAGE_SIZE));
|
|
236
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
237
|
+
const slice = summary.balances.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
238
|
+
const payload = fortnoxLedgerPage.parse({
|
|
239
|
+
syncId: contentHash,
|
|
240
|
+
connectionId,
|
|
241
|
+
company: { name: ledger.company, organizationNumber: ledger.organizationNumber },
|
|
242
|
+
financialYear: {
|
|
243
|
+
id: financialYear.Id,
|
|
244
|
+
from: financialYear.FromDate,
|
|
245
|
+
to: financialYear.ToDate,
|
|
246
|
+
},
|
|
247
|
+
page,
|
|
248
|
+
pageCount,
|
|
249
|
+
final: page === pageCount - 1,
|
|
250
|
+
// The labels ride page 0 only: they are the same on every page, and repeating a
|
|
251
|
+
// full chart of accounts across twenty pages is bytes through a clone pipe for
|
|
252
|
+
// nothing.
|
|
253
|
+
accounts: page === 0 ? summary.accounts : [],
|
|
254
|
+
costCentres: page === 0 ? summary.costCentres : [],
|
|
255
|
+
balances: slice,
|
|
256
|
+
});
|
|
257
|
+
await scope.invoke(binding.operation, payload);
|
|
258
|
+
}
|
|
259
|
+
const synced = {
|
|
260
|
+
...binding,
|
|
261
|
+
lastSync: {
|
|
262
|
+
financialYearId: financialYear.Id,
|
|
263
|
+
syncedAt: new Date(nowMs).toISOString(),
|
|
264
|
+
contentHash,
|
|
265
|
+
balances: summary.balances.length,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
// Written only AFTER every page landed, so a failure mid-way leaves the cursor at the
|
|
269
|
+
// previous hash and the next sweep retries the whole year rather than resuming into a
|
|
270
|
+
// half-written ledger.
|
|
271
|
+
await admin.putConnectorState(connectionId, bindingKey(binding.scopeId), synced);
|
|
272
|
+
return {
|
|
273
|
+
scopeId: binding.scopeId,
|
|
274
|
+
changed: true,
|
|
275
|
+
syncId: contentHash,
|
|
276
|
+
financialYearId: financialYear.Id,
|
|
277
|
+
balances: summary.balances.length,
|
|
278
|
+
pages: pageCount,
|
|
279
|
+
outOfRange: summary.outOfRange,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Poll Fortnox for every scope this connection is bound to — the {@link ConnectorSweeper}
|
|
284
|
+
* a deployment schedules.
|
|
285
|
+
*
|
|
286
|
+
* A timer calls this; it holds no timer itself. That keeps the trigger a deployment
|
|
287
|
+
* concern (`startPlatformSweeper` on node, `definePlatformSweeperDO`'s alarm on
|
|
288
|
+
* Cloudflare) and this a plain, testable function.
|
|
289
|
+
*
|
|
290
|
+
* Robust the way a poller must be: an unchanged year is skipped without landing
|
|
291
|
+
* anything, and a failure on one scope is recorded and stepped over rather than sinking
|
|
292
|
+
* the pass — one vertical's missing grant must not stop another tenant's books syncing.
|
|
293
|
+
*/
|
|
294
|
+
export async function sweepFortnoxLedger(host, connectionId, options) {
|
|
295
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
296
|
+
const result = { found: 0, synced: [], unchanged: 0, failed: [] };
|
|
297
|
+
for (const { value } of rows) {
|
|
298
|
+
// A tombstoned binding (`unbindFortnoxScope`) — present as a row, not a target.
|
|
299
|
+
if (value === null || typeof value !== 'object')
|
|
300
|
+
continue;
|
|
301
|
+
const binding = value;
|
|
302
|
+
result.found += 1;
|
|
303
|
+
try {
|
|
304
|
+
const r = await syncFortnoxScope(host, connectionId, binding, options);
|
|
305
|
+
if (r.changed)
|
|
306
|
+
result.synced.push(r);
|
|
307
|
+
else
|
|
308
|
+
result.unchanged += 1;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
result.failed.push({
|
|
312
|
+
scopeId: binding.scopeId,
|
|
313
|
+
error: err instanceof Error ? err.message : String(err),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Probe a credential that is not stored yet — the connect-time check (#605).
|
|
321
|
+
*
|
|
322
|
+
* Takes the candidate secret directly, touches no connection and no store, and records
|
|
323
|
+
* no health: there may be no connection to record against, and a candidate's failure is
|
|
324
|
+
* not a fact about a live one.
|
|
325
|
+
*
|
|
326
|
+
* The probe is a token mint followed by `companyinformation`, and it must be both:
|
|
327
|
+
* minting alone proves the client pair and the consent, but only the company read proves
|
|
328
|
+
* the `TenantId` names a company this integration can actually see — which is the field
|
|
329
|
+
* an operator is most likely to paste wrong.
|
|
330
|
+
*/
|
|
331
|
+
export async function probeFortnoxSecret(secret, options) {
|
|
332
|
+
const parsed = fortnoxSecret.safeParse(secret);
|
|
333
|
+
if (!parsed.success) {
|
|
334
|
+
// A malformed credential IS a refusal — Fortnox would reject it, and there is no
|
|
335
|
+
// point spending a round trip to hear so.
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
refused: true,
|
|
339
|
+
accountRef: null,
|
|
340
|
+
accountLabel: null,
|
|
341
|
+
facts: [],
|
|
342
|
+
error: `incomplete Fortnox credential: ${parsed.error.issues
|
|
343
|
+
.map((i) => i.path.join('.'))
|
|
344
|
+
.join(', ')}`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
348
|
+
const conn = {
|
|
349
|
+
id: connectionIdSchema.parse('00000000000000000000000000'), // no row yet; never read
|
|
350
|
+
tenantId: '',
|
|
351
|
+
vertical: '',
|
|
352
|
+
provider: 'fortnox',
|
|
353
|
+
secret: parsed.data,
|
|
354
|
+
expiresAt: null,
|
|
355
|
+
fetch: (input, init) => options.fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
|
|
356
|
+
};
|
|
357
|
+
return probeWith(conn, options);
|
|
358
|
+
}
|
|
359
|
+
/** Probe the credential a live connection already holds. Verifying is itself a use. */
|
|
360
|
+
export async function probeFortnoxConnection(host, connection, options) {
|
|
361
|
+
const conn = await openFortnoxConnection(host.admin, options.fetch, connection.tenantId, connection.vertical, options.timeoutMs ?? 15_000);
|
|
362
|
+
return probeWith(conn, options);
|
|
363
|
+
}
|
|
364
|
+
async function probeWith(conn, options) {
|
|
365
|
+
const api = new FortnoxApi(conn, {
|
|
366
|
+
apiBase: options.apiBase,
|
|
367
|
+
oauthBase: options.oauthBase,
|
|
368
|
+
now: options.now,
|
|
369
|
+
});
|
|
370
|
+
try {
|
|
371
|
+
const company = await api.companyInformation();
|
|
372
|
+
const years = await api.financialYears();
|
|
373
|
+
const ref = company.DatabaseNumber === undefined ? null : String(company.DatabaseNumber);
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
refused: false,
|
|
377
|
+
accountRef: ref,
|
|
378
|
+
accountLabel: company.CompanyName || null,
|
|
379
|
+
facts: [
|
|
380
|
+
{ label: 'Organisationsnummer', value: company.OrganizationNumber || '—' },
|
|
381
|
+
{ label: 'Database number', value: ref ?? '—' },
|
|
382
|
+
{
|
|
383
|
+
label: 'Financial years',
|
|
384
|
+
value: years.length === 0
|
|
385
|
+
? 'none'
|
|
386
|
+
: `${years.length} (${years
|
|
387
|
+
.map((y) => y.FromDate.slice(0, 4))
|
|
388
|
+
.sort()
|
|
389
|
+
.join(', ')})`,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
error: null,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
// Only "not with these credentials" counts. A timeout or a 5xx says nothing about
|
|
399
|
+
// the credential, and treating it as a refusal would make a Fortnox outage look
|
|
400
|
+
// like every tenant's keys going bad at once.
|
|
401
|
+
refused: err instanceof FortnoxApiError && err.refused,
|
|
402
|
+
accountRef: null,
|
|
403
|
+
accountLabel: null,
|
|
404
|
+
facts: [],
|
|
405
|
+
error: err instanceof Error ? err.message : String(err),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* What this connection has been doing, for a console — one entry per bound scope.
|
|
411
|
+
*
|
|
412
|
+
* Reads the binding ledger rather than the provider: this answers "what has the platform
|
|
413
|
+
* synced", which is the question an operator asks when a figure looks stale, and it
|
|
414
|
+
* answers it without spending a provider round trip.
|
|
415
|
+
*/
|
|
416
|
+
export async function fortnoxConnectionActivity(host, connectionId) {
|
|
417
|
+
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
418
|
+
const entries = rows
|
|
419
|
+
.filter(({ value }) => value !== null && typeof value === 'object')
|
|
420
|
+
.map(({ key, value }) => {
|
|
421
|
+
const b = value;
|
|
422
|
+
return {
|
|
423
|
+
key,
|
|
424
|
+
title: `${b.vertical} — ${b.scopeId}`,
|
|
425
|
+
reference: b.lastSync ? String(b.lastSync.financialYearId) : null,
|
|
426
|
+
status: b.lastSync ? 'synced' : 'bound — not yet synced',
|
|
427
|
+
at: instant.parse(b.lastSync?.syncedAt ?? b.boundAt),
|
|
428
|
+
facts: [
|
|
429
|
+
{ label: 'Lands through', value: b.operation },
|
|
430
|
+
{ label: 'Permission', value: b.permission },
|
|
431
|
+
...(b.lastSync
|
|
432
|
+
? [
|
|
433
|
+
{ label: 'Balances', value: String(b.lastSync.balances) },
|
|
434
|
+
{ label: 'Financial year', value: String(b.lastSync.financialYearId) },
|
|
435
|
+
// The content hash IS the sync identity, so an operator comparing two
|
|
436
|
+
// scopes can tell "same books" from "same moment" at a glance.
|
|
437
|
+
{ label: 'Content hash', value: b.lastSync.contentHash.slice(0, 12) },
|
|
438
|
+
]
|
|
439
|
+
: []),
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
return connectionActivity.parse({
|
|
444
|
+
source: 'ledger',
|
|
445
|
+
entries,
|
|
446
|
+
// Never live: this reads the binding ledger, never the provider. The ledger knows
|
|
447
|
+
// what the platform synced, not what Fortnox has since booked, and a console that
|
|
448
|
+
// blurs the two invents facts.
|
|
449
|
+
live: false,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** Open the live Fortnox connection for a (tenant, vertical), with health recorded. */
|
|
453
|
+
async function openFortnoxConnection(admin, fetchImpl, tenant, vertical, timeoutMs) {
|
|
454
|
+
const parsedTenant = tenantIdSchema.parse(tenant);
|
|
455
|
+
const open = await admin.openConnection(parsedTenant, vertical, 'fortnox');
|
|
456
|
+
if (!open) {
|
|
457
|
+
throw new Error(`no live 'fortnox' connection for tenant ${tenant} / vertical '${vertical}'`);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
...open,
|
|
461
|
+
fetch: async (input, init) => {
|
|
462
|
+
try {
|
|
463
|
+
const res = await fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
464
|
+
await admin.recordConnectionUse(open.id, res.ok ? { ok: true } : { ok: false, error: `HTTP ${res.status} from fortnox` });
|
|
465
|
+
return res;
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
await admin.recordConnectionUse(open.id, {
|
|
469
|
+
ok: false,
|
|
470
|
+
error: err instanceof Error ? err.message : String(err),
|
|
471
|
+
});
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { FetchLike } from '@substrat-run/kernel';
|
|
2
|
+
/**
|
|
3
|
+
* Fortnox in memory — the token endpoint and the three REST reads, enough to run the
|
|
4
|
+
* whole sweep without a provider account.
|
|
5
|
+
*
|
|
6
|
+
* **What a mock proves:** that our shape works — the client-credentials mint, the
|
|
7
|
+
* latin1 decode, the year-overlap pick, the paging, the unchanged-hash skip.
|
|
8
|
+
* **What it cannot prove:** that our reading of Fortnox's API is correct. The mock IS
|
|
9
|
+
* our reading. Green here means *ready to check against a real company*, which is what
|
|
10
|
+
* `test/live.test.ts` does when a credential is present.
|
|
11
|
+
*
|
|
12
|
+
* The one thing it models with real bytes rather than a convenient string is the SIE
|
|
13
|
+
* response: {@link FortnoxMock.sieBytes} encodes to ISO-8859-1, so a connector that
|
|
14
|
+
* forgot to decode explicitly fails here rather than in production with mangled
|
|
15
|
+
* account names.
|
|
16
|
+
*/
|
|
17
|
+
export interface FortnoxMockOptions {
|
|
18
|
+
clientId?: string;
|
|
19
|
+
clientSecret?: string;
|
|
20
|
+
tenantId?: string;
|
|
21
|
+
company?: {
|
|
22
|
+
CompanyName: string;
|
|
23
|
+
OrganizationNumber: string;
|
|
24
|
+
DatabaseNumber: number;
|
|
25
|
+
};
|
|
26
|
+
financialYears?: {
|
|
27
|
+
Id: number;
|
|
28
|
+
FromDate: string;
|
|
29
|
+
ToDate: string;
|
|
30
|
+
}[];
|
|
31
|
+
/** SIE4 text per financial-year id. */
|
|
32
|
+
sie?: Record<number, string>;
|
|
33
|
+
/** Seconds a minted token claims to live. */
|
|
34
|
+
expiresIn?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Encode to ISO-8859-1. Any code point above 0xff becomes `?`, exactly as Fortnox would. */
|
|
37
|
+
export declare function latin1Bytes(text: string): Uint8Array;
|
|
38
|
+
export declare class FortnoxMock {
|
|
39
|
+
readonly apiBase = "https://api.fortnox.test/3";
|
|
40
|
+
readonly oauthBase = "https://apps.fortnox.test/oauth-v1";
|
|
41
|
+
private readonly clientId;
|
|
42
|
+
private readonly clientSecret;
|
|
43
|
+
private readonly tenantId;
|
|
44
|
+
private readonly company;
|
|
45
|
+
private readonly years;
|
|
46
|
+
private readonly sie;
|
|
47
|
+
private readonly expiresIn;
|
|
48
|
+
/** Every token this mock has minted — a test asserts the mint happened exactly once. */
|
|
49
|
+
readonly mints: string[];
|
|
50
|
+
/** Every REST path fetched, in order. */
|
|
51
|
+
readonly calls: string[];
|
|
52
|
+
constructor(options?: FortnoxMockOptions);
|
|
53
|
+
/** Replace one year's SIE payload — how a test simulates "the books changed". */
|
|
54
|
+
setSie(financialYearId: number, text: string): void;
|
|
55
|
+
private json;
|
|
56
|
+
/** The `fetch` a connector is handed. */
|
|
57
|
+
readonly fetch: FetchLike;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=mock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../src/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAMtD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC;IACtF,cAAc,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACpE,uCAAuC;IACvC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAOpD;AAED,qBAAa,WAAW;IACtB,QAAQ,CAAC,OAAO,gCAAgC;IAChD,QAAQ,CAAC,SAAS,wCAAwC;IAE1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6C;IACrE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAoD;IAC1E,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAyB;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,wFAAwF;IACxF,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAM;IAC9B,yCAAyC;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAM;IAE9B,YAAY,OAAO,GAAE,kBAAuB,EAe3C;IAED,iFAAiF;IACjF,MAAM,CAAC,eAAe,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAElD;IAED,OAAO,CAAC,IAAI;IAUZ,yCAAyC;IACzC,QAAQ,CAAC,KAAK,EAAE,SAAS,CA0DvB;CACH"}
|