Files
accounted/lib/providers/__tests__/hydrate-invoices.test.ts
T
Jakob Wennberg 521f437072 feat(migration): link migrated invoices to their registration voucher (#1463) (#2024)
Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463
2026-08-30 11:52:14 +02:00

209 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchSalesInvoicesHydrated } from '../provider-data-fetcher';
/**
* Hydration is what makes the VAT fix work in production: the list payload
* Fortnox returns has no `Net`, no `TotalVAT` and no `InvoiceRows`, so without
* a second call there is nothing to map. These tests pin the three properties
* that keep it safe at the observed volumes (up to 1 911 invoices per company
* against a 4 req/s limit and a 300 s function ceiling): only fetch what is
* missing, serve open invoices first, and report whatever the budget missed.
*/
function listResponse(invoices: Record<string, unknown>[]) {
return {
Invoices: invoices,
MetaInformation: { '@TotalPages': 1, '@CurrentPage': 1, '@TotalResources': invoices.length },
};
}
const OPEN = { DocumentNumber: 4, InvoiceDate: '2025-11-01', Currency: 'SEK', Total: 1250, Balance: 1250 };
const PAID = { DocumentNumber: 5, InvoiceDate: '2025-11-02', Currency: 'SEK', Total: 500, Balance: 0, FullyPaid: true };
function detailFor(documentNumber: number, total: number) {
return {
Invoice: {
DocumentNumber: documentNumber,
InvoiceDate: '2025-11-01',
Currency: 'SEK',
Total: total,
Balance: total,
Net: total * 0.8,
TotalVAT: total * 0.2,
InvoiceRows: [{ RowId: 1, Total: total * 0.8, VAT: 25 }],
},
};
}
describe('fetchSalesInvoicesHydrated (fortnox)', () => {
let requested: string[];
beforeEach(() => {
requested = [];
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function stubFetch(handler: (url: string) => Response) {
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requested.push(url);
return handler(url);
}));
}
const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
it('fills in the VAT the list payload omitted', async () => {
stubFetch((url) => url.includes('/invoices/4') ? json(detailFor(4, 1250)) : json(listResponse([OPEN])));
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 1, failed: 0, skippedForBudget: 0 });
expect(unhydratedIds.size).toBe(0);
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
expect(invoices[0]?.legalMonetaryTotal.lineExtensionAmount?.value).toBe(1000);
expect(invoices[0]?.lines).toHaveLength(1);
});
it('does not spend a request on an invoice that is already complete', async () => {
// A list payload that already carries Net, TotalVAT and rows has nothing
// to gain from its detail form.
const complete = { ...OPEN, Net: 1000, TotalVAT: 250, InvoiceRows: [{ RowId: 1, Total: 1000, VAT: 25 }] };
stubFetch(() => json(listResponse([complete])));
const { hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 0, hydrated: 0 });
expect(requested.filter((u) => u.includes('/invoices/4'))).toHaveLength(0);
});
it('requests the OPEN invoice before the paid one', async () => {
// Open invoices are the ones a later payment match or credit note books,
// so they must not be the ones a tight budget drops.
stubFetch((url) => {
if (url.includes('/invoices/5')) return json(detailFor(5, 500));
if (url.includes('/invoices/4')) return json(detailFor(4, 1250));
return json(listResponse([PAID, OPEN]));
});
await fetchSalesInvoicesHydrated('fortnox', 'token');
const details = requested.filter((u) => /\/invoices\/\d/.test(u));
expect(details[0]).toContain('/invoices/4');
});
it('reports what the budget could not reach instead of looking complete', async () => {
stubFetch(() => json(listResponse([OPEN, PAID])));
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token', undefined, 0);
expect(hydration).toMatchObject({ needed: 2, hydrated: 0, skippedForBudget: 2 });
// The invoices themselves are still returned, unhydrated, and named as
// such: a detail-only field (Fortnox's voucher ref) is unknown for these.
expect(invoices).toHaveLength(2);
expect([...unhydratedIds].sort()).toEqual(['4', '5']);
expect(requested.filter((u) => /\/invoices\/\d/.test(u))).toHaveLength(0);
});
it('stops the whole pass when the provider rejects the token', async () => {
// 401/403 fails identically for every remaining invoice. Issuing hundreds
// more doomed calls would spend the Fortnox rate-limit budget (shared
// platform-wide) for nothing.
// More invoices than HYDRATION_CONCURRENCY, so the abort has something
// left to prevent: the first few are already in flight when the first 401
// lands, and everything after that must never be requested.
const many = Array.from({ length: 20 }, (_, i) => ({ ...OPEN, DocumentNumber: 100 + i }));
stubFetch((url) => url.includes('/invoices/1')
? new Response('unauthorized', { status: 401 })
: json(listResponse(many)));
const { invoices, hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration.abortedBy).toBe('auth');
expect(hydration.hydrated).toBe(0);
// Every invoice is still returned, unhydrated.
expect(invoices).toHaveLength(20);
// Only the concurrency window was spent, not all 20.
const details = requested.filter((u) => /\/invoices\/\d\d\d/.test(u));
expect(details.length).toBeGreaterThan(0);
expect(details.length).toBeLessThanOrEqual(3);
});
it('does not stop the pass on a 404 for one invoice', async () => {
// A missing invoice is about that invoice, not the credential.
stubFetch((url) => {
if (url.includes('/invoices/4')) return new Response('gone', { status: 404 });
if (url.includes('/invoices/5')) return json(detailFor(5, 500));
return json(listResponse([OPEN, PAID]));
});
const { hydration } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration.abortedBy).toBeUndefined();
expect(hydration.failed).toBe(1);
expect(hydration.hydrated).toBe(1);
});
it('keeps the list-form invoice when its detail fetch fails', async () => {
stubFetch((url) => url.includes('/invoices/4')
? new Response('boom', { status: 404 })
: json(listResponse([OPEN])));
const { invoices, hydration, unhydratedIds } = await fetchSalesInvoicesHydrated('fortnox', 'token');
expect(hydration).toMatchObject({ needed: 1, hydrated: 0, failed: 1 });
expect(invoices).toHaveLength(1);
expect(invoices[0]?.legalMonetaryTotal.payableAmount.value).toBe(1250);
expect(unhydratedIds.has('4')).toBe(true);
});
});
describe('fetchSalesInvoicesHydrated: detail id comes from the configured idField', () => {
beforeEach(() => {
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('uses BL invoiceNumber, not the entityId its DTO id is built from', async () => {
// Björn Lundén's sales config names `invoiceNumber` as idField, while its
// mapper sets dto.id from `entityId`. Addressing the detail endpoint by
// dto.id would request invoice 99001 instead of 5. The two differ here on
// purpose: with equal values the test would pass either way.
const seen: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
seen.push(url);
if (url.includes('/customerinvoice/batch')) {
return new Response(JSON.stringify({
data: [{ entityId: 99001, invoiceNumber: 5, invoiceDate: '2025-11-01', currency: 'SEK', amountInLocalCurrency: 1250, paid: false }],
totalPages: 1,
pageRequested: 1,
totalRows: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({
entityId: 99001, invoiceNumber: 5, invoiceDate: '2025-11-01',
currency: 'SEK', amountInLocalCurrency: 1250, vatAmount: 250, paid: false,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}));
const { invoices } = await fetchSalesInvoicesHydrated('bjornlunden', 'token', 'user-key');
const detail = seen.find((u) => u.includes('/customerinvoice/') && !u.includes('/batch'));
expect(detail).toContain('/customerinvoice/5');
expect(detail).not.toContain('99001');
// And the hydrated payload's VAT actually landed.
expect(invoices[0]?.taxTotal?.taxAmount.value).toBe(250);
});
});