* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration: - Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT - Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type - Include ruta 30-32 in ruta 49 formula and totalOutputVat summary - Display reverse charge section in reports UI and composition chart - Add comprehensive test coverage for all reverse charge scenarios Improve mobile UX across the app: - Convert nav drawer to bottom sheet with drag handle and safe area padding - Add mobile card layout for PaymentBookingDialog journal lines - Replace settings tab pills with dropdown selector on mobile - Make wizard step indicators responsive (collapsed on mobile) - Ensure all dialog footers stack buttons full-width on mobile - Add 44px minimum touch targets throughout - Make onboarding buttons full-width on mobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — indentation, query efficiency, tab dedup - Fix misleading try-block indentation in mark-paid route - Filter reversed entries at DB level (.eq('status', 'posted')) instead of fetching then discarding in memory - Extract shared settingsTabs array so mobile Select and desktop TabsList stay in sync automatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add resilience fallbacks, Arcim retry logic, and client tests Add FallbackPrompt component and integrate it across banking and migration error states so users always have a manual import escape hatch. Add retry with exponential backoff to Arcim API client for transient failures (429, 502, 503, 504) and timeouts. Expand import page deep-linking with ?mode= parameter. Add persistent error banner on settings page for bank connection failures. Include 18 new tests for the Arcim client covering retry, backoff, pagination, timeout, env validation, and singleton resource unwrapping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout - Add missing setActiveTab('banking') when handling bank_error query param so the error banner is actually visible (P1) - Guard env-var cleanup with try/finally in arcim-client tests to prevent state leakage on assertion failure (P2) - Only mock retry-range setTimeout delays in backoff test, letting AbortController timers pass through real setTimeout (P2) - Remove redundant clearTimeout in catch block — finally handles it (P2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
/**
|
|
* HTTP client for the Arcim Sync gateway API.
|
|
*
|
|
* Targets the consent-based resource API (/api/v1/consents/...) which
|
|
* provides typed, normalized access to any Swedish accounting provider.
|
|
*/
|
|
|
|
import type {
|
|
ArcimProvider,
|
|
ConsentRecord,
|
|
OtcResponse,
|
|
PaginatedResponse,
|
|
CompanyInformationDto,
|
|
CustomerDto,
|
|
SupplierDto,
|
|
SalesInvoiceDto,
|
|
SupplierInvoiceDto,
|
|
} from '../types'
|
|
|
|
function getBaseUrl(): string {
|
|
const url = process.env.ARCIM_SYNC_GATEWAY_URL
|
|
if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured')
|
|
return url.replace(/\/$/, '')
|
|
}
|
|
|
|
function getApiKey(): string {
|
|
const key = process.env.ARCIM_SYNC_API_KEY
|
|
if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured')
|
|
return key
|
|
}
|
|
|
|
const MAX_RETRIES = 2
|
|
const RETRY_DELAY_MS = 1_000
|
|
const RETRYABLE_STATUSES = [429, 502, 503, 504]
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
timeoutMs: number = 120_000
|
|
): Promise<T> {
|
|
const url = `${getBaseUrl()}${path}`
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
let response: Response
|
|
try {
|
|
response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Authorization': `Bearer ${getApiKey()}`,
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
const isAbort = err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')
|
|
if (attempt < MAX_RETRIES && isAbort) {
|
|
console.warn(`[arcim] ${path} timed out, retrying (attempt ${attempt + 1})`)
|
|
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
|
|
continue
|
|
}
|
|
if (isAbort) {
|
|
throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`)
|
|
}
|
|
throw err
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
|
|
if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) {
|
|
console.warn(`[arcim] ${path} returned ${response.status}, retrying (attempt ${attempt + 1})`)
|
|
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
|
|
continue
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '')
|
|
throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
throw new Error(`Arcim API failed after ${MAX_RETRIES + 1} attempts: ${path}`)
|
|
}
|
|
|
|
// ── Consent lifecycle ───────────────────────────────────────────────
|
|
|
|
export async function createConsent(
|
|
provider: ArcimProvider,
|
|
name: string,
|
|
orgNumber?: string,
|
|
companyName?: string
|
|
): Promise<ConsentRecord> {
|
|
return request<ConsentRecord>('/api/v1/consents', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, provider, orgNumber, companyName }),
|
|
})
|
|
}
|
|
|
|
export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
|
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
|
}
|
|
|
|
export async function generateOtc(
|
|
consentId: string,
|
|
expiresInMinutes: number = 60
|
|
): Promise<OtcResponse> {
|
|
return request<OtcResponse>(`/api/v1/consents/${consentId}/otc`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ expiresInMinutes }),
|
|
})
|
|
}
|
|
|
|
export async function deleteConsent(consentId: string): Promise<void> {
|
|
await request(`/api/v1/consents/${consentId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
// ── OAuth helpers ───────────────────────────────────────────────────
|
|
|
|
export async function getAuthUrl(
|
|
provider: ArcimProvider,
|
|
state?: string,
|
|
redirectUri?: string
|
|
): Promise<{ url: string }> {
|
|
const params = new URLSearchParams()
|
|
if (state) params.set('state', state)
|
|
if (redirectUri) params.set('redirectUri', redirectUri)
|
|
const qs = params.toString()
|
|
return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`)
|
|
}
|
|
|
|
export async function exchangeAuthToken(
|
|
consentId: string,
|
|
provider: ArcimProvider,
|
|
otcCode: string,
|
|
oauthCode: string,
|
|
redirectUri?: string
|
|
): Promise<{ success: boolean; consentId: string }> {
|
|
return request(`/api/v1/auth/${provider}/callback`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
code: oauthCode,
|
|
consentId,
|
|
otcCode,
|
|
...(redirectUri ? { redirectUri } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
// ── Token-based auth (Bokio, Björn Lundén, Briox) ──────────────────
|
|
|
|
export async function submitProviderToken(
|
|
consentId: string,
|
|
provider: ArcimProvider,
|
|
apiToken: string,
|
|
companyId?: string
|
|
): Promise<{ success: boolean; consentId: string }> {
|
|
return request(`/api/v1/auth/${provider}/callback`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
code: apiToken,
|
|
consentId,
|
|
...(companyId ? { companyId } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
// ── Resource fetching (paginated) ───────────────────────────────────
|
|
|
|
async function fetchAllPages<T>(
|
|
consentId: string,
|
|
resource: string,
|
|
params?: Record<string, string>,
|
|
pageSize: number = 100,
|
|
maxPages: number = 500
|
|
): Promise<T[]> {
|
|
const all: T[] = []
|
|
let page = 1
|
|
|
|
while (page <= maxPages) {
|
|
const query = new URLSearchParams({
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
...params,
|
|
})
|
|
const result = await request<PaginatedResponse<T>>(
|
|
`/api/v1/consents/${consentId}/${resource}?${query}`
|
|
)
|
|
all.push(...result.data)
|
|
|
|
if (!result.hasMore || result.data.length === 0) break
|
|
page++
|
|
}
|
|
|
|
return all
|
|
}
|
|
|
|
// ── Typed resource accessors ────────────────────────────────────────
|
|
|
|
export async function fetchCompanyInfo(
|
|
consentId: string
|
|
): Promise<CompanyInformationDto | null> {
|
|
// CompanyInformation is a singleton resource — gateway returns { data: object }
|
|
const result = await request<{ data: CompanyInformationDto }>(
|
|
`/api/v1/consents/${consentId}/companyinformation`
|
|
)
|
|
return result.data ?? null
|
|
}
|
|
|
|
export async function fetchCustomers(consentId: string): Promise<CustomerDto[]> {
|
|
return fetchAllPages<CustomerDto>(consentId, 'customers')
|
|
}
|
|
|
|
export async function fetchSuppliers(consentId: string): Promise<SupplierDto[]> {
|
|
return fetchAllPages<SupplierDto>(consentId, 'suppliers')
|
|
}
|
|
|
|
export async function fetchSalesInvoices(
|
|
consentId: string,
|
|
params?: Record<string, string>
|
|
): Promise<SalesInvoiceDto[]> {
|
|
return fetchAllPages<SalesInvoiceDto>(consentId, 'salesinvoices', params)
|
|
}
|
|
|
|
export async function fetchSupplierInvoices(
|
|
consentId: string,
|
|
params?: Record<string, string>
|
|
): Promise<SupplierInvoiceDto[]> {
|
|
return fetchAllPages<SupplierInvoiceDto>(consentId, 'supplierinvoices', params)
|
|
}
|
|
|
|
// ── SIE export ────────────────────────────────────────────────────
|
|
|
|
export interface SIEExportFile {
|
|
fiscalYear: number
|
|
sieType: number
|
|
rawContent: string
|
|
accountCount: number
|
|
transactionCount: number
|
|
}
|
|
|
|
export async function fetchSIEExport(
|
|
consentId: string,
|
|
sieType?: number
|
|
): Promise<{ files: SIEExportFile[] }> {
|
|
const params = new URLSearchParams()
|
|
if (sieType) params.set('sieType', String(sieType))
|
|
const qs = params.toString()
|
|
return request<{ files: SIEExportFile[] }>(
|
|
`/api/v1/consents/${consentId}/sie/export${qs ? `?${qs}` : ''}`
|
|
)
|
|
}
|