Files
accounted/lib/providers/bjornlunden/mapper.ts
T
MattssonandClaude Fable 5 db8983ba9e Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:30 +02:00

335 lines
12 KiB
TypeScript

import type {
SalesInvoiceDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
SupplierInvoiceDto,
CustomerDto,
SupplierDto,
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto,
} from '../dto';
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: value ?? 0, currencyCode: currency };
}
/**
* Derive an invoice lifecycle status from BL's fields.
*
* Sandbox-verified: `status` is an ARRAY of numeric codes. Documented for
* customer invoices (supplier invoices observed to follow the same scheme):
* 0 Unpaid, 1 Overdue, 2 Fully paid, 3 Partially paid, 4 Overpaid,
* 5 Deleted, 6 Customer loss, 7 Marked for collection, 8 Sent for collection.
* Codes ≥40 are ROT/RUT, factoring ("Invoier") and e-invoice transport noise —
* ignored here. The `paid`/`preliminary` booleans are the primary signals;
* the codes refine terminal states the booleans can't express.
*/
function deriveBLInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const codes = Array.isArray(raw['status'])
? (raw['status'] as unknown[]).map(Number).filter(Number.isFinite)
: [];
// Terminal states win over payment flags: a deleted (makulerad) or
// written-off (kundförlust) invoice must not be imported as open/paid.
if (codes.includes(5) || codes.includes(6)) return 'cancelled';
if (raw['paid'] === true) return 'paid';
if (raw['preliminary'] === true) return 'draft';
if (codes.includes(2) || codes.includes(4)) return 'paid';
if (codes.includes(1) || codes.includes(7) || codes.includes(8)) return 'overdue';
// Defensive: handle a plain string status should BL ever send one
const status = typeof raw['status'] === 'string' ? raw['status'].toLowerCase() : undefined;
if (status === 'cancelled') return 'cancelled';
if (status === 'credited') return 'credited';
if (status === 'sent') return 'sent';
return 'booked';
}
/**
* Map BL Customer Invoice to SalesInvoiceDto.
*
* BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
* customerId, customerName, amount, amountInLocalCurrency,
* amountPaidInLocalCurrency, paid, preliminary, status
*/
export function mapBLToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['amountInLocalCurrency'] as number) ?? (raw['amount'] as number) ?? 0;
const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
const balance = totalAmount - paidAmount;
const customer: PartyDto = {
name: (raw['customerName'] as string) ?? '',
identifications: raw['customerId'] ? [{ id: String(raw['customerId']), schemeId: 'BL:CUSTOMER_ID' }] : [],
};
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount, currency),
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: raw['paid'] === true,
balance: amount(balance, currency),
};
return {
id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
invoiceNumber: String(raw['invoiceNumber'] ?? ''),
issueDate: (raw['invoiceDate'] as string) ?? '',
dueDate: raw['dueDate'] as string | undefined,
currencyCode: currency,
status: deriveBLInvoiceStatus(raw),
supplier: { name: '', identifications: [] },
customer,
lines: [], // BL doesn't include line items in list responses
legalMonetaryTotal,
paymentStatus,
_raw: raw,
};
}
/**
* Map BL Supplier Invoice to SupplierInvoiceDto.
*
* BL fields: entityId, invoiceNumber, invoiceDate, dueDate, currency,
* supplierId, supplierName, amountInLocalCurrency,
* amountPaidInLocalCurrency, amountRemainingInLocalCurrency, paid, preliminary, status
*/
export function mapBLToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
const currency = (raw['currency'] as string) ?? 'SEK';
const totalAmount = (raw['amountInLocalCurrency'] as number) ?? 0;
const paidAmount = (raw['amountPaidInLocalCurrency'] as number) ?? 0;
const remaining = (raw['amountRemainingInLocalCurrency'] as number) ?? (totalAmount - paidAmount);
const supplier: PartyDto = {
name: (raw['supplierName'] as string) ?? '',
identifications: raw['supplierId'] ? [{ id: String(raw['supplierId']), schemeId: 'BL:SUPPLIER_ID' }] : [],
};
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalAmount, currency),
taxInclusiveAmount: amount(totalAmount, currency),
payableAmount: amount(totalAmount, currency),
};
const paymentStatus: PaymentStatusDto = {
paid: raw['paid'] === true,
balance: amount(remaining, currency),
};
return {
id: String(raw['entityId'] ?? raw['invoiceNumber'] ?? ''),
invoiceNumber: String(raw['invoiceNumber'] ?? ''),
issueDate: (raw['invoiceDate'] as string) ?? '',
dueDate: raw['dueDate'] as string | undefined,
currencyCode: currency,
status: deriveBLInvoiceStatus(raw),
supplier,
buyer: { name: '', identifications: [] },
lines: [], // BL doesn't include line items in list responses
legalMonetaryTotal,
paymentStatus,
_raw: raw,
};
}
/**
* Map BL Customer to CustomerDto.
*
* BL fields: entityId, id, name, organisationNumber, street, box, zip, city,
* country, phone, email, currency, vatNumber, paymentTerms, closed
*/
export function mapBLToCustomer(raw: Record<string, unknown>): CustomerDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['organisationNumber'] as string | undefined;
const party: PartyDto = {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: {
streetName: raw['street'] as string | undefined,
additionalStreetName: raw['box'] as string | undefined,
postalZone: raw['zip'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['country'] as string | undefined,
},
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
};
return {
id: String(raw['id'] ?? raw['entityId'] ?? ''),
customerNumber: String(raw['id'] ?? ''),
type: 'company',
party,
active: raw['closed'] !== true,
vatNumber: raw['vatNumber'] as string | undefined,
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
_raw: raw,
};
}
/**
* Map BL Supplier to SupplierDto.
*
* BL fields: entityId, id, name, organisationId, address1, address2, zipCode, city,
* countryCode, phone, email, bg, pg, iban, vatNr, paymentTerms, closed
*/
export function mapBLToSupplier(raw: Record<string, unknown>): SupplierDto {
const name = (raw['name'] as string) ?? '';
const orgNumber = raw['organisationId'] as string | undefined;
const party: PartyDto = {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: {
streetName: raw['address1'] as string | undefined,
additionalStreetName: raw['address2'] as string | undefined,
postalZone: raw['zipCode'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['countryCode'] as string | undefined,
},
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
};
return {
id: String(raw['id'] ?? raw['entityId'] ?? ''),
supplierNumber: String(raw['id'] ?? ''),
party,
active: raw['closed'] !== true,
vatNumber: raw['vatNr'] as string | undefined,
bankGiro: raw['bg'] as string | undefined,
plusGiro: raw['pg'] as string | undefined,
bankAccount: raw['iban'] as string | undefined,
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
_raw: raw,
};
}
/**
* Map BL Journal/Ledger Entry to JournalDto.
*
* BL fields: entityId, journalId, journalEntryId, journalEntryDate,
* journalEntryText, financialYearId, ledgerEntries[{ accountId, amount, text }],
* totalCreditSum, totalDebitSum
*/
export function mapBLToJournal(raw: Record<string, unknown>): JournalDto {
const rawEntries = (raw['ledgerEntries'] as Record<string, unknown>[] | undefined) ?? [];
const entries: AccountingEntryDto[] = rawEntries.map((entry) => {
// BL LedgerEntry has a single `amount` field: positive = debit, negative = credit
const amt = (entry['amount'] as number) ?? 0;
return {
accountNumber: String(entry['accountId'] ?? ''),
debit: amt > 0 ? amt : 0,
credit: amt < 0 ? Math.abs(amt) : 0,
description: entry['text'] as string | undefined,
};
});
const totalCredit = (raw['totalCreditSum'] as number) ?? 0;
const totalDebit = (raw['totalDebitSum'] as number) ?? entries.reduce((sum, e) => sum + e.debit, 0);
return {
id: String(raw['entityId'] ?? raw['journalEntryId'] ?? ''),
journalNumber: String(raw['journalId'] ?? raw['journalEntryId'] ?? ''),
description: raw['journalEntryText'] as string | undefined,
registrationDate: (raw['journalEntryDate'] as string) ?? '',
fiscalYear: raw['financialYearId'] != null ? Number(raw['financialYearId']) : undefined,
entries,
totalDebit: { value: totalDebit, currencyCode: 'SEK' },
totalCredit: { value: totalCredit, currencyCode: 'SEK' },
_raw: raw,
};
}
/** BL's own account `type` values (sandbox-verified) → our AccountType. */
const BL_ACCOUNT_TYPE_MAP: Record<string, AccountType> = {
asset: 'asset',
liability: 'liability',
income: 'revenue',
cost: 'expense',
};
/**
* Map BL Account to AccountingAccountDto.
*
* BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
* BL sends an explicit `type` (asset|liability|income|cost) — prefer it, since
* it also covers off-plan accounts like 0099 "Konvertering"; fall back to BAS
* number ranges when absent.
*/
export function mapBLToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
const num = Number(raw['id']);
let type: AccountType | undefined = BL_ACCOUNT_TYPE_MAP[String(raw['type'] ?? '').toLowerCase()];
if (!type) {
if (num >= 1000 && num < 2000) type = 'asset';
else if (num >= 2000 && num < 3000) type = 'liability';
else if (num >= 3000 && num < 4000) type = 'revenue';
else if (num >= 4000 && num < 9000) type = 'expense';
}
return {
accountNumber: String(raw['id'] ?? ''),
name: (raw['name'] as string) ?? '',
type,
vatCode: raw['vatCode'] as string | undefined,
sruCode: raw['sruCode'] != null ? String(raw['sruCode']) : undefined,
active: raw['closed'] !== true,
balanceCarriedForward: (raw['debit'] != null || raw['credit'] != null)
? (Number(raw['debit'] ?? 0) - Number(raw['credit'] ?? 0))
: undefined,
_raw: raw,
};
}
/**
* Map BL Company Details to CompanyInformationDto.
*
* BL fields: name, orgNumber, street, box, zip, city, country,
* phone, email, bg, pg, iban, vatNumber, preferredSettings.currency
*/
export function mapBLToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
const settings = raw['preferredSettings'] as Record<string, unknown> | undefined;
return {
companyName: (raw['name'] as string) ?? '',
organizationNumber: raw['orgNumber'] as string | undefined,
legalEntity: {
registrationName: (raw['name'] as string) ?? '',
companyId: raw['orgNumber'] as string | undefined,
companyIdSchemeId: 'SE:ORGNR',
},
address: {
streetName: raw['street'] as string | undefined,
additionalStreetName: raw['box'] as string | undefined,
postalZone: raw['zip'] as string | undefined,
cityName: raw['city'] as string | undefined,
countryCode: raw['country'] as string | undefined,
},
contact: {
telephone: raw['phone'] as string | undefined,
email: raw['email'] as string | undefined,
},
vatNumber: raw['vatNumber'] as string | undefined,
baseCurrency: (settings?.['currency'] as string) ?? 'SEK',
_raw: raw,
};
}