Staging (#181)
* refactor: remove unnecessary secondary action from EmptyInvoices component * feat: add direct provider layer and provider_consents migration Replace Arcim Sync gateway dependency with direct provider clients for Fortnox, Visma, Briox, Bokio, and Björn Lundén. Adds OAuth config, rate limiting, retry logic, data fetching, and consent storage via new provider_consents/tokens/otc tables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: migrate arcim extension to direct provider APIs Replace Arcim Sync gateway calls with direct provider API access. Use FortnoxClient.getText() for SIE endpoints that return plain text instead of JSON. OAuth callback now returns HTML with postMessage to communicate with the opener window instead of redirecting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use OAuth popup window instead of new tab Open provider login in a centered popup that auto-closes on completion via postMessage, keeping the user on a single tab. Falls back to redirect flow if popup is blocked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add connection status, consent reuse, and SIE duplicate detection - Add listConsents() to query active consents by company - Add GET /status route returning consents, SIE import history, and entity counts - /connect reuses existing accepted consent instead of creating duplicates, and cleans up abandoned (status 0) consents - /status only returns accepted (status 1) consents - /sie-data checks each file's SHA-256 hash against sie_imports to report per-file import status (alreadyImported, importedAt) - /sie-data blocks on SIE validation failure (mirrors manual upload) - /import-sie validates unmapped accounts and auto-activates missing BAS accounts in chart_of_accounts (mirrors manual upload) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show active connections, SIE file status, and smart re-sync UI - ProviderStep shows active connections with last import date, entity counts, "Synka igen" button, and disconnect option - Already-connected providers greyed out in selection grid - OptionsStep shows per-fiscal-year import status (imported vs new) - SIE toggle disabled with explanation when all files already imported - handleStartMigration skips already-imported SIE files - Auto-skip mapping step and disable SIE on re-sync when up to date - Result step hides empty "0 importerade" rows and shows "Allt är uppdaterat" when nothing new was fetched - OptionRow supports disabled state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: adjust COMING_SOON_PROVIDERS based on NODE_ENV for development and production * Removed duplicate * Removed duplicate * refactor: redesign reports page navigation from grid boxes to bordered card layout Replace the 4-column grid of uneven TabsList boxes with a CSS grid card using auto-sized columns separated by 1px border dividers. All sections now share equal height via items-stretch, with clear visual separation between groups. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger Vercel deployment * Update supabase/migrations/20260402010000_provider_consents.sql Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update lib/providers/rate-limiter.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * chore: re-trigger checks after migration sync --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
greptile-apps[bot]
parent
e2d9c8bb85
commit
e42da5c32b
@@ -32,13 +32,13 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
},
|
||||
{
|
||||
"slug": "arcim-migration",
|
||||
"name": "Systemmigration (Arcim Sync)",
|
||||
"name": "Systemmigration",
|
||||
"sector": "general",
|
||||
"category": "import",
|
||||
"icon": "ArrowRightLeft",
|
||||
"dataPattern": "manual",
|
||||
"description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
|
||||
"longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration."
|
||||
"longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören."
|
||||
},
|
||||
{
|
||||
"slug": "tic",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
import { withRetry } from '../retry';
|
||||
import { BL_BASE_URL, BL_RATE_LIMIT } from './config';
|
||||
|
||||
export class BjornLundenApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BjornLundenApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof BjornLundenApiError) {
|
||||
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
return error.statusCode === 429 || error.statusCode >= 500;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface BLPaginatedResponse<T> {
|
||||
pageRequested: number;
|
||||
totalPages: number;
|
||||
totalRows: number;
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export class BjornLundenClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? BL_BASE_URL;
|
||||
this.rateLimiter = new TokenBucketRateLimiter(BL_RATE_LIMIT, 'ratelimit:bjornlunden');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, userKey: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Key': userKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new BjornLundenApiError(
|
||||
`Björn Lunden API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
userKey: string,
|
||||
relativePath: string,
|
||||
options?: { page?: number; pageSize?: number },
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('pageRequested', String(options?.page ?? 1));
|
||||
params.set('rowsRequested', String(options?.pageSize ?? 50));
|
||||
params.set('rows', String(options?.pageSize ?? 50));
|
||||
|
||||
const path = `${relativePath}?${params.toString()}`;
|
||||
const response = await this.get<BLPaginatedResponse<T>>(accessToken, userKey, path);
|
||||
|
||||
return {
|
||||
items: Array.isArray(response.data) ? response.data : [],
|
||||
page: response.pageRequested ?? (options?.page ?? 1),
|
||||
totalPages: response.totalPages ?? 1,
|
||||
totalCount: response.totalRows ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getAll<T>(accessToken: string, userKey: string, path: string): Promise<T[]> {
|
||||
const response = await this.get<T[] | BLPaginatedResponse<T>>(accessToken, userKey, path);
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async getDetail<T>(accessToken: string, userKey: string, path: string): Promise<T> {
|
||||
return this.get<T>(accessToken, userKey, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ResourceType } from '../dto';
|
||||
import type { BjornLundenResourceConfig, RateLimitConfig } from '../types';
|
||||
import {
|
||||
mapBLToSalesInvoice,
|
||||
mapBLToSupplierInvoice,
|
||||
mapBLToCustomer,
|
||||
mapBLToSupplier,
|
||||
mapBLToJournal,
|
||||
mapBLToAccountingAccount,
|
||||
mapBLToCompanyInformation,
|
||||
} from './mapper';
|
||||
|
||||
export const BL_BASE_URL = 'https://apigateway.blinfo.se/bla-api/v1/sp';
|
||||
export const BL_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
|
||||
|
||||
export const BL_RESOURCE_CONFIGS: Partial<Record<ResourceType, BjornLundenResourceConfig>> = {
|
||||
[ResourceType.SalesInvoices]: {
|
||||
listEndpoint: '/customerinvoice/batch',
|
||||
detailEndpoint: '/customerinvoice/{id}',
|
||||
idField: 'invoiceNumber',
|
||||
mapper: mapBLToSalesInvoice,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.SupplierInvoices]: {
|
||||
listEndpoint: '/supplierinvoice/batch',
|
||||
detailEndpoint: '/supplierinvoice/byId/{id}',
|
||||
idField: 'entityId',
|
||||
mapper: mapBLToSupplierInvoice,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.Customers]: {
|
||||
listEndpoint: '/customer',
|
||||
detailEndpoint: '/customer/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBLToCustomer,
|
||||
paginated: false,
|
||||
},
|
||||
[ResourceType.Suppliers]: {
|
||||
listEndpoint: '/supplier',
|
||||
detailEndpoint: '/supplier/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBLToSupplier,
|
||||
paginated: false,
|
||||
},
|
||||
[ResourceType.Journals]: {
|
||||
listEndpoint: '/journal/entry/batch',
|
||||
detailEndpoint: '/journal/entry/{id}',
|
||||
idField: 'entityId',
|
||||
mapper: mapBLToJournal,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.AccountingAccounts]: {
|
||||
listEndpoint: '/account',
|
||||
detailEndpoint: '/account/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBLToAccountingAccount,
|
||||
paginated: false,
|
||||
},
|
||||
[ResourceType.CompanyInformation]: {
|
||||
listEndpoint: '/details',
|
||||
detailEndpoint: '',
|
||||
idField: '',
|
||||
mapper: mapBLToCompanyInformation,
|
||||
singleton: true,
|
||||
paginated: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, 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 };
|
||||
}
|
||||
|
||||
function deriveBLInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
if (raw['paid'] === true) return 'paid';
|
||||
if (raw['preliminary'] === true) return 'draft';
|
||||
const status = raw['status'] != null ? 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map BL Account to AccountingAccountDto.
|
||||
*
|
||||
* BL fields: entityId, id (account number), name, vatCode, sruCode, closed, type
|
||||
* Type derived from BAS plan number ranges.
|
||||
*/
|
||||
export function mapBLToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
const num = Number(raw['id']);
|
||||
|
||||
let type: AccountType | undefined;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TokenResponse } from '../types';
|
||||
|
||||
const BL_AUTH_URL = 'https://apigateway.blinfo.se/auth/oauth/v2/token';
|
||||
|
||||
export async function fetchBjornLundenToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(BL_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Björn Lunden token request failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
return {
|
||||
access_token: data.access_token as string,
|
||||
refresh_token: '',
|
||||
token_type: (data.token_type as string) ?? 'Bearer',
|
||||
expires_in: (data.expires_in as number) ?? 3600,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshBjornLundenToken(): Promise<TokenResponse> {
|
||||
const clientId = process.env.BJORN_LUNDEN_CLIENT_ID ?? '';
|
||||
const clientSecret = process.env.BJORN_LUNDEN_CLIENT_SECRET ?? '';
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('BJORN_LUNDEN_CLIENT_ID and BJORN_LUNDEN_CLIENT_SECRET must be set');
|
||||
}
|
||||
return fetchBjornLundenToken(clientId, clientSecret);
|
||||
}
|
||||
|
||||
export const storeBjornLundenToken = refreshBjornLundenToken;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
import { withRetry } from '../retry';
|
||||
import { BOKIO_BASE_URL, BOKIO_RATE_LIMIT } from './config';
|
||||
|
||||
export class BokioApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BokioApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof BokioApiError) {
|
||||
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
return error.statusCode === 429 || error.statusCode >= 500;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface BokioPaginatedResponse<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export class BokioClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? BOKIO_BASE_URL;
|
||||
this.rateLimiter = new TokenBucketRateLimiter(BOKIO_RATE_LIMIT, 'ratelimit:bokio');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new BokioApiError(
|
||||
`Bokio API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a paginated list endpoint.
|
||||
* Bokio returns `{ data: [...], pagination: { page, pageSize, totalPages, totalCount } }`.
|
||||
*/
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
relativePath: string,
|
||||
options?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
query?: string;
|
||||
},
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(options?.page ?? 1));
|
||||
params.set('pageSize', String(options?.pageSize ?? 50));
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
|
||||
const path = `/companies/${companyId}${relativePath}?${params.toString()}`;
|
||||
const response = await this.get<BokioPaginatedResponse<T>>(accessToken, path);
|
||||
|
||||
return {
|
||||
items: Array.isArray(response.items) ? response.items : [],
|
||||
page: response.currentPage ?? (options?.page ?? 1),
|
||||
totalPages: response.totalPages ?? 1,
|
||||
totalCount: response.totalItems ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a non-paginated list endpoint (e.g. chart-of-accounts).
|
||||
* Returns the full `data` array.
|
||||
*/
|
||||
async getAll<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
relativePath: string,
|
||||
): Promise<T[]> {
|
||||
const path = `/companies/${companyId}${relativePath}`;
|
||||
const response = await this.get<T[] | { items: T[] }>(accessToken, path);
|
||||
// Bokio returns a raw array for some endpoints (e.g. chart-of-accounts)
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
return Array.isArray(response.items) ? response.items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single resource detail.
|
||||
* Bokio returns the object directly (no wrapper).
|
||||
*/
|
||||
async getDetail<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
relativePath: string,
|
||||
): Promise<T> {
|
||||
const path = `/companies/${companyId}${relativePath}`;
|
||||
return this.get<T>(accessToken, path);
|
||||
}
|
||||
|
||||
async getCompany<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await this.get<T>(accessToken, `/companies/${companyId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof BokioApiError && err.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ResourceType } from '../dto';
|
||||
import type { BokioResourceConfig, RateLimitConfig } from '../types';
|
||||
import {
|
||||
mapBokioToSalesInvoice,
|
||||
mapBokioToCustomer,
|
||||
mapBokioToJournal,
|
||||
mapBokioToAccountingAccount,
|
||||
mapBokioToCompanyInformation,
|
||||
} from './mapper';
|
||||
|
||||
export const BOKIO_BASE_URL = 'https://api.bokio.se/v1';
|
||||
export const BOKIO_RATE_LIMIT: RateLimitConfig = { maxRequests: 5, windowMs: 1000 };
|
||||
|
||||
export const BOKIO_RESOURCE_CONFIGS: Partial<Record<ResourceType, BokioResourceConfig>> = {
|
||||
[ResourceType.SalesInvoices]: {
|
||||
listEndpoint: '/invoices',
|
||||
detailEndpoint: '/invoices/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBokioToSalesInvoice,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.Customers]: {
|
||||
listEndpoint: '/customers',
|
||||
detailEndpoint: '/customers/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBokioToCustomer,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.Journals]: {
|
||||
listEndpoint: '/journal-entries',
|
||||
detailEndpoint: '/journal-entries/{id}',
|
||||
idField: 'id',
|
||||
mapper: mapBokioToJournal,
|
||||
paginated: true,
|
||||
},
|
||||
[ResourceType.AccountingAccounts]: {
|
||||
listEndpoint: '/chart-of-accounts',
|
||||
detailEndpoint: '/chart-of-accounts/{id}',
|
||||
idField: 'number',
|
||||
mapper: mapBokioToAccountingAccount,
|
||||
paginated: false,
|
||||
},
|
||||
[ResourceType.CompanyInformation]: {
|
||||
listEndpoint: '',
|
||||
detailEndpoint: '',
|
||||
idField: 'id',
|
||||
mapper: mapBokioToCompanyInformation,
|
||||
singleton: true,
|
||||
paginated: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
|
||||
LegalMonetaryTotalDto, PaymentStatusDto,
|
||||
CustomerDto,
|
||||
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 };
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
const status = (raw['status'] as string | undefined)?.toLowerCase();
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
if (status === 'paid') return 'paid';
|
||||
if (status === 'overdue') return 'overdue';
|
||||
if (status === 'published') return 'sent';
|
||||
if (status === 'draft') return 'draft';
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
function buildParty(name: string, orgNumber?: string, address?: Record<string, unknown>): PartyDto {
|
||||
return {
|
||||
name,
|
||||
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
|
||||
postalAddress: address ? {
|
||||
streetName: address['line1'] as string | undefined,
|
||||
additionalStreetName: address['line2'] as string | undefined,
|
||||
cityName: address['city'] as string | undefined,
|
||||
postalZone: address['postalCode'] as string | undefined,
|
||||
countryCode: address['country'] as string | undefined,
|
||||
} : undefined,
|
||||
legalEntity: orgNumber ? {
|
||||
registrationName: name,
|
||||
companyId: orgNumber,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Bokio Invoice to SalesInvoiceDto.
|
||||
*
|
||||
* Bokio Invoice fields:
|
||||
* - id, invoiceNumber, status (draft|published|paid|overdue|cancelled)
|
||||
* - invoiceDate, dueDate, currency, totalAmount, totalTax, paidAmount
|
||||
* - customerRef: { id, name }, lineItems: [{ id, description, quantity, unitPrice, taxRate, unitType }]
|
||||
*/
|
||||
export function mapBokioToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['currency'] as string) ?? 'SEK';
|
||||
const totalAmount = (raw['totalAmount'] as number) ?? 0;
|
||||
const totalTax = (raw['totalTax'] as number) ?? 0;
|
||||
const paidAmount = (raw['paidAmount'] as number) ?? 0;
|
||||
const balance = totalAmount - paidAmount;
|
||||
|
||||
const customerRef = raw['customerRef'] as Record<string, unknown> | undefined;
|
||||
const rawLines = (raw['lineItems'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
|
||||
const lines: SalesInvoiceLineDto[] = rawLines.map((line, idx) => {
|
||||
const unitPrice = line['unitPrice'] as number | undefined;
|
||||
const quantity = line['quantity'] as number | undefined;
|
||||
const lineTotal = unitPrice != null && quantity != null ? unitPrice * quantity : 0;
|
||||
|
||||
return {
|
||||
id: String(line['id'] ?? idx + 1),
|
||||
description: line['description'] as string | undefined,
|
||||
quantity,
|
||||
unitCode: line['unitType'] as string | undefined,
|
||||
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
|
||||
lineExtensionAmount: amount(lineTotal, currency),
|
||||
taxPercent: line['taxRate'] as number | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(totalAmount - totalTax, currency),
|
||||
taxInclusiveAmount: amount(totalAmount, currency),
|
||||
payableAmount: amount(totalAmount, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: paidAmount >= totalAmount && totalAmount > 0,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
invoiceNumber: String(raw['invoiceNumber'] ?? raw['id'] ?? ''),
|
||||
issueDate: (raw['invoiceDate'] as string) ?? '',
|
||||
dueDate: raw['dueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(''),
|
||||
customer: buildParty(
|
||||
(customerRef?.['name'] as string) ?? '',
|
||||
),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Bokio Customer to CustomerDto.
|
||||
*
|
||||
* Bokio Customer fields:
|
||||
* - id, name, type (company|individual), orgNumber, vatNumber, paymentTerms
|
||||
* - address: { line1, line2, city, postalCode, country }
|
||||
* - contactsDetails: [{ email, phone, name }]
|
||||
*/
|
||||
export function mapBokioToCustomer(raw: Record<string, unknown>): CustomerDto {
|
||||
const name = (raw['name'] as string) ?? '';
|
||||
const orgNumber = raw['orgNumber'] as string | undefined;
|
||||
const address = raw['address'] as Record<string, unknown> | undefined;
|
||||
const contacts = (raw['contactsDetails'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const firstContact = contacts[0];
|
||||
|
||||
const party = buildParty(name, orgNumber, address);
|
||||
if (firstContact) {
|
||||
party.contact = {
|
||||
email: firstContact['email'] as string | undefined,
|
||||
telephone: firstContact['phone'] as string | undefined,
|
||||
name: firstContact['name'] as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
customerNumber: String(raw['id'] ?? ''),
|
||||
type: raw['type'] === 'individual' ? 'private' : 'company',
|
||||
party,
|
||||
active: true,
|
||||
vatNumber: raw['vatNumber'] as string | undefined,
|
||||
defaultPaymentTermsDays: raw['paymentTerms'] != null ? Number(raw['paymentTerms']) : undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Bokio JournalEntry to JournalDto.
|
||||
*
|
||||
* Bokio JournalEntry fields:
|
||||
* - id, date, title, number (int), createdAt
|
||||
* - items: [{ accountNumber (int), debit, credit, description }]
|
||||
*/
|
||||
export function mapBokioToJournal(raw: Record<string, unknown>): JournalDto {
|
||||
const rawItems = (raw['items'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const entries: AccountingEntryDto[] = rawItems.map((item) => ({
|
||||
accountNumber: String(item['account'] ?? item['accountNumber'] ?? ''),
|
||||
debit: (item['debit'] as number) ?? 0,
|
||||
credit: (item['credit'] as number) ?? 0,
|
||||
description: item['description'] as string | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
journalNumber: String(raw['journalEntryNumber'] ?? raw['number'] ?? raw['id'] ?? ''),
|
||||
description: raw['title'] as string | undefined,
|
||||
registrationDate: (raw['date'] as string) ?? '',
|
||||
entries,
|
||||
createdAt: raw['createdAt'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Bokio Account to AccountingAccountDto.
|
||||
*
|
||||
* Bokio Account fields:
|
||||
* - number (int, used as ID), name, category (asset|liability|income|cost), isActive
|
||||
*/
|
||||
export function mapBokioToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
// Bokio uses 'account' (int) as field name, not 'number' or 'accountNumber'
|
||||
const rawNum = raw['account'] ?? raw['accountNumber'] ?? raw['number'];
|
||||
const num = Number(rawNum);
|
||||
|
||||
// Bokio returns accountType: 'basePlanAccount' — derive type from BAS plan number range
|
||||
let type: AccountType | undefined;
|
||||
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(rawNum ?? ''),
|
||||
name: (raw['name'] as string) ?? '',
|
||||
type,
|
||||
active: raw['isActive'] !== false,
|
||||
balanceCarriedForward: raw['accountBalance'] != null ? Number(raw['accountBalance']) : undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Bokio Company to CompanyInformationDto.
|
||||
*
|
||||
* Bokio Company fields:
|
||||
* - id, name, orgNumber, vatNumber, currency, country
|
||||
* - address: { line1, line2, city, postalCode, country }
|
||||
*/
|
||||
export function mapBokioToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
|
||||
const address = raw['address'] 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: address ? {
|
||||
streetName: address['line1'] as string | undefined,
|
||||
additionalStreetName: address['line2'] as string | undefined,
|
||||
cityName: address['city'] as string | undefined,
|
||||
postalZone: address['postalCode'] as string | undefined,
|
||||
countryCode: address['country'] as string | undefined,
|
||||
} : undefined,
|
||||
vatNumber: raw['vatNumber'] as string | undefined,
|
||||
baseCurrency: raw['currency'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TokenResponse } from '../types';
|
||||
|
||||
export function storeBokioToken(apiToken: string): TokenResponse {
|
||||
return {
|
||||
access_token: apiToken,
|
||||
refresh_token: '',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
import { withRetry } from '../retry';
|
||||
import { BRIOX_BASE_URL, BRIOX_RATE_LIMIT } from './config';
|
||||
|
||||
export class BrioxApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BrioxApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof BrioxApiError) {
|
||||
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
return error.statusCode === 429 || error.statusCode >= 500;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface BrioxListResponse {
|
||||
data: Record<string, unknown> & {
|
||||
metainformation?: {
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
total_count: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class BrioxClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? BRIOX_BASE_URL;
|
||||
this.rateLimiter = new TokenBucketRateLimiter(BRIOX_RATE_LIMIT, 'ratelimit:briox');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: accessToken,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new BrioxApiError(
|
||||
`Briox API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
listKey: string,
|
||||
options?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
fromModifiedDate?: string;
|
||||
},
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(options?.page ?? 1));
|
||||
if (options?.pageSize) {
|
||||
params.set('limit', String(options.pageSize));
|
||||
}
|
||||
if (options?.fromModifiedDate) {
|
||||
params.set('frommodifieddate', options.fromModifiedDate);
|
||||
}
|
||||
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const fullPath = `${path}${separator}${params.toString()}`;
|
||||
|
||||
const response = await this.get<BrioxListResponse>(accessToken, fullPath);
|
||||
|
||||
const meta = response.data?.metainformation;
|
||||
const totalPages = meta?.total_pages ?? 1;
|
||||
const currentPage = meta?.current_page ?? (options?.page ?? 1);
|
||||
const totalCount = meta?.total_count ?? 0;
|
||||
|
||||
const items = listKey ? response.data?.[listKey] : response.data;
|
||||
|
||||
return {
|
||||
items: Array.isArray(items) ? items as T[] : [],
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async getPaginated<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
listKey: string,
|
||||
options?: {
|
||||
fromModifiedDate?: string;
|
||||
pageSize?: number;
|
||||
},
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const result = await this.getPage<T>(accessToken, path, listKey, {
|
||||
page,
|
||||
pageSize: options?.pageSize,
|
||||
fromModifiedDate: options?.fromModifiedDate,
|
||||
});
|
||||
|
||||
allItems.push(...result.items);
|
||||
totalPages = result.totalPages;
|
||||
page++;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
async getCurrentFinancialYear(accessToken: string): Promise<string> {
|
||||
const response = await this.get<{
|
||||
data: {
|
||||
financialyears: { id: string; fromdate: string; todate: string }[];
|
||||
};
|
||||
}>(accessToken, '/financialyear');
|
||||
|
||||
const years = response.data?.financialyears ?? [];
|
||||
if (years.length === 0) {
|
||||
throw new BrioxApiError('No financial years found in Briox', 404);
|
||||
}
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const completed = years.filter((y) => y.todate < now);
|
||||
return completed.length > 0 ? completed[completed.length - 1]!.id : years[0]!.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ResourceType } from '../dto';
|
||||
import type { BrioxResourceConfig, RateLimitConfig } from '../types';
|
||||
import {
|
||||
mapBrioxToSalesInvoice,
|
||||
mapBrioxToSupplierInvoice,
|
||||
mapBrioxToCustomer,
|
||||
mapBrioxToSupplier,
|
||||
mapBrioxToJournal,
|
||||
mapBrioxToAccountingAccount,
|
||||
mapBrioxToCompanyInformation,
|
||||
} from './mapper';
|
||||
|
||||
export const BRIOX_BASE_URL = 'https://api-se.briox.services/v2';
|
||||
export const BRIOX_TOKEN_URL = 'https://api-se.briox.services/v2/token';
|
||||
export const BRIOX_REFRESH_URL = 'https://api-se.briox.services/v2/tokenrefresh';
|
||||
export const BRIOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
|
||||
|
||||
export const BRIOX_RESOURCE_CONFIGS: Partial<Record<ResourceType, BrioxResourceConfig>> = {
|
||||
[ResourceType.SalesInvoices]: {
|
||||
listEndpoint: '/customerinvoice',
|
||||
detailEndpoint: '/customerinvoice/{id}',
|
||||
listKey: 'invoices',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToSalesInvoice,
|
||||
supportsModifiedFilter: true,
|
||||
},
|
||||
[ResourceType.SupplierInvoices]: {
|
||||
listEndpoint: '/supplierinvoice',
|
||||
detailEndpoint: '/supplierinvoice/{id}',
|
||||
listKey: 'supplierinvoices',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToSupplierInvoice,
|
||||
supportsModifiedFilter: true,
|
||||
},
|
||||
[ResourceType.Customers]: {
|
||||
listEndpoint: '/customer',
|
||||
detailEndpoint: '/customer/{id}',
|
||||
listKey: 'customers',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToCustomer,
|
||||
supportsModifiedFilter: true,
|
||||
},
|
||||
[ResourceType.Suppliers]: {
|
||||
listEndpoint: '/supplier',
|
||||
detailEndpoint: '/supplier/{id}',
|
||||
listKey: 'suppliers',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToSupplier,
|
||||
supportsModifiedFilter: true,
|
||||
},
|
||||
[ResourceType.Journals]: {
|
||||
listEndpoint: '/journal',
|
||||
detailEndpoint: '/journal/{id}',
|
||||
listKey: 'journals',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToJournal,
|
||||
supportsModifiedFilter: false,
|
||||
yearScoped: true,
|
||||
supportsEntryHydration: true,
|
||||
detailKey: 'journal',
|
||||
},
|
||||
[ResourceType.AccountingAccounts]: {
|
||||
listEndpoint: '/account',
|
||||
detailEndpoint: '/account/{id}',
|
||||
listKey: 'accounts',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToAccountingAccount,
|
||||
supportsModifiedFilter: false,
|
||||
},
|
||||
[ResourceType.CompanyInformation]: {
|
||||
listEndpoint: '/user/info',
|
||||
detailEndpoint: '/user/info',
|
||||
listKey: '',
|
||||
idField: 'id',
|
||||
mapper: mapBrioxToCompanyInformation,
|
||||
supportsModifiedFilter: false,
|
||||
singleton: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
|
||||
LegalMonetaryTotalDto, PaymentStatusDto,
|
||||
SupplierInvoiceDto, SupplierInvoiceLineDto,
|
||||
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 };
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
const status = raw['status'] as string | undefined;
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
if (status === 'credited') return 'credited';
|
||||
if (status === 'paid' || raw['fully_paid'] === true) return 'paid';
|
||||
if (status === 'booked' || raw['booked'] === true) return 'booked';
|
||||
if (status === 'sent' || raw['sent'] === true) return 'sent';
|
||||
if (status === 'overdue') return 'overdue';
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
function buildParty(name: string, orgNumber?: string, raw?: Record<string, unknown>): PartyDto {
|
||||
return {
|
||||
name,
|
||||
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
|
||||
postalAddress: raw ? {
|
||||
streetName: (raw['address1'] ?? raw['address']) as string | undefined,
|
||||
additionalStreetName: raw['address2'] as string | undefined,
|
||||
cityName: raw['city'] as string | undefined,
|
||||
postalZone: (raw['zip_code'] ?? raw['postal_code']) as string | undefined,
|
||||
countryCode: raw['country'] as string | undefined,
|
||||
} : undefined,
|
||||
legalEntity: orgNumber ? {
|
||||
registrationName: name,
|
||||
companyId: orgNumber,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
} : undefined,
|
||||
contact: {
|
||||
email: raw?.['email'] as string | undefined,
|
||||
telephone: raw?.['phone'] as string | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
||||
const total = raw['total_amount'] as number ?? 0;
|
||||
const balance = raw['balance'] as number ?? 0;
|
||||
|
||||
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['id'] ?? idx + 1),
|
||||
description: row['description'] as string | undefined,
|
||||
quantity: row['quantity'] as number | undefined,
|
||||
unitCode: row['unit'] as string | undefined,
|
||||
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
|
||||
taxPercent: row['vat_rate'] as number | undefined,
|
||||
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
||||
articleNumber: row['article_number'] as string | undefined,
|
||||
itemName: row['description'] as string | undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
|
||||
issueDate: (raw['invoice_date'] as string) ?? '',
|
||||
dueDate: raw['due_date'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(''),
|
||||
customer: buildParty(
|
||||
(raw['customer_name'] ?? '') as string,
|
||||
raw['customer_org_number'] as string | undefined,
|
||||
),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
paymentTerms: raw['payment_terms'] as string | undefined,
|
||||
note: raw['remarks'] as string | undefined,
|
||||
buyerReference: raw['your_reference'] as string | undefined,
|
||||
orderReference: raw['your_order_number'] as string | undefined,
|
||||
updatedAt: raw['modified_date'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
||||
const currency = (raw['currency_code'] as string) ?? 'SEK';
|
||||
const total = raw['total_amount'] as number ?? 0;
|
||||
const balance = raw['balance'] as number ?? 0;
|
||||
|
||||
const rows = (raw['rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['id'] ?? idx + 1),
|
||||
description: row['description'] as string | undefined,
|
||||
quantity: row['quantity'] as number | undefined,
|
||||
unitPrice: row['price'] != null ? amount(row['price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['total'] as number ?? 0, currency),
|
||||
accountNumber: row['account_number'] != null ? String(row['account_number']) : undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['net_amount'] as number ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
invoiceNumber: String(raw['invoice_number'] ?? raw['id'] ?? ''),
|
||||
issueDate: (raw['invoice_date'] as string) ?? '',
|
||||
dueDate: raw['due_date'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(
|
||||
(raw['supplier_name'] ?? '') as string,
|
||||
raw['supplier_org_number'] as string | undefined,
|
||||
),
|
||||
buyer: buildParty(''),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
ocrNumber: raw['ocr'] as string | undefined,
|
||||
updatedAt: raw['modified_date'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToCustomer(raw: Record<string, unknown>): CustomerDto {
|
||||
const name = (raw['name'] as string) ?? '';
|
||||
const orgNumber = raw['org_number'] as string | undefined;
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
customerNumber: String(raw['customer_number'] ?? raw['id'] ?? ''),
|
||||
type: raw['type'] === 'private' ? 'private' : 'company',
|
||||
party: buildParty(name, orgNumber, raw),
|
||||
active: raw['active'] !== false,
|
||||
vatNumber: raw['vat_number'] as string | undefined,
|
||||
defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
|
||||
note: raw['note'] as string | undefined,
|
||||
updatedAt: raw['modified_date'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToSupplier(raw: Record<string, unknown>): SupplierDto {
|
||||
const name = (raw['name'] as string) ?? '';
|
||||
const orgNumber = raw['org_number'] as string | undefined;
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
supplierNumber: String(raw['supplier_number'] ?? raw['id'] ?? ''),
|
||||
party: buildParty(name, orgNumber, raw),
|
||||
active: raw['active'] !== false,
|
||||
vatNumber: raw['vat_number'] as string | undefined,
|
||||
bankAccount: raw['bank_account'] as string | undefined,
|
||||
bankGiro: raw['bank_giro'] as string | undefined,
|
||||
plusGiro: raw['plus_giro'] as string | undefined,
|
||||
defaultPaymentTermsDays: raw['payment_terms_days'] != null ? Number(raw['payment_terms_days']) : undefined,
|
||||
note: raw['note'] as string | undefined,
|
||||
updatedAt: raw['modified_date'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToJournal(raw: Record<string, unknown>): JournalDto {
|
||||
// Briox detail API returns rows as "journal_rows" (list endpoint omits them)
|
||||
const rows = (raw['journal_rows'] as Record<string, unknown>[] | undefined)
|
||||
?? (raw['journalrows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const entries: AccountingEntryDto[] = rows.map((row) => ({
|
||||
// Briox uses "account" (not "account_number") for the account field
|
||||
accountNumber: String(row['account'] ?? row['account_number'] ?? ''),
|
||||
accountName: row['account_name'] as string | undefined,
|
||||
// Briox returns debit/credit as strings
|
||||
debit: Number(row['debit'] ?? 0),
|
||||
credit: Number(row['credit'] ?? 0),
|
||||
transactionDate: (row['transactiondate'] ?? row['transaction_date']) as string | undefined,
|
||||
description: (row['transactioninfo'] ?? row['description']) as string | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(raw['id'] ?? ''),
|
||||
journalNumber: String(raw['id'] ?? raw['journal_number'] ?? ''),
|
||||
series: raw['series'] ? {
|
||||
id: String(raw['series']),
|
||||
} : undefined,
|
||||
// Briox uses "descr" for the journal description
|
||||
description: (raw['descr'] ?? raw['description']) as string | undefined,
|
||||
// Briox uses "transactiondate" for the date
|
||||
registrationDate: ((raw['transactiondate'] ?? raw['journal_date'] ?? raw['date']) as string) ?? '',
|
||||
fiscalYear: raw['year'] != null ? Number(raw['year']) : (raw['financial_year'] != null ? Number(raw['financial_year']) : undefined),
|
||||
entries,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
// Briox uses "id" as the account number field
|
||||
const num = Number(raw['id'] ?? raw['account_number'] ?? raw['number']);
|
||||
let type: AccountType | undefined;
|
||||
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'] ?? raw['account_number'] ?? raw['number'] ?? ''),
|
||||
// Briox uses "description" for the account name
|
||||
name: ((raw['description'] ?? raw['name']) as string) ?? '',
|
||||
type,
|
||||
// Briox returns active as "1"/"0" strings
|
||||
active: raw['active'] !== false && raw['active'] !== '0' && raw['active'] !== 0,
|
||||
vatCode: raw['vat_code'] != null ? String(raw['vat_code']) : undefined,
|
||||
// Briox uses "incoming_balance" for opening balance
|
||||
balanceCarriedForward: raw['incoming_balance'] != null ? Number(raw['incoming_balance']) : undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrioxToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
|
||||
// /user/info returns { info: { company_name, accounts: [...] } }
|
||||
const info = (raw['info'] as Record<string, unknown> | undefined) ?? raw;
|
||||
const accounts = (info['accounts'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const account = accounts[0] as Record<string, unknown> | undefined;
|
||||
const addr = account?.['address'] as Record<string, unknown> | undefined;
|
||||
|
||||
const companyName = (info['company_name'] ?? account?.['database_label'] ?? '') as string;
|
||||
const orgNumber = account?.['organization_number'] as string | undefined;
|
||||
|
||||
return {
|
||||
companyName,
|
||||
organizationNumber: orgNumber,
|
||||
legalEntity: {
|
||||
registrationName: companyName,
|
||||
companyId: orgNumber,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
},
|
||||
address: {
|
||||
streetName: addr?.['addressline1'] as string | undefined,
|
||||
additionalStreetName: addr?.['addressline2'] as string | undefined,
|
||||
cityName: addr?.['city'] as string | undefined,
|
||||
postalZone: addr?.['zip'] as string | undefined,
|
||||
countryCode: (addr?.['countrycode'] ?? addr?.['country']) as string | undefined,
|
||||
},
|
||||
contact: {
|
||||
email: (account?.['email'] ?? info['email']) as string | undefined,
|
||||
telephone: (account?.['phone'] ?? info['phone']) as string | undefined,
|
||||
website: account?.['website'] as string | undefined,
|
||||
},
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BRIOX_TOKEN_URL, BRIOX_REFRESH_URL } from './config';
|
||||
import type { TokenResponse } from '../types';
|
||||
|
||||
interface BrioxTokenData {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
client_id: number;
|
||||
expire_date: string;
|
||||
expire_timestamp: number;
|
||||
}
|
||||
|
||||
interface BrioxTokenApiResponse {
|
||||
data: BrioxTokenData;
|
||||
}
|
||||
|
||||
function toTokenResponse(brioxData: BrioxTokenData): TokenResponse {
|
||||
const expiresIn = brioxData.expire_timestamp - Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
access_token: brioxData.access_token,
|
||||
refresh_token: brioxData.refresh_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: expiresIn > 0 ? expiresIn : 3600,
|
||||
};
|
||||
}
|
||||
|
||||
export async function exchangeBrioxCode(
|
||||
clientId: string,
|
||||
applicationToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const url = `${BRIOX_TOKEN_URL}?clientid=${encodeURIComponent(clientId)}&token=${encodeURIComponent(applicationToken)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Briox token exchange failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as BrioxTokenApiResponse;
|
||||
return toTokenResponse(result.data);
|
||||
}
|
||||
|
||||
export async function refreshBrioxToken(
|
||||
clientId: string,
|
||||
refreshToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const url = `${BRIOX_REFRESH_URL}?refreshtoken=${encodeURIComponent(refreshToken)}&token=${encodeURIComponent(refreshToken)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Briox token refresh failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as BrioxTokenApiResponse;
|
||||
return toTokenResponse(result.data);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Canonical DTO types for provider data normalization
|
||||
|
||||
// ============================================
|
||||
// Resource Type
|
||||
// ============================================
|
||||
|
||||
export const ResourceType = {
|
||||
SalesInvoices: 'salesinvoices',
|
||||
SupplierInvoices: 'supplierinvoices',
|
||||
Customers: 'customers',
|
||||
Suppliers: 'suppliers',
|
||||
Journals: 'journals',
|
||||
AccountingAccounts: 'accountingaccounts',
|
||||
CompanyInformation: 'companyinformation',
|
||||
AccountingPeriods: 'accountingperiods',
|
||||
FinancialDimensions: 'financialdimensions',
|
||||
BalanceSheet: 'balancesheet',
|
||||
IncomeStatement: 'incomestatement',
|
||||
TrialBalances: 'trialbalances',
|
||||
Payments: 'payments',
|
||||
Attachments: 'attachments',
|
||||
} as const;
|
||||
|
||||
export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType];
|
||||
|
||||
// ============================================
|
||||
// Common
|
||||
// ============================================
|
||||
|
||||
export interface AmountType {
|
||||
value: number;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export interface PostalAddress {
|
||||
streetName?: string;
|
||||
additionalStreetName?: string;
|
||||
buildingNumber?: string;
|
||||
cityName?: string;
|
||||
postalZone?: string;
|
||||
countrySubentity?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
name?: string;
|
||||
telephone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface PartyIdentification {
|
||||
id: string;
|
||||
schemeId?: string;
|
||||
}
|
||||
|
||||
export interface PartyLegalEntity {
|
||||
registrationName: string;
|
||||
companyId?: string;
|
||||
companyIdSchemeId?: string;
|
||||
}
|
||||
|
||||
export interface PartyDto {
|
||||
name: string;
|
||||
identifications: PartyIdentification[];
|
||||
postalAddress?: PostalAddress;
|
||||
legalEntity?: PartyLegalEntity;
|
||||
contact?: Contact;
|
||||
}
|
||||
|
||||
export interface FinancialDimensionRef {
|
||||
dimensionId: string;
|
||||
dimensionValueId: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AllowanceChargeDto {
|
||||
chargeIndicator: boolean;
|
||||
reason?: string;
|
||||
amount: AmountType;
|
||||
taxPercent?: number;
|
||||
}
|
||||
|
||||
export interface TaxTotalDto {
|
||||
taxAmount: AmountType;
|
||||
taxSubtotals?: TaxSubtotalDto[];
|
||||
}
|
||||
|
||||
export interface TaxSubtotalDto {
|
||||
taxableAmount: AmountType;
|
||||
taxAmount: AmountType;
|
||||
taxCategory?: string;
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sales Invoice
|
||||
// ============================================
|
||||
|
||||
export type InvoiceStatusCode = 'draft' | 'sent' | 'booked' | 'paid' | 'overdue' | 'cancelled' | 'credited';
|
||||
|
||||
export interface LegalMonetaryTotalDto {
|
||||
lineExtensionAmount: AmountType;
|
||||
taxExclusiveAmount?: AmountType;
|
||||
taxInclusiveAmount?: AmountType;
|
||||
allowanceTotalAmount?: AmountType;
|
||||
chargeTotalAmount?: AmountType;
|
||||
payableRoundingAmount?: AmountType;
|
||||
payableAmount: AmountType;
|
||||
}
|
||||
|
||||
export interface PaymentStatusDto {
|
||||
paid: boolean;
|
||||
balance: AmountType;
|
||||
lastPaymentDate?: string;
|
||||
}
|
||||
|
||||
export interface SalesInvoiceLineDto {
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unitCode?: string;
|
||||
unitPrice?: AmountType;
|
||||
lineExtensionAmount: AmountType;
|
||||
taxPercent?: number;
|
||||
taxAmount?: AmountType;
|
||||
accountNumber?: string;
|
||||
itemName?: string;
|
||||
articleNumber?: string;
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
}
|
||||
|
||||
export interface SalesInvoiceDto {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
dueDate?: string;
|
||||
deliveryDate?: string;
|
||||
invoiceTypeCode?: string;
|
||||
currencyCode: string;
|
||||
status: InvoiceStatusCode;
|
||||
supplier: PartyDto;
|
||||
customer: PartyDto;
|
||||
lines: SalesInvoiceLineDto[];
|
||||
allowanceCharges?: AllowanceChargeDto[];
|
||||
taxTotal?: TaxTotalDto;
|
||||
legalMonetaryTotal: LegalMonetaryTotalDto;
|
||||
paymentStatus: PaymentStatusDto;
|
||||
paymentTerms?: string;
|
||||
note?: string;
|
||||
buyerReference?: string;
|
||||
orderReference?: string;
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Supplier Invoice
|
||||
// ============================================
|
||||
|
||||
export interface SupplierInvoiceLineDto {
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unitCode?: string;
|
||||
unitPrice?: AmountType;
|
||||
lineExtensionAmount: AmountType;
|
||||
taxPercent?: number;
|
||||
taxAmount?: AmountType;
|
||||
accountNumber?: string;
|
||||
itemName?: string;
|
||||
articleNumber?: string;
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
}
|
||||
|
||||
export interface SupplierInvoiceDto {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
dueDate?: string;
|
||||
deliveryDate?: string;
|
||||
invoiceTypeCode?: string;
|
||||
currencyCode: string;
|
||||
status: InvoiceStatusCode;
|
||||
supplier: PartyDto;
|
||||
buyer: PartyDto;
|
||||
lines: SupplierInvoiceLineDto[];
|
||||
allowanceCharges?: AllowanceChargeDto[];
|
||||
taxTotal?: TaxTotalDto;
|
||||
legalMonetaryTotal: LegalMonetaryTotalDto;
|
||||
paymentStatus: PaymentStatusDto;
|
||||
paymentTerms?: string;
|
||||
note?: string;
|
||||
ocrNumber?: string;
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Customer
|
||||
// ============================================
|
||||
|
||||
export type CustomerType = 'company' | 'private';
|
||||
|
||||
export interface CustomerDto {
|
||||
id: string;
|
||||
customerNumber: string;
|
||||
type?: CustomerType;
|
||||
party: PartyDto;
|
||||
deliveryAddresses?: PostalAddress[];
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
active: boolean;
|
||||
vatNumber?: string;
|
||||
defaultPaymentTermsDays?: number;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Supplier
|
||||
// ============================================
|
||||
|
||||
export interface SupplierDto {
|
||||
id: string;
|
||||
supplierNumber: string;
|
||||
party: PartyDto;
|
||||
deliveryAddresses?: PostalAddress[];
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
active: boolean;
|
||||
vatNumber?: string;
|
||||
bankAccount?: string;
|
||||
bankGiro?: string;
|
||||
plusGiro?: string;
|
||||
defaultPaymentTermsDays?: number;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Journal
|
||||
// ============================================
|
||||
|
||||
export interface AccountingEntryDto {
|
||||
accountNumber: string;
|
||||
accountName?: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
transactionDate?: string;
|
||||
description?: string;
|
||||
financialDimensions?: FinancialDimensionRef[];
|
||||
}
|
||||
|
||||
export interface AccountingSeriesDto {
|
||||
id: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface JournalDto {
|
||||
id: string;
|
||||
journalNumber: string;
|
||||
series?: AccountingSeriesDto;
|
||||
description?: string;
|
||||
registrationDate: string;
|
||||
fiscalYear?: number;
|
||||
entries: AccountingEntryDto[];
|
||||
totalDebit?: AmountType;
|
||||
totalCredit?: AmountType;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Accounting Account
|
||||
// ============================================
|
||||
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'other';
|
||||
|
||||
export interface AccountingAccountDto {
|
||||
accountNumber: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: AccountType;
|
||||
vatCode?: string;
|
||||
active: boolean;
|
||||
balanceBroughtForward?: number;
|
||||
balanceCarriedForward?: number;
|
||||
sruCode?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Company Information
|
||||
// ============================================
|
||||
|
||||
export interface CompanyInformationDto {
|
||||
companyName: string;
|
||||
organizationNumber?: string;
|
||||
legalEntity?: PartyLegalEntity;
|
||||
address?: PostalAddress;
|
||||
contact?: Contact;
|
||||
vatNumber?: string;
|
||||
fiscalYearStart?: string;
|
||||
baseCurrency?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Payment
|
||||
// ============================================
|
||||
|
||||
export type PaymentMethodCode = 'bank_transfer' | 'card' | 'cash' | 'autogiro' | 'bankgiro' | 'plusgiro' | 'swish' | 'other';
|
||||
|
||||
export interface PaymentDto {
|
||||
id: string;
|
||||
paymentNumber?: string;
|
||||
invoiceId: string;
|
||||
paymentDate: string;
|
||||
amount: AmountType;
|
||||
paymentMethod?: PaymentMethodCode;
|
||||
reference?: string;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Accounting Period
|
||||
// ============================================
|
||||
|
||||
export type PeriodStatus = 'open' | 'closed' | 'locked';
|
||||
|
||||
export interface AccountingPeriodDto {
|
||||
id: string;
|
||||
fiscalYear: number;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
status?: PeriodStatus;
|
||||
description?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Financial Dimension
|
||||
// ============================================
|
||||
|
||||
export interface FinancialDimensionValueDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface FinancialDimensionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
values: FinancialDimensionValueDto[];
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Reports
|
||||
// ============================================
|
||||
|
||||
export interface FinancialReportCategoryDto {
|
||||
name: string;
|
||||
amount: AmountType;
|
||||
children?: FinancialReportCategoryDto[];
|
||||
accounts?: { accountNumber: string; name?: string; amount: AmountType }[];
|
||||
}
|
||||
|
||||
export interface BalanceSheetDto {
|
||||
fiscalYear: number;
|
||||
periodEnd: string;
|
||||
baseCurrency: string;
|
||||
assets: FinancialReportCategoryDto;
|
||||
liabilities: FinancialReportCategoryDto;
|
||||
equity: FinancialReportCategoryDto;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IncomeStatementDto {
|
||||
fiscalYear: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
baseCurrency: string;
|
||||
revenue: FinancialReportCategoryDto;
|
||||
expenses: FinancialReportCategoryDto;
|
||||
netIncome: AmountType;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TrialBalanceEntryDto {
|
||||
accountNumber: string;
|
||||
accountName?: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface TrialBalanceDto {
|
||||
fiscalYear: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
baseCurrency: string;
|
||||
entries: TrialBalanceEntryDto[];
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Attachment
|
||||
// ============================================
|
||||
|
||||
export type AttachmentType = 'pdf' | 'image' | 'xml' | 'other';
|
||||
|
||||
export interface AttachmentDto {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
type?: AttachmentType;
|
||||
size?: number;
|
||||
downloadUrl?: string;
|
||||
createdAt?: string;
|
||||
_raw?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
import { withRetry } from '../retry';
|
||||
import { FORTNOX_BASE_URL, FORTNOX_RATE_LIMIT } from './config';
|
||||
|
||||
export class FortnoxApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly body?: string,
|
||||
public readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FortnoxApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof FortnoxApiError) {
|
||||
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
return error.statusCode === 429 || error.statusCode >= 500;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class FortnoxClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? FORTNOX_BASE_URL;
|
||||
this.rateLimiter = new TokenBucketRateLimiter(FORTNOX_RATE_LIMIT, 'ratelimit:fortnox');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
let retryAfterMs: number | undefined;
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
|
||||
}
|
||||
throw new FortnoxApiError(
|
||||
`Fortnox API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
retryAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
{
|
||||
maxAttempts: 6,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 60_000,
|
||||
shouldRetry: isRetryableError,
|
||||
getDelayMs: (error) => {
|
||||
if (error instanceof FortnoxApiError && error.retryAfterMs) {
|
||||
return error.retryAfterMs;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getText(accessToken: string, path: string): Promise<string> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
let retryAfterMs: number | undefined;
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
|
||||
}
|
||||
throw new FortnoxApiError(
|
||||
`Fortnox API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
retryAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
},
|
||||
{
|
||||
maxAttempts: 6,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 60_000,
|
||||
shouldRetry: isRetryableError,
|
||||
getDelayMs: (error) => {
|
||||
if (error instanceof FortnoxApiError && error.retryAfterMs) {
|
||||
return error.retryAfterMs;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
listKey: string,
|
||||
options?: { page?: number; pageSize?: number; lastModified?: string },
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(options?.page ?? 1));
|
||||
if (options?.pageSize) {
|
||||
params.set('limit', String(options.pageSize));
|
||||
}
|
||||
if (options?.lastModified) {
|
||||
params.set('lastmodified', options.lastModified);
|
||||
}
|
||||
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const fullPath = `${path}${separator}${params.toString()}`;
|
||||
|
||||
const response = await this.get<Record<string, unknown>>(accessToken, fullPath);
|
||||
|
||||
const meta = response['MetaInformation'] as
|
||||
| { '@TotalPages': number; '@CurrentPage': number; '@TotalResources': number }
|
||||
| undefined;
|
||||
|
||||
const totalPages = meta?.['@TotalPages'] ?? 1;
|
||||
const currentPage = meta?.['@CurrentPage'] ?? 1;
|
||||
const totalCount = meta?.['@TotalResources'] ?? 0;
|
||||
|
||||
const items = response[listKey];
|
||||
return {
|
||||
items: Array.isArray(items) ? (items as T[]) : [],
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async getPaginated<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
listKey: string,
|
||||
options?: { lastModified?: string; pageSize?: number },
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const result = await this.getPage<T>(accessToken, path, listKey, {
|
||||
page,
|
||||
pageSize: options?.pageSize,
|
||||
lastModified: options?.lastModified,
|
||||
});
|
||||
|
||||
allItems.push(...result.items);
|
||||
totalPages = result.totalPages;
|
||||
page++;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ResourceType } from '../dto';
|
||||
import type { FortnoxResourceConfig, RateLimitConfig } from '../types';
|
||||
import {
|
||||
mapFortnoxToSalesInvoice,
|
||||
mapFortnoxToSupplierInvoice,
|
||||
mapFortnoxToCustomer,
|
||||
mapFortnoxToSupplier,
|
||||
mapFortnoxToJournal,
|
||||
mapFortnoxToAccountingAccount,
|
||||
mapFortnoxToCompanyInformation,
|
||||
} from './mapper';
|
||||
|
||||
export const FORTNOX_BASE_URL = 'https://api.fortnox.se/3';
|
||||
export const FORTNOX_AUTH_URL = 'https://apps.fortnox.se/oauth-v1/auth';
|
||||
export const FORTNOX_TOKEN_URL = 'https://apps.fortnox.se/oauth-v1/token';
|
||||
export const FORTNOX_RATE_LIMIT: RateLimitConfig = { maxRequests: 4, windowMs: 1000 };
|
||||
|
||||
export const FORTNOX_RESOURCE_CONFIGS: Partial<Record<ResourceType, FortnoxResourceConfig>> = {
|
||||
[ResourceType.SalesInvoices]: {
|
||||
listEndpoint: '/invoices',
|
||||
listKey: 'Invoices',
|
||||
detailEndpoint: '/invoices/{id}',
|
||||
detailKey: 'Invoice',
|
||||
idField: 'DocumentNumber',
|
||||
mapper: mapFortnoxToSalesInvoice,
|
||||
supportsLastModified: true,
|
||||
},
|
||||
[ResourceType.SupplierInvoices]: {
|
||||
listEndpoint: '/supplierinvoices',
|
||||
listKey: 'SupplierInvoices',
|
||||
detailEndpoint: '/supplierinvoices/{id}',
|
||||
detailKey: 'SupplierInvoice',
|
||||
idField: 'GivenNumber',
|
||||
mapper: mapFortnoxToSupplierInvoice,
|
||||
supportsLastModified: true,
|
||||
},
|
||||
[ResourceType.Customers]: {
|
||||
listEndpoint: '/customers',
|
||||
listKey: 'Customers',
|
||||
detailEndpoint: '/customers/{id}',
|
||||
detailKey: 'Customer',
|
||||
idField: 'CustomerNumber',
|
||||
mapper: mapFortnoxToCustomer,
|
||||
supportsLastModified: true,
|
||||
},
|
||||
[ResourceType.Suppliers]: {
|
||||
listEndpoint: '/suppliers',
|
||||
listKey: 'Suppliers',
|
||||
detailEndpoint: '/suppliers/{id}',
|
||||
detailKey: 'Supplier',
|
||||
idField: 'SupplierNumber',
|
||||
mapper: mapFortnoxToSupplier,
|
||||
supportsLastModified: true,
|
||||
},
|
||||
[ResourceType.Journals]: {
|
||||
listEndpoint: '/vouchers',
|
||||
listKey: 'Vouchers',
|
||||
detailEndpoint: '/vouchers/{id}',
|
||||
detailKey: 'Voucher',
|
||||
idField: 'VoucherNumber',
|
||||
mapper: mapFortnoxToJournal,
|
||||
supportsLastModified: false,
|
||||
supportsEntryHydration: true,
|
||||
resolveDetailPath: (resourceId, query) => {
|
||||
const dashIdx = resourceId.indexOf('-');
|
||||
const series = dashIdx >= 0 ? resourceId.slice(0, dashIdx) : resourceId;
|
||||
const number = dashIdx >= 0 ? resourceId.slice(dashIdx + 1) : resourceId;
|
||||
const fy = query?.['financialyear'] ?? '';
|
||||
const params = fy ? `?financialyear=${fy}` : '';
|
||||
return `/vouchers/${series}/${number}${params}`;
|
||||
},
|
||||
},
|
||||
[ResourceType.AccountingAccounts]: {
|
||||
listEndpoint: '/accounts',
|
||||
listKey: 'Accounts',
|
||||
detailEndpoint: '/accounts/{id}',
|
||||
detailKey: 'Account',
|
||||
idField: 'Number',
|
||||
mapper: mapFortnoxToAccountingAccount,
|
||||
supportsLastModified: false,
|
||||
},
|
||||
[ResourceType.CompanyInformation]: {
|
||||
listEndpoint: '/companyinformation',
|
||||
listKey: 'CompanyInformation',
|
||||
detailEndpoint: '/companyinformation',
|
||||
detailKey: 'CompanyInformation',
|
||||
idField: 'OrganizationNumber',
|
||||
mapper: mapFortnoxToCompanyInformation,
|
||||
supportsLastModified: false,
|
||||
singleton: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
|
||||
LegalMonetaryTotalDto, PaymentStatusDto,
|
||||
SupplierInvoiceDto, SupplierInvoiceLineDto,
|
||||
CustomerDto, SupplierDto,
|
||||
JournalDto, AccountingEntryDto,
|
||||
AccountingAccountDto, AccountType,
|
||||
CompanyInformationDto,
|
||||
PaymentDto,
|
||||
AmountType, PartyDto,
|
||||
} from '../dto';
|
||||
|
||||
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
|
||||
return { value: value ?? 0, currencyCode: currency };
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
if (raw['Cancelled'] === true) return 'cancelled';
|
||||
if (raw['Credit'] === true) return 'credited';
|
||||
if (raw['FullyPaid'] === true || raw['Balance'] === 0) return 'paid';
|
||||
if (raw['Booked'] === true) return 'booked';
|
||||
if (raw['Sent'] === true) return 'sent';
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
function buildParty(name: string, orgNumber?: string, address?: Record<string, unknown>): PartyDto {
|
||||
return {
|
||||
name,
|
||||
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
|
||||
postalAddress: address ? {
|
||||
streetName: (address['Address1'] ?? address['Address']) as string | undefined,
|
||||
additionalStreetName: address['Address2'] as string | undefined,
|
||||
cityName: (address['City'] ?? address['CityName']) as string | undefined,
|
||||
postalZone: (address['ZipCode'] ?? address['PostalCode']) as string | undefined,
|
||||
countryCode: address['Country'] as string | undefined,
|
||||
} : undefined,
|
||||
legalEntity: orgNumber ? {
|
||||
registrationName: name,
|
||||
companyId: orgNumber,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
} : undefined,
|
||||
contact: {
|
||||
email: (address?.['Email'] ?? address?.['EmailInvoice']) as string | undefined,
|
||||
telephone: address?.['Phone1'] as string | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['Currency'] as string) ?? 'SEK';
|
||||
const total = raw['Total'] as number ?? 0;
|
||||
const balance = raw['Balance'] as number ?? 0;
|
||||
|
||||
const rows = (raw['InvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['RowId'] ?? idx + 1),
|
||||
description: row['Description'] as string | undefined,
|
||||
quantity: row['DeliveredQuantity'] as number | undefined,
|
||||
unitCode: row['Unit'] as string | undefined,
|
||||
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
|
||||
taxPercent: row['VAT'] as number | undefined,
|
||||
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
|
||||
articleNumber: row['ArticleNumber'] as string | undefined,
|
||||
itemName: row['Description'] as string | undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['DocumentNumber'] ?? ''),
|
||||
invoiceNumber: String(raw['DocumentNumber'] ?? ''),
|
||||
issueDate: (raw['InvoiceDate'] as string) ?? '',
|
||||
dueDate: raw['DueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(
|
||||
(raw['CompanyName'] ?? '') as string,
|
||||
raw['OrganisationNumber'] as string | undefined,
|
||||
),
|
||||
customer: buildParty(
|
||||
(raw['CustomerName'] ?? '') as string,
|
||||
raw['OrganisationNumber'] as string | undefined,
|
||||
raw as Record<string, unknown>,
|
||||
),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
paymentTerms: raw['TermsOfPayment'] as string | undefined,
|
||||
note: raw['Remarks'] as string | undefined,
|
||||
buyerReference: raw['YourReference'] as string | undefined,
|
||||
orderReference: raw['YourOrderNumber'] as string | undefined,
|
||||
updatedAt: raw['@LastModified'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
||||
const currency = (raw['Currency'] as string) ?? 'SEK';
|
||||
const total = raw['Total'] as number ?? 0;
|
||||
const balance = raw['Balance'] as number ?? 0;
|
||||
|
||||
const rows = (raw['SupplierInvoiceRows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['RowId'] ?? idx + 1),
|
||||
description: row['Description'] as string | undefined,
|
||||
quantity: row['Quantity'] as number | undefined,
|
||||
unitPrice: row['Price'] != null ? amount(row['Price'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['Total'] as number ?? 0, currency),
|
||||
accountNumber: row['Account'] != null ? String(row['Account']) : undefined,
|
||||
articleNumber: row['ArticleNumber'] as string | undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(raw['Net'] as number ?? total, currency),
|
||||
taxInclusiveAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: balance === 0 && total > 0,
|
||||
balance: amount(balance, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['GivenNumber'] ?? ''),
|
||||
invoiceNumber: String(raw['GivenNumber'] ?? ''),
|
||||
issueDate: (raw['InvoiceDate'] as string) ?? '',
|
||||
dueDate: raw['DueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(
|
||||
(raw['SupplierName'] ?? '') as string,
|
||||
raw['OrganisationNumber'] as string | undefined,
|
||||
),
|
||||
buyer: buildParty(''),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
ocrNumber: raw['OCR'] as string | undefined,
|
||||
updatedAt: raw['@LastModified'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToCustomer(raw: Record<string, unknown>): CustomerDto {
|
||||
const name = (raw['Name'] as string) ?? '';
|
||||
const orgNumber = raw['OrganisationNumber'] as string | undefined;
|
||||
|
||||
return {
|
||||
id: String(raw['CustomerNumber'] ?? ''),
|
||||
customerNumber: String(raw['CustomerNumber'] ?? ''),
|
||||
type: raw['Type'] === 'PRIVATE' ? 'private' : 'company',
|
||||
party: buildParty(name, orgNumber, raw),
|
||||
active: raw['Active'] !== false,
|
||||
vatNumber: raw['VATNumber'] as string | undefined,
|
||||
defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
|
||||
note: raw['Comments'] as string | undefined,
|
||||
updatedAt: raw['@LastModified'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToSupplier(raw: Record<string, unknown>): SupplierDto {
|
||||
const name = (raw['Name'] as string) ?? '';
|
||||
const orgNumber = raw['OrganisationNumber'] as string | undefined;
|
||||
|
||||
return {
|
||||
id: String(raw['SupplierNumber'] ?? ''),
|
||||
supplierNumber: String(raw['SupplierNumber'] ?? ''),
|
||||
party: buildParty(name, orgNumber, raw),
|
||||
active: raw['Active'] !== false,
|
||||
vatNumber: raw['VATNumber'] as string | undefined,
|
||||
bankAccount: raw['BankAccountNumber'] as string | undefined,
|
||||
bankGiro: raw['BG'] as string | undefined,
|
||||
plusGiro: raw['PG'] as string | undefined,
|
||||
defaultPaymentTermsDays: raw['TermsOfPayment'] != null ? Number(raw['TermsOfPayment']) : undefined,
|
||||
note: raw['Comments'] as string | undefined,
|
||||
updatedAt: raw['@LastModified'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToJournal(raw: Record<string, unknown>): JournalDto {
|
||||
const voucherRows = (raw['VoucherRows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const entries: AccountingEntryDto[] = voucherRows.map((row) => ({
|
||||
accountNumber: String(row['Account'] ?? ''),
|
||||
accountName: row['AccountDescription'] as string | undefined,
|
||||
debit: (row['Debit'] as number) ?? 0,
|
||||
credit: (row['Credit'] as number) ?? 0,
|
||||
transactionDate: row['TransactionDate'] as string | undefined,
|
||||
description: row['Description'] as string | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: `${raw['VoucherSeries'] ?? ''}-${raw['VoucherNumber'] ?? ''}`,
|
||||
journalNumber: String(raw['VoucherNumber'] ?? ''),
|
||||
series: raw['VoucherSeries'] ? {
|
||||
id: String(raw['VoucherSeries']),
|
||||
description: raw['VoucherSeriesDescription'] as string | undefined,
|
||||
} : undefined,
|
||||
description: raw['Description'] as string | undefined,
|
||||
registrationDate: (raw['TransactionDate'] as string) ?? '',
|
||||
fiscalYear: raw['Year'] != null ? Number(raw['Year']) : undefined,
|
||||
entries,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
let type: AccountType | undefined;
|
||||
const num = Number(raw['Number']);
|
||||
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['Number'] ?? ''),
|
||||
name: (raw['Description'] as string) ?? '',
|
||||
type,
|
||||
vatCode: raw['VATCode'] as string | undefined,
|
||||
active: raw['Active'] !== false,
|
||||
balanceBroughtForward: raw['BalanceBroughtForward'] as number | undefined,
|
||||
balanceCarriedForward: raw['BalanceCarriedForward'] as number | undefined,
|
||||
sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
|
||||
return {
|
||||
companyName: (raw['CompanyName'] as string) ?? '',
|
||||
organizationNumber: raw['OrganizationNumber'] as string | undefined,
|
||||
legalEntity: {
|
||||
registrationName: (raw['CompanyName'] as string) ?? '',
|
||||
companyId: raw['OrganizationNumber'] as string | undefined,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
},
|
||||
address: {
|
||||
streetName: raw['Address'] as string | undefined,
|
||||
cityName: raw['City'] as string | undefined,
|
||||
postalZone: raw['ZipCode'] as string | undefined,
|
||||
countryCode: raw['Country'] as string | undefined,
|
||||
},
|
||||
contact: {
|
||||
email: raw['Email'] as string | undefined,
|
||||
telephone: raw['Phone1'] as string | undefined,
|
||||
website: raw['WWW'] as string | undefined,
|
||||
},
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFortnoxToPayment(raw: Record<string, unknown>, invoiceId?: string): PaymentDto {
|
||||
return {
|
||||
id: String(raw['Number'] ?? ''),
|
||||
paymentNumber: String(raw['Number'] ?? ''),
|
||||
invoiceId: invoiceId ?? String(raw['InvoiceNumber'] ?? ''),
|
||||
paymentDate: (raw['PaymentDate'] as string) ?? '',
|
||||
amount: amount(raw['Amount'] as number ?? 0, (raw['Currency'] as string) ?? 'SEK'),
|
||||
reference: raw['Reference'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FORTNOX_AUTH_URL, FORTNOX_TOKEN_URL } from './config';
|
||||
import type { OAuthConfig, TokenResponse } from '../types';
|
||||
|
||||
const DEFAULT_SCOPES = [
|
||||
'companyinformation',
|
||||
'invoice',
|
||||
'supplierinvoice',
|
||||
'customer',
|
||||
'supplier',
|
||||
'bookkeeping',
|
||||
];
|
||||
|
||||
export function buildFortnoxAuthUrl(
|
||||
config: OAuthConfig,
|
||||
options?: { scopes?: string[]; state?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
response_type: 'code',
|
||||
access_type: 'offline',
|
||||
});
|
||||
|
||||
const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES;
|
||||
params.set('scope', scopes.join(' '));
|
||||
|
||||
if (options?.state) {
|
||||
params.set('state', options.state);
|
||||
}
|
||||
|
||||
return `${FORTNOX_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function basicAuthHeader(config: OAuthConfig): string {
|
||||
const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
|
||||
return `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
export async function exchangeFortnoxCode(
|
||||
config: OAuthConfig,
|
||||
code: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(FORTNOX_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Fortnox token exchange failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
export async function refreshFortnoxToken(
|
||||
config: OAuthConfig,
|
||||
refreshToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(FORTNOX_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Fortnox token refresh failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
export async function revokeFortnoxToken(
|
||||
config: OAuthConfig,
|
||||
refreshToken: string,
|
||||
): Promise<boolean> {
|
||||
const response = await fetch(FORTNOX_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { OAuthConfig } from './types';
|
||||
|
||||
export function getOAuthConfig(provider: string): OAuthConfig {
|
||||
if (provider === 'fortnox') {
|
||||
return {
|
||||
clientId: process.env.FORTNOX_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.FORTNOX_CLIENT_SECRET ?? '',
|
||||
redirectUri: process.env.FORTNOX_REDIRECT_URI ?? '',
|
||||
};
|
||||
}
|
||||
if (provider === 'visma') {
|
||||
return {
|
||||
clientId: process.env.VISMA_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.VISMA_CLIENT_SECRET ?? '',
|
||||
redirectUri: process.env.VISMA_REDIRECT_URI ?? '',
|
||||
};
|
||||
}
|
||||
if (provider === 'briox') {
|
||||
return {
|
||||
clientId: process.env.BRIOX_CLIENT_ID ?? '',
|
||||
clientSecret: '',
|
||||
redirectUri: '',
|
||||
};
|
||||
}
|
||||
if (provider === 'bokio') {
|
||||
return {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
redirectUri: '',
|
||||
};
|
||||
}
|
||||
if (provider === 'bjornlunden') {
|
||||
return {
|
||||
clientId: process.env.BJORN_LUNDEN_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.BJORN_LUNDEN_CLIENT_SECRET ?? '',
|
||||
redirectUri: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
export function validateProvider(provider: string): boolean {
|
||||
return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden';
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
CompanyInformationDto,
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
SalesInvoiceDto,
|
||||
SupplierInvoiceDto,
|
||||
} from './dto';
|
||||
import type { ProviderName } from './types';
|
||||
|
||||
import { FortnoxClient } from './fortnox/client';
|
||||
import { FORTNOX_RESOURCE_CONFIGS } from './fortnox/config';
|
||||
import { VismaClient } from './visma/client';
|
||||
import { VISMA_RESOURCE_CONFIGS } from './visma/config';
|
||||
import { BrioxClient } from './briox/client';
|
||||
import { BRIOX_RESOURCE_CONFIGS } from './briox/config';
|
||||
import { BokioClient } from './bokio/client';
|
||||
import { BOKIO_RESOURCE_CONFIGS } from './bokio/config';
|
||||
import { BjornLundenClient } from './bjornlunden/client';
|
||||
import { BL_RESOURCE_CONFIGS } from './bjornlunden/config';
|
||||
import { ResourceType } from './dto';
|
||||
|
||||
// Singleton clients (they hold rate limiters)
|
||||
const fortnoxClient = new FortnoxClient();
|
||||
const vismaClient = new VismaClient();
|
||||
const brioxClient = new BrioxClient();
|
||||
const bokioClient = new BokioClient();
|
||||
const bjornLundenClient = new BjornLundenClient();
|
||||
|
||||
// ── Helper to paginate Bokio (uses getPage with companyId) ──────────
|
||||
|
||||
async function bokioPaginate<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
path: string,
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const result = await bokioClient.getPage<T>(accessToken, companyId, path, { page });
|
||||
allItems.push(...result.items);
|
||||
totalPages = result.totalPages;
|
||||
page++;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
// ── Helper to paginate BjornLunden (uses getPage with userKey) ──────
|
||||
|
||||
async function blPaginate<T>(
|
||||
accessToken: string,
|
||||
userKey: string,
|
||||
path: string,
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const result = await bjornLundenClient.getPage<T>(accessToken, userKey, path, { page });
|
||||
allItems.push(...result.items);
|
||||
totalPages = result.totalPages;
|
||||
page++;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
// ── Public fetch functions ──────────────────────────────────────────
|
||||
|
||||
export async function fetchCompanyInfoDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<CompanyInformationDto | null> {
|
||||
try {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await fortnoxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
const data = response[config.detailKey];
|
||||
return data ? config.mapper(data as Record<string, unknown>) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await vismaClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
const response = await brioxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation];
|
||||
if (!config || !providerCompanyId) return null;
|
||||
const response = await bokioClient.getCompany<Record<string, unknown>>(accessToken, providerCompanyId);
|
||||
return response ? config.mapper(response) as CompanyInformationDto : null;
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
||||
if (!providerCompanyId) return null;
|
||||
const response = await bjornLundenClient.get<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return config.mapper(response) as CompanyInformationDto;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[provider-data-fetcher] Failed to fetch company info from ${provider}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCustomersDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<CustomerDto[]> {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
||||
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
||||
accessToken, config.listEndpoint, config.listKey,
|
||||
);
|
||||
return items.map((item) => config.mapper(item) as CustomerDto);
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
||||
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as CustomerDto);
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
||||
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
||||
return items.map((item) => config.mapper(item) as CustomerDto);
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Customers];
|
||||
if (!config || !providerCompanyId) return [];
|
||||
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as CustomerDto);
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
||||
if (!providerCompanyId) return [];
|
||||
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as CustomerDto);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchSuppliersDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<SupplierDto[]> {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
||||
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
||||
accessToken, config.listEndpoint, config.listKey,
|
||||
);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
||||
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
||||
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Suppliers];
|
||||
if (!config || !providerCompanyId) return [];
|
||||
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
||||
if (!providerCompanyId) return [];
|
||||
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierDto);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchSalesInvoicesDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<SalesInvoiceDto[]> {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
||||
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
||||
accessToken, config.listEndpoint, config.listKey,
|
||||
);
|
||||
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
||||
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
||||
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
||||
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SalesInvoices];
|
||||
if (!config || !providerCompanyId) return [];
|
||||
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
||||
if (!providerCompanyId) return [];
|
||||
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchSupplierInvoicesDirect(
|
||||
provider: ProviderName,
|
||||
accessToken: string,
|
||||
providerCompanyId?: string,
|
||||
): Promise<SupplierInvoiceDto[]> {
|
||||
if (provider === 'fortnox') {
|
||||
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
||||
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
||||
accessToken, config.listEndpoint, config.listKey,
|
||||
);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'visma') {
|
||||
const config = VISMA_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
||||
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'briox') {
|
||||
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
||||
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'bokio') {
|
||||
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SupplierInvoices];
|
||||
if (!config || !providerCompanyId) return [];
|
||||
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
}
|
||||
|
||||
if (provider === 'bjornlunden') {
|
||||
const config = BL_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
||||
if (!providerCompanyId) return [];
|
||||
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
||||
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Ratelimit } from '@upstash/ratelimit';
|
||||
import { Redis } from '@upstash/redis';
|
||||
import type { RateLimitConfig } from './types';
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
if (redis) return redis;
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
if (!url || !token) return null;
|
||||
redis = new Redis({ url, token });
|
||||
return redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributed rate limiter backed by Upstash Redis.
|
||||
* Falls back to in-memory token bucket when Upstash env vars are not set (local dev).
|
||||
*/
|
||||
export class TokenBucketRateLimiter {
|
||||
private readonly upstashLimiter: Ratelimit | null;
|
||||
|
||||
// In-memory fallback fields
|
||||
private tokens: number;
|
||||
private lastRefill: number;
|
||||
private readonly maxTokens: number;
|
||||
private readonly refillRateMs: number;
|
||||
|
||||
constructor(config: RateLimitConfig, prefix?: string) {
|
||||
this.maxTokens = config.maxRequests;
|
||||
this.tokens = config.maxRequests;
|
||||
this.refillRateMs = config.windowMs / config.maxRequests;
|
||||
this.lastRefill = Date.now();
|
||||
|
||||
const redisClient = getRedis();
|
||||
if (redisClient) {
|
||||
this.upstashLimiter = new Ratelimit({
|
||||
redis: redisClient,
|
||||
limiter: Ratelimit.slidingWindow(config.maxRequests, `${config.windowMs} ms`),
|
||||
prefix: prefix ?? 'ratelimit',
|
||||
});
|
||||
} else {
|
||||
this.upstashLimiter = null;
|
||||
}
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.upstashLimiter) {
|
||||
return this.acquireDistributed();
|
||||
}
|
||||
return this.acquireLocal();
|
||||
}
|
||||
|
||||
private async acquireDistributed(): Promise<void> {
|
||||
const { success, reset } = await this.upstashLimiter!.limit('global');
|
||||
if (success) return;
|
||||
|
||||
// Wait until the window resets, then retry
|
||||
const waitMs = Math.max(0, reset - Date.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
// Retry once after waiting
|
||||
const retry = await this.upstashLimiter!.limit('global');
|
||||
if (!retry.success) {
|
||||
// Still limited — wait for the new reset
|
||||
const retryWait = Math.max(0, retry.reset - Date.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, retryWait));
|
||||
}
|
||||
}
|
||||
|
||||
private refill(): void {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastRefill;
|
||||
const newTokens = Math.floor(elapsed / this.refillRateMs);
|
||||
if (newTokens > 0) {
|
||||
this.tokens = Math.min(this.maxTokens, this.tokens + newTokens);
|
||||
this.lastRefill = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireLocal(): Promise<void> {
|
||||
this.refill();
|
||||
if (this.tokens > 0) {
|
||||
this.tokens--;
|
||||
return;
|
||||
}
|
||||
|
||||
const waitMs = this.refillRateMs - (Date.now() - this.lastRefill);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, waitMs)));
|
||||
this.refill();
|
||||
if (this.tokens > 0) {
|
||||
this.tokens--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server';
|
||||
import type { TokenResponse } from './types';
|
||||
import { getOAuthConfig } from './oauth-config';
|
||||
import { refreshFortnoxToken } from './fortnox/oauth';
|
||||
import { refreshVismaToken } from './visma/oauth';
|
||||
import { refreshBrioxToken } from './briox/oauth';
|
||||
import { refreshBjornLundenToken } from './bjornlunden/oauth';
|
||||
|
||||
export interface ResolvedConsent {
|
||||
consent: Record<string, unknown>;
|
||||
accessToken: string;
|
||||
providerCompanyId?: string;
|
||||
}
|
||||
|
||||
export async function resolveConsent(companyId: string, consentId: string): Promise<ResolvedConsent> {
|
||||
const supabase = createServiceClient();
|
||||
|
||||
// Load consent
|
||||
const { data: consentRows } = await supabase
|
||||
.from('provider_consents')
|
||||
.select('*')
|
||||
.eq('id', consentId)
|
||||
.eq('company_id', companyId)
|
||||
.limit(1);
|
||||
|
||||
if (!consentRows || consentRows.length === 0) {
|
||||
throw { status: 404, message: 'Consent not found' };
|
||||
}
|
||||
|
||||
const consent = consentRows[0]!;
|
||||
if (consent.status !== 1) {
|
||||
throw { status: 403, message: 'Consent is not in Accepted status' };
|
||||
}
|
||||
|
||||
if (!consent.provider) {
|
||||
throw { status: 400, message: 'Consent has no provider set — complete onboarding first' };
|
||||
}
|
||||
|
||||
// Load tokens
|
||||
const { data: tokenRows } = await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.select('*')
|
||||
.eq('consent_id', consentId)
|
||||
.limit(1);
|
||||
|
||||
if (!tokenRows || tokenRows.length === 0) {
|
||||
throw { status: 401, message: 'No tokens found for this consent — complete OAuth first' };
|
||||
}
|
||||
|
||||
const tokens = tokenRows[0]!;
|
||||
|
||||
// Bokio: private API tokens that don't expire
|
||||
if (consent.provider === 'bokio') {
|
||||
return {
|
||||
consent,
|
||||
accessToken: tokens.access_token as string,
|
||||
providerCompanyId: tokens.provider_company_id as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Björn Lunden: client credentials — auto-refresh when expired
|
||||
if (consent.provider === 'bjornlunden') {
|
||||
if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) {
|
||||
const refreshed = await refreshBjornLundenToken();
|
||||
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
|
||||
|
||||
await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.update({
|
||||
access_token: refreshed.access_token,
|
||||
token_expires_at: newExpiresAt,
|
||||
})
|
||||
.eq('consent_id', consentId);
|
||||
|
||||
return {
|
||||
consent,
|
||||
accessToken: refreshed.access_token,
|
||||
providerCompanyId: tokens.provider_company_id as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
consent,
|
||||
accessToken: tokens.access_token as string,
|
||||
providerCompanyId: tokens.provider_company_id as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Check expiry, auto-refresh if needed
|
||||
if (tokens.token_expires_at && new Date(tokens.token_expires_at as string) < new Date()) {
|
||||
if (!tokens.refresh_token) {
|
||||
throw { status: 401, message: 'Access token expired and no refresh token available' };
|
||||
}
|
||||
|
||||
const config = getOAuthConfig(consent.provider as string);
|
||||
let refreshed: TokenResponse;
|
||||
|
||||
if (consent.provider === 'fortnox') {
|
||||
refreshed = await refreshFortnoxToken(config, tokens.refresh_token as string);
|
||||
} else if (consent.provider === 'briox') {
|
||||
refreshed = await refreshBrioxToken(config.clientId, tokens.refresh_token as string);
|
||||
} else {
|
||||
refreshed = await refreshVismaToken(config, tokens.refresh_token as string);
|
||||
}
|
||||
|
||||
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
|
||||
|
||||
await supabase
|
||||
.from('provider_consent_tokens')
|
||||
.update({
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
token_expires_at: newExpiresAt,
|
||||
})
|
||||
.eq('consent_id', consentId);
|
||||
|
||||
return { consent, accessToken: refreshed.access_token };
|
||||
}
|
||||
|
||||
return { consent, accessToken: tokens.access_token as string };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface RetryOptions {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffMultiplier?: number;
|
||||
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
||||
/** Return a custom delay in ms for this error, or undefined to use default backoff. */
|
||||
getDelayMs?: (error: unknown, attempt: number) => number | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30_000,
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: RetryOptions,
|
||||
): Promise<T> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === opts.maxAttempts) break;
|
||||
|
||||
if (options?.shouldRetry && !options.shouldRetry(error, attempt)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const customDelay = options?.getDelayMs?.(error, attempt);
|
||||
const delay = customDelay ?? Math.min(
|
||||
opts.initialDelayMs * opts.backoffMultiplier ** (attempt - 1),
|
||||
opts.maxDelayMs,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ResourceType } from './dto';
|
||||
|
||||
export type ProviderName = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden';
|
||||
|
||||
export interface RateLimitConfig {
|
||||
maxRequests: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
listEndpoint: string;
|
||||
detailEndpoint: string;
|
||||
idField: string;
|
||||
mapper: (raw: Record<string, unknown>) => unknown;
|
||||
singleton?: boolean;
|
||||
}
|
||||
|
||||
export interface FortnoxResourceConfig extends ResourceConfig {
|
||||
listKey: string;
|
||||
detailKey: string;
|
||||
supportsLastModified: boolean;
|
||||
resolveDetailPath?: (resourceId: string, query?: Record<string, string>) => string;
|
||||
supportsEntryHydration?: boolean;
|
||||
}
|
||||
|
||||
export interface VismaResourceConfig extends ResourceConfig {
|
||||
supportsModifiedFilter: boolean;
|
||||
modifiedField?: string;
|
||||
}
|
||||
|
||||
export interface BrioxResourceConfig extends ResourceConfig {
|
||||
listKey: string;
|
||||
supportsModifiedFilter: boolean;
|
||||
yearScoped?: boolean;
|
||||
supportsEntryHydration?: boolean;
|
||||
detailKey?: string;
|
||||
}
|
||||
|
||||
export interface BokioResourceConfig extends ResourceConfig {
|
||||
paginated?: boolean;
|
||||
}
|
||||
|
||||
export interface BjornLundenResourceConfig extends ResourceConfig {
|
||||
paginated?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { TokenBucketRateLimiter } from '../rate-limiter';
|
||||
import { withRetry } from '../retry';
|
||||
import { VISMA_BASE_URL, VISMA_RATE_LIMIT } from './config';
|
||||
|
||||
export class VismaApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'VismaApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof VismaApiError) {
|
||||
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
||||
return false;
|
||||
}
|
||||
return error.statusCode === 429 || error.statusCode >= 500;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface VismaPaginatedResponse<T> {
|
||||
Meta?: { TotalNumberOfPages?: number };
|
||||
Data: T[];
|
||||
}
|
||||
|
||||
export class VismaClient {
|
||||
private readonly rateLimiter: TokenBucketRateLimiter;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl ?? VISMA_BASE_URL;
|
||||
this.rateLimiter = new TokenBucketRateLimiter(VISMA_RATE_LIMIT, 'ratelimit:visma');
|
||||
}
|
||||
|
||||
async get<T>(accessToken: string, path: string): Promise<T> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
await this.rateLimiter.acquire();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new VismaApiError(
|
||||
`Visma API error: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
shouldRetry: isRetryableError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getPage<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
options?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
modifiedSince?: string;
|
||||
modifiedField?: string;
|
||||
},
|
||||
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
|
||||
const pageSize = options?.pageSize ?? 100;
|
||||
const page = options?.page ?? 1;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('$top', String(pageSize));
|
||||
params.set('$skip', String((page - 1) * pageSize));
|
||||
|
||||
if (options?.modifiedSince && options?.modifiedField) {
|
||||
params.set(
|
||||
'$filter',
|
||||
`${options.modifiedField} gt ${options.modifiedSince}`,
|
||||
);
|
||||
}
|
||||
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
const fullPath = `${path}${separator}${params.toString()}`;
|
||||
|
||||
const response = await this.get<VismaPaginatedResponse<T> & { Meta?: { TotalNumberOfResults?: number; TotalNumberOfPages?: number } }>(accessToken, fullPath);
|
||||
|
||||
const totalPages = response.Meta?.TotalNumberOfPages ?? 1;
|
||||
const totalCount = response.Meta?.TotalNumberOfResults ?? 0;
|
||||
|
||||
return {
|
||||
items: Array.isArray(response.Data) ? response.Data : [],
|
||||
page,
|
||||
totalPages,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async getPaginated<T>(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
options?: {
|
||||
modifiedSince?: string;
|
||||
modifiedField?: string;
|
||||
pageSize?: number;
|
||||
},
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const result = await this.getPage<T>(accessToken, path, {
|
||||
page,
|
||||
pageSize: options?.pageSize,
|
||||
modifiedSince: options?.modifiedSince,
|
||||
modifiedField: options?.modifiedField,
|
||||
});
|
||||
|
||||
allItems.push(...result.items);
|
||||
totalPages = result.totalPages;
|
||||
page++;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ResourceType } from '../dto';
|
||||
import type { VismaResourceConfig, RateLimitConfig } from '../types';
|
||||
import {
|
||||
mapVismaToSalesInvoice,
|
||||
mapVismaToSupplierInvoice,
|
||||
mapVismaToCustomer,
|
||||
mapVismaToSupplier,
|
||||
mapVismaToJournal,
|
||||
mapVismaToAccountingAccount,
|
||||
mapVismaToCompanyInformation,
|
||||
} from './mapper';
|
||||
|
||||
export const VISMA_BASE_URL = 'https://eaccountingapi.vismaonline.com/v2';
|
||||
export const VISMA_AUTH_URL = 'https://identity.vismaonline.com/connect/authorize';
|
||||
export const VISMA_TOKEN_URL = 'https://identity.vismaonline.com/connect/token';
|
||||
export const VISMA_REVOKE_URL = 'https://identity.vismaonline.com/connect/revocation';
|
||||
export const VISMA_RATE_LIMIT: RateLimitConfig = { maxRequests: 10, windowMs: 1000 };
|
||||
|
||||
export const VISMA_RESOURCE_CONFIGS: Partial<Record<ResourceType, VismaResourceConfig>> = {
|
||||
[ResourceType.SalesInvoices]: {
|
||||
listEndpoint: '/customerinvoices',
|
||||
detailEndpoint: '/customerinvoices/{id}',
|
||||
idField: 'Id',
|
||||
mapper: mapVismaToSalesInvoice,
|
||||
supportsModifiedFilter: true,
|
||||
modifiedField: 'ModifiedUtc',
|
||||
},
|
||||
[ResourceType.SupplierInvoices]: {
|
||||
listEndpoint: '/supplierinvoices',
|
||||
detailEndpoint: '/supplierinvoices/{id}',
|
||||
idField: 'Id',
|
||||
mapper: mapVismaToSupplierInvoice,
|
||||
supportsModifiedFilter: true,
|
||||
modifiedField: 'ModifiedUtc',
|
||||
},
|
||||
[ResourceType.Customers]: {
|
||||
listEndpoint: '/customers',
|
||||
detailEndpoint: '/customers/{id}',
|
||||
idField: 'Id',
|
||||
mapper: mapVismaToCustomer,
|
||||
supportsModifiedFilter: true,
|
||||
modifiedField: 'ChangedUtc',
|
||||
},
|
||||
[ResourceType.Suppliers]: {
|
||||
listEndpoint: '/suppliers',
|
||||
detailEndpoint: '/suppliers/{id}',
|
||||
idField: 'Id',
|
||||
mapper: mapVismaToSupplier,
|
||||
supportsModifiedFilter: true,
|
||||
modifiedField: 'ModifiedUtc',
|
||||
},
|
||||
[ResourceType.Journals]: {
|
||||
listEndpoint: '/vouchers',
|
||||
detailEndpoint: '/vouchers/{id}',
|
||||
idField: 'Id',
|
||||
mapper: mapVismaToJournal,
|
||||
supportsModifiedFilter: false,
|
||||
},
|
||||
[ResourceType.AccountingAccounts]: {
|
||||
listEndpoint: '/accounts',
|
||||
detailEndpoint: '/accounts/{id}',
|
||||
idField: 'Number',
|
||||
mapper: mapVismaToAccountingAccount,
|
||||
supportsModifiedFilter: false,
|
||||
},
|
||||
[ResourceType.CompanyInformation]: {
|
||||
listEndpoint: '/companysettings',
|
||||
detailEndpoint: '/companysettings',
|
||||
idField: 'CorporateIdentityNumber',
|
||||
mapper: mapVismaToCompanyInformation,
|
||||
supportsModifiedFilter: false,
|
||||
singleton: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
|
||||
LegalMonetaryTotalDto, PaymentStatusDto,
|
||||
SupplierInvoiceDto, SupplierInvoiceLineDto,
|
||||
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 };
|
||||
}
|
||||
|
||||
function deriveInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
|
||||
const remaining = raw['RemainingAmount'] as number ?? 0;
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
if (raw['IsCancelled'] === true) return 'cancelled';
|
||||
if (remaining === 0 && total > 0) return 'paid';
|
||||
if (raw['IsBooked'] === true) return 'booked';
|
||||
if (raw['IsSent'] === true || raw['SendType'] != null) return 'sent';
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
function buildParty(name: string, orgNumber?: string, raw?: Record<string, unknown>): PartyDto {
|
||||
return {
|
||||
name,
|
||||
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
|
||||
postalAddress: raw ? {
|
||||
streetName: (raw['InvoiceAddress1'] ?? raw['Address1']) as string | undefined,
|
||||
additionalStreetName: (raw['InvoiceAddress2'] ?? raw['Address2']) as string | undefined,
|
||||
cityName: (raw['InvoiceCity'] ?? raw['City']) as string | undefined,
|
||||
postalZone: (raw['InvoicePostalCode'] ?? raw['PostalCode']) as string | undefined,
|
||||
countryCode: raw['CountryCode'] as string | undefined,
|
||||
} : undefined,
|
||||
legalEntity: orgNumber ? {
|
||||
registrationName: name,
|
||||
companyId: orgNumber,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
} : undefined,
|
||||
contact: {
|
||||
email: (raw?.['EmailAddress'] ?? raw?.['Email']) as string | undefined,
|
||||
telephone: (raw?.['Telephone'] ?? raw?.['Phone']) as string | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
|
||||
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
const remaining = raw['RemainingAmount'] as number ?? 0;
|
||||
|
||||
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => ({
|
||||
id: String(row['LineNumber'] ?? idx + 1),
|
||||
description: row['Text'] as string | undefined,
|
||||
quantity: row['Quantity'] as number | undefined,
|
||||
unitCode: row['UnitAbbreviation'] as string | undefined,
|
||||
unitPrice: row['UnitPrice'] != null ? amount(row['UnitPrice'] as number, currency) : undefined,
|
||||
lineExtensionAmount: amount(row['LineTotal'] as number ?? 0, currency),
|
||||
taxPercent: row['VatRatePercent'] as number | undefined,
|
||||
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
|
||||
articleNumber: row['ArticleNumber'] as string | undefined,
|
||||
}));
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: remaining === 0 && total > 0,
|
||||
balance: amount(remaining, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['Id'] ?? ''),
|
||||
invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
|
||||
issueDate: (raw['InvoiceDate'] as string) ?? '',
|
||||
dueDate: raw['DueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty(''),
|
||||
customer: buildParty(
|
||||
(raw['InvoiceCustomerName'] ?? '') as string,
|
||||
undefined,
|
||||
),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
createdAt: raw['CreatedUtc'] as string | undefined,
|
||||
updatedAt: raw['ModifiedUtc'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToSupplierInvoice(raw: Record<string, unknown>): SupplierInvoiceDto {
|
||||
const currency = (raw['CurrencyCode'] as string) ?? 'SEK';
|
||||
const total = raw['TotalAmount'] as number ?? 0;
|
||||
const remaining = raw['RemainingAmount'] as number ?? 0;
|
||||
|
||||
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const lines: SupplierInvoiceLineDto[] = rows.map((row, idx) => {
|
||||
const debit = (row['DebetAmount'] as number) ?? 0;
|
||||
const credit = (row['CreditAmount'] as number) ?? 0;
|
||||
const rowAmount = debit || credit;
|
||||
return {
|
||||
id: String(row['LineNumber'] ?? idx + 1),
|
||||
description: row['TransactionText'] as string | undefined,
|
||||
quantity: row['Quantity'] as number | undefined,
|
||||
lineExtensionAmount: amount(rowAmount, currency),
|
||||
accountNumber: row['AccountNumber'] != null ? String(row['AccountNumber']) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const legalMonetaryTotal: LegalMonetaryTotalDto = {
|
||||
lineExtensionAmount: amount(total, currency),
|
||||
payableAmount: amount(total, currency),
|
||||
};
|
||||
|
||||
const paymentStatus: PaymentStatusDto = {
|
||||
paid: remaining === 0 && total > 0,
|
||||
balance: amount(remaining, currency),
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(raw['Id'] ?? ''),
|
||||
invoiceNumber: String(raw['InvoiceNumber'] ?? ''),
|
||||
issueDate: (raw['InvoiceDate'] as string) ?? '',
|
||||
dueDate: raw['DueDate'] as string | undefined,
|
||||
currencyCode: currency,
|
||||
status: deriveInvoiceStatus(raw),
|
||||
supplier: buildParty((raw['SupplierName'] ?? '') as string),
|
||||
buyer: buildParty(''),
|
||||
lines,
|
||||
legalMonetaryTotal,
|
||||
paymentStatus,
|
||||
updatedAt: raw['ModifiedUtc'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToCustomer(raw: Record<string, unknown>): CustomerDto {
|
||||
return {
|
||||
id: String(raw['Id'] ?? ''),
|
||||
customerNumber: String(raw['CustomerNumber'] ?? ''),
|
||||
type: raw['IsPrivatePerson'] === true ? 'private' : 'company',
|
||||
party: buildParty(
|
||||
(raw['Name'] as string) ?? '',
|
||||
raw['CorporateIdentityNumber'] as string | undefined,
|
||||
raw,
|
||||
),
|
||||
active: raw['IsActive'] !== false,
|
||||
note: raw['Note'] as string | undefined,
|
||||
updatedAt: raw['ChangedUtc'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToSupplier(raw: Record<string, unknown>): SupplierDto {
|
||||
return {
|
||||
id: String(raw['Id'] ?? ''),
|
||||
supplierNumber: String(raw['SupplierNumber'] ?? ''),
|
||||
party: buildParty(
|
||||
(raw['Name'] as string) ?? '',
|
||||
raw['CorporateIdentityNumber'] as string | undefined,
|
||||
raw,
|
||||
),
|
||||
active: raw['IsActive'] !== false,
|
||||
bankAccount: raw['BankAccountNumber'] as string | undefined,
|
||||
bankGiro: raw['BankGiro'] as string | undefined,
|
||||
plusGiro: raw['PlusGiro'] as string | undefined,
|
||||
updatedAt: raw['ModifiedUtc'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
|
||||
const num = Number(raw['Number']);
|
||||
let type: AccountType | undefined;
|
||||
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['Number'] ?? ''),
|
||||
name: (raw['Name'] as string) ?? '',
|
||||
type,
|
||||
vatCode: raw['VatCodeId'] != null ? String(raw['VatCodeId']) : undefined,
|
||||
active: raw['IsActive'] !== false,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
|
||||
return {
|
||||
companyName: (raw['Name'] as string) ?? '',
|
||||
organizationNumber: raw['CorporateIdentityNumber'] as string | undefined,
|
||||
legalEntity: {
|
||||
registrationName: (raw['Name'] as string) ?? '',
|
||||
companyId: raw['CorporateIdentityNumber'] as string | undefined,
|
||||
companyIdSchemeId: 'SE:ORGNR',
|
||||
},
|
||||
address: {
|
||||
streetName: raw['Address1'] as string | undefined,
|
||||
cityName: raw['City'] as string | undefined,
|
||||
postalZone: raw['PostalCode'] as string | undefined,
|
||||
countryCode: raw['CountryCode'] as string | undefined,
|
||||
},
|
||||
contact: {
|
||||
email: raw['Email'] as string | undefined,
|
||||
telephone: raw['Phone'] as string | undefined,
|
||||
},
|
||||
baseCurrency: raw['CurrencyCode'] as string | undefined,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVismaToJournal(raw: Record<string, unknown>): JournalDto {
|
||||
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
|
||||
const entries: AccountingEntryDto[] = rows.map((row) => ({
|
||||
accountNumber: String(row['AccountNumber'] ?? ''),
|
||||
accountName: row['AccountName'] as string | undefined,
|
||||
debit: (row['DebitAmount'] as number) ?? 0,
|
||||
credit: (row['CreditAmount'] as number) ?? 0,
|
||||
description: row['Description'] as string | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(raw['Id'] ?? ''),
|
||||
journalNumber: String(raw['VoucherNumber'] ?? raw['Number'] ?? ''),
|
||||
description: raw['Description'] as string | undefined,
|
||||
registrationDate: (raw['VoucherDate'] as string) ?? '',
|
||||
entries,
|
||||
_raw: raw,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { VISMA_AUTH_URL, VISMA_TOKEN_URL, VISMA_REVOKE_URL } from './config';
|
||||
import type { OAuthConfig, TokenResponse } from '../types';
|
||||
|
||||
const DEFAULT_SCOPES = [
|
||||
'ea:api',
|
||||
'offline_access',
|
||||
'ea:sales_readonly',
|
||||
'ea:accounting_readonly',
|
||||
'ea:purchase_readonly',
|
||||
];
|
||||
|
||||
const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934';
|
||||
|
||||
export function buildVismaAuthUrl(
|
||||
config: OAuthConfig,
|
||||
options?: { scopes?: string[]; state?: string; acrValues?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
response_type: 'code',
|
||||
acr_values: options?.acrValues ?? EACCOUNTING_ACR_VALUE,
|
||||
});
|
||||
|
||||
const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES;
|
||||
params.set('scope', scopes.join(' '));
|
||||
|
||||
if (options?.state) {
|
||||
params.set('state', options.state);
|
||||
}
|
||||
|
||||
return `${VISMA_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function basicAuthHeader(config: OAuthConfig): string {
|
||||
const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
|
||||
return `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
export async function exchangeVismaCode(
|
||||
config: OAuthConfig,
|
||||
code: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(VISMA_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Visma token exchange failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
export async function refreshVismaToken(
|
||||
config: OAuthConfig,
|
||||
refreshToken: string,
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(VISMA_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Visma token refresh failed: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
export async function revokeVismaToken(
|
||||
config: OAuthConfig,
|
||||
refreshToken: string,
|
||||
): Promise<boolean> {
|
||||
const response = await fetch(VISMA_REVOKE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basicAuthHeader(config),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
Reference in New Issue
Block a user