@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/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
- /** How far back a binding's first pass reads. */
159
- const INVOICE_FIRST_LOOKBACK_MS = 90 * 86_400_000;
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 invoice binding (#2422): list the supplier invoices changed since its cursor, read
381
- * each one's rows, and land them in one page through the binding's operation as the connection.
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. Reads at most `budget.details` invoices, oldest number first, and
384
- * spends from the budget so bindings sharing a pass share Fortnox's rate limit. The cursor is
385
- * written only after the landing succeeded, so a refused page is read again next pass.
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 since = cursor ? Date.parse(cursor.since) : nowMs - INVOICE_FIRST_LOOKBACK_MS;
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 changed = [...new Set(await api.supplierInvoicesModifiedSince(lastModifiedFilter(since - INVOICE_OVERLAP_MS)))]
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 { scopeId: binding.scopeId, landed: 0, pending: changed.length > 0 };
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 ((options.now?.() ?? Date.now()) - nowMs >= INVOICE_PASS_DEADLINE_MS)
753
+ if (expired())
429
754
  break;
430
755
  }
431
756
  if (handled === 0)
432
- throw failure;
757
+ return { landed: 0, more: true, failure };
433
758
  if (invoices.length > 0) {
434
- const company = await api.companyInformation();
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
- if (failure)
457
- throw failure;
458
- return { scopeId: binding.scopeId, landed: invoices.length, pending: more };
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, { ...pass, budget });
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
- const r = await syncFortnoxScope(host, connectionId, binding, pass);
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
- status: b.lastSync || b.invoiceSync ? 'synced' : 'bound — not yet synced',
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
- { label: 'Balances', value: String(b.lastSync.balances) },
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
  });