@substrat-run/connector-fortnox 0.5.8 → 0.6.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 +232 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +661 -25
- 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,5 +1,5 @@
|
|
|
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';
|
|
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
3
|
import { deadlineFetch, settleConnectionUse } 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';
|
|
@@ -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.
|
|
655
|
+
*
|
|
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.
|
|
382
660
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* written only after the landing succeeded, so a refused page is read again next pass.
|
|
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;
|
|
672
|
+
const cursor = binding.invoiceSync;
|
|
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 };
|
|
394
719
|
const cursor = binding.invoiceSync;
|
|
395
|
-
const since = cursor ? Date.parse(cursor.since) : nowMs
|
|
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,11 +1064,26 @@ 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
|
});
|
|
@@ -504,15 +1117,34 @@ export async function sweepFortnoxLedger(host, connectionId, options) {
|
|
|
504
1117
|
result.failed.push({ scopeId: binding.scopeId, error: `unknown Fortnox binding kind '${String(binding.kind)}'; upgrade this platform before polling this binding` });
|
|
505
1118
|
continue;
|
|
506
1119
|
}
|
|
1120
|
+
// The same for the form, so a file binding is never polled as sums of a shape its landing refuses.
|
|
1121
|
+
if (binding.form !== undefined && binding.form !== 'summary' && binding.form !== 'sie') {
|
|
1122
|
+
result.failed.push({ scopeId: binding.scopeId, error: `unknown Fortnox ledger form '${String(binding.form)}'; upgrade this platform before polling this binding` });
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
507
1125
|
try {
|
|
508
1126
|
if (binding.kind === 'supplier-invoice') {
|
|
509
1127
|
const r = await syncFortnoxSupplierInvoices(host, connectionId, binding, { ...pass, budget });
|
|
510
|
-
if (r.landed > 0)
|
|
1128
|
+
if (r.landed > 0 || r.files > 0 || r.held > 0 || r.written > 0 || r.skipped)
|
|
511
1129
|
result.invoices.push(r);
|
|
512
1130
|
else
|
|
513
1131
|
result.unchanged += 1;
|
|
514
1132
|
continue;
|
|
515
1133
|
}
|
|
1134
|
+
if (binding.form === 'sie') {
|
|
1135
|
+
const { synced, failed } = await syncFortnoxSieFiles(host, connectionId, binding, pass);
|
|
1136
|
+
const landed = synced.filter((r) => r.changed);
|
|
1137
|
+
result.synced.push(...landed);
|
|
1138
|
+
for (const f of failed) {
|
|
1139
|
+
result.failed.push({
|
|
1140
|
+
scopeId: binding.scopeId,
|
|
1141
|
+
error: `financial year ${f.financialYearId}: ${f.error instanceof Error ? f.error.message : String(f.error)}`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
if (landed.length === 0 && failed.length === 0)
|
|
1145
|
+
result.unchanged += 1;
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
516
1148
|
const r = await syncFortnoxScope(host, connectionId, binding, pass);
|
|
517
1149
|
if (r.changed)
|
|
518
1150
|
result.synced.push(r);
|
|
@@ -636,20 +1268,24 @@ export async function fortnoxConnectionActivity(host, connectionId) {
|
|
|
636
1268
|
key,
|
|
637
1269
|
title: `${b.vertical} — ${b.scopeId}`,
|
|
638
1270
|
reference: b.lastSync ? String(b.lastSync.financialYearId) : null,
|
|
639
|
-
|
|
1271
|
+
// A file binding may have landed an older year while its current one is refused.
|
|
1272
|
+
status: b.lastSync || b.invoiceSync || (b.yearSync && Object.keys(b.yearSync).length > 0) ? 'synced' : 'bound — not yet synced',
|
|
640
1273
|
at: instant.parse(b.lastSync?.syncedAt ?? b.invoiceSync?.syncedAt ?? b.boundAt),
|
|
641
1274
|
facts: [
|
|
642
1275
|
{ label: 'Lands through', value: b.operation },
|
|
643
1276
|
{ label: 'Permission', value: b.permission },
|
|
644
1277
|
...(b.lastSync
|
|
645
1278
|
? [
|
|
646
|
-
|
|
1279
|
+
// A file binding lands no balance rows, so it has no count to show.
|
|
1280
|
+
...(b.form === 'sie' ? [] : [{ label: 'Balances', value: String(b.lastSync.balances) }]),
|
|
647
1281
|
{ label: 'Financial year', value: String(b.lastSync.financialYearId) },
|
|
648
1282
|
// The content hash IS the sync identity, so an operator comparing two
|
|
649
1283
|
// scopes can tell "same books" from "same moment" at a glance.
|
|
650
1284
|
{ label: 'Content hash', value: b.lastSync.contentHash.slice(0, 12) },
|
|
651
1285
|
]
|
|
652
1286
|
: []),
|
|
1287
|
+
...(b.yearSync ? [{ label: 'Financial years held', value: String(Object.keys(b.yearSync).length) }] : []),
|
|
1288
|
+
...(b.files?.length ? [{ label: 'Invoice files waiting', value: String(b.files.length) }] : []),
|
|
653
1289
|
],
|
|
654
1290
|
};
|
|
655
1291
|
});
|