@substrat-run/connector-fortnox 0.5.8 → 0.7.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 +3 -2
- package/dist/api.d.ts +61 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +117 -5
- package/dist/api.js.map +1 -1
- package/dist/index.d.ts +250 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +745 -30
- package/dist/index.js.map +1 -1
- package/dist/mock.d.ts +23 -0
- package/dist/mock.d.ts.map +1 -1
- package/dist/mock.js +70 -3
- package/dist/mock.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { addDecimal, connectionActivity, connectionId as connectionIdSchema, instant, money, mulDecimal, scopeId as scopeIdSchema, tenantId as tenantIdSchema, } from '@substrat-run/contracts';
|
|
3
|
-
import { deadlineFetch, settleConnectionUse } from '@substrat-run/kernel';
|
|
2
|
+
import { addDecimal, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, connectionActivity, connectionId as connectionIdSchema, entityRef, instant, money, mulDecimal, scopeId as scopeIdSchema, tenantId as tenantIdSchema, } from '@substrat-run/contracts';
|
|
3
|
+
import { deadlineFetch, settleConnectionUse, ulid } from '@substrat-run/kernel';
|
|
4
4
|
import { FortnoxApi, FortnoxApiError, FORTNOX_API_BASE, FORTNOX_OAUTH_BASE, fortnoxSecret, } from './api.js';
|
|
5
5
|
import { financialYearFor, summarizeLedger } from './aggregate.js';
|
|
6
6
|
import { parseSie4 } from './sie4.js';
|
|
@@ -60,6 +60,8 @@ export const FORTNOX_CONNECTION_GRANTS = [];
|
|
|
60
60
|
/** The connector-state key prefix every binding lives under — what the sweep enumerates. */
|
|
61
61
|
const BINDING_PREFIX = 'fortnox:binding:';
|
|
62
62
|
const bindingKey = (scope) => `${BINDING_PREFIX}${scope}`;
|
|
63
|
+
const FILES_LANDED_PREFIX = 'fortnox:files-landed:';
|
|
64
|
+
const filesLandedKey = (scope, givenNumber) => `${FILES_LANDED_PREFIX}${scope}:${givenNumber}`;
|
|
63
65
|
/**
|
|
64
66
|
* One page of parsed bookkeeping, as it crosses into a scope.
|
|
65
67
|
*
|
|
@@ -104,6 +106,33 @@ export const fortnoxLedgerPage = z.object({
|
|
|
104
106
|
});
|
|
105
107
|
/** How many balance rows ride one `invoke`. */
|
|
106
108
|
const PAGE_SIZE = 500;
|
|
109
|
+
/**
|
|
110
|
+
* One financial year's whole SIE4 file, as a `form: 'sie'` binding lands it, in one `invoke`.
|
|
111
|
+
*
|
|
112
|
+
* The file itself, because it already is the neutral statement of the books, and everything a
|
|
113
|
+
* reader could want is in it: the opening and closing balances the sums skip, every voucher, every
|
|
114
|
+
* account name. A vertical that takes uploaded SIE files has a reader for it already, and this way
|
|
115
|
+
* a file from Fortnox and a file from a person go through the same door.
|
|
116
|
+
*
|
|
117
|
+
* Decoded from PC8, so `sie` is text. Parsed on the way out like {@link fortnoxLedgerPage}.
|
|
118
|
+
*/
|
|
119
|
+
export const fortnoxSieFile = z.object({
|
|
120
|
+
/** The content hash: unchanged books present the same id, so a landing can answer it once. */
|
|
121
|
+
syncId: z.string().min(1),
|
|
122
|
+
connectionId: z.string().min(1),
|
|
123
|
+
company: z.object({
|
|
124
|
+
name: z.string(),
|
|
125
|
+
organizationNumber: z.string(),
|
|
126
|
+
}),
|
|
127
|
+
financialYear: z.object({
|
|
128
|
+
id: z.number(),
|
|
129
|
+
from: z.string(),
|
|
130
|
+
to: z.string(),
|
|
131
|
+
}),
|
|
132
|
+
sie: z.string().min(1),
|
|
133
|
+
});
|
|
134
|
+
/** How long a `form: 'sie'` binding leaves an older year before reading it again. */
|
|
135
|
+
const OLDER_YEAR_REFRESH_MS = 24 * 3_600_000;
|
|
107
136
|
/**
|
|
108
137
|
* The supplier invoices an invoice binding lands (#2422) — one page per pass that found changes.
|
|
109
138
|
*
|
|
@@ -132,17 +161,83 @@ export const fortnoxSupplierInvoicePage = z.object({
|
|
|
132
161
|
dueDate: z.string(),
|
|
133
162
|
/** In the invoice's own currency. */
|
|
134
163
|
total: money,
|
|
164
|
+
/** What is left to pay, in the invoice's currency. Absent when Fortnox states none. */
|
|
165
|
+
balance: money.optional(),
|
|
166
|
+
/** In the invoice's currency. Absent when Fortnox states none. */
|
|
167
|
+
vat: money.optional(),
|
|
168
|
+
ocr: z.string().nullable().optional(),
|
|
135
169
|
booked: z.boolean(),
|
|
136
170
|
cancelled: z.boolean(),
|
|
171
|
+
/** A credit note. */
|
|
172
|
+
credit: z.boolean().optional(),
|
|
173
|
+
/** A payment of the invoice is on its way. */
|
|
174
|
+
paymentPending: z.boolean().optional(),
|
|
175
|
+
/** The old single comment, while an invoice still has one. New comments are in `commentLog`. */
|
|
176
|
+
comments: z.string().nullable().optional(),
|
|
177
|
+
/** Fortnox's comment log, oldest first as Fortnox lists it. Its web UI writes only here. */
|
|
178
|
+
commentLog: z
|
|
179
|
+
.array(z.object({ comment: z.string(), createdAt: z.string().nullable(), userName: z.string().nullable() }))
|
|
180
|
+
.optional(),
|
|
181
|
+
/** The voucher the invoice was booked on. `year` is Fortnox's number for the financial year. */
|
|
182
|
+
voucher: z.object({ series: z.string().nullable(), number: z.number().int(), year: z.number().int().nullable() }).nullable().optional(),
|
|
183
|
+
project: z.string().nullable().optional(),
|
|
184
|
+
costCentre: z.string().nullable().optional(),
|
|
137
185
|
/** The invoice's accounting rows: debit-positive, in SEK, as the ledger page states them. */
|
|
138
186
|
rows: z.array(z.object({
|
|
139
187
|
account: z.string(),
|
|
188
|
+
/** The account's name, as Fortnox writes it on the row. */
|
|
189
|
+
accountDescription: z.string().nullable().optional(),
|
|
140
190
|
costCentre: z.string().nullable(),
|
|
191
|
+
project: z.string().nullable().optional(),
|
|
192
|
+
transactionInformation: z.string().nullable().optional(),
|
|
141
193
|
amount: money,
|
|
142
194
|
})),
|
|
143
195
|
}))
|
|
144
196
|
.min(1),
|
|
145
197
|
});
|
|
198
|
+
/**
|
|
199
|
+
* What a landing operation may answer. `files` names the invoices whose file the vertical wants
|
|
200
|
+
* and the entity each one lands on, which must be a declared attachment target the connection
|
|
201
|
+
* can write. An answer without it, or that does not parse, asks for no file.
|
|
202
|
+
*/
|
|
203
|
+
export const fortnoxSupplierInvoiceLandingAnswer = z
|
|
204
|
+
.object({ files: z.array(z.object({ givenNumber: z.string().min(1), entity: entityRef })).optional() })
|
|
205
|
+
.passthrough();
|
|
206
|
+
/**
|
|
207
|
+
* What a binding's `writeback.read` answers when the sweep asks what to write for one company.
|
|
208
|
+
*
|
|
209
|
+
* The sweep asks rather than listens. Asking is what makes a lost pass harmless: what the vertical
|
|
210
|
+
* still answers is what is still to do, and nothing about a decision has to ride an event or sit
|
|
211
|
+
* in a ledger of its own. It reads `{ connectionId, company }`, and a vertical holding several
|
|
212
|
+
* companies answers for that company alone, because `GivenNumber` repeats between companies.
|
|
213
|
+
*
|
|
214
|
+
* The answer is instructions, not vocabulary: what a decision means is the vertical's, and all the
|
|
215
|
+
* connector knows is what to do in Fortnox.
|
|
216
|
+
*/
|
|
217
|
+
export const fortnoxInvoiceWriteback = z.object({
|
|
218
|
+
/** Invoices to keep from being paid until they are decided: `DisablePaymentFile`, set once. */
|
|
219
|
+
hold: z.array(z.object({ givenNumber: z.string().min(1) })).default([]),
|
|
220
|
+
decisions: z
|
|
221
|
+
.array(z.object({
|
|
222
|
+
/** Handed back in the receipt. A retry of the same decision carries the same key. */
|
|
223
|
+
key: z.string().min(1),
|
|
224
|
+
givenNumber: z.string().min(1),
|
|
225
|
+
/** Book the invoice, unless it already is. */
|
|
226
|
+
book: z.boolean(),
|
|
227
|
+
/** Clear a hold this binding set. A block anyone else set is never touched. */
|
|
228
|
+
release: z.boolean(),
|
|
229
|
+
/** One entry for Fortnox's comment log, added once. It is written last. */
|
|
230
|
+
comment: z.string().min(1).nullable(),
|
|
231
|
+
}))
|
|
232
|
+
.default([]),
|
|
233
|
+
});
|
|
234
|
+
/** What a binding's `writeback.record` receives, once for each decision that is settled. */
|
|
235
|
+
export const fortnoxInvoiceWritebackReceipt = z.object({
|
|
236
|
+
key: z.string().min(1),
|
|
237
|
+
status: z.enum(['done', 'failed']),
|
|
238
|
+
/** Fortnox's reason, for a `failed` one. */
|
|
239
|
+
error: z.string().nullable(),
|
|
240
|
+
});
|
|
146
241
|
/**
|
|
147
242
|
* Invoice details read per connection per pass. The client paces every read to Fortnox's limit,
|
|
148
243
|
* so this bounds how long one pass runs (about 40 seconds at the limit) rather than the rate. A
|
|
@@ -155,8 +250,39 @@ const INVOICE_DETAILS_PER_PASS = 200;
|
|
|
155
250
|
* it runs. Past this, what was read lands and the next pass resumes after it.
|
|
156
251
|
*/
|
|
157
252
|
const INVOICE_PASS_DEADLINE_MS = 90_000;
|
|
158
|
-
/**
|
|
159
|
-
const
|
|
253
|
+
/** What one held invoice can cost a pass: its read and the write. */
|
|
254
|
+
const INVOICE_HOLD_CALLS = 2;
|
|
255
|
+
/** What one decision can cost a pass: its read, booking, the block, the log's read and its write. */
|
|
256
|
+
const INVOICE_DECISION_CALLS = 5;
|
|
257
|
+
/** Tries at a write that keeps failing for no stated reason, before the vertical is told. */
|
|
258
|
+
const INVOICE_WRITE_ATTEMPTS = 8;
|
|
259
|
+
/** Passes a queued file may fail before it is skipped, so a file that never reads cannot hold the budget. */
|
|
260
|
+
const INVOICE_FILE_ATTEMPTS = 8;
|
|
261
|
+
const invoiceHoldKey = (givenNumber) => `fortnox:invoice-hold:${givenNumber}`;
|
|
262
|
+
const invoiceWriteKey = (key) => `fortnox:invoice-write:${key}`;
|
|
263
|
+
/** How far back a binding's first pass reads when its declaration does not say. */
|
|
264
|
+
export const INVOICE_FIRST_LOOKBACK_DAYS = 90;
|
|
265
|
+
/** What one queued invoice costs from a pass's budget at the least: its connections, one file. */
|
|
266
|
+
const INVOICE_FILE_READS = 2;
|
|
267
|
+
/** The largest file a pass will hold. A larger one is skipped by name, never truncated. */
|
|
268
|
+
const INVOICE_FILE_MAX_BYTES = CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES;
|
|
269
|
+
/** A file's type by its name, for an archive read that does not say or says only "bytes". */
|
|
270
|
+
const FILE_TYPES = {
|
|
271
|
+
pdf: 'application/pdf',
|
|
272
|
+
png: 'image/png',
|
|
273
|
+
jpg: 'image/jpeg',
|
|
274
|
+
jpeg: 'image/jpeg',
|
|
275
|
+
gif: 'image/gif',
|
|
276
|
+
tif: 'image/tiff',
|
|
277
|
+
tiff: 'image/tiff',
|
|
278
|
+
};
|
|
279
|
+
const fileType = (name, stated) => {
|
|
280
|
+
const type = stated?.split(';')[0]?.trim().toLowerCase();
|
|
281
|
+
if (type && type !== 'application/octet-stream')
|
|
282
|
+
return type;
|
|
283
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
284
|
+
return Object.hasOwn(FILE_TYPES, ext) ? FILE_TYPES[ext] : 'application/octet-stream';
|
|
285
|
+
};
|
|
160
286
|
/**
|
|
161
287
|
* Read back from each cursor. Fortnox's filter is to the minute in Swedish local time, and the
|
|
162
288
|
* landing upserts, so re-reading a few minutes costs a resend and missing them costs an invoice.
|
|
@@ -208,6 +334,18 @@ const apiOptions = (options) => ({
|
|
|
208
334
|
export async function bindFortnoxScope(host, input) {
|
|
209
335
|
const tenant = tenantIdSchema.parse(input.tenantId);
|
|
210
336
|
const scope = scopeIdSchema.parse(input.scopeId);
|
|
337
|
+
if (input.form !== undefined && (input.kind ?? 'ledger') !== 'ledger') {
|
|
338
|
+
throw new Error(`form applies to a ledger binding, not to '${String(input.kind)}'`);
|
|
339
|
+
}
|
|
340
|
+
if (input.years !== undefined && input.form !== 'sie') {
|
|
341
|
+
throw new Error("years applies to a binding with form 'sie': the summary form lands the current year");
|
|
342
|
+
}
|
|
343
|
+
if (input.years !== undefined && input.years !== 'all' && !(Number.isInteger(input.years) && input.years >= 0)) {
|
|
344
|
+
throw new Error(`years is 'all' or a whole number of years, not ${String(input.years)}`);
|
|
345
|
+
}
|
|
346
|
+
if ((input.firstPass !== undefined || input.writeback !== undefined) && input.kind !== 'supplier-invoice') {
|
|
347
|
+
throw new Error(`firstPass and writeback apply to a supplier-invoice binding, not to '${input.kind ?? 'ledger'}'`);
|
|
348
|
+
}
|
|
211
349
|
const current = (await listFortnoxBindings(host, input.connectionId)).find((b) => b.scopeId === input.scopeId);
|
|
212
350
|
if (current && (current.kind ?? 'ledger') !== (input.kind ?? 'ledger')) {
|
|
213
351
|
throw new Error(`one Fortnox landing kind per scope; use a separate scope for the other kind (${current.kind ?? 'ledger'} to ${input.kind ?? 'ledger'} on scope ${input.scopeId})`);
|
|
@@ -229,7 +367,27 @@ export async function bindFortnoxScope(host, input) {
|
|
|
229
367
|
operation: input.operation,
|
|
230
368
|
permission: input.permission,
|
|
231
369
|
...(input.kind ? { kind: input.kind } : {}),
|
|
370
|
+
...(input.form !== undefined ? { form: input.form } : {}),
|
|
371
|
+
...(input.years !== undefined ? { years: input.years } : {}),
|
|
372
|
+
...(input.firstPass !== undefined ? { firstPass: input.firstPass } : {}),
|
|
373
|
+
...(input.writeback !== undefined ? { writeback: input.writeback } : {}),
|
|
232
374
|
...(input.externalAccountRef ? { externalAccountRef: input.externalAccountRef } : {}),
|
|
375
|
+
// A re-bind that changes nothing keeps the years already landed: an operator re-PUT or a scope
|
|
376
|
+
// asking again must not re-download a whole history. Any change starts over, a newly recorded
|
|
377
|
+
// company included, since the years held may be another company's.
|
|
378
|
+
...(current?.yearSync && input.form === 'sie' && current.form === 'sie' && current.years === input.years &&
|
|
379
|
+
current.operation === input.operation && (current.externalAccountRef ?? null) === (input.externalAccountRef ?? null)
|
|
380
|
+
? { yearSync: current.yearSync }
|
|
381
|
+
: {}),
|
|
382
|
+
// A re-bind starts the cursor over but keeps the files already asked for: an invoice outside a
|
|
383
|
+
// narrowed first pass would otherwise never be asked about again.
|
|
384
|
+
...(current?.files?.length ? { files: current.files } : {}),
|
|
385
|
+
// A re-bind that changes what is written back keeps the invoice cursor and its queued files, so
|
|
386
|
+
// turning writeback on does not re-land the company's history. A different operation or first
|
|
387
|
+
// pass reads from the start again, since what was landed was landed for the old one.
|
|
388
|
+
...(current?.invoiceSync && current.operation === input.operation && current.firstPass === input.firstPass
|
|
389
|
+
? { invoiceSync: current.invoiceSync }
|
|
390
|
+
: {}),
|
|
233
391
|
boundAt: new Date(input.now?.() ?? Date.now()).toISOString(),
|
|
234
392
|
};
|
|
235
393
|
await host.admin.putConnectorState(input.connectionId, bindingKey(input.scopeId), binding);
|
|
@@ -377,12 +535,131 @@ export async function syncFortnoxScope(host, connectionId, binding, options) {
|
|
|
377
535
|
};
|
|
378
536
|
}
|
|
379
537
|
/**
|
|
380
|
-
* Sync ONE
|
|
381
|
-
*
|
|
538
|
+
* Sync ONE `form: 'sie'` binding: land the current year's SIE4 file if it changed, and bring one
|
|
539
|
+
* older year up to date.
|
|
540
|
+
*
|
|
541
|
+
* One older year per pass, because a year is one large download and a pass runs on a timer. A
|
|
542
|
+
* company's history therefore lands newest year first, a year a quarter of an hour, and after that
|
|
543
|
+
* each older year is read once a day: a closed year rarely moves, but nothing stops it moving. A
|
|
544
|
+
* year whose file has not changed lands nothing.
|
|
545
|
+
*
|
|
546
|
+
* Each year is written to the binding right after it lands, so a failure on one costs only that one,
|
|
547
|
+
* and the years that did land are answered beside the ones that failed.
|
|
548
|
+
*/
|
|
549
|
+
export async function syncFortnoxSieFiles(host, connectionId, binding, options) {
|
|
550
|
+
const conn = await openFortnoxConnection(host.admin, options.fetch, binding.tenantId, binding.vertical, options.timeoutMs ?? 30_000, { externalAccountRef: binding.externalAccountRef ?? null, expected: connectionId });
|
|
551
|
+
const api = new FortnoxApi(conn, apiOptions(options));
|
|
552
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
553
|
+
const year = new Date(nowMs).toISOString().slice(0, 4);
|
|
554
|
+
const years = await api.financialYears();
|
|
555
|
+
const current = financialYearFor(years, options.period ?? { from: `${year}-01-01`, to: `${year}-12-31` });
|
|
556
|
+
if (!current)
|
|
557
|
+
return { synced: [], failed: [] };
|
|
558
|
+
const held = (y) => binding.yearSync?.[String(y.Id)];
|
|
559
|
+
// The last time a year was tried, landed or failed: a failed year waits its turn like a read one.
|
|
560
|
+
const tried = (y) => {
|
|
561
|
+
const read = held(y)?.syncedAt;
|
|
562
|
+
const failed = binding.yearFailedAt?.[String(y.Id)];
|
|
563
|
+
return read === undefined || (failed !== undefined && failed > read) ? failed : read;
|
|
564
|
+
};
|
|
565
|
+
const kept = binding.years === 'all' ? Infinity : (binding.years ?? 0);
|
|
566
|
+
// Never tried comes first, newest first (the sort is stable), because the recent years are the
|
|
567
|
+
// ones a reader wants. After that, the one tried longest ago.
|
|
568
|
+
const due = years
|
|
569
|
+
.filter((y) => y.FromDate < current.FromDate)
|
|
570
|
+
.sort((a, b) => (a.FromDate < b.FromDate ? 1 : -1))
|
|
571
|
+
.slice(0, kept)
|
|
572
|
+
.filter((y) => {
|
|
573
|
+
const last = tried(y);
|
|
574
|
+
return last === undefined || nowMs - Date.parse(last) >= OLDER_YEAR_REFRESH_MS;
|
|
575
|
+
})
|
|
576
|
+
.sort((a, b) => (tried(a) ?? '').localeCompare(tried(b) ?? ''));
|
|
577
|
+
const key = bindingKey(binding.scopeId);
|
|
578
|
+
const synced = [];
|
|
579
|
+
const failed = [];
|
|
580
|
+
for (const financialYear of [current, ...due.slice(0, 1)]) {
|
|
581
|
+
try {
|
|
582
|
+
const sie = await api.sieFile(financialYear.Id);
|
|
583
|
+
const contentHash = await sha256Hex(`sie\n${financialYear.Id}\n${sie}`);
|
|
584
|
+
const changed = held(financialYear)?.contentHash !== contentHash;
|
|
585
|
+
if (changed) {
|
|
586
|
+
// The file names its company (#FNAMN, #ORGNR), as the summary form reads it.
|
|
587
|
+
const ledger = parseSie4(sie);
|
|
588
|
+
// The connection acting as itself (#97), gated on the grant the binding was seated with.
|
|
589
|
+
const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
|
|
590
|
+
await scope.invoke(binding.operation, fortnoxSieFile.parse({
|
|
591
|
+
syncId: contentHash,
|
|
592
|
+
connectionId,
|
|
593
|
+
company: { name: ledger.company, organizationNumber: ledger.organizationNumber },
|
|
594
|
+
financialYear: { id: financialYear.Id, from: financialYear.FromDate, to: financialYear.ToDate },
|
|
595
|
+
sie,
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
// A year is a long download, so the binding read at the start of the pass may be stale by now:
|
|
599
|
+
// the cursor goes onto the binding as it stands, and one unbound or re-bound meanwhile is left
|
|
600
|
+
// as the person left it rather than brought back from this pass's copy.
|
|
601
|
+
const now = (await host.admin.getConnectorState(connectionId, key));
|
|
602
|
+
if (!now || typeof now !== 'object' || now.boundAt !== binding.boundAt)
|
|
603
|
+
break;
|
|
604
|
+
const syncedAt = new Date(nowMs).toISOString();
|
|
605
|
+
const { [String(financialYear.Id)]: _landed, ...stillFailed } = now.yearFailedAt ?? {};
|
|
606
|
+
await host.admin.putConnectorState(connectionId, key, {
|
|
607
|
+
...now,
|
|
608
|
+
yearSync: { ...now.yearSync, [String(financialYear.Id)]: { syncedAt, contentHash } },
|
|
609
|
+
yearFailedAt: Object.keys(stillFailed).length > 0 ? stillFailed : undefined,
|
|
610
|
+
...(financialYear === current ? { lastSync: { financialYearId: current.Id, syncedAt, contentHash, balances: 0 } } : {}),
|
|
611
|
+
});
|
|
612
|
+
synced.push({
|
|
613
|
+
scopeId: binding.scopeId,
|
|
614
|
+
changed,
|
|
615
|
+
syncId: contentHash,
|
|
616
|
+
financialYearId: financialYear.Id,
|
|
617
|
+
balances: 0,
|
|
618
|
+
pages: changed ? 1 : 0,
|
|
619
|
+
outOfRange: 0,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
failed.push({ financialYearId: financialYear.Id, error });
|
|
624
|
+
// The current year is tried every pass anyway; an older one is put back in the queue.
|
|
625
|
+
if (financialYear !== current)
|
|
626
|
+
await markYearFailed(host, connectionId, key, binding, financialYear.Id, nowMs);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return { synced, failed };
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Record that an older year failed, onto the binding as it stands (see the cursor write above). The
|
|
633
|
+
* year's error is already answered, so a failure to record it only means the year is tried again
|
|
634
|
+
* next pass, as it was before this was recorded.
|
|
635
|
+
*/
|
|
636
|
+
async function markYearFailed(host, connectionId, key, binding, financialYearId, nowMs) {
|
|
637
|
+
try {
|
|
638
|
+
const now = (await host.admin.getConnectorState(connectionId, key));
|
|
639
|
+
if (!now || typeof now !== 'object' || now.boundAt !== binding.boundAt)
|
|
640
|
+
return;
|
|
641
|
+
await host.admin.putConnectorState(connectionId, key, {
|
|
642
|
+
...now,
|
|
643
|
+
yearFailedAt: { ...now.yearFailedAt, [String(financialYearId)]: new Date(nowMs).toISOString() },
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// Answered above: the year's own error is in `failed`.
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Sync ONE invoice binding (#2422): read the files earlier passes left, list the supplier invoices
|
|
652
|
+
* changed since its cursor, read each one's rows, land them in one page through the binding's
|
|
653
|
+
* operation as the connection, read the files the landing asked for, then write back what the
|
|
654
|
+
* vertical says is waiting.
|
|
382
655
|
*
|
|
383
|
-
* Read-only toward Fortnox
|
|
384
|
-
*
|
|
385
|
-
*
|
|
656
|
+
* Read-only toward Fortnox unless the binding declares `writeback`. Reads at most
|
|
657
|
+
* `budget.details` times for invoices and files, oldest number first, and spends from the budget so
|
|
658
|
+
* bindings sharing a pass share Fortnox's rate limit. The cursor is written only after the landing
|
|
659
|
+
* succeeded, so a refused page is read again next pass.
|
|
660
|
+
*
|
|
661
|
+
* Files come after the cursor, because a file that cannot be read says nothing about the invoice:
|
|
662
|
+
* the invoices keep landing, and the file stays queued and fails the scope's row by its number.
|
|
386
663
|
*/
|
|
387
664
|
export async function syncFortnoxSupplierInvoices(host, connectionId, binding, options) {
|
|
388
665
|
if (binding.kind !== 'supplier-invoice') {
|
|
@@ -391,16 +668,64 @@ export async function syncFortnoxSupplierInvoices(host, connectionId, binding, o
|
|
|
391
668
|
const conn = await openFortnoxConnection(host.admin, options.fetch, binding.tenantId, binding.vertical, options.timeoutMs ?? 30_000, { externalAccountRef: binding.externalAccountRef ?? null, expected: connectionId });
|
|
392
669
|
const api = new FortnoxApi(conn, apiOptions(options));
|
|
393
670
|
const nowMs = options.now?.() ?? Date.now();
|
|
671
|
+
const expired = () => (options.now?.() ?? Date.now()) - nowMs >= INVOICE_PASS_DEADLINE_MS;
|
|
394
672
|
const cursor = binding.invoiceSync;
|
|
395
|
-
const
|
|
673
|
+
const budget = options.budget ?? { details: INVOICE_DETAILS_PER_PASS };
|
|
674
|
+
const scopeRef = scopeIdSchema.parse(binding.scopeId);
|
|
675
|
+
let companyRead;
|
|
676
|
+
const company = () => (companyRead ??= api.companyInformation());
|
|
677
|
+
const files = new InvoiceFiles(host, connectionId, binding.scopeId, scopeRef, api, budget, expired, binding.files);
|
|
678
|
+
const withFiles = (base) => {
|
|
679
|
+
const { files: _held, ...rest } = base;
|
|
680
|
+
return { ...rest, ...files.held() };
|
|
681
|
+
};
|
|
682
|
+
const saveFiles = async (base) => {
|
|
683
|
+
if (!files.moved)
|
|
684
|
+
return;
|
|
685
|
+
await host.admin.putConnectorState(connectionId, bindingKey(binding.scopeId), withFiles(base));
|
|
686
|
+
files.moved = false;
|
|
687
|
+
};
|
|
688
|
+
// The queue goes first, so it never outgrows one pass's budget, and it is saved at once, so a
|
|
689
|
+
// landing that throws below does not cost what was read. Its failures wait for the end.
|
|
690
|
+
await files.drain();
|
|
691
|
+
await saveFiles(binding);
|
|
692
|
+
// A pass's parts are independent: a landing that throws says nothing about a decision waiting to be
|
|
693
|
+
// written, and an idle pass is when most decisions are. The first failure is thrown at the end.
|
|
694
|
+
const landing = await landChangedInvoices(host, connectionId, binding, { api, company, files, budget, expired, nowMs, withFiles, saveFiles })
|
|
695
|
+
.catch((err) => ({ landed: 0, more: true, failure: asError(err) }));
|
|
696
|
+
const writes = binding.writeback
|
|
697
|
+
? await writeBackInvoices(host, connectionId, binding, binding.writeback, { api, company, budget, expired, nowMs })
|
|
698
|
+
.catch((err) => ({ held: 0, written: 0, more: true, failure: asError(err) }))
|
|
699
|
+
: { held: 0, written: 0, more: false, failure: null };
|
|
700
|
+
const failure = landing.failure ?? files.failed ?? writes.failure;
|
|
701
|
+
if (failure)
|
|
702
|
+
throw failure;
|
|
703
|
+
return {
|
|
704
|
+
scopeId: binding.scopeId,
|
|
705
|
+
landed: landing.landed,
|
|
706
|
+
files: files.landed,
|
|
707
|
+
...(files.skipped.length > 0 ? { skipped: files.skipped } : {}),
|
|
708
|
+
held: writes.held,
|
|
709
|
+
written: writes.written,
|
|
710
|
+
pending: landing.more || writes.more || files.queued > 0,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const asError = (err) => (err instanceof Error ? err : new Error(String(err)));
|
|
714
|
+
/** Land what changed since the cursor, then read the files the landing asked for. */
|
|
715
|
+
async function landChangedInvoices(host, connectionId, binding, pass) {
|
|
716
|
+
const { api, company: companyOf, files, budget, expired, nowMs, withFiles, saveFiles } = pass;
|
|
717
|
+
if (budget.details <= 0 || expired())
|
|
718
|
+
return { landed: 0, more: true, failure: null };
|
|
719
|
+
const cursor = binding.invoiceSync;
|
|
720
|
+
const since = cursor ? (cursor.since === null ? null : Date.parse(cursor.since)) : firstPassSince(binding, nowMs);
|
|
396
721
|
const through = cursor?.pending ? Number(cursor.pending.through) : -Infinity;
|
|
397
|
-
const
|
|
722
|
+
const filter = since === null ? null : lastModifiedFilter(since - INVOICE_OVERLAP_MS);
|
|
723
|
+
const changed = [...new Set(await api.supplierInvoicesModifiedSince(filter))]
|
|
398
724
|
.filter((n) => Number(n) > through)
|
|
399
725
|
.sort((a, b) => Number(a) - Number(b));
|
|
400
|
-
const budget = options.budget ?? { details: INVOICE_DETAILS_PER_PASS };
|
|
401
726
|
const batch = changed.slice(0, budget.details);
|
|
402
727
|
if (batch.length === 0)
|
|
403
|
-
return {
|
|
728
|
+
return { landed: 0, more: changed.length > 0, failure: null };
|
|
404
729
|
budget.details -= batch.length;
|
|
405
730
|
// Read in number order, stopping at the first read that fails. What was read before it still
|
|
406
731
|
// lands and the cursor moves past it, so a failure costs the pass the rest of its batch, never
|
|
@@ -425,13 +750,13 @@ export async function syncFortnoxSupplierInvoices(host, connectionId, binding, o
|
|
|
425
750
|
}
|
|
426
751
|
handled += 1;
|
|
427
752
|
// Checked after a read, so every pass makes progress however slow the list was.
|
|
428
|
-
if ((
|
|
753
|
+
if (expired())
|
|
429
754
|
break;
|
|
430
755
|
}
|
|
431
756
|
if (handled === 0)
|
|
432
|
-
|
|
757
|
+
return { landed: 0, more: true, failure };
|
|
433
758
|
if (invoices.length > 0) {
|
|
434
|
-
const company = await
|
|
759
|
+
const company = await companyOf();
|
|
435
760
|
const payload = fortnoxSupplierInvoicePage.parse({
|
|
436
761
|
connectionId,
|
|
437
762
|
company: { name: company.CompanyName, organizationNumber: company.OrganizationNumber },
|
|
@@ -439,23 +764,296 @@ export async function syncFortnoxSupplierInvoices(host, connectionId, binding, o
|
|
|
439
764
|
});
|
|
440
765
|
// The connection acting as itself (#97), gated on the grant the binding was seated with.
|
|
441
766
|
const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
|
|
442
|
-
await scope.invoke(binding.operation, payload);
|
|
767
|
+
files.ask(await scope.invoke(binding.operation, payload));
|
|
443
768
|
}
|
|
444
769
|
const more = changed.length > handled;
|
|
445
770
|
const startedAt = cursor?.pending?.startedAt ?? new Date(nowMs).toISOString();
|
|
446
|
-
const synced = {
|
|
771
|
+
const synced = withFiles({
|
|
447
772
|
...binding,
|
|
448
773
|
invoiceSync: {
|
|
449
|
-
since: more ? new Date(since).toISOString() : startedAt,
|
|
774
|
+
since: more ? (since === null ? null : new Date(since).toISOString()) : startedAt,
|
|
450
775
|
syncedAt: new Date(nowMs).toISOString(),
|
|
451
776
|
landed: invoices.length,
|
|
452
777
|
...(more ? { pending: { startedAt, through: batch[handled - 1] } } : {}),
|
|
453
778
|
},
|
|
454
|
-
};
|
|
779
|
+
});
|
|
455
780
|
await host.admin.putConnectorState(connectionId, bindingKey(binding.scopeId), synced);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
781
|
+
files.moved = false;
|
|
782
|
+
await files.drain();
|
|
783
|
+
await saveFiles(synced);
|
|
784
|
+
return { landed: invoices.length, more, failure };
|
|
785
|
+
}
|
|
786
|
+
/** A refusal in Fortnox's own words: the request was read and not carried out. */
|
|
787
|
+
const statedRefusal = (err) => err instanceof FortnoxApiError && err.status >= 400 && err.status < 500 && err.status !== 429;
|
|
788
|
+
/**
|
|
789
|
+
* Write what the vertical says is waiting: hold what is undecided, then settle the decisions.
|
|
790
|
+
*
|
|
791
|
+
* A decision's steps run in the order that fails safe. The block is cleared only after booking went
|
|
792
|
+
* through, so an invoice that could not be booked stays unpaid, and the comment goes last because
|
|
793
|
+
* the log is append-only. Every step reads first, so a pass cut off part-way repeats nothing.
|
|
794
|
+
*
|
|
795
|
+
* Only `hold` errors and transient failures are retried here. A refusal in Fortnox's own words is
|
|
796
|
+
* the vertical's at once, because the next pass would get the same answer and a person has to act.
|
|
797
|
+
*/
|
|
798
|
+
async function writeBackInvoices(host, connectionId, binding, operations, pass) {
|
|
799
|
+
const { api, budget, expired, nowMs } = pass;
|
|
800
|
+
// A pass with no budget left for one write asks nothing of Fortnox, not even who the company is.
|
|
801
|
+
if (budget.details < INVOICE_HOLD_CALLS || expired())
|
|
802
|
+
return { held: 0, written: 0, more: true, failure: null };
|
|
803
|
+
const scope = await host.getConnectorScope(connectionId, scopeIdSchema.parse(binding.scopeId));
|
|
804
|
+
const company = await pass.company();
|
|
805
|
+
const waiting = fortnoxInvoiceWriteback.parse(await scope.invoke(operations.read, {
|
|
806
|
+
connectionId,
|
|
807
|
+
company: { name: company.CompanyName, organizationNumber: company.OrganizationNumber },
|
|
808
|
+
}));
|
|
809
|
+
const at = new Date(nowMs).toISOString();
|
|
810
|
+
const out = { held: 0, written: 0, more: false, failure: null };
|
|
811
|
+
for (const { givenNumber } of waiting.hold) {
|
|
812
|
+
const key = invoiceHoldKey(givenNumber);
|
|
813
|
+
// A released hold does not count: an invoice can come back to the queue, and then it is held again.
|
|
814
|
+
const known = (await host.admin.getConnectorState(connectionId, key));
|
|
815
|
+
const live = known && !known.cleared ? known : null;
|
|
816
|
+
// Only a block this connector saw set is left alone. Someone else's is looked at again, because
|
|
817
|
+
// lifting it would leave the undecided invoice payable, and so is one Fortnox never confirmed.
|
|
818
|
+
if (live?.ours && !live.unconfirmed)
|
|
819
|
+
continue;
|
|
820
|
+
if (budget.details < INVOICE_HOLD_CALLS || expired()) {
|
|
821
|
+
out.more = true;
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
budget.details -= INVOICE_HOLD_CALLS;
|
|
825
|
+
let asked = false;
|
|
826
|
+
try {
|
|
827
|
+
if ((await api.supplierInvoice(givenNumber)).DisablePaymentFile) {
|
|
828
|
+
// Blocked already: ours when our unconfirmed write went through, someone else's otherwise.
|
|
829
|
+
if (!live || live.unconfirmed) {
|
|
830
|
+
await host.admin.putConnectorState(connectionId, key, { ours: !!live?.ours, at: live?.at ?? at });
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// Remembered before the write: a pass that dies between them must not leave a block nobody
|
|
835
|
+
// knows is ours, which no approval would ever clear and which keeps a good invoice unpaid.
|
|
836
|
+
await host.admin.putConnectorState(connectionId, key, { ours: true, at, unconfirmed: true });
|
|
837
|
+
asked = true;
|
|
838
|
+
await api.setSupplierInvoicePaymentBlock(givenNumber, true);
|
|
839
|
+
await host.admin.putConnectorState(connectionId, key, { ours: true, at });
|
|
840
|
+
out.held += 1;
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
// Only a stated refusal says the block was not set. Any other failure may have set it, so the
|
|
844
|
+
// note stays unconfirmed and the next pass reads the invoice to find out.
|
|
845
|
+
if (asked && statedRefusal(err))
|
|
846
|
+
await host.admin.putConnectorState(connectionId, key, known ?? null);
|
|
847
|
+
out.failure ??= new Error(`hold of supplier invoice ${givenNumber}: ${asError(err).message}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
for (const decision of waiting.decisions) {
|
|
851
|
+
if (budget.details < INVOICE_DECISION_CALLS || expired()) {
|
|
852
|
+
out.more = true;
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
budget.details -= INVOICE_DECISION_CALLS;
|
|
856
|
+
const tries = invoiceWriteKey(decision.key);
|
|
857
|
+
try {
|
|
858
|
+
await settleInvoiceDecision(host, connectionId, api, decision, at);
|
|
859
|
+
await scope.invoke(operations.record, fortnoxInvoiceWritebackReceipt.parse({ key: decision.key, status: 'done', error: null }));
|
|
860
|
+
await host.admin.putConnectorState(connectionId, tries, null);
|
|
861
|
+
out.written += 1;
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
const error = asError(err);
|
|
865
|
+
const attempts = ((await host.admin.getConnectorState(connectionId, tries))?.attempts ?? 0) + 1;
|
|
866
|
+
out.failure ??= new Error(`write-back of supplier invoice ${decision.givenNumber}: ${error.message}`);
|
|
867
|
+
if (statedRefusal(err) || attempts >= INVOICE_WRITE_ATTEMPTS) {
|
|
868
|
+
try {
|
|
869
|
+
await scope.invoke(operations.record, fortnoxInvoiceWritebackReceipt.parse({ key: decision.key, status: 'failed', error: error.message }));
|
|
870
|
+
await host.admin.putConnectorState(connectionId, tries, null);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
// The receipt did not land, so the vertical still lists the decision: the next pass tries
|
|
875
|
+
// again, and this pass goes on to the rest. The first failure is already the one reported.
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
await host.admin.putConnectorState(connectionId, tries, { attempts, lastError: error.message });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
/** Bring Fortnox to what one decision says. Safe to repeat: every step looks before it writes. */
|
|
884
|
+
async function settleInvoiceDecision(host, connectionId, api, decision, at) {
|
|
885
|
+
const { givenNumber, book, release, comment } = decision;
|
|
886
|
+
const invoice = await api.supplierInvoice(givenNumber);
|
|
887
|
+
if (book && !invoice.Booked)
|
|
888
|
+
await api.bookSupplierInvoice(givenNumber);
|
|
889
|
+
if (release) {
|
|
890
|
+
const key = invoiceHoldKey(givenNumber);
|
|
891
|
+
const hold = (await host.admin.getConnectorState(connectionId, key));
|
|
892
|
+
if (hold?.ours && !hold.cleared) {
|
|
893
|
+
if (invoice.DisablePaymentFile)
|
|
894
|
+
await api.setSupplierInvoicePaymentBlock(givenNumber, false);
|
|
895
|
+
await host.admin.putConnectorState(connectionId, key, { ...hold, cleared: at });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (comment !== null && !(await api.supplierInvoiceComments(givenNumber)).includes(comment)) {
|
|
899
|
+
await api.addSupplierInvoiceComment(givenNumber, comment);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** Where an invoice binding's first pass starts: a number of days back, or `null` for everything. */
|
|
903
|
+
const firstPassSince = (binding, nowMs) => binding.firstPass === 'all' ? null : nowMs - (binding.firstPass ?? INVOICE_FIRST_LOOKBACK_DAYS) * 86_400_000;
|
|
904
|
+
const triedKey = (entry) => JSON.stringify([entry.givenNumber, entry.entity.entityType, entry.entity.entityId]);
|
|
905
|
+
/**
|
|
906
|
+
* The invoice files one pass reads: the queue the binding carries, plus what the landing asks for.
|
|
907
|
+
*
|
|
908
|
+
* Each queued invoice costs its connections plus one read per file. A failure names the invoice and
|
|
909
|
+
* moves it to the back, so one file that cannot be read does not stop the others. A refusal stops
|
|
910
|
+
* them all: it is the company's consent, and every other file would answer the same.
|
|
911
|
+
*/
|
|
912
|
+
class InvoiceFiles {
|
|
913
|
+
host;
|
|
914
|
+
connectionId;
|
|
915
|
+
scopeId;
|
|
916
|
+
scopeRef;
|
|
917
|
+
api;
|
|
918
|
+
budget;
|
|
919
|
+
expired;
|
|
920
|
+
queue;
|
|
921
|
+
attachments;
|
|
922
|
+
failure = null;
|
|
923
|
+
/** The company's consent refused a read, so the rest of the pass asks nothing more. */
|
|
924
|
+
refused = false;
|
|
925
|
+
/**
|
|
926
|
+
* What failed this pass, by invoice and entity, so the drain after the landing does not read it
|
|
927
|
+
* again. A landing that names another entity for the invoice is a new request, and is read.
|
|
928
|
+
*/
|
|
929
|
+
tried = new Set();
|
|
930
|
+
/** Files landed so far this pass. */
|
|
931
|
+
landed = 0;
|
|
932
|
+
skipped = [];
|
|
933
|
+
/** True once the queue differs from what the binding holds. */
|
|
934
|
+
moved = false;
|
|
935
|
+
constructor(host, connectionId, scopeId, scopeRef, api, budget, expired, held = []) {
|
|
936
|
+
this.host = host;
|
|
937
|
+
this.connectionId = connectionId;
|
|
938
|
+
this.scopeId = scopeId;
|
|
939
|
+
this.scopeRef = scopeRef;
|
|
940
|
+
this.api = api;
|
|
941
|
+
this.budget = budget;
|
|
942
|
+
this.expired = expired;
|
|
943
|
+
this.queue = [...held];
|
|
944
|
+
}
|
|
945
|
+
get queued() {
|
|
946
|
+
return this.queue.length;
|
|
947
|
+
}
|
|
948
|
+
/** The binding's `files` field: absent when nothing is queued. */
|
|
949
|
+
held() {
|
|
950
|
+
return this.queue.length > 0 ? { files: this.queue } : {};
|
|
951
|
+
}
|
|
952
|
+
/** Queue what a landing asked for. Re-asking for a queued invoice takes its newer entity. */
|
|
953
|
+
ask(answer) {
|
|
954
|
+
const asked = fortnoxSupplierInvoiceLandingAnswer.safeParse(answer);
|
|
955
|
+
if (!asked.success)
|
|
956
|
+
return;
|
|
957
|
+
for (const { givenNumber, entity } of asked.data.files ?? []) {
|
|
958
|
+
const entry = { givenNumber, entity };
|
|
959
|
+
const at = this.queue.findIndex((e) => e.givenNumber === givenNumber);
|
|
960
|
+
if (at >= 0)
|
|
961
|
+
this.queue[at] = { ...this.queue[at], entity };
|
|
962
|
+
else
|
|
963
|
+
this.queue.push(entry);
|
|
964
|
+
this.moved = true;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Read what the budget and the deadline allow. */
|
|
968
|
+
async drain() {
|
|
969
|
+
const failed = [];
|
|
970
|
+
while (!this.refused && this.budget.details >= INVOICE_FILE_READS && !this.expired()) {
|
|
971
|
+
const at = this.queue.findIndex((e) => !this.tried.has(triedKey(e)));
|
|
972
|
+
if (at < 0)
|
|
973
|
+
break;
|
|
974
|
+
const [entry] = this.queue.splice(at, 1);
|
|
975
|
+
this.moved = true;
|
|
976
|
+
try {
|
|
977
|
+
if (!(await this.read(entry))) {
|
|
978
|
+
// The budget or the deadline ran out between its files: it stays first, and what it
|
|
979
|
+
// landed is recorded, so the next pass reads only the rest.
|
|
980
|
+
this.queue.unshift(entry);
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
986
|
+
if (err instanceof FortnoxApiError && err.refused) {
|
|
987
|
+
this.queue.unshift(entry);
|
|
988
|
+
this.refused = true;
|
|
989
|
+
this.failure = new Error(`invoice files: Fortnox refused the read (${message}). The company's consent must cover ` +
|
|
990
|
+
`the connectfile and archive scopes; connect the company again.`);
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
this.tried.add(triedKey(entry));
|
|
994
|
+
entry.attempts = (entry.attempts ?? 0) + 1;
|
|
995
|
+
// The last try is reported as a skip, so the pass that drops the file says which and why.
|
|
996
|
+
if (entry.attempts >= INVOICE_FILE_ATTEMPTS) {
|
|
997
|
+
this.skipped.push({ givenNumber: entry.givenNumber, reason: `its file failed ${entry.attempts} passes: ${message}` });
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
failed.push(entry);
|
|
1001
|
+
this.failure ??= new Error(`file of supplier invoice ${entry.givenNumber}: ${message}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
this.queue.push(...failed);
|
|
1006
|
+
}
|
|
1007
|
+
/** Land an invoice's files not yet landed on its entity. False when the pass ran out first. */
|
|
1008
|
+
async read(entry) {
|
|
1009
|
+
this.budget.details -= 1;
|
|
1010
|
+
const connected = await this.api.supplierInvoiceFiles(entry.givenNumber);
|
|
1011
|
+
const key = filesLandedKey(this.scopeId, entry.givenNumber);
|
|
1012
|
+
const held = (await this.host.admin.getConnectorState(this.connectionId, key));
|
|
1013
|
+
const sameEntity = held?.entity.entityType === entry.entity.entityType && held.entity.entityId === entry.entity.entityId;
|
|
1014
|
+
let landed = sameEntity ? held.fileIds : [];
|
|
1015
|
+
for (const file of connected) {
|
|
1016
|
+
if (landed.includes(file.FileId))
|
|
1017
|
+
continue;
|
|
1018
|
+
if (this.budget.details < 1 || this.expired())
|
|
1019
|
+
return false;
|
|
1020
|
+
this.budget.details -= 1;
|
|
1021
|
+
await this.land(entry, file);
|
|
1022
|
+
landed = [...landed, file.FileId];
|
|
1023
|
+
await this.host.admin.putConnectorState(this.connectionId, key, { entity: entry.entity, fileIds: landed });
|
|
1024
|
+
}
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
async land(entry, file) {
|
|
1028
|
+
const name = file.Name || `supplier-invoice-${entry.givenNumber}`;
|
|
1029
|
+
let read;
|
|
1030
|
+
try {
|
|
1031
|
+
read = await this.api.archiveFile(file.FileId, INVOICE_FILE_MAX_BYTES);
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
if (!(err instanceof FortnoxApiError && err.status === 404))
|
|
1035
|
+
throw err;
|
|
1036
|
+
this.skipped.push({ givenNumber: entry.givenNumber, reason: `${name} is gone from the archive` });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (read.tooLarge !== undefined) {
|
|
1040
|
+
this.skipped.push({ givenNumber: entry.givenNumber, reason: `${name} is ${read.tooLarge} bytes, over ${INVOICE_FILE_MAX_BYTES}` });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
this.attachments ??= await this.host.getConnectorAttachments(this.connectionId, this.scopeRef);
|
|
1044
|
+
await this.attachments.upload({
|
|
1045
|
+
entity: entry.entity,
|
|
1046
|
+
filename: name,
|
|
1047
|
+
contentType: fileType(name, read.contentType),
|
|
1048
|
+
visibility: 'internal',
|
|
1049
|
+
body: read.bytes,
|
|
1050
|
+
});
|
|
1051
|
+
this.landed += 1;
|
|
1052
|
+
}
|
|
1053
|
+
/** The first file that could not be read this pass, or the refusal that stopped them. */
|
|
1054
|
+
get failed() {
|
|
1055
|
+
return this.failure;
|
|
1056
|
+
}
|
|
459
1057
|
}
|
|
460
1058
|
/** One Fortnox invoice as the page carries it, parsed here so a bad one is named by number. */
|
|
461
1059
|
const landedInvoice = (invoice) => fortnoxSupplierInvoicePage.shape.invoices.element.parse({
|
|
@@ -466,14 +1064,79 @@ const landedInvoice = (invoice) => fortnoxSupplierInvoicePage.shape.invoices.ele
|
|
|
466
1064
|
invoiceDate: invoice.InvoiceDate,
|
|
467
1065
|
dueDate: invoice.DueDate,
|
|
468
1066
|
total: { amount: invoice.Total, currency: invoice.Currency },
|
|
1067
|
+
...(invoice.Balance === null ? {} : { balance: { amount: invoice.Balance, currency: invoice.Currency } }),
|
|
1068
|
+
...(invoice.VAT === null ? {} : { vat: { amount: invoice.VAT, currency: invoice.Currency } }),
|
|
1069
|
+
ocr: invoice.OCR || null,
|
|
469
1070
|
booked: invoice.Booked,
|
|
470
1071
|
cancelled: invoice.Cancelled,
|
|
1072
|
+
credit: invoice.Credit,
|
|
1073
|
+
paymentPending: invoice.PaymentPending,
|
|
1074
|
+
comments: invoice.Comments || null,
|
|
1075
|
+
commentLog: invoice.CommentLog.map((c) => ({ comment: c.Comment, createdAt: c.CreatedAt || null, userName: c.UserName || null })),
|
|
1076
|
+
voucher: invoice.VoucherNumber === null || invoice.VoucherNumber === '0'
|
|
1077
|
+
? null
|
|
1078
|
+
: { series: invoice.VoucherSeries || null, number: Number(invoice.VoucherNumber), year: invoice.VoucherYear === null ? null : Number(invoice.VoucherYear) },
|
|
1079
|
+
project: invoice.Project || null,
|
|
1080
|
+
costCentre: invoice.CostCenter || null,
|
|
471
1081
|
rows: invoice.SupplierInvoiceRows.map((row) => ({
|
|
472
1082
|
account: row.Account,
|
|
1083
|
+
accountDescription: row.AccountDescription || null,
|
|
473
1084
|
costCentre: row.CostCenter || null,
|
|
1085
|
+
project: row.Project || null,
|
|
1086
|
+
transactionInformation: row.TransactionInformation || null,
|
|
474
1087
|
amount: { amount: addDecimal(row.Debit, mulDecimal('-1', row.Credit)), currency: 'SEK' },
|
|
475
1088
|
})),
|
|
476
1089
|
});
|
|
1090
|
+
/**
|
|
1091
|
+
* How long a binding's poll lease lasts (#2672): the invoice pass's deadline and a margin, so a
|
|
1092
|
+
* poll that crashed holds no binding past it.
|
|
1093
|
+
*/
|
|
1094
|
+
const POLL_LEASE_MS = INVOICE_PASS_DEADLINE_MS + 60_000;
|
|
1095
|
+
const leaseKey = (scope) => `fortnox:lease:${scope}`;
|
|
1096
|
+
/**
|
|
1097
|
+
* Take a binding's poll lease, so a pass and a poll asked for on request do not poll one binding at
|
|
1098
|
+
* once (#2672). Null when a live lease is another's. Best effort: connector state has no
|
|
1099
|
+
* compare-and-set, so the take is a write and a read back of its token, and two takers that
|
|
1100
|
+
* interleave within those few milliseconds can both hold it.
|
|
1101
|
+
*/
|
|
1102
|
+
async function takePollLease(host, connectionId, scopeId, nowMs) {
|
|
1103
|
+
const key = leaseKey(scopeId);
|
|
1104
|
+
const held = (await host.admin.getConnectorState(connectionId, key));
|
|
1105
|
+
if (held && Date.parse(held.until) > nowMs)
|
|
1106
|
+
return null;
|
|
1107
|
+
const token = ulid();
|
|
1108
|
+
await host.admin.putConnectorState(connectionId, key, { token, until: new Date(nowMs + POLL_LEASE_MS).toISOString() });
|
|
1109
|
+
const after = (await host.admin.getConnectorState(connectionId, key));
|
|
1110
|
+
return after?.token === token ? token : null;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* `fetch`, renewing the lease while the poll still asks Fortnox, so a slow poll (the ledger has no
|
|
1114
|
+
* deadline of its own) keeps it. Every request is a step boundary; a lease another poll took over is
|
|
1115
|
+
* left alone. A body read is not a boundary: one that streams longer than the lease can outlive it.
|
|
1116
|
+
* Best effort, as the release: a renewal that fails leaves the lease to expire, never the request.
|
|
1117
|
+
*/
|
|
1118
|
+
function leaseRenewing(host, connectionId, scopeId, token, fetch, now) {
|
|
1119
|
+
let renewedAt = now();
|
|
1120
|
+
const renew = async (at) => {
|
|
1121
|
+
const held = (await host.admin.getConnectorState(connectionId, leaseKey(scopeId)));
|
|
1122
|
+
if (held?.token === token)
|
|
1123
|
+
await host.admin.putConnectorState(connectionId, leaseKey(scopeId), { token, until: new Date(at + POLL_LEASE_MS).toISOString() });
|
|
1124
|
+
};
|
|
1125
|
+
return async (input, init) => {
|
|
1126
|
+
if (now() - renewedAt >= POLL_LEASE_MS / 3) {
|
|
1127
|
+
renewedAt = now();
|
|
1128
|
+
await renew(renewedAt).catch(() => undefined);
|
|
1129
|
+
}
|
|
1130
|
+
return fetch(input, init);
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
/** Give the lease back, unless it expired and another poll took it over. */
|
|
1134
|
+
async function releasePollLease(host, connectionId, scopeId, token) {
|
|
1135
|
+
const key = leaseKey(scopeId);
|
|
1136
|
+
const held = (await host.admin.getConnectorState(connectionId, key));
|
|
1137
|
+
if (held?.token === token)
|
|
1138
|
+
await host.admin.putConnectorState(connectionId, key, null);
|
|
1139
|
+
}
|
|
477
1140
|
/**
|
|
478
1141
|
* Poll Fortnox for every scope this connection is bound to — the {@link ConnectorSweeper}
|
|
479
1142
|
* a deployment schedules. A ledger binding syncs its year of books
|
|
@@ -489,11 +1152,12 @@ const landedInvoice = (invoice) => fortnoxSupplierInvoicePage.shape.invoices.ele
|
|
|
489
1152
|
* the pass — one vertical's missing grant must not stop another tenant's books syncing.
|
|
490
1153
|
*/
|
|
491
1154
|
export async function sweepFortnoxLedger(host, connectionId, options) {
|
|
492
|
-
const rows = await host.admin.listConnectorState(connectionId, BINDING_PREFIX);
|
|
493
|
-
const result = { found: 0, synced: [], invoices: [], unchanged: 0, failed: [] };
|
|
1155
|
+
const rows = await host.admin.listConnectorState(connectionId, options.scopeId === undefined ? BINDING_PREFIX : bindingKey(options.scopeId));
|
|
1156
|
+
const result = { found: 0, synced: [], invoices: [], unchanged: 0, failed: [], busy: [] };
|
|
494
1157
|
// One budget and one rate window per pass: every binding on this connection reads the same company.
|
|
495
1158
|
const budget = { details: INVOICE_DETAILS_PER_PASS };
|
|
496
1159
|
const pass = { ...options, rateWindow: options.rateWindow ?? [] };
|
|
1160
|
+
const clock = () => options.now?.() ?? Date.now();
|
|
497
1161
|
for (const { value } of rows) {
|
|
498
1162
|
// A tombstoned binding (`unbindFortnoxScope`) — present as a row, not a target.
|
|
499
1163
|
if (value === null || typeof value !== 'object')
|
|
@@ -504,16 +1168,42 @@ export async function sweepFortnoxLedger(host, connectionId, options) {
|
|
|
504
1168
|
result.failed.push({ scopeId: binding.scopeId, error: `unknown Fortnox binding kind '${String(binding.kind)}'; upgrade this platform before polling this binding` });
|
|
505
1169
|
continue;
|
|
506
1170
|
}
|
|
1171
|
+
// The same for the form, so a file binding is never polled as sums of a shape its landing refuses.
|
|
1172
|
+
if (binding.form !== undefined && binding.form !== 'summary' && binding.form !== 'sie') {
|
|
1173
|
+
result.failed.push({ scopeId: binding.scopeId, error: `unknown Fortnox ledger form '${String(binding.form)}'; upgrade this platform before polling this binding` });
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
let lease = null;
|
|
507
1177
|
try {
|
|
1178
|
+
lease = await takePollLease(host, connectionId, binding.scopeId, clock());
|
|
1179
|
+
if (lease === null) {
|
|
1180
|
+
result.busy.push(binding.scopeId);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const leased = { ...pass, fetch: leaseRenewing(host, connectionId, binding.scopeId, lease, pass.fetch, clock) };
|
|
508
1184
|
if (binding.kind === 'supplier-invoice') {
|
|
509
|
-
const r = await syncFortnoxSupplierInvoices(host, connectionId, binding, { ...
|
|
510
|
-
if (r.landed > 0)
|
|
1185
|
+
const r = await syncFortnoxSupplierInvoices(host, connectionId, binding, { ...leased, budget });
|
|
1186
|
+
if (r.landed > 0 || r.files > 0 || r.held > 0 || r.written > 0 || r.skipped || r.pending)
|
|
511
1187
|
result.invoices.push(r);
|
|
512
1188
|
else
|
|
513
1189
|
result.unchanged += 1;
|
|
514
1190
|
continue;
|
|
515
1191
|
}
|
|
516
|
-
|
|
1192
|
+
if (binding.form === 'sie') {
|
|
1193
|
+
const { synced, failed } = await syncFortnoxSieFiles(host, connectionId, binding, leased);
|
|
1194
|
+
const landed = synced.filter((r) => r.changed);
|
|
1195
|
+
result.synced.push(...landed);
|
|
1196
|
+
for (const f of failed) {
|
|
1197
|
+
result.failed.push({
|
|
1198
|
+
scopeId: binding.scopeId,
|
|
1199
|
+
error: `financial year ${f.financialYearId}: ${f.error instanceof Error ? f.error.message : String(f.error)}`,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
if (landed.length === 0 && failed.length === 0)
|
|
1203
|
+
result.unchanged += 1;
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const r = await syncFortnoxScope(host, connectionId, binding, leased);
|
|
517
1207
|
if (r.changed)
|
|
518
1208
|
result.synced.push(r);
|
|
519
1209
|
else
|
|
@@ -525,9 +1215,30 @@ export async function sweepFortnoxLedger(host, connectionId, options) {
|
|
|
525
1215
|
error: err instanceof Error ? err.message : String(err),
|
|
526
1216
|
});
|
|
527
1217
|
}
|
|
1218
|
+
finally {
|
|
1219
|
+
// A release that fails leaves the lease to expire.
|
|
1220
|
+
if (lease !== null)
|
|
1221
|
+
await releasePollLease(host, connectionId, binding.scopeId, lease).catch(() => undefined);
|
|
1222
|
+
}
|
|
528
1223
|
}
|
|
529
1224
|
return result;
|
|
530
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Poll ONE scope's bindings now — {@link sweepFortnoxLedger} narrowed to the scope, so the same
|
|
1228
|
+
* budget, deadline and rate window bound it as bound a pass.
|
|
1229
|
+
*/
|
|
1230
|
+
export async function pollFortnoxScope(host, connectionId, scopeId, options) {
|
|
1231
|
+
const r = await sweepFortnoxLedger(host, connectionId, { ...options, scopeId });
|
|
1232
|
+
const pending = r.invoices.some((i) => i.pending);
|
|
1233
|
+
if (r.failed.length > 0)
|
|
1234
|
+
return { outcome: 'failed', pending, errors: r.failed.map((f) => f.error) };
|
|
1235
|
+
if (r.found === 0)
|
|
1236
|
+
return { outcome: 'not-bound', pending: false, errors: [] };
|
|
1237
|
+
if (r.busy.length > 0)
|
|
1238
|
+
return { outcome: 'in-progress', pending: false, errors: [] };
|
|
1239
|
+
const landed = r.synced.length > 0 || r.invoices.some((i) => i.landed > 0 || i.files > 0 || i.held > 0 || i.written > 0);
|
|
1240
|
+
return { outcome: landed ? 'landed' : 'unchanged', pending, errors: [] };
|
|
1241
|
+
}
|
|
531
1242
|
/**
|
|
532
1243
|
* Probe a credential that is not stored yet — the connect-time check (#605).
|
|
533
1244
|
*
|
|
@@ -636,20 +1347,24 @@ export async function fortnoxConnectionActivity(host, connectionId) {
|
|
|
636
1347
|
key,
|
|
637
1348
|
title: `${b.vertical} — ${b.scopeId}`,
|
|
638
1349
|
reference: b.lastSync ? String(b.lastSync.financialYearId) : null,
|
|
639
|
-
|
|
1350
|
+
// A file binding may have landed an older year while its current one is refused.
|
|
1351
|
+
status: b.lastSync || b.invoiceSync || (b.yearSync && Object.keys(b.yearSync).length > 0) ? 'synced' : 'bound — not yet synced',
|
|
640
1352
|
at: instant.parse(b.lastSync?.syncedAt ?? b.invoiceSync?.syncedAt ?? b.boundAt),
|
|
641
1353
|
facts: [
|
|
642
1354
|
{ label: 'Lands through', value: b.operation },
|
|
643
1355
|
{ label: 'Permission', value: b.permission },
|
|
644
1356
|
...(b.lastSync
|
|
645
1357
|
? [
|
|
646
|
-
|
|
1358
|
+
// A file binding lands no balance rows, so it has no count to show.
|
|
1359
|
+
...(b.form === 'sie' ? [] : [{ label: 'Balances', value: String(b.lastSync.balances) }]),
|
|
647
1360
|
{ label: 'Financial year', value: String(b.lastSync.financialYearId) },
|
|
648
1361
|
// The content hash IS the sync identity, so an operator comparing two
|
|
649
1362
|
// scopes can tell "same books" from "same moment" at a glance.
|
|
650
1363
|
{ label: 'Content hash', value: b.lastSync.contentHash.slice(0, 12) },
|
|
651
1364
|
]
|
|
652
1365
|
: []),
|
|
1366
|
+
...(b.yearSync ? [{ label: 'Financial years held', value: String(Object.keys(b.yearSync).length) }] : []),
|
|
1367
|
+
...(b.files?.length ? [{ label: 'Invoice files waiting', value: String(b.files.length) }] : []),
|
|
653
1368
|
],
|
|
654
1369
|
};
|
|
655
1370
|
});
|