- ${escapeHtml(L.confirmationHeading(getCompanyPrimaryName(company)))}
+ ${escapeHtml(L.confirmationHeading(getCompanyDisplayName(company)))}
${L.documentNumber(L.docInvoice)} ${invoiceNumber}
@@ -532,7 +532,7 @@ export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): st
${L.confirmationQuestions}
${L.sincerely}
- ${escapeHtml(getCompanyPrimaryName(company))}
+ ${escapeHtml(getCompanyDisplayName(company))}
${company.org_number ? `
@@ -556,7 +556,7 @@ export function generatePaymentConfirmationEmailText(data: InvoiceEmailData): st
const paidDate = paidDateForCustomer(invoice)
const number = invoice.invoice_number ?? ''
- let text = `${L.confirmationHeading(getCompanyPrimaryName(company))}\n`
+ let text = `${L.confirmationHeading(getCompanyDisplayName(company))}\n`
text += `${L.documentNumber(L.docInvoice)} ${number}\n\n`
text += `${L.greeting(firstName)}\n\n`
text += `${L.confirmationBody(number)}\n\n`
diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts
index 94d5792c..62ec1934 100644
--- a/lib/email/reminder-templates.ts
+++ b/lib/email/reminder-templates.ts
@@ -1,5 +1,5 @@
import type { Invoice, Customer, CompanySettings, ReminderTextOverrides } from '@/types'
-import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
+import { formatCurrency, formatDate, getCompanyDisplayName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine } from './user-text'
@@ -208,7 +208,7 @@ function buildReminderPlaceholderValues(data: ReminderEmailData): Record
Med vänliga hälsningar,
- ${getCompanyPrimaryName(company)}
+ ${getCompanyDisplayName(company)}
${company.org_number ? `
diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts
index 2ec67819..961fd5b5 100644
--- a/lib/entitlements/has-capability.ts
+++ b/lib/entitlements/has-capability.ts
@@ -1,3 +1,4 @@
+import { chunk as chunksOf } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { isSelfHosted } from '@/lib/env/public-flags'
@@ -8,6 +9,7 @@ import {
type MultiUserAccess,
type MultiUserGrantRow,
} from './multi-user-state'
+import { isUuid } from '@/lib/invariants/uuid'
/**
* Entitlement gate: the single primitive behind the paywall ("non-payer loses
@@ -99,25 +101,13 @@ function isPaywallBypassed(): boolean {
return isDevBypass()
}
-const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
-/**
- * Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
- * filter below: commas/dots/parens are filter syntax. companyId/teamId always
- * come from the DB, but we validate at this boundary as defense in depth.
- */
-function isUuid(v: string): boolean {
- return UUID_RE.test(v)
-}
+// Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
+// filter below: commas/dots/parens are filter syntax. companyId/teamId always
+// come from the DB, but we validate with isUuid at this boundary as defense
+// in depth.
const CAPABILITY_SCOPE_CHUNK_SIZE = 100
-function chunksOf(values: T[], size: number): T[][] {
- const chunks: T[][] = []
- for (let index = 0; index < values.length; index += size) {
- chunks.push(values.slice(index, index + size))
- }
- return chunks
-}
function grantIsActive(expiresAt: string | null, now: number): boolean {
return expiresAt === null || new Date(expiresAt).getTime() > now
@@ -611,11 +601,3 @@ export async function getCompanyEntitlements(
multiUser,
}
}
-
-/** Capability list only; see getCompanyEntitlements for the full shape. */
-export async function getCompanyCapabilities(
- supabase: SupabaseClient,
- companyId: string,
-): Promise {
- return (await getCompanyEntitlements(supabase, companyId)).capabilities
-}
diff --git a/lib/entitlements/metering.ts b/lib/entitlements/metering.ts
deleted file mode 100644
index ea97ff3d..00000000
--- a/lib/entitlements/metering.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { SupabaseClient } from '@supabase/supabase-js'
-import type { CapabilityKey } from './keys'
-
-/**
- * Append a usage event to metered_events. Best-effort and non-blocking:
- * metering must never break the feature it measures, so failures are swallowed.
- *
- * Usage cannot be backfilled, so we capture it from day one even though no
- * usage-based pricing exists yet: it is the raw material for future firm-level
- * "active company" / consumption billing.
- */
-export async function recordMeteredEvent(
- supabase: SupabaseClient,
- params: {
- companyId: string
- teamId?: string | null
- key: CapabilityKey
- eventType: string
- attribution?: Record
- },
-): Promise {
- try {
- await supabase.from('metered_events').insert({
- company_id: params.companyId,
- team_id: params.teamId ?? null,
- capability_key: params.key,
- event_type: params.eventType,
- attribution: params.attribution ?? {},
- })
- } catch {
- // best-effort; never block the metered operation
- }
-}
diff --git a/lib/entitlements/multi-user.ts b/lib/entitlements/multi-user.ts
index ab045900..e692a92c 100644
--- a/lib/entitlements/multi-user.ts
+++ b/lib/entitlements/multi-user.ts
@@ -16,9 +16,7 @@ export {
type MultiUserAccess,
type MultiUserGrantRow,
} from './multi-user-state'
-export type { MultiUserState } from './multi-user-state'
-
-const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+import { UUID_RE } from '@/lib/invariants/uuid'
/**
* Whether the owner-only dormancy rule is enforced at all in this
diff --git a/lib/events/index.ts b/lib/events/index.ts
index b898c3e1..7080c9ca 100644
--- a/lib/events/index.ts
+++ b/lib/events/index.ts
@@ -1,8 +1 @@
export { eventBus } from './bus'
-export type {
- CoreEvent,
- CoreEventType,
- EventPayload,
- EventHandler,
- EventSubscription,
-} from './types'
diff --git a/lib/events/types.ts b/lib/events/types.ts
index 33b8f5ff..7629bf86 100644
--- a/lib/events/types.ts
+++ b/lib/events/types.ts
@@ -368,8 +368,3 @@ export type EventPayload = Extract = (payload: EventPayload) => Promise | void
-/** Subscription: event type + handler */
-export interface EventSubscription {
- eventType: T
- handler: EventHandler
-}
diff --git a/lib/export/register-export.ts b/lib/export/register-export.ts
index 80e452ef..42846ed1 100644
--- a/lib/export/register-export.ts
+++ b/lib/export/register-export.ts
@@ -42,6 +42,4 @@ export function buildRegisterExport(
}
/** Today's date as `YYYY-MM-DD` for export filenames. */
-export function todayIso(): string {
- return new Date().toISOString().slice(0, 10)
-}
+export { todayIsoUtc as todayIso } from '@/lib/dates/iso'
diff --git a/lib/extensions/index.ts b/lib/extensions/index.ts
deleted file mode 100644
index ebfd200a..00000000
--- a/lib/extensions/index.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export { extensionRegistry } from './registry'
-export { loadExtensions } from './loader'
-export type {
- Extension,
- ApiRouteDefinition,
- MappingRuleTypeDefinition,
- ExtensionEventHandler,
- ExtensionContext,
- SettingsPanelDefinition,
-} from './types'
diff --git a/lib/extensions/validation.ts b/lib/extensions/validation.ts
index ff27e357..d7fcd057 100644
--- a/lib/extensions/validation.ts
+++ b/lib/extensions/validation.ts
@@ -1,3 +1,5 @@
+import { luhnValidate } from '@/lib/bankgiro/luhn'
+
/**
* Validates a Swedish personal number (YYYYMMDD-XXXX) using Luhn checksum.
* Returns an error message string, or null if valid.
@@ -21,17 +23,7 @@ export function validateSwedishPersonalNumber(pnr: string): string | null {
// Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2)
- let sum = 0
- for (let i = 0; i < 10; i++) {
- let digit = parseInt(luhnDigits[i])
- if (i % 2 === 0) {
- digit *= 2
- if (digit > 9) digit -= 9
- }
- sum += digit
- }
-
- if (sum % 10 !== 0) return 'Ogiltig kontrollsiffra'
+ if (!luhnValidate(luhnDigits)) return 'Ogiltig kontrollsiffra'
return null
}
diff --git a/lib/hooks/use-fetch.ts b/lib/hooks/use-fetch.ts
index 6c8156c9..f801fcff 100644
--- a/lib/hooks/use-fetch.ts
+++ b/lib/hooks/use-fetch.ts
@@ -24,7 +24,8 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag
* don't blank out on refresh. Read `loading` to show a pending indicator.
* - When `url`/`enabled` start inactive and later become active, `loading`
* flips true on the effect tick, not synchronously on the activating render.
- * Pair with `DataState` (which branches on `loading` first) to avoid a flash.
+ * Branch on `loading` first to avoid a flash.
+
*
* Response convention: the JSON body is returned as-is, typed as `T`. Most
* Accounted routes wrap payloads as `{ data: ... }`, so the common usage is
diff --git a/lib/import/articles/types.ts b/lib/import/articles/types.ts
index a47b1d0c..4e9ae3e7 100644
--- a/lib/import/articles/types.ts
+++ b/lib/import/articles/types.ts
@@ -72,12 +72,6 @@ export interface ArticleImportParseResult {
warnings: string[]
}
-/** Input for executing the article import. */
-export interface ArticleImportExecuteInput {
- rows: ParsedArticleRow[]
- update_duplicates: boolean
-}
-
/** Result of executing the article import. */
export interface ArticleImportExecuteResult {
success: boolean
diff --git a/lib/import/bank-file/formats/generic-csv.ts b/lib/import/bank-file/formats/generic-csv.ts
index 643920f9..6f2b88f9 100644
--- a/lib/import/bank-file/formats/generic-csv.ts
+++ b/lib/import/bank-file/formats/generic-csv.ts
@@ -165,15 +165,6 @@ export function parseGenericCSV(
}
}
-/**
- * Get column headers from a CSV file for the mapping UI
- */
-export function getCSVHeaders(content: string, delimiter: string = ','): string[] {
- const prepared = prepareContent(content)
- const firstLine = prepared.split('\n')[0] || ''
- return parseCSVLine(firstLine, delimiter).map((h) => h.trim().replace(/^"|"$/g, ''))
-}
-
/**
* Get a preview of the first few rows of a CSV file
*/
diff --git a/lib/import/bank-file/types.ts b/lib/import/bank-file/types.ts
index 0fa43dfc..2c921b7d 100644
--- a/lib/import/bank-file/types.ts
+++ b/lib/import/bank-file/types.ts
@@ -79,25 +79,6 @@ export interface BankFileFormat {
parse: (content: string) => BankFileParseResult
}
-/** Import tracking record stored in DB */
-export interface BankFileImport {
- id: string
- user_id: string
- filename: string
- file_hash: string
- file_format: string
- transaction_count: number
- imported_count: number
- duplicate_count: number
- matched_count: number
- date_from: string | null
- date_to: string | null
- status: 'pending' | 'processing' | 'completed' | 'failed'
- error_message: string | null
- created_at: string
- updated_at: string
-}
-
/** Column mapping for generic CSV format */
export interface GenericCSVColumnMapping {
date: number
diff --git a/lib/import/customers/types.ts b/lib/import/customers/types.ts
index ad4144cd..a8e56b40 100644
--- a/lib/import/customers/types.ts
+++ b/lib/import/customers/types.ts
@@ -61,12 +61,6 @@ export interface CustomerImportParseResult {
warnings: string[]
}
-/** Input for executing the customer import. */
-export interface CustomerImportExecuteInput {
- rows: ParsedCustomerRow[]
- update_duplicates: boolean
-}
-
/** Result of executing the customer import. */
export interface CustomerImportExecuteResult {
success: boolean
diff --git a/lib/import/opening-balance/types.ts b/lib/import/opening-balance/types.ts
index 28a9d4eb..9ec29303 100644
--- a/lib/import/opening-balance/types.ts
+++ b/lib/import/opening-balance/types.ts
@@ -52,16 +52,6 @@ export interface OpeningBalanceParseResult {
detected_bank_format: string | null
}
-/** Input for executing the opening balance import */
-export interface OpeningBalanceExecuteInput {
- fiscal_period_id: string
- lines: {
- account_number: string
- debit_amount: number
- credit_amount: number
- }[]
-}
-
/** Result of executing the opening balance import */
export interface OpeningBalanceExecuteResult {
success: boolean
diff --git a/lib/import/shared/column-utils.ts b/lib/import/shared/column-utils.ts
index 2b474721..e7c47bdd 100644
--- a/lib/import/shared/column-utils.ts
+++ b/lib/import/shared/column-utils.ts
@@ -66,3 +66,12 @@ export function normalizeEmail(value: string | null): string | null {
if (!value) return null
return value.trim().toLowerCase() || null
}
+
+/**
+ * Lowercased dedup key for matching a row by name (articles, and any other
+ * importer that dedupes on a free-text name). Same rule as normalizeEmail.
+ */
+export function normalizeNameKey(value: string | null): string | null {
+ if (!value) return null
+ return value.trim().toLowerCase() || null
+}
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index 4c8829e5..970f29ad 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -541,7 +541,7 @@ export async function ensureFiscalPeriod(
// lib/bookkeeping/validate-period-duration.ts, the same arithmetic and the
// same threshold validatePeriodDuration() applies on every other
// period-creation path (fiscal-periods POST/PATCH, period-service's
- // createNextPeriod/createPreviousPeriod, onboarding's computeFiscalPeriod),
+ // createNextPeriod, onboarding's computeFiscalPeriod),
// so an 18-month förlängt räkenskapsår imports exactly as it does there and
// a 19-month one does not. 18 is the ceiling for an extended or re-laid
// year; ongoing years are 12. BFL sets no minimum, so there is no floor here.
diff --git a/lib/import/suppliers/types.ts b/lib/import/suppliers/types.ts
index a4b6d9d3..018ebd6d 100644
--- a/lib/import/suppliers/types.ts
+++ b/lib/import/suppliers/types.ts
@@ -73,11 +73,6 @@ export interface SupplierImportParseResult {
warnings: string[]
}
-/** Input for executing the supplier import. */
-export interface SupplierImportExecuteInput {
- rows: ParsedSupplierRow[]
- update_duplicates: boolean
-}
/** Result of executing the supplier import. */
export interface SupplierImportExecuteResult {
diff --git a/lib/import/types.ts b/lib/import/types.ts
index 97a639d0..19d15058 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -192,14 +192,6 @@ export interface AccountMapping {
requiresVatTreatmentReview?: boolean
}
-/**
- * Account mapping context for the mapper
- */
-export interface MappingContext {
- sourceAccounts: SIEAccount[]
- existingMappings?: Map
-}
-
/**
* SIE import record (matches database table)
*/
@@ -243,39 +235,6 @@ export interface SIEAccountMappingRecord {
updated_at: string
}
-/**
- * Options for executing an import
- */
-export interface ImportOptions {
- // The parsed SIE data
- parsed: ParsedSIEFile
-
- // Account mappings to use
- mappings: AccountMapping[]
-
- // Whether to create a new fiscal period
- createFiscalPeriod: boolean
-
- // Whether to import opening balances as a journal entry
- importOpeningBalances: boolean
-
- // Whether to import transactions (SIE4 only)
- importTransactions: boolean
-
- // Voucher series to use for imported entries
- voucherSeries?: string
-
- // Voucher series for the opening-balance (Ingående balanser) entry.
- // Defaults to a series the file's own vouchers do not use (see
- // lib/import/opening-balance-defaults.ts) so the IB voucher never shifts
- // the numbering of the file's series (issue #1882).
- openingBalanceSeries?: string
-
- // Opt-in: mark imported verifikat as "Inget underlag krävs" so a migration
- // doesn't flood "Att hantera: saknade underlag". OFF by default.
- markImportedNoDocRequired?: boolean
-}
-
/**
* Structured import details for UI display.
* Provides machine-readable data so the UI can render proper explanations
@@ -495,17 +454,3 @@ export interface MigrationDocumentation {
* Wizard step state
*/
export type ImportWizardStep = 'upload' | 'preview' | 'mapping' | 'review' | 'result'
-
-/**
- * Full wizard state
- */
-export interface ImportWizardState {
- step: ImportWizardStep
- file: File | null
- parsed: ParsedSIEFile | null
- mappings: AccountMapping[]
- preview: ImportPreview | null
- importResult: ImportResult | null
- isLoading: boolean
- error: string | null
-}
diff --git a/lib/invariants/__tests__/uuid.test.ts b/lib/invariants/__tests__/uuid.test.ts
new file mode 100644
index 00000000..13be8983
--- /dev/null
+++ b/lib/invariants/__tests__/uuid.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import { UUID_RE, isUuid } from '../uuid'
+
+describe('UUID_RE / isUuid', () => {
+ it('accepts lower- and upper-case hex layouts', () => {
+ expect(isUuid('123e4567-e89b-12d3-a456-426614174000')).toBe(true)
+ expect(isUuid('123E4567-E89B-12D3-A456-426614174000')).toBe(true)
+ expect(UUID_RE.test('00000000-0000-0000-0000-000000000000')).toBe(true)
+ })
+
+ it('rejects anything that is not exactly the 8-4-4-4-12 shape', () => {
+ expect(isUuid('123e4567e89b12d3a456426614174000')).toBe(false)
+ expect(isUuid('123e4567-e89b-12d3-a456-42661417400')).toBe(false)
+ expect(isUuid('123e4567-e89b-12d3-a456-426614174000,x.eq.1')).toBe(false)
+ expect(isUuid(' 123e4567-e89b-12d3-a456-426614174000')).toBe(false)
+ expect(isUuid('')).toBe(false)
+ })
+
+ it('narrows non-strings to false', () => {
+ expect(isUuid(null)).toBe(false)
+ expect(isUuid(undefined)).toBe(false)
+ expect(isUuid(42)).toBe(false)
+ })
+})
diff --git a/lib/invariants/index.ts b/lib/invariants/index.ts
index 67807d78..387478de 100644
--- a/lib/invariants/index.ts
+++ b/lib/invariants/index.ts
@@ -34,8 +34,8 @@ export {
} from './fiscal-year'
export {
- ORG_NUMBER_LENGTH,
stripOrgNumberFormatting,
+
isOrgNumberShaped,
normalizeOrgNumber,
isValidOrgNumber,
diff --git a/lib/invariants/org-number.ts b/lib/invariants/org-number.ts
index f2da068a..9483d333 100644
--- a/lib/invariants/org-number.ts
+++ b/lib/invariants/org-number.ts
@@ -38,8 +38,6 @@ import { luhnValidate } from '@/lib/bankgiro/luhn'
* with its own message. Tighten the intake, not the outflow.
*/
-/** Digits-only canonical storage length. */
-export const ORG_NUMBER_LENGTH = 10
/**
* Strip the separators Swedish users and provider APIs put in org numbers.
diff --git a/lib/invariants/uuid.ts b/lib/invariants/uuid.ts
new file mode 100644
index 00000000..8ae254a4
--- /dev/null
+++ b/lib/invariants/uuid.ts
@@ -0,0 +1,12 @@
+/**
+ * UUID shape, as stored in every `id` column. Anchored and case-insensitive;
+ * version/variant nibbles are NOT checked (Postgres accepts any hex layout, and
+ * fixtures use non-RFC ids). Consumers use it as an injection guard before
+ * interpolating a server-resolved id into a PostgREST filter, or to validate a
+ * route parameter before hitting the database.
+ */
+export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+export function isUuid(value: unknown): value is string {
+ return typeof value === 'string' && UUID_RE.test(value)
+}
diff --git a/lib/invariants/zod.ts b/lib/invariants/zod.ts
index 3ad64714..da18419e 100644
--- a/lib/invariants/zod.ts
+++ b/lib/invariants/zod.ts
@@ -2,7 +2,7 @@ import { z } from 'zod'
import { ACCOUNT_NUMBER_RE, ACCOUNT_NUMBER_MESSAGE } from './account-number'
import { ISO_DATE_RE, ISO_DATE_MESSAGE, SANE_DATE_MESSAGE, isSaneDateString } from './iso-date'
import { FISCAL_YEAR_RE, FISCAL_YEAR_MESSAGE } from './fiscal-year'
-import { isValidOrgNumber, normalizeOrgNumber } from './org-number'
+import { isValidOrgNumber } from './org-number'
/**
* Zod primitives built from the shared rules.
@@ -41,8 +41,3 @@ export const fiscalYearSchema = z.string().regex(FISCAL_YEAR_RE, FISCAL_YEAR_MES
export const orgNumberSchema = z
.string()
.refine(isValidOrgNumber, 'Ogiltigt organisationsnummer (10 eller 12 siffror, giltig kontrollsiffra)')
-
-/** Org number that is normalized to the canonical 10-digit storage form on parse. */
-export const normalizedOrgNumberSchema = orgNumberSchema.transform(
- (v) => normalizeOrgNumber(v) as string,
-)
diff --git a/lib/invoices/link-migrated-registration-vouchers.ts b/lib/invoices/link-migrated-registration-vouchers.ts
index 9f438755..759e5ccf 100644
--- a/lib/invoices/link-migrated-registration-vouchers.ts
+++ b/lib/invoices/link-migrated-registration-vouchers.ts
@@ -47,6 +47,7 @@
* by lib/invoices/bulk-reconcile-supplier-vouchers.ts.
*/
+import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
@@ -175,12 +176,6 @@ function daysBetween(from: string, to: string): number | null {
return Math.round((b - a) / MS_PER_DAY)
}
-function chunk(items: T[], size: number): T[][] {
- const out: T[][] = []
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
- return out
-}
-
function emptyCounts(): RegistrationLinkCounts {
return {
scanned: 0,
diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx
index 479421b3..53c7cd80 100644
--- a/lib/invoices/pdf-template.tsx
+++ b/lib/invoices/pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import {
Document,
@@ -675,15 +676,6 @@ function formatDate(date: string): string {
return date.slice(0, 10)
}
-// Format org number
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
/**
* Payment state the PDF prints for a real faktura (#1693): the BETALD stamp
* and the "Betalt / Att betala" rows. Null for every other document or status,
diff --git a/lib/invoices/peppol-bis-billing.ts b/lib/invoices/peppol-bis-billing.ts
index 37423c77..8af03508 100644
--- a/lib/invoices/peppol-bis-billing.ts
+++ b/lib/invoices/peppol-bis-billing.ts
@@ -1,3 +1,4 @@
+import { escapeXml } from '@/lib/xml/escape'
import {
generateOcrReference,
validateBankgiroNumber,
@@ -111,15 +112,6 @@ function formatDecimal(value: number): string {
return fraction ? `${sign}${whole}.${fraction}` : `${sign}${whole}`
}
-function escapeXml(value: string): string {
- return value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
function hasText(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0
}
diff --git a/lib/invoices/peppol-delivery-sync.ts b/lib/invoices/peppol-delivery-sync.ts
index 61f8fd71..98aa3240 100644
--- a/lib/invoices/peppol-delivery-sync.ts
+++ b/lib/invoices/peppol-delivery-sync.ts
@@ -9,7 +9,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
-import { persistPeppolEvidence, persistVerifiedPeppolEvent } from '@/lib/invoices/peppol-delivery'
+import { persistPeppolEvidence, persistVerifiedPeppolEvent, describeError } from '@/lib/invoices/peppol-delivery'
import type { PeppolTransport } from '@/lib/invoices/peppol-transport'
/** Deliveries the provider may still say something new about. */
@@ -42,10 +42,6 @@ export interface PeppolDeliveryPollResult {
errors: Array<{ providerSubmissionId: string; reason: string }>
}
-function describeError(err: unknown): string {
- return (err instanceof Error ? err.message : String(err)).slice(0, 500)
-}
-
/** Open deliveries for a provider, oldest status first; default horizon 45 days. */
export async function listOpenPeppolDeliveries(args: {
service: SupabaseClient
diff --git a/lib/invoices/peppol-delivery.ts b/lib/invoices/peppol-delivery.ts
index d54b2d2b..3e2823a3 100644
--- a/lib/invoices/peppol-delivery.ts
+++ b/lib/invoices/peppol-delivery.ts
@@ -33,6 +33,11 @@ export interface StagedPeppolDelivery extends PeppolDeliverySummary {
filename: string
}
+/** Error text for a peppol_* row's failure column: message or String(err), capped at 500 chars. */
+export function describeError(err: unknown): string {
+ return (err instanceof Error ? err.message : String(err)).slice(0, 500)
+}
+
export function sha256Hex(value: string | Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
diff --git a/lib/invoices/peppol-inbound.ts b/lib/invoices/peppol-inbound.ts
index b2393915..36ae741c 100644
--- a/lib/invoices/peppol-inbound.ts
+++ b/lib/invoices/peppol-inbound.ts
@@ -12,7 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { ISO_DATE_RE } from '@/lib/invariants'
import { roundOre } from '@/lib/money'
-import { sha256Hex } from '@/lib/invoices/peppol-delivery'
+import { describeError, sha256Hex } from '@/lib/invoices/peppol-delivery'
import {
parseUblJsonDocument,
type PeppolInboundDocument,
@@ -88,10 +88,6 @@ function roundMoney(value: number | null): number | null {
return value === null ? null : roundOre(value)
}
-function describeError(err: unknown): string {
- return (err instanceof Error ? err.message : String(err)).slice(0, 500)
-}
-
/** Company for a recipient identifier, via a live registration; null when nobody is registered. */
export async function resolvePeppolRecipientCompany(args: {
service: SupabaseClient
diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts
index 50703b56..9b57a76c 100644
--- a/lib/invoices/rot-rut-file.ts
+++ b/lib/invoices/rot-rut-file.ts
@@ -1,3 +1,4 @@
+import { escapeXml } from '@/lib/xml/escape'
import type { Invoice, InvoiceItem } from '@/types'
import { truncateToWholeKronor } from '@/lib/money'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
@@ -431,15 +432,6 @@ export function evaluateInvoiceForFile(
}
}
-function escapeXml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
/**
* The 31 January deadline: a begäran must reach Skatteverket no later than
* 31 January the year AFTER the buyer paid. Returns true when `paidDate`'s
diff --git a/lib/invoices/supplier-voucher-matching.ts b/lib/invoices/supplier-voucher-matching.ts
index abc24087..2732b4b9 100644
--- a/lib/invoices/supplier-voucher-matching.ts
+++ b/lib/invoices/supplier-voucher-matching.ts
@@ -29,6 +29,18 @@ import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggesti
import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount'
import type { SupplierInvoice, Supplier } from '@/types'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
+import {
+ AMOUNT_TOLERANCE,
+ DATE_PROXIMITY_BUMP,
+ DEFAULT_DATE_WINDOW_DAYS,
+ EXCLUDED_SOURCE_TYPES,
+ isDateWithinDays,
+ round2,
+ type FiscalPeriodRow,
+ type VoucherMatchLineRow as JournalEntryLine,
+ type VoucherRow,
+} from './voucher-matching-shared'
+import { formatAmount as formatNumber } from '@/lib/utils'
const log = createLogger('supplier-voucher-matching')
@@ -40,15 +52,6 @@ const log = createLogger('supplier-voucher-matching')
* the 244x range catches that. PR #602 Swedish-compliance fix. */
const AP_ACCOUNT_PREFIX = '244'
-/** ±90 days from the invoice's due_date as the default search window. */
-const DEFAULT_DATE_WINDOW_DAYS = 90
-
-/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
-const AMOUNT_TOLERANCE = 0.005
-
-/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
-const DATE_PROXIMITY_BUMP = 0.05
-
export interface SupplierVoucherCandidate {
journal_entry_id: string
voucher_series: string | null
@@ -73,46 +76,11 @@ export interface SupplierVoucherCandidate {
match_reason: string
}
-interface JournalEntryLine {
- id: string
- journal_entry_id: string
- account_number: string
- debit_amount: number | null
- credit_amount: number | null
- /** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
- currency: string | null
- /** The line's amount in `currency`: the only non-SEK figure on the row. */
- amount_in_currency: number | string | null
-}
-
-interface VoucherRow {
- id: string
- voucher_series: string | null
- voucher_number: number | null
- entry_date: string
- description: string
- status: string
- source_type: string | null
- fiscal_period_id: string
-}
-
-/** fiscal_periods carries no `status` column: lock state is the (is_closed,
- * locked_at) pair, which is exactly what enforce_period_lock() reads in
- * migration 20240101000017 and what resolvePeriodStatusForDate() uses in
- * lib/core/bookkeeping/period-service.ts. */
-interface FiscalPeriodRow {
- id: string
- is_closed: boolean | null
- locked_at: string | null
-}
-
interface CandidateContext {
invoice: SupplierInvoice & { supplier?: Supplier }
remainingAmount: number
}
-const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
-
/**
* Find posted journal entries whose lines debit 2440 and could plausibly be
* the payment for this supplier invoice. Ranking mirrors the customer side:
@@ -797,17 +765,6 @@ function computeRemaining(invoice: SupplierInvoice): number {
return Math.max(0, round2(invoice.total - paid))
}
-function round2(n: number): number {
- return Math.round(n * 100) / 100
-}
-
-function isDateWithinDays(a: string, b: string, days: number): boolean {
- const ad = new Date(a).getTime()
- const bd = new Date(b).getTime()
- if (Number.isNaN(ad) || Number.isNaN(bd)) return false
- return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
-}
-
function descriptionMentionsToken(description: string | null, token: string): boolean {
if (!description || !token) return false
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
@@ -815,10 +772,3 @@ function descriptionMentionsToken(description: string | null, token: string): bo
if (normalizedTok.length < 2) return false
return normalizedDesc.includes(normalizedTok)
}
-
-function formatNumber(n: number): string {
- return new Intl.NumberFormat('sv-SE', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(n)
-}
diff --git a/lib/invoices/transports/qvalia.ts b/lib/invoices/transports/qvalia.ts
index 84645bd4..71a3c33b 100644
--- a/lib/invoices/transports/qvalia.ts
+++ b/lib/invoices/transports/qvalia.ts
@@ -53,7 +53,6 @@ import {
export const QVALIA_PROVIDER = 'qvalia'
export const QVALIA_PRODUCTION_BASE_URL = 'https://api.qvalia.com'
-export const QVALIA_SANDBOX_BASE_URL = 'https://api-test.qvalia.com'
export const QVALIA_DEFAULT_WEBHOOK_HEADER = 'x-accounted-webhook-key'
export type QvaliaAuthScheme = 'apikey' | 'raw'
@@ -143,10 +142,6 @@ export class QvaliaApiError extends PeppolTransportError {
}
}
-export function isQvaliaApiError(error: unknown): error is QvaliaApiError {
- return error instanceof QvaliaApiError
-}
-
function authorizationHeader(config: QvaliaConfig): string {
return config.authScheme === 'raw' ? config.apiKey : `ApiKey ${config.apiKey}`
}
diff --git a/lib/invoices/voucher-matching-shared.ts b/lib/invoices/voucher-matching-shared.ts
new file mode 100644
index 00000000..9f83ef8f
--- /dev/null
+++ b/lib/invoices/voucher-matching-shared.ts
@@ -0,0 +1,62 @@
+/**
+ * Row shapes, tolerances and helpers shared by the customer-side
+ * (voucher-matching.ts) and supplier-side (supplier-voucher-matching.ts)
+ * "link an existing verifikat as the payment" flows. Both files read the same
+ * journal tables and rank candidates the same way; only the account side
+ * (151x credits vs 244x debits) and the invoice type differ.
+ */
+
+/** ±90 days from the invoice's due_date as the default search window. */
+export const DEFAULT_DATE_WINDOW_DAYS = 90
+
+/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
+export const AMOUNT_TOLERANCE = 0.005
+
+/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
+export const DATE_PROXIMITY_BUMP = 0.05
+
+export interface VoucherMatchLineRow {
+ id: string
+ journal_entry_id: string
+ account_number: string
+ debit_amount: number | null
+ credit_amount: number | null
+ /** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
+ currency: string | null
+ /** The line's amount in `currency`: the only non-SEK figure on the row. */
+ amount_in_currency: number | string | null
+}
+
+export interface VoucherRow {
+ id: string
+ voucher_series: string | null
+ voucher_number: number | null
+ entry_date: string
+ description: string
+ status: string
+ source_type: string | null
+ fiscal_period_id: string
+}
+
+/** `fiscal_periods` has no `status` column: open/locked/closed is derived from
+ * `is_closed` + `locked_at`, exactly as the `enforce_period_lock` trigger
+ * (migration 017) and `resolvePeriodStatusForDate()` do it. */
+export interface FiscalPeriodRow {
+ id: string
+ is_closed: boolean | null
+ locked_at: string | null
+}
+
+/** SQL-side filter for posted, non-storno, non-opening entries. */
+export const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
+
+export function round2(n: number): number {
+ return Math.round(n * 100) / 100
+}
+
+export function isDateWithinDays(a: string, b: string, days: number): boolean {
+ const ad = new Date(a).getTime()
+ const bd = new Date(b).getTime()
+ if (Number.isNaN(ad) || Number.isNaN(bd)) return false
+ return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
+}
diff --git a/lib/invoices/voucher-matching.ts b/lib/invoices/voucher-matching.ts
index 68d29524..3fd627e8 100644
--- a/lib/invoices/voucher-matching.ts
+++ b/lib/invoices/voucher-matching.ts
@@ -32,6 +32,18 @@ import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/b
import { clearSettledInvoiceSuggestions } from './clear-settled-invoice-suggestions'
import { documentCurrency, ledgerLineSideAmountIn } from '@/lib/bookkeeping/ledger-line-amount'
import type { Invoice, Customer } from '@/types'
+import {
+ AMOUNT_TOLERANCE,
+ DATE_PROXIMITY_BUMP,
+ DEFAULT_DATE_WINDOW_DAYS,
+ EXCLUDED_SOURCE_TYPES,
+ isDateWithinDays,
+ round2,
+ type FiscalPeriodRow,
+ type VoucherMatchLineRow as JournalEntryLine,
+ type VoucherRow,
+} from './voucher-matching-shared'
+import { formatAmount as formatNumber } from '@/lib/utils'
const log = createLogger('voucher-matching')
@@ -71,15 +83,6 @@ async function resolveAccountingMethod(
: 'accrual'
}
-/** ±90 days from the invoice's due_date as the default search window. */
-const DEFAULT_DATE_WINDOW_DAYS = 90
-
-/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
-const AMOUNT_TOLERANCE = 0.005
-
-/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
-const DATE_PROXIMITY_BUMP = 0.05
-
export interface VoucherCandidate {
journal_entry_id: string
voucher_series: string | null
@@ -104,46 +107,11 @@ export interface VoucherCandidate {
match_reason: string
}
-interface JournalEntryLine {
- id: string
- journal_entry_id: string
- account_number: string
- debit_amount: number | null
- credit_amount: number | null
- /** Labels the DOCUMENT, NOT the unit of debit_amount/credit_amount. */
- currency: string | null
- /** The line's amount in `currency`: the only non-SEK figure on the row. */
- amount_in_currency: number | string | null
-}
-
-interface VoucherRow {
- id: string
- voucher_series: string | null
- voucher_number: number | null
- entry_date: string
- description: string
- status: string
- source_type: string | null
- fiscal_period_id: string
-}
-
-/** `fiscal_periods` has no `status` column: open/locked/closed is derived from
- * `is_closed` + `locked_at`, exactly as the `enforce_period_lock` trigger
- * (migration 017) and `resolvePeriodStatusForDate()` do it. */
-interface FiscalPeriodRow {
- id: string
- is_closed: boolean | null
- locked_at: string | null
-}
-
interface CandidateContext {
invoice: Invoice & { customer?: Customer }
remainingAmount: number
}
-/** Internal: SQL-side filter for posted, non-storno, non-opening entries. */
-const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
-
/**
* Find posted journal entries that could plausibly be the payment for this
* invoice and return up to `limit` ranked candidates. On faktureringsmetoden
@@ -891,27 +859,9 @@ function computeRemaining(invoice: Invoice): number {
return Math.max(0, round2(invoice.total - paid))
}
-function round2(n: number): number {
- return Math.round(n * 100) / 100
-}
-
-function isDateWithinDays(a: string, b: string, days: number): boolean {
- const ad = new Date(a).getTime()
- const bd = new Date(b).getTime()
- if (Number.isNaN(ad) || Number.isNaN(bd)) return false
- return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
-}
-
function descriptionMentionsInvoice(description: string | null, invoiceNumber: string): boolean {
if (!description || !invoiceNumber) return false
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
const normalizedNum = invoiceNumber.replace(/\s+/g, '').toLowerCase()
return normalizedDesc.includes(normalizedNum)
}
-
-function formatNumber(n: number): string {
- return new Intl.NumberFormat('sv-SE', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(n)
-}
diff --git a/lib/notifications/bookkeeping-digest.ts b/lib/notifications/bookkeeping-digest.ts
index fea34e04..4fd69b46 100644
--- a/lib/notifications/bookkeeping-digest.ts
+++ b/lib/notifications/bookkeeping-digest.ts
@@ -33,6 +33,8 @@ import { getSenderForBrand, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { resolveBrandForCompany } from '@/lib/branding/resolve'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { chunk } from '@/lib/utils'
+import { escapeHtml } from '@/lib/email/user-text'
const log = createLogger('bookkeeping-digest')
@@ -482,13 +484,6 @@ function toReferenceUuid(referenceKey: string): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
-/** Split ids into .in()-safe chunks (see IN_CLAUSE_CHUNK). */
-function chunk(items: T[], size: number): T[][] {
- const out: T[][] = []
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
- return out
-}
-
/**
* Company names are user-controlled and reach the Subject header: strip
* CR/LF and other control characters so the value can never smuggle extra
@@ -499,11 +494,3 @@ function sanitizeHeaderText(input: string): string {
return input.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
}
-function escapeHtml(input: string): string {
- return input
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
diff --git a/lib/observability/index.ts b/lib/observability/index.ts
index e98641e0..71991a12 100644
--- a/lib/observability/index.ts
+++ b/lib/observability/index.ts
@@ -28,17 +28,8 @@
export {
captureException,
captureMessage,
- flushObservability,
- getEnvironment,
- getObservabilitySink,
- getRelease,
- isObservabilityConfigured,
- noopSink,
registerObservabilitySink,
- resetObservabilitySink,
type ObservabilityContext,
type ObservabilityLevel,
type ObservabilitySink,
} from './sink'
-
-export { REDACTED, REDACT_KEYS, redact, redactString } from './redact'
diff --git a/lib/packs/schema.ts b/lib/packs/schema.ts
index d842e96b..1f7cdca8 100644
--- a/lib/packs/schema.ts
+++ b/lib/packs/schema.ts
@@ -136,5 +136,3 @@ export const PackSchema = z
.strict()
export type Pack = z.infer
-export type PackLine = z.infer
-export type PackMeta = z.infer
diff --git a/lib/payments/pain001-supplier.ts b/lib/payments/pain001-supplier.ts
index c2ba712a..214cc47d 100644
--- a/lib/payments/pain001-supplier.ts
+++ b/lib/payments/pain001-supplier.ts
@@ -57,6 +57,7 @@
* payments it initiates. Subject to 7-year retention.
*/
+import { escapeXml } from '@/lib/xml/escape'
import { roundOre } from '@/lib/money'
import { splitDomesticBankAccount } from '@/lib/salary/payment/bank-account'
import type { PaymentReference, SupplierPayee } from './supplier-payee'
@@ -345,15 +346,6 @@ function sanitizeText(value: string): string {
return transliterated.replace(DISALLOWED_TEXT, '?')
}
-function escapeXml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
function formatDecimal(amount: number): string {
return roundOre(amount).toFixed(2)
}
diff --git a/lib/pdf/number-text.ts b/lib/pdf/number-text.ts
index 8c1ae33d..831f6bc9 100644
--- a/lib/pdf/number-text.ts
+++ b/lib/pdf/number-text.ts
@@ -38,3 +38,23 @@ export function pdfNumberText(text: string): string {
export function pdfText(text: string): string {
return text.replaceAll(UNICODE_MINUS, '-')
}
+
+/**
+ * Two-decimal sv-SE amount for a react-pdf : the same Intl call as
+ * lib/utils formatAmount, run through pdfNumberText so a negative årets
+ * resultat or a credit note never prints unsigned (issue #1982).
+ */
+export function pdfAmount(amount: number): string {
+ return pdfNumberText(
+ new Intl.NumberFormat('sv-SE', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount),
+ )
+}
+
+/** ISO date string as sv-SE (YYYY-MM-DD in the server timezone); empty input stays empty. */
+export function formatDateSv(iso: string): string {
+ if (!iso) return ''
+ return new Date(iso).toLocaleDateString('sv-SE')
+}
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 9d2fb492..00850e8b 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -82,10 +82,13 @@ import {
createSupplierCreditNoteEntry,
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
-import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
+import { linkInvoiceToVoucher, type LinkInvoiceToVoucherResult } from '@/lib/invoices/voucher-matching'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
-import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
+import {
+ linkSupplierInvoiceToVoucher,
+ type LinkSupplierInvoiceToVoucherResult,
+} from '@/lib/invoices/supplier-voucher-matching'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import {
@@ -176,7 +179,7 @@ import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
-import { BulkBookInboxSchema } from '@/lib/api/schemas'
+import { BulkBookInboxSchema, OpeningBalancesBulkSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { z } from 'zod'
@@ -200,6 +203,7 @@ import type {
CreditNote,
CreateJournalEntryLineInput,
JournalEntrySourceType,
+ FiscalPeriod,
} from '@/types'
const log = createLogger('pending-operations/commit')
@@ -300,6 +304,34 @@ async function recordSkippedInvoiceJournalEntry(
// ── Executors ────────────────────────────────────────────────────
+/**
+ * The company's booking context for invoice-shaped executors: accounting
+ * method and entity type with the engine defaults when the settings row is
+ * missing or the read fails (errors are deliberately ignored, as before).
+ */
+async function loadBookingContext(
+ supabase: SupabaseClient,
+ companyId: string
+): Promise<{ accountingMethod: AccountingMethod; entityType: EntityType }> {
+ const { data: settings } = await supabase
+ .from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
+
+ return {
+ accountingMethod: (settings?.accounting_method as AccountingMethod) || 'accrual',
+ entityType: (settings?.entity_type as EntityType) || 'enskild_firma',
+ }
+}
+
+/**
+ * Catch-body shared by the ledger-writing executors: a BookkeepingError is
+ * rethrown so the dispatcher maps it to a structured code; anything else
+ * becomes a plain failure with the executor's fallback text and status.
+ */
+function failUnlessBookkeepingError(err: unknown, fallback: string, status: number): ExecutorResult {
+ if (isBookkeepingError(err)) throw err
+ return { error: err instanceof Error ? err.message : fallback, status }
+}
+
type ExecutorResult = {
data?: Record
error?: string
@@ -2253,11 +2285,7 @@ async function commitMarkInvoicePaid(
}
}
- const { data: settings } = await supabase
- .from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
-
- const accountingMethod = settings?.accounting_method || 'accrual'
- const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
+ const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -2911,11 +2939,7 @@ async function commitMatchTransactionInvoice(
// (BookkeepingDatabaseError on a failed cash_accounts lookup), and a throw
// here must reject the op with NOTHING posted. Behavior-preserving on the
// happy path: these are pure reads.
- const { data: settings } = await supabase
- .from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
-
- const accountingMethod = settings?.accounting_method || 'accrual'
- const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
+ const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
// Route on invoice state, not the company's current setting. Mirror of
// the match-invoice route fix: see that handler for the full rationale.
@@ -3137,6 +3161,38 @@ async function commitMatchTransactionInvoice(
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
}
+type VoucherLinkOutcome =
+ | { ok: true; result: LinkInvoiceToVoucherResult | LinkSupplierInvoiceToVoucherResult }
+ | { ok: false; code: string }
+
+/**
+ * Map a voucher-link outcome (customer or supplier invoice) to the executor
+ * result. 404/409 are auto-rejected by the dispatcher (the user can re-stage
+ * with adjusted inputs); 400 surfaces as a normal failure so the UI can
+ * explain what went wrong.
+ */
+function voucherLinkOutcomeToResult(outcome: VoucherLinkOutcome): ExecutorResult {
+ if (!outcome.ok) {
+ const entry = getErrorEntry(outcome.code)
+ return {
+ error: entry?.message_en ?? outcome.code,
+ status: entry?.httpStatus ?? 500,
+ }
+ }
+
+ return {
+ data: {
+ invoice_status: outcome.result.invoiceStatus,
+ paid_amount: outcome.result.paidAmount,
+ remaining_amount: outcome.result.remainingAmount,
+ payment_amount: outcome.result.paymentAmount,
+ payment_id: outcome.result.paymentId,
+ journal_entry_id: outcome.result.journalEntryId,
+ reconciled_transaction_id: outcome.result.reconciledTransactionId,
+ },
+ }
+}
+
async function commitLinkInvoiceVoucher(
supabase: SupabaseClient,
userId: string,
@@ -3157,29 +3213,7 @@ async function commitLinkInvoiceVoucher(
notes,
})
- if (!outcome.ok) {
- const entry = getErrorEntry(outcome.code)
- const httpStatus = entry?.httpStatus ?? 500
- // 404/409 are auto-rejected by the dispatcher (the user can re-stage with
- // adjusted inputs); 400 surfaces as a normal failure so the UI can
- // explain what went wrong.
- return {
- error: entry?.message_en ?? outcome.code,
- status: httpStatus,
- }
- }
-
- return {
- data: {
- invoice_status: outcome.result.invoiceStatus,
- paid_amount: outcome.result.paidAmount,
- remaining_amount: outcome.result.remainingAmount,
- payment_amount: outcome.result.paymentAmount,
- payment_id: outcome.result.paymentId,
- journal_entry_id: outcome.result.journalEntryId,
- reconciled_transaction_id: outcome.result.reconciledTransactionId,
- },
- }
+ return voucherLinkOutcomeToResult(outcome)
}
async function commitLinkSupplierInvoiceVoucher(
@@ -3202,45 +3236,43 @@ async function commitLinkSupplierInvoiceVoucher(
notes,
})
- if (!outcome.ok) {
- const entry = getErrorEntry(outcome.code)
- // 404/409 are auto-rejected by the dispatcher (the user can re-stage with
- // adjusted inputs); 400 surfaces as a normal failure so the UI can explain.
- return {
- error: entry?.message_en ?? outcome.code,
- status: entry?.httpStatus ?? 500,
- }
- }
-
- return {
- data: {
- invoice_status: outcome.result.invoiceStatus,
- paid_amount: outcome.result.paidAmount,
- remaining_amount: outcome.result.remainingAmount,
- payment_amount: outcome.result.paymentAmount,
- payment_id: outcome.result.paymentId,
- journal_entry_id: outcome.result.journalEntryId,
- reconciled_transaction_id: outcome.result.reconciledTransactionId,
- },
- }
+ return voucherLinkOutcomeToResult(outcome)
}
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
+/**
+ * Close / lock / unlock share one shape: require fiscal_period_id, run the
+ * period-service transition, answer with the period id plus the timestamp
+ * the transition set (closed_at or locked_at), and turn any throw into a
+ * 400 with the service's message.
+ */
+async function runPeriodTransition(
+ supabase: SupabaseClient,
+ userId: string,
+ companyId: string,
+ params: Record,
+ transition: (supabase: SupabaseClient, companyId: string, userId: string, id: string) => Promise,
+ timestampKey: 'closed_at' | 'locked_at',
+ fallback: string
+): Promise {
+ const id = params.fiscal_period_id as string
+ if (!id) return { error: 'fiscal_period_id is required', status: 400 }
+ try {
+ const period = await transition(supabase, companyId, userId, id)
+ return { data: { period_id: period.id, [timestampKey]: period[timestampKey] } }
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : fallback, status: 400 }
+ }
+}
+
async function commitClosePeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record
): Promise {
- const id = params.fiscal_period_id as string
- if (!id) return { error: 'fiscal_period_id is required', status: 400 }
- try {
- const period = await closePeriod(supabase, companyId, userId, id)
- return { data: { period_id: period.id, closed_at: period.closed_at } }
- } catch (err) {
- return { error: err instanceof Error ? err.message : 'Close failed', status: 400 }
- }
+ return runPeriodTransition(supabase, userId, companyId, params, closePeriod, 'closed_at', 'Close failed')
}
async function commitLockPeriod(
@@ -3249,14 +3281,7 @@ async function commitLockPeriod(
companyId: string,
params: Record
): Promise {
- const id = params.fiscal_period_id as string
- if (!id) return { error: 'fiscal_period_id is required', status: 400 }
- try {
- const period = await lockPeriod(supabase, companyId, userId, id)
- return { data: { period_id: period.id, locked_at: period.locked_at } }
- } catch (err) {
- return { error: err instanceof Error ? err.message : 'Lock failed', status: 400 }
- }
+ return runPeriodTransition(supabase, userId, companyId, params, lockPeriod, 'locked_at', 'Lock failed')
}
async function commitUnlockPeriod(
@@ -3265,14 +3290,7 @@ async function commitUnlockPeriod(
companyId: string,
params: Record
): Promise {
- const id = params.fiscal_period_id as string
- if (!id) return { error: 'fiscal_period_id is required', status: 400 }
- try {
- const period = await unlockPeriod(supabase, companyId, userId, id)
- return { data: { period_id: period.id, locked_at: period.locked_at } }
- } catch (err) {
- return { error: err instanceof Error ? err.message : 'Unlock failed', status: 400 }
- }
+ return runPeriodTransition(supabase, userId, companyId, params, unlockPeriod, 'locked_at', 'Unlock failed')
}
async function commitUncategorizeTransaction(
@@ -3288,8 +3306,7 @@ async function commitUncategorizeTransaction(
try {
await reverseEntry(supabase, companyId, userId, journalEntryId)
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Reversal failed', status: 500 }
+ return failUnlessBookkeepingError(err, 'Reversal failed', 500)
}
const { error: updateError } = await supabase
@@ -3716,8 +3733,7 @@ async function commitRunYearEnd(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Year-end failed', status: 400 }
+ return failUnlessBookkeepingError(err, 'Year-end failed', 400)
}
}
@@ -3831,11 +3847,7 @@ async function commitPostKontantmetodCutoff(
if (err instanceof KontantmetodCutoffPartialError) {
throw new PartialCommitError(err.message, err.postedIds, err.cause)
}
- if (isBookkeepingError(err)) throw err
- return {
- error: err instanceof Error ? err.message : 'Kontantmetodens bokslutsavgränsning misslyckades',
- status: 400,
- }
+ return failUnlessBookkeepingError(err, 'Kontantmetodens bokslutsavgränsning misslyckades', 400)
}
}
@@ -3853,8 +3865,7 @@ async function commitSetOpeningBalances(
const entry = await generateOpeningBalances(supabase, companyId, userId, closedId, nextId)
return { data: { opening_balance_entry_id: entry.id } }
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Opening balances failed', status: 400 }
+ return failUnlessBookkeepingError(err, 'Opening balances failed', 400)
}
}
@@ -3876,8 +3887,7 @@ async function commitRunCurrencyRevaluation(
: { entry_id: null, items_revalued: 0, message: 'No foreign-currency items to revalue' },
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Revaluation failed', status: 400 }
+ return failUnlessBookkeepingError(err, 'Revaluation failed', 400)
}
}
@@ -3910,8 +3920,7 @@ async function commitPostAnnualDepreciation(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Depreciation posting failed', status: 400 }
+ return failUnlessBookkeepingError(err, 'Depreciation posting failed', 400)
}
}
@@ -4517,8 +4526,7 @@ async function commitCreditSupplierInvoice(
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Failed to book credit note', status: 500 }
+ return failUnlessBookkeepingError(err, 'Failed to book credit note', 500)
}
}
@@ -4660,14 +4668,7 @@ async function commitCreditInvoice(
.eq('id', creditNote.id)
.single()
- const { data: settings } = await supabase
- .from('company_settings')
- .select('entity_type, accounting_method')
- .eq('company_id', companyId)
- .single()
-
- const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
- const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
+ const { accountingMethod, entityType } = await loadBookingContext(supabase, companyId)
// Resolve the original verifikation reference so the credit-note JE can
// point back to the corrected entry per BFL 5 kap. 5 §. We tolerate
@@ -4959,8 +4960,7 @@ async function commitImportSie(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'SIE import failed', status: 500 }
+ return failUnlessBookkeepingError(err, 'SIE import failed', 500)
}
}
@@ -5216,8 +5216,7 @@ async function commitCreateVoucher(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Failed to create voucher', status: 500 }
+ return failUnlessBookkeepingError(err, 'Failed to create voucher', 500)
}
}
@@ -5309,8 +5308,7 @@ async function commitCorrectEntry(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Failed to correct entry', status: 500 }
+ return failUnlessBookkeepingError(err, 'Failed to correct entry', 500)
}
}
@@ -5394,8 +5392,7 @@ async function commitReverseEntry(
},
}
} catch (err) {
- if (isBookkeepingError(err)) throw err
- return { error: err instanceof Error ? err.message : 'Failed to reverse entry', status: 500 }
+ return failUnlessBookkeepingError(err, 'Failed to reverse entry', 500)
}
}
@@ -5597,7 +5594,6 @@ async function commitUpdatePayslipLine(
try {
const { updatePayslipLine } = await import('@/lib/salary/payslip-lines')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await updatePayslipLine(supabase, {
companyId,
salaryRunId,
@@ -5642,7 +5638,6 @@ async function commitSetRunSalary(
try {
const { setRunEmployeeSalary } = await import('@/lib/salary/run-employees')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await setRunEmployeeSalary(supabase, {
companyId,
salaryRunId,
@@ -5686,7 +5681,6 @@ async function commitUpdateSalaryRun(
try {
const { updateDraftSalaryRun } = await import('@/lib/salary/update-run')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await updateDraftSalaryRun(supabase, {
companyId,
salaryRunId,
@@ -5724,7 +5718,6 @@ async function commitCreateEmployee(
): Promise {
try {
const { createEmployee } = await import('@/lib/salary/employee-commands')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await createEmployee(supabase, {
companyId,
userId,
@@ -5761,7 +5754,6 @@ async function commitUpdateEmployee(
try {
const { updateEmployee } = await import('@/lib/salary/employee-commands')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await updateEmployee(supabase, { companyId, employeeId, patch })
if (!result.ok) {
const entry = getErrorEntry(result.code)
@@ -5796,7 +5788,6 @@ async function commitRegisterAbsence(
try {
const { upsertAbsenceRange } = await import('@/lib/salary/absence')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await upsertAbsenceRange(supabase, {
companyId,
employeeId,
@@ -5856,7 +5847,6 @@ async function commitBookSalaryRun(
try {
const { advanceAndBookSalaryRun } = await import('@/lib/salary/book-run')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await advanceAndBookSalaryRun(supabase, {
companyId,
userId,
@@ -5912,7 +5902,6 @@ async function commitDeleteAbsence(
try {
const { deleteAbsenceRange } = await import('@/lib/salary/absence')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await deleteAbsenceRange(supabase, {
companyId,
employeeId,
@@ -5966,13 +5955,11 @@ async function commitSetEmployeeOpeningBalances(
}
try {
- const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas')
const parsed = OpeningBalancesBulkSchema.safeParse({ items })
if (!parsed.success) {
return { error: 'Ogiltiga ingående saldon i den godkända operationen', status: 400 }
}
const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await setOpeningBalancesBulk(supabase, {
companyId,
userId,
@@ -6014,7 +6001,6 @@ async function commitVacationYearClose(
try {
const { commitVacationYearClose: runClose } = await import('@/lib/salary/semesterberedning')
- const { getErrorEntry } = await import('@/lib/errors/structured-errors')
const result = await runClose(supabase, companyId, userId, yearStart, { bookAdjustment })
if (!result.ok) {
const entry = getErrorEntry(result.code)
@@ -6049,20 +6035,25 @@ async function commitVacationYearClose(
// Error, which the dispatcher catch releases back to 'pending'; a non-recoverable
// failure becomes a plain { error, status } that rejects the op.
-function getSkatteverketServices(): SkatteverketCommitServices {
- const services = extensionRegistry.get('skatteverket')?.services as
- | Partial
- | undefined
- if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
- // Extension absent or not wired. Recoverable: leave the op pending so a
- // re-enable + re-approve works without re-staging.
+/**
+ * Resolve the Skatteverket extension's services and require the given keys
+ * to be wired. Extension absent or not wired is recoverable: the op is left
+ * pending so a re-enable + re-approve works without re-staging.
+ */
+function requireSkatteverketServices(keys: ReadonlyArray): T {
+ const services = extensionRegistry.get('skatteverket')?.services as Partial | undefined
+ if (!services || keys.some((key) => !services[key])) {
throw new SkatteverketRecoverableError(
'Skatteverket-integrationen är inte tillgänglig.',
'EXTENSION_DISABLED',
503,
)
}
- return services as SkatteverketCommitServices
+ return services as T
+}
+
+function getSkatteverketServices(): SkatteverketCommitServices {
+ return requireSkatteverketServices(['commitSubmitVatDeclaration', 'commitSubmitAgi'])
}
function handleSkvSubmitResult(result: SkvSubmitResult): ExecutorResult {
@@ -6115,19 +6106,7 @@ async function commitSubmitAgi(
// work (or fail recoverable) independently of the SKV filing services.
function getSkattekontoBookingService(): SkattekontoBookingCommitService {
- const services = extensionRegistry.get('skatteverket')?.services as
- | Partial
- | undefined
- if (!services?.commitBookSkattekontoRows) {
- // Extension absent or not wired. Recoverable: leave the op pending so a
- // re-enable + re-approve works without re-staging.
- throw new SkatteverketRecoverableError(
- 'Skatteverket-integrationen är inte tillgänglig.',
- 'EXTENSION_DISABLED',
- 503,
- )
- }
- return services as SkattekontoBookingCommitService
+ return requireSkatteverketServices(['commitBookSkattekontoRows'])
}
async function commitBookSkattekontoRows(
diff --git a/lib/pending-operations/errors.ts b/lib/pending-operations/errors.ts
index e3b0571a..c4ac4bff 100644
--- a/lib/pending-operations/errors.ts
+++ b/lib/pending-operations/errors.ts
@@ -29,7 +29,3 @@ export class PartialCommitError extends Error {
this.cause = cause
}
}
-
-export function isPartialCommitError(err: unknown): err is PartialCommitError {
- return err instanceof PartialCommitError
-}
diff --git a/lib/pending-operations/schemas/account.ts b/lib/pending-operations/schemas/account.ts
index c5b819b8..c4758ac4 100644
--- a/lib/pending-operations/schemas/account.ts
+++ b/lib/pending-operations/schemas/account.ts
@@ -137,6 +137,3 @@ export const UpdateAccountParamsSchema = z.object({
})
}
})
-
-export type CreateAccountParams = z.infer
-export type UpdateAccountParams = z.infer
diff --git a/lib/pending-operations/schemas/article.ts b/lib/pending-operations/schemas/article.ts
index 9091f22a..012ae5e3 100644
--- a/lib/pending-operations/schemas/article.ts
+++ b/lib/pending-operations/schemas/article.ts
@@ -89,5 +89,3 @@ export const UpdateArticleParamsSchema = z.object({
active: z.boolean().optional(),
})
-export type CreateArticleParams = z.infer
-export type UpdateArticleParams = z.infer
diff --git a/lib/pending-operations/schemas/company-settings.ts b/lib/pending-operations/schemas/company-settings.ts
index 7ce8eaa2..0299c659 100644
--- a/lib/pending-operations/schemas/company-settings.ts
+++ b/lib/pending-operations/schemas/company-settings.ts
@@ -94,6 +94,3 @@ export const UpdateCompanySettingsParamsSchema = z
})
.strict()
-export type UpdateCompanySettingsParams = z.infer<
- typeof UpdateCompanySettingsParamsSchema
->
diff --git a/lib/pending-operations/schemas/create-supplier.ts b/lib/pending-operations/schemas/create-supplier.ts
index f0ef182c..9d54b88d 100644
--- a/lib/pending-operations/schemas/create-supplier.ts
+++ b/lib/pending-operations/schemas/create-supplier.ts
@@ -168,5 +168,3 @@ export const CreateSupplierParamsSchema = z
// An EU supplier below its national registration threshold has no VAT number at
// all, so requiring one refused legitimate suppliers on the staged/agent path
// while the dashboard form created the very same company without complaint.
-
-export type CreateSupplierParams = z.infer
diff --git a/lib/pending-operations/schemas/customer.ts b/lib/pending-operations/schemas/customer.ts
index 90785b5a..06674b75 100644
--- a/lib/pending-operations/schemas/customer.ts
+++ b/lib/pending-operations/schemas/customer.ts
@@ -49,4 +49,3 @@ export const UpdateCustomerParamsSchema = z
})
.strict()
-export type UpdateCustomerParams = z.infer
diff --git a/lib/pending-operations/schemas/dimension-value.ts b/lib/pending-operations/schemas/dimension-value.ts
index b4d792e1..dbdbc188 100644
--- a/lib/pending-operations/schemas/dimension-value.ts
+++ b/lib/pending-operations/schemas/dimension-value.ts
@@ -69,5 +69,3 @@ export const CreateDimensionValueParamsSchema = z
})
}
})
-
-export type CreateDimensionValueParams = z.infer
diff --git a/lib/pending-operations/schemas/ignore-transaction.ts b/lib/pending-operations/schemas/ignore-transaction.ts
index 0865f2df..33ee3f58 100644
--- a/lib/pending-operations/schemas/ignore-transaction.ts
+++ b/lib/pending-operations/schemas/ignore-transaction.ts
@@ -15,5 +15,3 @@ export const IgnoreTransactionParamsSchema = z.object({
// true = ignore (default), false = restore a previously ignored row.
ignored: z.boolean().default(true),
})
-
-export type IgnoreTransactionParams = z.infer
diff --git a/lib/pending-operations/schemas/recurring-schedule.ts b/lib/pending-operations/schemas/recurring-schedule.ts
index f70f4e91..5307337e 100644
--- a/lib/pending-operations/schemas/recurring-schedule.ts
+++ b/lib/pending-operations/schemas/recurring-schedule.ts
@@ -34,6 +34,3 @@ export const UpdateRecurringScheduleParamsSchema = z
changes: RecurringScheduleChangesSchema,
})
.strict()
-
-export type CreateRecurringScheduleParams = z.infer
-export type UpdateRecurringScheduleParams = z.infer
diff --git a/lib/pending-operations/schemas/retag-line-dimensions.ts b/lib/pending-operations/schemas/retag-line-dimensions.ts
index 691d307c..ad29f8d3 100644
--- a/lib/pending-operations/schemas/retag-line-dimensions.ts
+++ b/lib/pending-operations/schemas/retag-line-dimensions.ts
@@ -56,5 +56,3 @@ export const RetagLineDimensionsParamsSchema = z
filter_summary: z.string().max(500).optional(),
})
.strict()
-
-export type RetagLineDimensionsParams = z.infer
diff --git a/lib/pending-operations/schemas/update-invoice.ts b/lib/pending-operations/schemas/update-invoice.ts
index 4bac1210..ef1e6a55 100644
--- a/lib/pending-operations/schemas/update-invoice.ts
+++ b/lib/pending-operations/schemas/update-invoice.ts
@@ -46,4 +46,3 @@ export const UpdateInvoiceParamsSchema = z
})
.strict()
-export type UpdateInvoiceParams = z.infer
diff --git a/lib/pending-operations/schemas/voucher-note.ts b/lib/pending-operations/schemas/voucher-note.ts
index 92ef8ac3..6d3f1855 100644
--- a/lib/pending-operations/schemas/voucher-note.ts
+++ b/lib/pending-operations/schemas/voucher-note.ts
@@ -17,5 +17,3 @@ export const SetVoucherNoteParamsSchema = z.object({
z.string().max(2000).nullable(),
),
})
-
-export type SetVoucherNoteParams = z.infer
diff --git a/lib/providers/bjornlunden/oauth.ts b/lib/providers/bjornlunden/oauth.ts
index 4974f936..2cf6dbdc 100644
--- a/lib/providers/bjornlunden/oauth.ts
+++ b/lib/providers/bjornlunden/oauth.ts
@@ -49,4 +49,3 @@ export async function refreshBjornLundenToken(): Promise {
return fetchBjornLundenToken(clientId, clientSecret);
}
-export const storeBjornLundenToken = refreshBjornLundenToken;
diff --git a/lib/providers/bokio/oauth.ts b/lib/providers/bokio/oauth.ts
deleted file mode 100644
index dac68554..00000000
--- a/lib/providers/bokio/oauth.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { TokenResponse } from '../types';
-
-export function storeBokioToken(apiToken: string): TokenResponse {
- return {
- access_token: apiToken,
- refresh_token: '',
- token_type: 'Bearer',
- expires_in: 0,
- };
-}
diff --git a/lib/providers/dto.ts b/lib/providers/dto.ts
index 576139cc..ffb140b7 100644
--- a/lib/providers/dto.ts
+++ b/lib/providers/dto.ts
@@ -368,107 +368,3 @@ export interface PaymentDto {
updatedAt?: string;
_raw?: Record;
}
-
-// ============================================
-// 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;
-}
-
-// ============================================
-// 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;
-}
-
-// ============================================
-// 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;
-}
-
-export interface IncomeStatementDto {
- fiscalYear: number;
- periodStart: string;
- periodEnd: string;
- baseCurrency: string;
- revenue: FinancialReportCategoryDto;
- expenses: FinancialReportCategoryDto;
- netIncome: AmountType;
- _raw?: Record;
-}
-
-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;
-}
-
-// ============================================
-// 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;
-}
diff --git a/lib/providers/fortnox/mapper.ts b/lib/providers/fortnox/mapper.ts
index 6f3be5e5..8fdf6cc1 100644
--- a/lib/providers/fortnox/mapper.ts
+++ b/lib/providers/fortnox/mapper.ts
@@ -6,7 +6,6 @@ import type {
JournalDto, AccountingEntryDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
- PaymentDto,
AmountType, PartyDto,
} from '../dto';
import { readNumber, resolveVatTriple, lineVatFromPercent } from '../amounts';
@@ -379,15 +378,3 @@ export function mapFortnoxToCompanyInformation(raw: Record): Co
_raw: raw,
};
}
-
-export function mapFortnoxToPayment(raw: Record, 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,
- };
-}
diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts
index 19e1aea1..0a9a1991 100644
--- a/lib/providers/fortnox/oauth.ts
+++ b/lib/providers/fortnox/oauth.ts
@@ -3,7 +3,6 @@ import type { OAuthConfig, TokenResponse } from '../types';
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
- OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout';
const BASE_SCOPES = [
@@ -187,26 +186,3 @@ export async function refreshFortnoxToken(
return response.json() as Promise;
}
-
-export async function revokeFortnoxToken(
- config: OAuthConfig,
- refreshToken: string,
-): Promise {
- const response = await fetchWithTimeout(
- 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(),
- },
- { timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Fortnox token revoke' },
- );
-
- return response.ok;
-}
diff --git a/lib/providers/oauth-config.ts b/lib/providers/oauth-config.ts
index fd175ba6..a7e035ed 100644
--- a/lib/providers/oauth-config.ts
+++ b/lib/providers/oauth-config.ts
@@ -49,7 +49,3 @@ export function getOAuthConfig(provider: string): OAuthConfig {
}
throw new Error(`Unknown provider: ${provider}`);
}
-
-export function validateProvider(provider: string): boolean {
- return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden' || provider === 'wint';
-}
diff --git a/lib/providers/visma/config.ts b/lib/providers/visma/config.ts
index 6aa32fb1..8a7ac706 100644
--- a/lib/providers/visma/config.ts
+++ b/lib/providers/visma/config.ts
@@ -13,7 +13,6 @@ import {
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> = {
diff --git a/lib/providers/visma/oauth.ts b/lib/providers/visma/oauth.ts
index 90110ce0..396adaeb 100644
--- a/lib/providers/visma/oauth.ts
+++ b/lib/providers/visma/oauth.ts
@@ -1,9 +1,8 @@
-import { VISMA_AUTH_URL, VISMA_TOKEN_URL, VISMA_REVOKE_URL } from './config';
+import { VISMA_AUTH_URL, VISMA_TOKEN_URL } from './config';
import type { OAuthConfig, TokenResponse } from '../types';
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
- OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout';
const DEFAULT_SCOPES = [
@@ -105,25 +104,3 @@ export async function refreshVismaToken(
return response.json() as Promise;
}
-export async function revokeVismaToken(
- config: OAuthConfig,
- refreshToken: string,
-): Promise {
- const response = await fetchWithTimeout(
- 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(),
- },
- { timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Visma token revoke' },
- );
-
- return response.ok;
-}
diff --git a/lib/providers/wint/client.ts b/lib/providers/wint/client.ts
index af72494a..21ec08af 100644
--- a/lib/providers/wint/client.ts
+++ b/lib/providers/wint/client.ts
@@ -47,12 +47,6 @@ export interface WintListResponse {
TotalItemsWithOutFilter?: number;
}
-export interface WintFinancialYear {
- Id: number;
- Start: string;
- End: string;
-}
-
const DEFAULT_PAGE_SIZE = 200;
export class WintClient {
diff --git a/lib/providers/with-provider-call.ts b/lib/providers/with-provider-call.ts
index e9f25f89..0265fa31 100644
--- a/lib/providers/with-provider-call.ts
+++ b/lib/providers/with-provider-call.ts
@@ -47,10 +47,6 @@ export class ProviderCallError extends Error {
}
}
-export function isProviderCallError(err: unknown): err is ProviderCallError {
- return err instanceof ProviderCallError
-}
-
interface ProviderCallOptions {
/** Provider id ('fortnox', 'bokio', 'visma', etc.). */
provider: string
diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts
index 474ba371..b609a393 100644
--- a/lib/reconciliation/bank-reconciliation.ts
+++ b/lib/reconciliation/bank-reconciliation.ts
@@ -1887,7 +1887,8 @@ export async function autoReconcileTransactionForLinkedVoucher(
* Fetch unlinked GL lines for a settlement account. `accountNumber` defaults to
* '1930' for back-compat; multi-account customers (Plusgiro 1920, kreditkort
* 1940, EUR-konto 1932, etc.) pass the BAS code of the account they're
- * reconciling. The CashAccountSelector populates this from cash_accounts.
+ * reconciling. The reconciliation UI populates this from cash_accounts.
+
*/
export async function fetchUnlinkedGLLines(
supabase: SupabaseClient,
diff --git a/lib/reconciliation/schemas.ts b/lib/reconciliation/schemas.ts
index 9b8828ed..c6f015c6 100644
--- a/lib/reconciliation/schemas.ts
+++ b/lib/reconciliation/schemas.ts
@@ -21,10 +21,35 @@ export const ACCOUNT_KEY_REGEX =
/^(bank:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|skattekonto|manual:\d{4})$/
export const AccountKeySchema = z.string().regex(ACCOUNT_KEY_REGEX, 'Ogiltig account_key')
-export type AccountKey = z.infer
export const ReconciliationKindSchema = z.enum(['bank', 'skattekonto', 'manual'])
-export type ReconciliationKind = z.infer
+
+/** One link request: outside rows against verifikat (N:1), or a bank 1:N with allocations. */
+export const ReconciliationPairSchema = z.object({
+ external_ids: z.array(z.string().uuid()).min(1).max(50),
+ journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
+ // Bank 1:N only: the signed slice per verifikat (transaction sign
+ // convention). Omitted: each slice defaults to the voucher's bank line.
+ allocations: z
+ .array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
+ .min(2)
+ .max(50)
+ .optional(),
+})
+
+/** Body fields shared by the dashboard and v1 POST .../links routes (spread into z.object). */
+export const reconciliationLinksBodyFields = {
+ pairs: z.array(ReconciliationPairSchema).max(200).optional(),
+ use_proposals: z.boolean().optional(),
+ confidence_threshold: z.number().min(0).max(1).optional(),
+}
+
+/** The links body must carry explicit pairs or opt into the persisted proposals. */
+export const reconciliationLinksBodyRefinement = [
+ (b: { pairs?: unknown[]; use_proposals?: boolean }) =>
+ (b.pairs && b.pairs.length > 0) || b.use_proposals === true,
+ { message: 'Ange pairs eller use_proposals: true.' },
+] as const
export type ParsedAccountKey =
| { kind: 'bank'; cashAccountId: string }
diff --git a/lib/reconciliation/service.ts b/lib/reconciliation/service.ts
index c751805e..824fccb1 100644
--- a/lib/reconciliation/service.ts
+++ b/lib/reconciliation/service.ts
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
+import { daysBetweenIso, toIsoDate } from '@/lib/dates/iso'
import { getReconciliationStatus as getBankReconciliationStatus } from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import {
@@ -59,15 +60,6 @@ interface CashAccountRow {
updated_at: string | null
}
-function isoDate(d: Date): string {
- return d.toISOString().slice(0, 10)
-}
-
-function daysBetween(a: string, b: string): number {
- const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime()
- return Math.round(ms / 86_400_000)
-}
-
function defaultWindow(today: string): { from: string; to: string } {
return { from: `${today.slice(0, 4)}-01-01`, to: today }
}
@@ -158,7 +150,7 @@ async function bankStatus(
Boolean(account.is_primary),
)
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
- const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
+ const stale = !syncedAt || daysBetweenIso(syncedAt.slice(0, 10), today) > STALE_AFTER_DAYS
// The bank-reported (booked) balance, mirrored from the last PSD2 balance
// refresh. Point-in-time and dated by balance_updated_at, NOT by any
// through-date a caller asks for. It therefore lives ONLY in the bank block
@@ -240,7 +232,7 @@ export async function listReconciliationAccounts(
companyId: string,
options: ListAccountsOptions = {},
): Promise {
- const today = options.today ?? isoDate(new Date())
+ const today = options.today ?? toIsoDate(new Date())
const withStatus = options.withStatus ?? true
const window = {
from: options.windowFrom ?? defaultWindow(today).from,
@@ -320,7 +312,7 @@ export async function listReconciliationAccounts(
} catch {
syncedAt = null
}
- const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
+ const stale = !syncedAt || daysBetweenIso(syncedAt.slice(0, 10), today) > STALE_AFTER_DAYS
return {
account_key: bankAccountKey(a.id),
kind: 'bank',
@@ -406,7 +398,7 @@ export async function getAccountStatus(
): Promise {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
- const today = options.today ?? isoDate(new Date())
+ const today = options.today ?? toIsoDate(new Date())
let status: ReconciliationStatus | null = null
if (parsed.kind === 'skattekonto') {
diff --git a/lib/reconciliation/signoff.ts b/lib/reconciliation/signoff.ts
index a383e506..8392c9f0 100644
--- a/lib/reconciliation/signoff.ts
+++ b/lib/reconciliation/signoff.ts
@@ -3,6 +3,7 @@ import { ISO_DATE_RE } from '@/lib/invariants'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
+import { todayIsoUtc } from '@/lib/dates/iso'
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
import { getAccountStatus } from './service'
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
@@ -81,10 +82,6 @@ export type SignoffResult =
| { dry_run: true; would_sign: SignoffPreview }
| { dry_run: false; signoff: ReconciliationSignoff }
-function isoToday(): string {
- return new Date().toISOString().slice(0, 10)
-}
-
/**
* Sign one account off through a date. Returns null when the account key does
* not resolve for this company (callers map that to 404); throws
@@ -100,7 +97,7 @@ export async function signOffAccount(
): Promise {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
- const today = options.today ?? isoToday()
+ const today = options.today ?? todayIsoUtc()
const throughDate = input.through_date
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
throw new ReconciliationSignoffError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'INVALID_DATE')
diff --git a/lib/reconciliation/skattekonto-reconciliation.ts b/lib/reconciliation/skattekonto-reconciliation.ts
index f9995a7d..57c17327 100644
--- a/lib/reconciliation/skattekonto-reconciliation.ts
+++ b/lib/reconciliation/skattekonto-reconciliation.ts
@@ -1,9 +1,11 @@
+import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
+import { addDaysIso, daysBetweenIso, toIsoDate } from '@/lib/dates/iso'
import { LEDGER_BALANCE_STATUSES, sumAccountBalance } from './gl-balance'
import {
AWAITING_EXTERNAL_DAYS,
@@ -95,20 +97,6 @@ export interface SkattekontoReconciliationResult extends ReconciliationStatus {
ledger_read_failed: boolean
}
-function round2(n: number): number {
- return roundOre(n)
-}
-
-function addDays(iso: string, days: number): string {
- const d = new Date(iso + 'T00:00:00Z')
- d.setUTCDate(d.getUTCDate() + days)
- return d.toISOString().slice(0, 10)
-}
-
-function isoDate(d: Date): string {
- return d.toISOString().slice(0, 10)
-}
-
function parseFetchedAt(value: unknown): Date | null {
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value)
if (typeof value === 'string') {
@@ -119,12 +107,6 @@ function parseFetchedAt(value: unknown): Date | null {
return null
}
-function chunk(xs: T[], size: number): T[][] {
- const out: T[][] = []
- for (let i = 0; i < xs.length; i += size) out.push(xs.slice(i, i + size))
- return out
-}
-
async function readSnapshot(
supabase: SupabaseClient,
companyId: string,
@@ -141,7 +123,7 @@ async function readSnapshot(
const fetchedAt = parseFetchedAt(value.fetchedAt)
const saldo = Number(value.saldo?.saldoSkatteverket)
if (!fetchedAt || !Number.isFinite(saldo)) return null
- return { saldo: round2(saldo), fetchedAt }
+ return { saldo: roundOre(saldo), fetchedAt }
}
async function fetchRows(supabase: SupabaseClient, companyId: string): Promise {
@@ -209,8 +191,8 @@ async function fetchLedgerEntries(
if (!head) continue
const amount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0)
const existing = byEntry.get(head.id)
- if (existing) existing.amount = round2(existing.amount + amount)
- else byEntry.set(head.id, { head, amount: round2(amount) })
+ if (existing) existing.amount = roundOre(existing.amount + amount)
+ else byEntry.set(head.id, { head, amount: roundOre(amount) })
}
return byEntry
}
@@ -226,16 +208,11 @@ function proposalFrom(head: EntryHead, row: SkattekontoRow): ReconciliationPropo
confidence: head.status === 'posted' ? 0.95 : 0.8,
reasons: [
'exakt belopp på 1630',
- `${Math.abs(daysBetween(head.entry_date, row.transaktionsdatum))} dagars avstånd`,
+ `${Math.abs(daysBetweenIso(row.transaktionsdatum, head.entry_date))} dagars avstånd`,
],
}
}
-function daysBetween(a: string, b: string): number {
- const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime()
- return Math.round(ms / 86_400_000)
-}
-
function inWindow(date: string, from: string | null | undefined, to: string | null | undefined): boolean {
if (from && date < from) return false
if (to && date > to) return false
@@ -275,16 +252,16 @@ export async function getSkattekontoReconciliationStatus(
companyId: string,
options: SkattekontoReconciliationOptions = {},
): Promise {
- const today = options.today ?? isoDate(new Date())
+ const today = options.today ?? toIsoDate(new Date())
const [snapshot, rows] = await Promise.all([
readSnapshot(supabase, companyId),
fetchRows(supabase, companyId),
])
if (!snapshot && rows.length === 0) return null
- const cutoffDate = snapshot ? isoDate(snapshot.fetchedAt) : today
+ const cutoffDate = snapshot ? toIsoDate(snapshot.fetchedAt) : today
const asOf = snapshot ? snapshot.fetchedAt.toISOString() : new Date(today + 'T00:00:00Z').toISOString()
- const stale = !snapshot || daysBetween(today, cutoffDate) > STALE_AFTER_DAYS
+ const stale = !snapshot || daysBetweenIso(cutoffDate, today) > STALE_AFTER_DAYS
const booked = rows.filter((r) => r.status === 'booked' && r.transaktionsdatum <= cutoffDate)
const upcoming = rows.filter((r) => r.status === 'upcoming' && !r.is_ignored)
@@ -340,7 +317,7 @@ export async function getSkattekontoReconciliationStatus(
const olderThanWindow = (date: string) => !!options.windowFrom && date < options.windowFrom
for (const row of booked) {
- const amount = round2(Number(row.belopp_skatteverket))
+ const amount = roundOre(Number(row.belopp_skatteverket))
const base = {
item_id: row.id,
item_type: 'skattekonto_transaction' as const,
@@ -352,7 +329,7 @@ export async function getSkattekontoReconciliationStatus(
}
if (row.is_ignored) {
- ignoredTotal = round2(ignoredTotal + amount)
+ ignoredTotal = roundOre(ignoredTotal + amount)
counts.ignored++
if (visible(row.transaktionsdatum)) {
pushCapped('ignored', { ...base, bucket: 'ignored', actions: ['unignore'] })
@@ -389,7 +366,7 @@ export async function getSkattekontoReconciliationStatus(
}
// Unlinked (or dead link): counts toward the bridge either way.
- unlinkedExternalTotal = round2(unlinkedExternalTotal + amount)
+ unlinkedExternalTotal = roundOre(unlinkedExternalTotal + amount)
if (olderThanWindow(row.transaktionsdatum)) olderUnmatched++
const suggestedHead = row.suggested_journal_entry_id
@@ -429,14 +406,14 @@ export async function getSkattekontoReconciliationStatus(
// Ledger entries in the comparable history that no live link settles.
let unlinkedLedgerTotal = 0
- const awaitingFrom = addDays(cutoffDate, -AWAITING_EXTERNAL_DAYS)
+ const awaitingFrom = addDaysIso(cutoffDate, -AWAITING_EXTERNAL_DAYS)
const sortedLedger = Array.from(ledgerEntries.values()).sort((a, b) =>
a.head.entry_date < b.head.entry_date ? -1 : a.head.entry_date > b.head.entry_date ? 1 : 0,
)
for (const { head, amount } of sortedLedger) {
if (liveLinkedEntryIds.has(head.id)) continue
if (amount === 0) continue
- unlinkedLedgerTotal = round2(unlinkedLedgerTotal + amount)
+ unlinkedLedgerTotal = roundOre(unlinkedLedgerTotal + amount)
counts.unmatched_ledger++
if (olderThanWindow(head.entry_date)) olderUnmatched++
if (!visible(head.entry_date)) continue
@@ -459,8 +436,8 @@ export async function getSkattekontoReconciliationStatus(
let upcomingTotal = 0
for (const row of upcoming) {
- const amount = round2(Number(row.belopp_skatteverket))
- upcomingTotal = round2(upcomingTotal + amount)
+ const amount = roundOre(Number(row.belopp_skatteverket))
+ upcomingTotal = roundOre(upcomingTotal + amount)
pushCapped('upcoming', {
item_id: row.id,
item_type: 'skattekonto_transaction',
@@ -475,16 +452,16 @@ export async function getSkattekontoReconciliationStatus(
}
// Totals and the identity.
- const allBookedTotal = booked.reduce((s, r) => round2(s + Number(r.belopp_skatteverket)), 0)
+ const allBookedTotal = booked.reduce((s, r) => roundOre(s + Number(r.belopp_skatteverket)), 0)
const saldo = snapshot?.saldo ?? null
- const saldoAtStart = saldo === null ? null : round2(saldo - allBookedTotal)
+ const saldoAtStart = saldo === null ? null : roundOre(saldo - allBookedTotal)
const openingDifference =
- saldoAtStart === null || ledgerBefore === null ? null : round2(saldoAtStart - ledgerBefore)
- const difference = saldo === null || ledgerBalance === null ? null : round2(saldo - ledgerBalance)
+ saldoAtStart === null || ledgerBefore === null ? null : roundOre(saldoAtStart - ledgerBefore)
+ const difference = saldo === null || ledgerBalance === null ? null : roundOre(saldo - ledgerBalance)
const unexplained =
difference === null || openingDifference === null
? null
- : round2(
+ : roundOre(
difference - openingDifference - unlinkedExternalTotal - ignoredTotal + unlinkedLedgerTotal,
)
@@ -510,7 +487,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'unmatched_external',
label_sv: 'Händelser som saknas i bokföringen',
label_en: 'Events missing from the ledger',
- amount: round2(-unlinkedExternalTotal),
+ amount: roundOre(-unlinkedExternalTotal),
count: counts.unmatched_external + counts.proposed,
items_bucket: 'unmatched_external',
},
@@ -528,7 +505,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'ignored',
label_sv: 'Ignorerade händelser',
label_en: 'Ignored events',
- amount: round2(-ignoredTotal),
+ amount: roundOre(-ignoredTotal),
count: counts.ignored,
items_bucket: 'ignored',
})
@@ -538,7 +515,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'opening_difference',
label_sv: `Ingående skillnad per ${historyStart ?? cutoffDate}`,
label_en: `Opening difference at ${historyStart ?? cutoffDate}`,
- amount: round2(-openingDifference),
+ amount: roundOre(-openingDifference),
count: null,
items_bucket: null,
})
diff --git a/lib/reference-data/fiscal-scope.ts b/lib/reference-data/fiscal-scope.ts
index acd4c058..8facddb6 100644
--- a/lib/reference-data/fiscal-scope.ts
+++ b/lib/reference-data/fiscal-scope.ts
@@ -7,16 +7,13 @@
import type { FiscalPeriod } from '@/types'
import { ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage'
-
-export function todayIso(): string {
- return new Date().toISOString().split('T')[0]
-}
+import { todayIsoUtc } from '@/lib/dates/iso'
/** Newest first; optionally drops periods that have not started yet. */
export function prepareFiscalPeriods(
periods: readonly FiscalPeriod[],
hideFuturePeriods: boolean,
- today: string = todayIso(),
+ today: string = todayIsoUtc(),
): FiscalPeriod[] {
return periods
.filter((p) => !hideFuturePeriods || p.period_start <= today)
@@ -52,7 +49,7 @@ export function resolveInitialFiscalScope(
const newest = periods[0] ?? null
if (options.preferLatestEnded) {
- const today = options.today ?? todayIso()
+ const today = options.today ?? todayIsoUtc()
const pick = periods.find((p) => p.period_end < today) ?? newest
return pick ? { periodId: pick.id, period: pick } : null
}
diff --git a/lib/reports/behandlingshistorik-pdf-template.tsx b/lib/reports/behandlingshistorik-pdf-template.tsx
index c633a44e..bb9ce928 100644
--- a/lib/reports/behandlingshistorik-pdf-template.tsx
+++ b/lib/reports/behandlingshistorik-pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import {
BEHANDLINGSHISTORIK_CATEGORIES,
@@ -125,11 +126,6 @@ export function pdfText(value: string): string {
return value.replace(/→/g, '->').replace(/−/g, '-')
}
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber
-}
-
function EventRow({ event }: { event: BehandlingshistorikEvent }) {
return (
diff --git a/lib/reports/bokslutsbilagor-pdf-template.tsx b/lib/reports/bokslutsbilagor-pdf-template.tsx
index be3532eb..12f85190 100644
--- a/lib/reports/bokslutsbilagor-pdf-template.tsx
+++ b/lib/reports/bokslutsbilagor-pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import type { BilagaAccount, BilagaChecklistItem, BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types'
import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik'
@@ -72,11 +73,6 @@ function amount(n: number | null): string {
return n == null ? '-' : pdfText(NUMBER.format(n))
}
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber
-}
-
const STATE_LABEL: Record = {
done: '[x] Klart',
not_applicable: '[-] Ej tillämpl.',
diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts
index 68ca6710..718d8447 100644
--- a/lib/reports/catalog.ts
+++ b/lib/reports/catalog.ts
@@ -84,15 +84,6 @@ export interface ReportDescriptor {
standalone?: boolean
}
-/** Categories shown in the legacy desktop rail, in order. */
-export const NAV_CATEGORIES: ReportCategory[] = [
- 'interim',
- 'year_end',
- 'tax_vat',
- 'ledgers',
- 'reconciliation',
-]
-
/** All categories shown on the library landing, in order. */
export const LIBRARY_CATEGORIES: ReportCategory[] = [
'interim',
@@ -385,23 +376,6 @@ export interface ReportSection {
items: ReportDescriptor[]
}
-/** Grouped reports for the legacy desktop rail (excludes library-only items). */
-export function getNavSections(
- entityType?: EntityType,
- dimensionsEnabled?: boolean,
-): ReportSection[] {
- return NAV_CATEGORIES.map((category) => ({
- category,
- labelKey: CATEGORY_LABEL_KEY[category],
- items: REPORT_CATALOG.filter(
- (r) =>
- r.category === category &&
- !r.libraryOnly &&
- isVisible(r, entityType, undefined, dimensionsEnabled),
- ),
- })).filter((s) => s.items.length > 0)
-}
-
/** Grouped reports for the library landing (includes everything visible). */
export function getLibrarySections(
entityType?: EntityType,
diff --git a/lib/reports/financial-statement-pdf-template.tsx b/lib/reports/financial-statement-pdf-template.tsx
index 23ab1f26..30b4ceb0 100644
--- a/lib/reports/financial-statement-pdf-template.tsx
+++ b/lib/reports/financial-statement-pdf-template.tsx
@@ -5,7 +5,8 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
-import { pdfNumberText } from '@/lib/pdf/number-text'
+import { formatDateSv, pdfAmount } from '@/lib/pdf/number-text'
+import { formatOrgNumber } from '@/lib/utils'
import type { CompanySettings } from '@/types'
const styles = StyleSheet.create({
@@ -209,30 +210,6 @@ const styles = StyleSheet.create({
},
})
-function formatAmount(amount: number): string {
- // pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
- // so a negative årets resultat would print as a profit (issue #1982).
- return pdfNumberText(
- new Intl.NumberFormat('sv-SE', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(amount),
- )
-}
-
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
-function formatDateSv(iso: string): string {
- if (!iso) return ''
- return new Date(iso).toLocaleDateString('sv-SE')
-}
-
export interface FinancialStatementSection {
title: string
rows: { account_number: string; account_name: string; amount: number }[]
@@ -321,7 +298,7 @@ export function FinancialStatementPDF({
{row.account_number}
{row.account_name}
- {formatAmount(displayAmount)}
+ {pdfAmount(displayAmount)}
)
})}
@@ -329,7 +306,7 @@ export function FinancialStatementPDF({
Summa {section.title.toLowerCase()}
- {formatAmount(group.negate ? -section.subtotal : section.subtotal)}
+ {pdfAmount(group.negate ? -section.subtotal : section.subtotal)}
)}
@@ -340,7 +317,7 @@ export function FinancialStatementPDF({
{group.totalLabel}
- {formatAmount(group.negate ? -group.total : group.total)}
+ {pdfAmount(group.negate ? -group.total : group.total)}
@@ -354,7 +331,7 @@ export function FinancialStatementPDF({
{row.label}
- {formatAmount(row.amount)}
+ {pdfAmount(row.amount)}
))}
diff --git a/lib/reports/ink2/sru-generator.ts b/lib/reports/ink2/sru-generator.ts
index 8c7eed41..e7639136 100644
--- a/lib/reports/ink2/sru-generator.ts
+++ b/lib/reports/ink2/sru-generator.ts
@@ -1,3 +1,4 @@
+import { sruAmount as formatAmount, sruDate as formatDate, sruTime as formatTime } from '@/lib/reports/sru/format'
import { getBranding } from '@/lib/branding/service'
import type {
INK2Declaration,
@@ -63,34 +64,6 @@ function formatOrgNumber12(orgNumber: string): string {
return `16${clean}`
}
-/**
- * Format a Date as YYYYMMDD
- */
-function formatDate(date: Date): string {
- const y = date.getFullYear()
- const m = String(date.getMonth() + 1).padStart(2, '0')
- const d = String(date.getDate()).padStart(2, '0')
- return `${y}${m}${d}`
-}
-
-/**
- * Format a Date as HHMMSS
- */
-function formatTime(date: Date): string {
- const h = String(date.getHours()).padStart(2, '0')
- const m = String(date.getMinutes()).padStart(2, '0')
- const s = String(date.getSeconds()).padStart(2, '0')
- return `${h}${m}${s}`
-}
-
-/**
- * Format integer amount for SRU. No decimals, no thousands separator.
- * Truncated to hela kronor by the engine.
- */
-function formatAmount(amount: number): string {
- return Math.trunc(amount).toString()
-}
-
/**
* Generate the INFO.SRU file content
*/
diff --git a/lib/reports/kassaflodesanalys-pdf-template.tsx b/lib/reports/kassaflodesanalys-pdf-template.tsx
index 1039731f..4e45a286 100644
--- a/lib/reports/kassaflodesanalys-pdf-template.tsx
+++ b/lib/reports/kassaflodesanalys-pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import {
Document,
Page,
@@ -5,7 +6,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
-import { pdfNumberText } from '@/lib/pdf/number-text'
+import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
import type { KassaflodesanalysReport } from './kassaflodesanalys'
import type { CompanySettings } from '@/types'
@@ -173,19 +174,6 @@ function formatAmount(n: number): string {
)
}
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
-function formatDateSv(iso: string): string {
- if (!iso) return ''
- return new Date(iso).toLocaleDateString('sv-SE')
-}
-
interface KassaflodePDFProps {
report: KassaflodesanalysReport
company: CompanySettings
diff --git a/lib/reports/kpi-definitions.ts b/lib/reports/kpi-definitions.ts
index 5b7eca55..417e1103 100644
--- a/lib/reports/kpi-definitions.ts
+++ b/lib/reports/kpi-definitions.ts
@@ -73,10 +73,6 @@ export const KPI_DEFINITIONS: KPIDefinition[] = [
export const ALL_KPI_IDS = KPI_DEFINITIONS.map((d) => d.id)
-export function getKPIDefinition(id: string): KPIDefinition | undefined {
- return KPI_DEFINITIONS.find((d) => d.id === id)
-}
-
export function getDefaultPreferences(): KPIPreferences {
return {
visibleKpis: KPI_DEFINITIONS.filter((d) => d.defaultVisible).map((d) => d.id),
diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts
index 7bc19e40..9b0417d5 100644
--- a/lib/reports/ne-bilaga/ne-engine.ts
+++ b/lib/reports/ne-bilaga/ne-engine.ts
@@ -318,20 +318,3 @@ export async function generateNEDeclaration(
warnings,
}
}
-
-/**
- * Get totals for display
- */
-export function getNEDeclarationTotals(declaration: NEDeclaration): {
- totalRevenue: number
- totalExpenses: number
- netResult: number
-} {
- const { rutor } = declaration
-
- return {
- totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
- totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
- netResult: rutor.R11,
- }
-}
diff --git a/lib/reports/ne-bilaga/sru-generator.ts b/lib/reports/ne-bilaga/sru-generator.ts
index 68beef9b..a1a1f748 100644
--- a/lib/reports/ne-bilaga/sru-generator.ts
+++ b/lib/reports/ne-bilaga/sru-generator.ts
@@ -1,4 +1,5 @@
import { getBranding } from '@/lib/branding/service'
+import { sruAmount as formatAmount, sruDate as formatDate, sruTime as formatTime } from '@/lib/reports/sru/format'
import type { NEDeclaration, NEDeclarationRutor, SRUSubmission } from '@/lib/reports/ne-bilaga/types'
/**
@@ -87,32 +88,11 @@ function formatIdentityNumber12(raw: string | null, incomeYear: number): string
return '000000000000'
}
-/** Format a Date as YYYYMMDD. */
-function formatDate(date: Date): string {
- const y = date.getFullYear()
- const m = String(date.getMonth() + 1).padStart(2, '0')
- const d = String(date.getDate()).padStart(2, '0')
- return `${y}${m}${d}`
-}
-
-/** Format a Date as HHMMSS. */
-function formatTime(date: Date): string {
- const h = String(date.getHours()).padStart(2, '0')
- const m = String(date.getMinutes()).padStart(2, '0')
- const s = String(date.getSeconds()).padStart(2, '0')
- return `${h}${m}${s}`
-}
-
/** Convert a YYYY-MM-DD string to SRU date format YYYYMMDD. */
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
-/** Format an integer amount: hela kronor, no decimals/thousands separators, öre truncated. */
-function formatAmount(amount: number): string {
- return Math.trunc(amount).toString()
-}
-
/** Sanitize string for SRU: '#' is reserved, strip newlines, cap at 250 chars (STR_250). */
function sanitizeString(str: string): string {
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
diff --git a/lib/reports/ne-bilaga/types.ts b/lib/reports/ne-bilaga/types.ts
index e11ee80a..b3855551 100644
--- a/lib/reports/ne-bilaga/types.ts
+++ b/lib/reports/ne-bilaga/types.ts
@@ -64,17 +64,3 @@ export interface SRUSubmission {
generatedAt: string
}
-// Labels for NE rutor
-export const NE_RUTA_LABELS: Record = {
- R1: 'Försäljning med moms (25%)',
- R2: 'Momsfria intäkter',
- R3: 'Bil/bostadsförmån',
- R4: 'Ränteintäkter',
- R5: 'Varuinköp',
- R6: 'Övriga kostnader',
- R7: 'Lönekostnader',
- R8: 'Räntekostnader',
- R9: 'Avskrivningar fastighet',
- R10: 'Avskrivningar övriga tillgångar',
- R11: 'Årets resultat'
-}
diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx
index 91f0bd84..edacb0f6 100644
--- a/lib/reports/operational-report-pdf-template.tsx
+++ b/lib/reports/operational-report-pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import {
Document,
Page,
@@ -5,7 +6,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
-import { pdfNumberText } from '@/lib/pdf/number-text'
+import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
import type {
CompanySettings,
LatestVoucherPerSeries,
@@ -237,19 +238,6 @@ function formatAmount(amount: number): string {
)
}
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
-function formatDateSv(iso: string): string {
- if (!iso) return ''
- return new Date(iso).toLocaleDateString('sv-SE')
-}
-
interface CommonHeaderProps {
title: string
company: CompanySettings
diff --git a/lib/reports/period-dates.ts b/lib/reports/period-dates.ts
index 827b01a1..482150ae 100644
--- a/lib/reports/period-dates.ts
+++ b/lib/reports/period-dates.ts
@@ -4,7 +4,9 @@
* specific belongs in the calling module.
*/
-export type PeriodType = 'monthly' | 'quarterly' | 'yearly'
+import type { MomsPeriod } from '@/types'
+
+export type PeriodType = MomsPeriod
/**
* Calculate inclusive start and end dates for a fiscal-calendar period.
diff --git a/lib/reports/reskontra-pdf-template.tsx b/lib/reports/reskontra-pdf-template.tsx
index e0e1d2fc..f3597b6d 100644
--- a/lib/reports/reskontra-pdf-template.tsx
+++ b/lib/reports/reskontra-pdf-template.tsx
@@ -5,7 +5,8 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
-import { pdfNumberText } from '@/lib/pdf/number-text'
+import { formatDateSv, pdfAmount } from '@/lib/pdf/number-text'
+import { formatOrgNumber } from '@/lib/utils'
import type { CompanySettings } from '@/types'
const styles = StyleSheet.create({
@@ -201,30 +202,6 @@ const styles = StyleSheet.create({
},
})
-function formatAmount(amount: number): string {
- // pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
- // so a credit note or overpayment would print unsigned (issue #1982).
- return pdfNumberText(
- new Intl.NumberFormat('sv-SE', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(amount),
- )
-}
-
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
-function formatDateSv(iso: string): string {
- if (!iso) return ''
- return new Date(iso).toLocaleDateString('sv-SE')
-}
-
export interface ReskontraAgingRow {
name: string
current: number
@@ -323,16 +300,16 @@ export function ReskontraPDF({
TOTALT UTESTÅENDE
- {formatAmount(totals.total_outstanding)} kr
+ {pdfAmount(totals.total_outstanding)} kr
EJ FÖRFALLET
- {formatAmount(totals.current)} kr
+ {pdfAmount(totals.current)} kr
FÖRFALLET
- {formatAmount(totals.total_outstanding - totals.current)} kr
+ {pdfAmount(totals.total_outstanding - totals.current)} kr
@@ -365,7 +342,7 @@ export function ReskontraPDF({
{row.name}
{AGING_COLUMNS.map((col) => (
- {formatAmount(row[col.key] as number)}
+ {pdfAmount(row[col.key] as number)}
))}
@@ -374,7 +351,7 @@ export function ReskontraPDF({
Summa
{AGING_COLUMNS.map((col) => (
- {formatAmount(totals[col.key] as number)}
+ {pdfAmount(totals[col.key] as number)}
))}
@@ -415,12 +392,12 @@ export function ReskontraPDF({
{inv.invoice_number}
{inv.invoice_date}
{inv.due_date}
- {formatAmount(inv.outstanding)}
+ {pdfAmount(inv.outstanding)}
{hasForeignCurrency &&
(inv.outstanding_sek === null ? (
saknas
) : (
- {formatAmount(inv.outstanding_sek)}
+ {pdfAmount(inv.outstanding_sek)}
))}
{inv.days_overdue > 0 ? inv.days_overdue : ''}
{inv.currency}
@@ -434,7 +411,7 @@ export function ReskontraPDF({
{/* Deliberately blank: mixed currencies do not add up. */}
- {formatAmount(invoiceSekTotal)}
+ {pdfAmount(invoiceSekTotal)}
SEK
diff --git a/lib/reports/sru/format.ts b/lib/reports/sru/format.ts
new file mode 100644
index 00000000..3f3896c9
--- /dev/null
+++ b/lib/reports/sru/format.ts
@@ -0,0 +1,26 @@
+/**
+ * Field formatters shared by the SRU generators (INK2 and NE-bilaga). SRU is a
+ * Skatteverket line format: dates are YYYYMMDD, times HHMMSS, amounts are hela
+ * kronor with the öre truncated and no separators.
+ */
+
+/** Format a Date as YYYYMMDD (local time, as the files are stamped on the user's clock). */
+export function sruDate(date: Date): string {
+ const y = date.getFullYear()
+ const m = String(date.getMonth() + 1).padStart(2, '0')
+ const d = String(date.getDate()).padStart(2, '0')
+ return `${y}${m}${d}`
+}
+
+/** Format a Date as HHMMSS. */
+export function sruTime(date: Date): string {
+ const h = String(date.getHours()).padStart(2, '0')
+ const m = String(date.getMinutes()).padStart(2, '0')
+ const s = String(date.getSeconds()).padStart(2, '0')
+ return `${h}${m}${s}`
+}
+
+/** Format an integer amount: hela kronor, no decimals/thousands separators, öre truncated. */
+export function sruAmount(amount: number): string {
+ return Math.trunc(amount).toString()
+}
diff --git a/lib/reports/vat-declaration-pdf-template.tsx b/lib/reports/vat-declaration-pdf-template.tsx
index c03cf8bd..c97a621c 100644
--- a/lib/reports/vat-declaration-pdf-template.tsx
+++ b/lib/reports/vat-declaration-pdf-template.tsx
@@ -1,3 +1,4 @@
+import { formatOrgNumber } from '@/lib/utils'
import {
Document,
Page,
@@ -5,7 +6,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
-import { pdfNumberText } from '@/lib/pdf/number-text'
+import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
import type { CompanySettings } from '@/types'
import type { ManualFilingRow } from '@/lib/reports/vat-manual-filing'
@@ -100,19 +101,6 @@ function formatKr(amount: number): string {
return pdfNumberText(new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount))
}
-function formatOrgNumber(orgNumber: string): string {
- const cleaned = orgNumber.replace(/\D/g, '')
- if (cleaned.length === 10) {
- return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
- }
- return orgNumber
-}
-
-function formatDateSv(iso: string): string {
- if (!iso) return ''
- return new Date(iso).toLocaleDateString('sv-SE')
-}
-
interface VatDeclarationPDFProps {
rows: ManualFilingRow[]
period: { start: string; end: string }
diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts
index 78bd7620..73436874 100644
--- a/lib/reports/vat-revenue-accounts.ts
+++ b/lib/reports/vat-revenue-accounts.ts
@@ -176,17 +176,3 @@ export async function fetchDynamicVatAccounts(
}
return result
}
-
-export async function fetchDynamicRuta05Accounts(
- supabase: SupabaseClient,
- companyId: string,
-): Promise> {
- const resolved = await fetchDynamicVatAccounts(supabase, companyId)
- return {
- accounts: [...resolved.mappingByAccount]
- .filter(([account, mapping]) => mapping.box === 'ruta05' && !ACCOUNT_TO_BOX[account])
- .map(([account]) => account),
- rateByAccount: resolved.rateByAccount,
- staticRateByAccount: resolved.staticRateByAccount,
- }
-}
diff --git a/lib/reports/xlsx-export.ts b/lib/reports/xlsx-export.ts
index 5a943cd3..37db833f 100644
--- a/lib/reports/xlsx-export.ts
+++ b/lib/reports/xlsx-export.ts
@@ -219,6 +219,17 @@ export function dateColumn(header: string): ColumnSpec {
return { header, format: 'date' }
}
+/**
+ * Parse an ISO date string into a Date for a `date` column cell. Returns null
+ * for an empty or unparseable string so the cell is left blank.
+ */
+export function parseCellDate(s: string): Date | null {
+ if (!s) return null
+ const d = new Date(s)
+ return isNaN(d.getTime()) ? null : d
+}
+
+
export function integerColumn(header: string): ColumnSpec {
return { header, format: 'integer' }
}
diff --git a/lib/salary/agi/kontrollera-schemas.ts b/lib/salary/agi/kontrollera-schemas.ts
index 93f77bb5..3eb30930 100644
--- a/lib/salary/agi/kontrollera-schemas.ts
+++ b/lib/salary/agi/kontrollera-schemas.ts
@@ -48,8 +48,6 @@ export const AGIKontrolleraHUSchema = z
})
.strict()
-export type AGIKontrolleraHU = z.infer
-
export const AGIKontrolleraIUSchema = z
.object({
agRegistreradId: IDENTITET,
@@ -95,8 +93,6 @@ export const AGIKontrolleraIUSchema = z
},
)
-export type AGIKontrolleraIU = z.infer
-
/**
* Hard cap on the raw JSON body for kontrollera endpoints. Even a fully
* legal IU is < 4 KB serialised; 64 KB is a generous safety margin that
diff --git a/lib/salary/agi/xml-generator.ts b/lib/salary/agi/xml-generator.ts
index cf915f98..315da6a6 100644
--- a/lib/salary/agi/xml-generator.ts
+++ b/lib/salary/agi/xml-generator.ts
@@ -1,6 +1,7 @@
import { decryptPersonnummer } from '../personnummer'
import { isOrgNumberShaped } from '@/lib/invariants/org-number'
import { truncateToWholeKronor } from '@/lib/money'
+import { escapeXml } from '@/lib/xml/escape'
/**
* AGI XML generator: Arbetsgivardeklaration på individnivå.
@@ -578,10 +579,6 @@ export function generateAGIXml(
// - VAB and parental leave are reported via the top-level
// section (FK820-827) as per-event date records,
// not as per-IU day counts. Not implemented in this generator yet.
- void emp.sickDays
- void emp.vabDays
- void emp.parentalDays
-
lines.push(' ')
lines.push(' ')
lines.push(' ')
@@ -684,15 +681,6 @@ export function buildIndividuppgifterSnapshot(
// Helpers
// ============================================================
-function escapeXml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
// AGI amounts are stated in whole kronor with the öre dropped (öretal
// bortfaller, SFF 2011:1261 22 kap. 1 §): truncation, never rounding.
// Math.round here would declare 16 074 kr for an underlag-computed
diff --git a/lib/salary/derive-absence-line-items.ts b/lib/salary/derive-absence-line-items.ts
index 4a02b77c..e5d37b5c 100644
--- a/lib/salary/derive-absence-line-items.ts
+++ b/lib/salary/derive-absence-line-items.ts
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { PayrollConfig } from './payroll-config'
+import { daysBetweenIso } from '@/lib/dates/iso'
import {
calculateVabDeduction,
calculateParentalLeaveDeduction,
@@ -87,10 +88,6 @@ function dateOnly(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
-function daysBetween(a: string, b: string): number {
- return Math.round((dateOnly(b).getTime() - dateOnly(a).getTime()) / ONE_DAY_MS)
-}
-
function addDays(d: string, n: number): string {
const t = new Date(dateOnly(d).getTime() + n * ONE_DAY_MS)
return t.toISOString().slice(0, 10)
@@ -124,7 +121,7 @@ export function buildSjukloneperioder(sickDates: string[]): SjukloneperiodSegmen
for (let i = 1; i < sorted.length; i++) {
const date = sorted[i]
- const gap = daysBetween(endDate, date)
+ const gap = daysBetweenIso(endDate, date)
if (gap === 0) continue
if (gap >= 1 && gap <= 5) {
// Within 5 calendar days: same period (contiguous OR återinsjuknande)
@@ -269,7 +266,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// index (calendar days from segment start, 1-based).
for (const d of periodSickDates) {
if (d < seg.startDate || d > seg.endDate) continue
- const segDayIndex = daysBetween(seg.startDate, d) + 1
+ const segDayIndex = daysBetweenIso(seg.startDate, d) + 1
if (segDayIndex === 1 && segmentStartsInPeriod) {
// already accounted for as karens (or suppressed); skip
continue
diff --git a/lib/salary/ku/ku10-generator.ts b/lib/salary/ku/ku10-generator.ts
index b932b0f6..e22bb8b0 100644
--- a/lib/salary/ku/ku10-generator.ts
+++ b/lib/salary/ku/ku10-generator.ts
@@ -1,3 +1,4 @@
+import { escapeXml } from '@/lib/xml/escape'
import { decryptPersonnummer } from '../personnummer'
import { getBranding } from '@/lib/branding/service'
import { stripOrgNumberFormatting } from '@/lib/invariants/org-number'
@@ -163,11 +164,3 @@ export function generateKU10Xml(
return lines.join('\n')
}
-function escapeXml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
diff --git a/lib/salary/payment/pain001-generator.ts b/lib/salary/payment/pain001-generator.ts
index 19a194d5..7dd394a6 100644
--- a/lib/salary/payment/pain001-generator.ts
+++ b/lib/salary/payment/pain001-generator.ts
@@ -30,6 +30,7 @@
* the salary journal entry. Subject to 7-year retention.
*/
+import { escapeXml } from '@/lib/xml/escape'
import { splitDomesticBankAccount } from './bank-account'
export interface Pain001CompanyData {
@@ -188,15 +189,6 @@ export function generatePain001(
// Helpers
// ============================================================
-function escapeXml(str: string): string {
- return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
/** Format number as decimal with 2 decimal places (ISO 20022 requires dot separator) */
function formatDecimal(amount: number): string {
return (Math.round(amount * 100) / 100).toFixed(2)
diff --git a/lib/salary/personnummer-format.ts b/lib/salary/personnummer-format.ts
index ace73948..e124a41f 100644
--- a/lib/salary/personnummer-format.ts
+++ b/lib/salary/personnummer-format.ts
@@ -9,6 +9,8 @@
/**
* Extract the last 4 digits of a personnummer for display.
*/
+import { luhnValidate } from '@/lib/bankgiro/luhn'
+
export function extractLast4(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return digits.slice(-4)
@@ -60,30 +62,13 @@ export function validatePersonnummer(personnummer: string): { valid: boolean; er
// Luhn check on digits 3-12 (YYMMDDNNNN, 10 digits)
const luhnDigits = digits.slice(2)
- if (!luhnCheck(luhnDigits)) {
+ if (!luhnValidate(luhnDigits)) {
return { valid: false, error: 'Ogiltigt kontrollnummer (Luhn)' }
}
return { valid: true }
}
-/**
- * Luhn checksum validation for 10-digit string.
- */
-function luhnCheck(digits: string): boolean {
- let sum = 0
- for (let i = 0; i < digits.length; i++) {
- let d = parseInt(digits[i])
- // Multiply every other digit by 2, starting from the first
- if (i % 2 === 0) {
- d *= 2
- if (d > 9) d -= 9
- }
- sum += d
- }
- return sum % 10 === 0
-}
-
/**
* Extract birth date from a 12-digit personnummer or samordningsnummer.
*
diff --git a/lib/salary/traktamente.ts b/lib/salary/traktamente.ts
index 3dd0a0ec..eead1097 100644
--- a/lib/salary/traktamente.ts
+++ b/lib/salary/traktamente.ts
@@ -1,4 +1,5 @@
import type { PayrollConfig } from './payroll-config'
+import type { MileageVehicleType } from '@/types'
/**
* Traktamente (per diem) and milersättning (mileage) calculations.
@@ -111,7 +112,7 @@ export function calculateTraktamente(params: {
// Milersättning (Mileage Allowance)
// ============================================================
-export type VehicleType = 'own_car' | 'company_car_fossil' | 'company_car_electric'
+export type VehicleType = MileageVehicleType
/**
* Calculate milersättning.
diff --git a/lib/skatteverket/manual-verifikat-prefill.ts b/lib/skatteverket/manual-verifikat-prefill.ts
index a1181eb7..8318bd5a 100644
--- a/lib/skatteverket/manual-verifikat-prefill.ts
+++ b/lib/skatteverket/manual-verifikat-prefill.ts
@@ -23,6 +23,7 @@
*/
import { isIsoDateShaped } from '@/lib/invariants'
+import { UUID_RE } from '@/lib/invariants/uuid'
import { roundOre } from '@/lib/money'
/**
@@ -31,7 +32,6 @@ import { roundOre } from '@/lib/money'
*/
export const SKATTEKONTO_ACCOUNT = '1630'
-const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const STORAGE_KEY = 'accounted.skv-manual-prefill'
export interface SkvManualPrefill {
diff --git a/lib/supabase/proxy-timing.ts b/lib/supabase/proxy-timing.ts
index 7e8d946c..f5948a17 100644
--- a/lib/supabase/proxy-timing.ts
+++ b/lib/supabase/proxy-timing.ts
@@ -1,3 +1,5 @@
+import { UUID_RE } from '@/lib/invariants/uuid'
+
/**
* Per-request timing for the auth proxy (lib/supabase/middleware.ts).
*
@@ -72,8 +74,6 @@ export function classifyProxyRequest(
return 'page'
}
-const UUID_RE =
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const NUMERIC_RE = /^\d+$/
/** Prefixes whose tail is a secret (invite tokens, payslip links, PKCE). */
const TOKEN_PREFIXES = ['/invite', '/payslip', '/auth']
diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts
index 0b01726b..c08e28f5 100644
--- a/lib/tax/deadline-config.ts
+++ b/lib/tax/deadline-config.ts
@@ -853,38 +853,3 @@ export function getApplicableDeadlineConfigs(
): TaxDeadlineConfig[] {
return TAX_DEADLINE_CONFIGS.filter((config) => config.condition(settings))
}
-
-/**
- * Map from tax deadline type to report URL generator
- */
-export const REPORT_URLS: Record string> = {
- vat: (p) => {
- if (p.quarter) {
- return `/reports?tab=vat&year=${p.year}&period=${p.quarter}`
- }
- if (p.month) {
- return `/reports?tab=vat&year=${p.year}&period=${p.month}`
- }
- return `/reports?tab=vat&year=${p.year}`
- },
- 'ne-declaration': () => '/reports?tab=ne-declaration',
-}
-
-/**
- * Get the report URL for a deadline
- */
-export function getReportUrl(
- linkedReportType: string | null,
- linkedReportPeriod: Record | null
-): string | null {
- if (!linkedReportType || !linkedReportPeriod) {
- return null
- }
-
- const urlGenerator = REPORT_URLS[linkedReportType]
- if (!urlGenerator) {
- return null
- }
-
- return urlGenerator(linkedReportPeriod as { year: number; quarter?: number; month?: number })
-}
diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts
index 3ac43237..8a69410e 100644
--- a/lib/tax/deadline-generator.ts
+++ b/lib/tax/deadline-generator.ts
@@ -15,6 +15,7 @@ import {
type TaxAssessmentNoticeForDeadline,
} from './deadline-config'
import { adjustDeadlineToNextBankingDay } from './swedish-holidays'
+import { formatDateISO } from '@/lib/calendar/utils'
/**
* Rolling generation horizons. Recurring skattekonto obligations (monthly
@@ -86,21 +87,6 @@ export const TAX_RELEVANT_FIELDS = [
export const DEADLINE_SETTINGS_SELECT =
'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, employer_registered, employer_seasonal, fiscal_year_start_month, vat_taxable_base_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, periodisk_sammanstallning_period, periodisk_sammanstallning_filing_method, kontrolluppgifter_enabled, rot_rut_enabled, oss_enabled, ioss_enabled, intrastat_enabled, punktskatt_enabled, fyllnadsinbetalning_enabled' as const
-/**
- * Check if any tax-relevant fields changed
- */
-export function didTaxFieldsChange(
- oldSettings: Partial,
- newSettings: Partial
-): boolean {
- for (const field of TAX_RELEVANT_FIELDS) {
- if (oldSettings[field] !== newSettings[field]) {
- return true
- }
- }
- return false
-}
-
export function hasTaxRelevantFields(body: Record): boolean {
return TAX_RELEVANT_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(body, field))
}
@@ -277,16 +263,6 @@ interface SupersededDeadlineRow {
*/
const MANUAL_STATUSES = new Set(['in_progress', 'submitted', 'confirmed'])
-/**
- * Format date to YYYY-MM-DD
- */
-function formatDateISO(date: Date): string {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
-}
-
/**
* Generate all tax deadlines for a user based on their company settings
*/
diff --git a/lib/tax/swedish-holidays.ts b/lib/tax/swedish-holidays.ts
index cdaadc92..5b16155a 100644
--- a/lib/tax/swedish-holidays.ts
+++ b/lib/tax/swedish-holidays.ts
@@ -2,6 +2,7 @@
* Swedish holidays including Easter calculation
* Used for adjusting tax deadlines that fall on weekends or holidays
*/
+import { formatDateISO } from '@/lib/calendar/utils'
/**
* Calculate Easter Sunday using the Anonymous Gregorian algorithm
@@ -121,16 +122,6 @@ function getAllaHelgonsDag(year: number): Date {
throw new Error('Could not calculate Alla helgons dag')
}
-/**
- * Format date to YYYY-MM-DD
- */
-function formatDateISO(date: Date): string {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
-}
-
/**
* Check if a date is a Swedish holiday
*/
@@ -181,21 +172,6 @@ export function getNextBankingDay(date: Date): Date {
return result
}
-/**
- * Get the previous banking day from a given date
- * If the date is already a banking day, return it
- * Otherwise, find the previous banking day
- */
-export function getPreviousBankingDay(date: Date): Date {
- const result = new Date(date)
-
- while (!isBankingDay(result)) {
- result.setDate(result.getDate() - 1)
- }
-
- return result
-}
-
/**
* Adjust a deadline date to the next banking day if it falls on a weekend or holiday
* Skatteverket deadlines that fall on non-banking days are moved to the next banking day
@@ -203,45 +179,3 @@ export function getPreviousBankingDay(date: Date): Date {
export function adjustDeadlineToNextBankingDay(date: Date): Date {
return getNextBankingDay(date)
}
-
-/**
- * Get the month name in Swedish
- */
-export function getSwedishMonthName(month: number): string {
- const months = [
- 'januari',
- 'februari',
- 'mars',
- 'april',
- 'maj',
- 'juni',
- 'juli',
- 'augusti',
- 'september',
- 'oktober',
- 'november',
- 'december',
- ]
- return months[month]
-}
-
-/**
- * Get quarter number (1-4) from month (0-11)
- */
-export function getQuarterFromMonth(month: number): number {
- return Math.floor(month / 3) + 1
-}
-
-/**
- * Get the first month of a quarter (0-indexed)
- */
-export function getFirstMonthOfQuarter(quarter: number): number {
- return (quarter - 1) * 3
-}
-
-/**
- * Get the last month of a quarter (0-indexed)
- */
-export function getLastMonthOfQuarter(quarter: number): number {
- return quarter * 3 - 1
-}
diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts
index 2cc86cd9..51c62371 100644
--- a/lib/transactions/__tests__/ingest.test.ts
+++ b/lib/transactions/__tests__/ingest.test.ts
@@ -5,7 +5,8 @@
* and result aggregation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { ingestTransactions, type RawTransaction } from '../ingest'
+import { ingestTransactions } from '../ingest'
+import type { RawTransaction } from '@/types'
import { makeJournalEntry, makeTransaction } from '@/tests/helpers'
// ---------------------------------------------------------------------------
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index 085df1b1..a9baf5c2 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -13,9 +13,6 @@ import { isImportedTransaction } from '@/lib/transactions/origin'
import { createLogger } from '@/lib/logger'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
-// Re-export types for backward compatibility
-export type { RawTransaction, IngestResult } from '@/types'
-
/**
* Sentinel for a (date, öre) bucket whose incoming rows carry more than one
* currency: the booked-hand-entered mirror's per-bucket currency gate cannot be
diff --git a/lib/transactions/period-filter.ts b/lib/transactions/period-filter.ts
index 4e03e928..4e8f03a5 100644
--- a/lib/transactions/period-filter.ts
+++ b/lib/transactions/period-filter.ts
@@ -1,4 +1,5 @@
import type { FiscalPeriod } from '@/types'
+import { addDaysIso as addDays } from '@/lib/dates/iso'
/** Inclusive ISO date bounds (yyyy-MM-dd) for a period filter. */
export interface PeriodBounds {
@@ -28,12 +29,6 @@ function addMonths(iso: string, months: number): string {
return toIso(year, monthIndex + 1, Math.min(d, lastDay))
}
-function addDays(iso: string, days: number): string {
- const date = new Date(`${iso}T00:00:00Z`)
- date.setUTCDate(date.getUTCDate() + days)
- return date.toISOString().slice(0, 10)
-}
-
/**
* Date bounds for one quarter of a fiscal period. Quarters follow the fiscal
* year, not the calendar: Q1 starts at period_start, so a brutet rakenskapsar
diff --git a/lib/utils.ts b/lib/utils.ts
index 68410157..4e5d9385 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -178,19 +178,30 @@ export function formatOrgNumber(orgNumber: string): string {
return orgNumber
}
+/** UTC YYYYMMDD stamp (e.g. for archive download filenames). */
+export function utcDateStamp(date: Date): string {
+ const year = date.getUTCFullYear()
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0')
+ const day = String(date.getUTCDate()).padStart(2, '0')
+ return `${year}${month}${day}`
+}
+
+/** Resolve after `ms` milliseconds (setTimeout as a promise). */
+export function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/** Split `items` into consecutive slices of at most `size` elements. */
+export function chunk(items: readonly T[], size: number): T[][] {
+ const out: T[][] = []
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
+ return out
+}
+
export function getCompanyDisplayName(settings: { company_name?: string | null }): string {
return settings.company_name?.trim() || ''
}
-export function getCompanyPrimaryName(settings: { company_name?: string | null }): string {
- return settings.company_name?.trim() || ''
-}
-
-export function generateInvoiceNumber(): string {
- const year = new Date().getFullYear()
- const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0')
- return `${year}-${random}`
-}
// Shared FX-rate validator: keeps UI, RPC (>= 100000 / <= 0), and the
// invoices/supplier_invoices CHECK constraints in sync. Single source
diff --git a/lib/vat/moms-box-mapping.ts b/lib/vat/moms-box-mapping.ts
index 406c1ee2..51abb4a3 100644
--- a/lib/vat/moms-box-mapping.ts
+++ b/lib/vat/moms-box-mapping.ts
@@ -201,15 +201,3 @@ export function getBoxForAccount(accountNumber: string): MomsBox | undefined {
export function getBoxLabel(box: MomsBox): string {
return BOX_LABELS[box]
}
-
-/** Boxes that represent VAT-exempt export/EU sales (no output VAT) */
-export const EXPORT_BOXES: MomsBox[] = ['35', '36', '38', '39', '40']
-
-/** Boxes that represent taxable domestic sales (have output VAT) */
-export const DOMESTIC_BOXES: MomsBox[] = ['05', '06', '07', '08']
-
-/** Boxes that represent output VAT */
-export const OUTPUT_VAT_BOXES: MomsBox[] = ['10', '11', '12']
-
-/** Boxes that represent input VAT */
-export const INPUT_VAT_BOXES: MomsBox[] = ['48']
diff --git a/lib/webhooks/signing.ts b/lib/webhooks/signing.ts
index 22fe7196..bf91e836 100644
--- a/lib/webhooks/signing.ts
+++ b/lib/webhooks/signing.ts
@@ -8,8 +8,10 @@
* `${t}.${rawBody}`
*
* The `t` (unix timestamp in seconds) is included in the signed payload
- * so receivers can implement replay-window checks. We default to a 5-minute
- * tolerance on the verify side; receivers can pick their own.
+ * so receivers can implement replay-window checks. The receiver-side
+ * verification (parse the header, recompute the HMAC, apply a 5-minute
+ * tolerance) is documented in the docs cookbook (lib/docs/content/webhooks.ts);
+ * receivers can pick their own tolerance.
*
* Why HMAC-SHA256 (not Ed25519): every Node/Python/Go/Ruby stdlib has it,
* receivers can verify without adding a dep. Asymmetric signing buys nothing
@@ -49,74 +51,6 @@ export function signPayload(args: {
}
}
-/**
- * Parse a signature header into its components. Returns null if malformed.
- * Used by the receiver-side example in the docs cookbook (Phase 6 PR-2);
- * exported here so a single canonical implementation lives in this file.
- */
-export function parseSignatureHeader(header: string): SignedHeaderParts | null {
- const parts = header.split(',').map((s) => s.trim())
- let t: number | null = null
- let v1: string | null = null
- for (const p of parts) {
- const eq = p.indexOf('=')
- if (eq === -1) continue
- const k = p.slice(0, eq)
- const v = p.slice(eq + 1)
- if (k === 't') {
- const parsed = Number.parseInt(v, 10)
- if (Number.isFinite(parsed)) t = parsed
- } else if (k === 'v1') {
- v1 = v
- }
- }
- if (t === null || !v1) return null
- return { t, v1 }
-}
-
-/**
- * Verify a signature against a raw body. Constant-time comparison.
- * Returns true if the signature is valid AND within the tolerance window.
- *
- * Use this in the cookbook examples and in the :test endpoint's loopback
- * verification.
- */
-export function verifySignature(args: {
- body: string
- header: string
- secret: string
- /** Tolerance window in seconds. Defaults to 300 (5 min). */
- toleranceSeconds?: number
- /** Override for tests. Defaults to current unix-seconds. */
- now?: number
-}): boolean {
- const parsed = parseSignatureHeader(args.header)
- if (!parsed) return false
-
- const tolerance = args.toleranceSeconds ?? 300
- const now = args.now ?? Math.floor(Date.now() / 1000)
- if (Math.abs(now - parsed.t) > tolerance) return false
-
- const expected = crypto
- .createHmac(ALGORITHM, args.secret)
- .update(`${parsed.t}.${args.body}`)
- .digest('hex')
-
- // timingSafeEqual requires equal-length buffers: return false (not throw)
- // for length mismatch, the common case for a forged signature.
- //
- // Compare buffer lengths AFTER decoding rather than hex-string lengths:
- // `Buffer.from(v1, 'hex')` silently drops invalid hex bytes, so a v1 that
- // is the right hex length (64 chars for SHA-256) but contains non-hex
- // characters decodes to a SHORTER buffer than `expected`. Without this
- // check the timingSafeEqual call throws RangeError instead of returning
- // false, exposing a crash path to any caller passing a malformed header.
- const expectedBuf = Buffer.from(expected, 'hex')
- const actualBuf = Buffer.from(parsed.v1, 'hex')
- if (expectedBuf.length !== actualBuf.length) return false
- return crypto.timingSafeEqual(expectedBuf, actualBuf)
-}
-
/**
* Generate a fresh webhook secret. 32 bytes of crypto-random hex (256 bits
* of entropy, 64-character output). Returned to the caller exactly once on
diff --git a/lib/webhooks/url-guard.ts b/lib/webhooks/url-guard.ts
index db1defb7..24ed4230 100644
--- a/lib/webhooks/url-guard.ts
+++ b/lib/webhooks/url-guard.ts
@@ -182,5 +182,3 @@ function classifyAddress(address: string): AddressClass {
if (/^fe[89ab]/.test(v6)) return 'link_local_address'
return 'public'
}
-
-export const __TESTING__ = { classifyAddress }
diff --git a/lib/webshop-orders/booking-lines.ts b/lib/webshop-orders/booking-lines.ts
index 152e6935..1531978a 100644
--- a/lib/webshop-orders/booking-lines.ts
+++ b/lib/webshop-orders/booking-lines.ts
@@ -37,8 +37,6 @@ import type {
* it is a claim on the payment provider, not on the customer.
*/
export const DEFAULT_PAYMENT_ACCOUNT = '1686'
-/** BAS 2026 name for DEFAULT_PAYMENT_ACCOUNT; used when adding it to a chart. */
-export const DEFAULT_PAYMENT_ACCOUNT_NAME = 'Fordringar för kontokort och kuponger'
/**
* Default revenue account per Swedish VAT rate: the standard BAS 2026
diff --git a/lib/webshop-orders/ingest.ts b/lib/webshop-orders/ingest.ts
index 9096676f..ac1a58b9 100644
--- a/lib/webshop-orders/ingest.ts
+++ b/lib/webshop-orders/ingest.ts
@@ -1,3 +1,4 @@
+import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre as round } from '@/lib/money'
@@ -85,12 +86,6 @@ type ExistingRow = Pick<
| 'line_items'
>
-function chunk(items: T[], size: number): T[][] {
- const out: T[][] = []
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
- return out
-}
-
/**
* Rows whose financials must not be silently refreshed. Booked/invoiced rows
* are frozen by the DB trigger; manually marked rows (#1879) are treated the
diff --git a/lib/xml/escape.ts b/lib/xml/escape.ts
new file mode 100644
index 00000000..13f92a6f
--- /dev/null
+++ b/lib/xml/escape.ts
@@ -0,0 +1,14 @@
+/**
+ * Escape a string for use as XML text or attribute content. Used by every
+ * XML-emitting generator (AGI, KU10, pain.001, Peppol BIS, ROT/RUT). The
+ * replace order matters: `&` first, so the entities it produces are not
+ * re-escaped.
+ */
+export function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
diff --git a/messages/en.json b/messages/en.json
index 01e541bb..bef56716 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7,69 +7,28 @@
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
- "create": "Create",
- "add": "Add",
"delete": "Delete",
"edit": "Edit",
"back": "Back",
- "next": "Next",
- "previous": "Previous",
"pager_previous": "Previous record",
"pager_next": "Next record",
"pager_position": "{index} of {total}",
"close": "Close",
- "search": "Search",
- "filter": "Filter",
"loading": "Loading...",
"load_more": "Load more",
"retry": "Try again",
"load_error": "Could not load data",
- "confirm": "Confirm",
- "yes": "Yes",
- "no": "No",
- "remove": "Remove",
- "submit": "Submit",
- "send": "Send",
- "download": "Download",
- "upload": "Upload",
- "copy": "Copy",
- "copied": "Copied",
"open": "Open",
- "select": "Select",
- "all": "All",
- "none": "None",
- "more": "More",
- "less": "Less",
- "show": "Show",
- "hide": "Hide",
- "actions": "Actions",
- "settings": "Settings",
- "language": "Language",
"language_swedish": "Swedish",
"language_english": "English",
- "appearance": "Appearance",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System",
"logout": "Sign out",
"logout_description": "Sign out of your account",
- "account_settings": "Account settings",
"status": {
- "posted": "Posted",
- "draft": "Draft",
- "pending": "Pending",
- "approved": "Approved",
- "archived": "Archived",
- "paid": "Paid",
- "unpaid": "Unpaid",
- "overdue": "Overdue",
- "cancelled": "Cancelled",
- "sent": "Sent",
- "matched": "Matched",
- "unmatched": "Unmatched"
},
"more_options": "More options",
- "popup_blocked_title": "The browser blocked the tab",
"popup_blocked_description": "Allow pop-ups for {appName} in your browser and try again.",
"source_code": "Source code"
},
@@ -406,14 +365,9 @@
"team": "Members and roles",
"brand": "Brand",
"banking": "Bank (PSD2)",
- "skatteverket": "Skatteverket",
"salary": "Payroll",
"templates": "Templates",
- "agent_profile": "Company profile",
- "agent_memory": "Assistant memory",
- "agent_skills": "Assistant knowledge",
"assistant": "Assistant",
- "backup": "Backup",
"account": "Account",
"api": "API & MCP",
"billing": "Subscription",
@@ -583,7 +537,6 @@
},
"settings": {
"group_profile": "Profile",
- "section_name": "Name",
"digest_group": "Notifications",
"digest_label": "New items to book",
"digest_description": "A daily email when new bank transactions or inbox documents are waiting to be booked. Counts only, never amounts.",
@@ -801,12 +754,9 @@
"generate_none_title": "No deadlines created",
"generate_none_description": "No new deadlines to create. Check that your tax settings are filled in if you expected more.",
"generate_failed_title": "Could not create deadlines",
- "generate_failed_description": "Tax deadlines could not be created right now.",
"group_skattekonto_title": "Skattekonto",
"group_same_day": "{count} deadlines on the same day",
"group_mark_done": "Mark as done",
- "group_confirm_question": "Mark {title} as done?",
- "group_cancel": "Cancel",
"group_confirm": "Confirm",
"new_deadline": "New deadline",
"subscribe_calendar": "Subscribe in your calendar",
@@ -836,68 +786,6 @@
"empty_filtered": "No deadlines match the filter.",
"confirm_done_title": "Mark as done?"
},
- "bureau": {
- "title": "Bureau",
- "subtitle": "Overview of every client company you have access to",
- "section_title": "Bureau mode",
- "enable_setting": "Enable bureau mode",
- "enable_setting_description": "Shows an aggregated overview of every client company you have access to",
- "toggle_saved": "Bureau mode updated",
- "toggle_save_failed": "Could not save bureau setting",
- "tiles": {
- "clients": "Clients total",
- "pending": "Awaiting approval",
- "deadlines": "Deadlines this week",
- "overdue_ar": "Overdue invoices"
- },
- "action": {
- "open": "Open"
- },
- "pending_label": "Pending operations",
- "deadlines_label": "Upcoming deadlines",
- "no_pending": "No pending operations",
- "no_deadlines": "No deadlines this week",
- "show_pending": "Show pending",
- "high_risk": "{count} high risk",
- "kpi": {
- "revenue_mtd": "Revenue MTD",
- "cash": "Cash",
- "overdue_ar": "Overdue AR"
- },
- "role": {
- "owner": "owner",
- "admin": "admin",
- "member": "member",
- "viewer": "viewer"
- },
- "deadline_status": {
- "overdue": "overdue"
- },
- "empty": {
- "title": "No client companies yet",
- "description": "Once you are added to a company it will appear here."
- }
- },
- "insights": {
- "title": "Insights",
- "empty": "No anomalies found",
- "severity_flag": "Address",
- "severity_warn": "Review",
- "severity_info": "Info",
- "action_dismiss": "Dismiss",
- "action_snooze": "Snooze 7d",
- "subject_open": "Open source",
- "dashboard_tile_title": "Anomalies",
- "dashboard_tile_subtitle": "{count} to address",
- "rules": {
- "outlier_amount": "Unusual amount for counterparty",
- "missing_recurring": "Missing recurring vendor",
- "duplicate_suspect": "Possible duplicate",
- "stale_uncategorized": "Old uncategorized transaction",
- "vat_rate_outlier": "Unusual VAT rate",
- "stale_customer_balance": "Stale customer balance"
- }
- },
"supplier_invoices": {
"title": "Supplier invoices",
"load_failed_title": "Could not load supplier invoices",
@@ -1010,34 +898,6 @@
"mark_all_result_failed": "{count} could not be booked. Open the invoices and try again.",
"mark_all_result_settled": "{count} were already paid."
},
- "purchase_orders": {
- "title": "Purchase orders",
- "new": "New purchase order",
- "empty_title": "No purchase orders yet",
- "empty_description": "Create a purchase order to plan a purchase, record goods receipts, and reconcile against the supplier invoice.",
- "filter_open": "Open",
- "filter_all": "All",
- "th_number": "Number",
- "th_supplier": "Supplier",
- "th_order_date": "Order date",
- "th_status": "Status",
- "th_expected_delivery": "Expected delivery",
- "th_amount": "Amount",
- "status_draft": "Draft",
- "status_sent": "Sent",
- "status_partially_received": "Partially received",
- "status_received": "Received",
- "status_partially_invoiced": "Partially invoiced",
- "status_closed": "Closed",
- "status_cancelled": "Cancelled",
- "send": "Mark as sent",
- "receive": "Receive goods",
- "cancel": "Cancel order",
- "three_way_match_failed": "Three-way match failed. Verify quantities and prices against the purchase order.",
- "po_link_required": "Company settings require supplier invoices to be linked to a purchase order.",
- "match_within_tolerance": "Within tolerance",
- "match_out_of_tolerance": "Outside tolerance"
- },
"suppliers": {
"title": "Suppliers",
"new_supplier": "New supplier",
@@ -1258,26 +1118,11 @@
"password_signup_unavailable": "Password sign-up is not available on this installation."
},
"mfa": {
- "enroll_title": "Set up two-step verification",
- "enroll_subtitle": "Secure your account with an authenticator app",
"verify_title": "Two-step verification",
- "verify_subtitle": "Enter the code from your authenticator app",
"verify_code_label": "Verification code",
"verify_button": "Verify",
"verifying": "Verifying...",
- "enroll_step_qr": "Scan the QR code",
- "enroll_step_qr_hint": "Use an authenticator app like Google Authenticator, Authy, or 1Password.",
- "enroll_show_secret": "Show secret manually",
- "enroll_step_verify": "Verify code",
- "enroll_step_verify_hint": "Enter the code from your app to complete enrollment.",
- "enroll_finish": "Enable",
- "enroll_finishing": "Enabling...",
- "enroll_failed_title": "Could not enable two-step verification",
"verify_failed_title": "Verification failed",
- "verify_failed_description": "The code is incorrect or has expired.",
- "enrolled_title": "Two-step verification enabled",
- "enrolled_description": "Your account is now protected.",
- "back": "Back",
"verify_subtitle_full": "Enter the 6-digit code from your authenticator app",
"verify_challenge_failed_description": "Could not start verification. Please try again.",
"wrong_code_title": "Wrong code",
@@ -1449,7 +1294,6 @@
"type_swedish_business": "Swedish company or organization",
"type_eu_business": "EU business",
"type_non_eu_business": "Business outside EU",
- "type_hint": "Supplier type affects how VAT is handled on purchases",
"email_label": "Email",
"email_placeholder": "contact@company.com",
"email_invalid": "Invalid email address",
@@ -1463,14 +1307,7 @@
"org_number_label": "Org. number",
"org_number_placeholder": "XXXXXX-XXXX",
"vat_label": "VAT number",
- "vat_placeholder_eu": "DE123456789",
"vat_placeholder_se": "SE123456789001",
- "vat_verify": "Verify",
- "vat_verified_title": "VAT number verified",
- "vat_verified_description": "Company: {name}",
- "vat_failed_title": "Verification failed",
- "vat_failed_default": "The VAT number could not be verified",
- "vat_error_title": "Could not verify VAT number",
"payment_section": "Payment details",
"bankgiro_label": "Bankgiro",
"bankgiro_placeholder": "123-4567",
@@ -1478,12 +1315,10 @@
"iban_label": "IBAN",
"iban_placeholder": "SE45 5000 0000 0583 9825 7466",
"swift_label": "SWIFT/BIC",
- "bank_account_label": "Bank account",
"clearing_label": "Clearing number",
"account_number_label": "Account number",
"default_payment_terms_label": "Payment terms (days)",
"default_account_label": "Default account",
- "default_account_placeholder": "e.g. 5410",
"default_account_clear": "Clear default account",
"default_currency_label": "Default currency",
"notes_label": "Notes",
@@ -1492,37 +1327,6 @@
"submit_saving": "Saving...",
"viewer_disabled_tooltip": "You only have viewer access in this company"
},
- "upcoming_deadlines": {
- "title": "Upcoming deadlines",
- "overdue_badge": "{count} overdue",
- "action_needed_badge": "{count} to act on",
- "today": "Today",
- "tomorrow": "Tomorrow",
- "time_prefix": "at",
- "tax_badge": "Tax",
- "mark_as_submitted": "Mark as submitted",
- "mark_as_confirmed": "Mark as confirmed",
- "view_all": "View all deadlines",
- "toast_status_updated": "Status updated",
- "toast_status_updated_description": "Deadline marked as {status}",
- "toast_status_update_failed": "Could not update status"
- },
- "tax_todo": {
- "title": "To do: Tax",
- "overdue_badge": "{count} overdue",
- "action_needed_badge": "{count} soon",
- "today": "Today",
- "tomorrow": "Tomorrow",
- "days_ago": "{count} days ago",
- "in_days": "In {count} days",
- "start": "Start",
- "submitted": "Submitted",
- "more_tasks": "+{count} more tasks",
- "view_all": "View all deadlines",
- "toast_status_updated": "Status updated",
- "toast_status_updated_description": "Marked as {status}",
- "toast_status_update_failed": "Could not update status"
- },
"initial_setup": {
"completed_verdict": "Your bookkeeping is up and running.",
"title": "{count} steps and your bookkeeping is running",
@@ -1536,10 +1340,6 @@
"step_bank_description": "Fetch transactions automatically, or import a bank statement.",
"step_bank_sweep_note": "{matched, plural, one {# matched against the import} other {# matched against the import}}, {toReview, plural, one {# to review} other {# to review}}",
"step_bank_action": "Connect the bank",
- "step_assistant_title": "Build your bookkeeping assistant",
- "step_assistant_beta": "Beta",
- "step_assistant_description": "A few questions about your business calibrate an assistant that suggests bookkeeping for you.",
- "step_assistant_action": "Get started",
"dismiss": "Hide",
"step_skv_title": "Connect Skatteverket",
"step_skv_description": "See the tax account and file VAT and employer declarations right from here. Connects with BankID in a couple of minutes.",
@@ -1812,7 +1612,6 @@
"phone_label": "Phone",
"email_label": "Email",
"website_label": "Website",
- "members_invite_title": "Invite to {companyName}",
"members_invite_description": "The person gets access to this company only.",
"members_invite_email_label": "Email address",
"members_invite_email_placeholder": "name@example.com",
@@ -1843,7 +1642,6 @@
"members_remove_failed": "Could not remove member.",
"members_invite_revoked": "Invitation revoked",
"members_invite_revoke_failed": "Could not revoke invitation.",
- "invitations_pending_title": "Pending invitations",
"invitations_expires": "Expires {date}",
"logo_heading": "Logo",
"logo_help": "Shown in the header of your invoices. Max 10 MB, PNG/JPG/WebP.",
@@ -1968,9 +1766,6 @@
"wrapper_save_changes": "Save changes",
"wrapper_readonly_tooltip": "You have read-only access to this company"
},
- "settings_invoicing": {
- "bank_validation_title": "Check bank details"
- },
"settings_invoicing_preview": {
"title": "Preview",
"preview_button": "Preview invoice",
@@ -2056,7 +1851,6 @@
"settings_team": {},
"settings_templates": {},
"settings_salary": {
- "title": "Payroll settings",
"payments_heading": "Payment",
"pay_day_label": "Pay day",
"pay_day_help": "Day of the month salaries are paid (1–28). Used as the default for new payroll runs and for reminders.",
@@ -2076,21 +1870,11 @@
"vacation_info_link": "Manage employees",
"accounting_heading": "Bookkeeping",
"voucher_series_label": "Default voucher series for payroll",
- "voucher_series_a": "A: Standard",
- "voucher_series_l": "L: Löner",
"voucher_series_help": "Can be changed per payroll run. Each series has unbroken voucher numbers per fiscal year.",
"tax_tables_heading": "Skattetabeller",
"tax_tables_help": "Tax tables and municipal tax rates are fetched automatically from Skatteverket's open data on every payroll run. No manual update is needed. If Skatteverket's API is unavailable, an embedded fallback copy is used until the service is back.",
"vacation_heading": "Vacation",
"vacation_rule_label": "Default vacation rule",
- "vacation_rule_percentage": "Procentregeln (12%)",
- "vacation_rule_same_pay": "Sammalöneregeln",
- "vacation_rule_none": "No vacation accrual",
- "vacation_supplement_label": "Vacation supplement",
- "vacation_supplement_min": "0.43% (statutory minimum)",
- "vacation_supplement_cba": "0.80% (common collective agreement level)",
- "vacation_supplement_help": "Applied with sammalöneregeln. Can be overridden per employee.",
- "info_heading": "Information",
"info_payroll_scope": "The payroll module handles salaries for your employees: tax deductions, employer contributions, vacation pay liability and the employer declaration (AGI). If you run a sole proprietorship (enskild firma), you as the owner take money out via owner's drawings (account 2013), not salary.",
"info_current_year": "Current year: 2026 (Arbetsgivaravgifter 31.42%, prisbasbelopp 59,200 SEK)"
},
@@ -2147,10 +1931,6 @@
"tax_mark_paid_timeout": "The server did not respond in time. Reload the page and check whether the change was saved.",
"tax_mark_paid_network": "No connection to the server. Check your internet connection and try again."
},
- "settings_backup": {
- "heading": "Backup",
- "intro": "Download your own copy of all accounting data (SIE files, receipts, supporting documents and processing history) in a single ZIP file. {appName} archives all accounting data for at least 7 years per BFL 7 kap. 2 §, so your backup does not replace our legal obligation; it complements it."
- },
"settings_api": {},
"settings_banking": {
"connect_failed_title": "Connection failed",
@@ -2199,28 +1979,6 @@
"blockers_load_failed": "The list of companies you own could not be loaded, so the account cannot be deleted right now.",
"blockers_load_retry": "Try again"
},
- "settings_bank_details_form": {
- "heading": "Bank details",
- "subheading": "Shown on your invoices",
- "bank_label": "Bank",
- "clearing_label": "Clearing",
- "clearing_error": "Must be 4-5 digits",
- "account_number_label": "Account number",
- "account_number_error": "Must be 6-12 digits",
- "bankgiro_label": "Bankgiro",
- "bankgiro_error": "Invalid bankgiro number",
- "plusgiro_label": "Plusgiro",
- "plusgiro_error": "Invalid plusgiro number",
- "swish_label": "Swish",
- "swish_placeholder": "123 XXX XX XX or 07X XXX XX XX",
- "swish_error": "Invalid Swish number (business number 123XXXXXXX or mobile number 07XXXXXXXX)",
- "iban_label": "IBAN",
- "iban_error": "Invalid IBAN (SE followed by 22 digits)",
- "iban_hint": "Required for the payment file (ISO 20022) when paying salaries",
- "bic_label": "BIC/SWIFT",
- "bic_placeholder": "Filled in automatically",
- "bic_error": "Invalid BIC/SWIFT (8 or 11 characters)"
- },
"settings_billing": {
"load_failed": "The subscription details could not be loaded, so your status cannot be shown right now.",
"load_retry": "Try again",
@@ -2369,7 +2127,6 @@
"access_enabled_send_only": "Enabled for sending e-invoices. Receiving is not included; write to support if you want to receive."
},
"settings_pdf_print": {
- "coming_soon": "Coming soon",
"heading": "Print & PDF",
"toast_save_failed": "Could not save",
"font_label": "Invoice font",
@@ -2573,7 +2330,6 @@
"settings_voucher_series": {
"heading": "Verifikationsserier",
"empty_state": "No voucher series yet. Series {series} is created automatically with the first voucher.",
- "active_series_label": "Active series",
"series_prefix": "Series",
"default_badge": "default",
"latest_number": "Latest no.",
@@ -3017,7 +2773,6 @@
"confirm_unlink": "Unlink BankID from your account?",
"toast_unlinked": "BankID unlinked",
"toast_unlink_failed": "Could not unlink BankID",
- "link_bankid_title": "Link BankID",
"link_bankid_description": "Scan the QR code with the BankID app",
"linked_description": "Your account is linked to BankID.",
"not_linked_description": "Link BankID for more secure sign-in.",
@@ -3051,7 +2806,6 @@
"toast_unenroll_failed_title": "Could not disable 2FA",
"toast_mfa_disabled_title": "Two-factor authentication disabled",
"toast_mfa_disabled_description": "2FA has been removed from your account.",
- "change_password_title": "Change password",
"change_password_description": "Update your password. If you sign in with an email link, you can set a password here.",
"new_password_label": "New password",
"new_password_placeholder": "At least 8 characters",
@@ -3112,7 +2866,6 @@
"skv_counterpart_body": "There is a skattekonto event on {date} that matches: post this voucher first, then link the skattekonto row to the same voucher instead of posting it twice.",
"match_invoice_btn": "Match invoice {number}",
"match_supplier_invoice_btn": "Match supplier invoice {number}",
- "choose_template_btn": "Choose template...",
"match_voucher_btn": "Match to existing voucher",
"attach_document_btn": "Match to document",
"more_actions_aria": "More actions",
@@ -3157,7 +2910,6 @@
"adjustment": "Adjustment"
},
"tx_quick_review": {
- "open_attached_failed": "Could not open the receipt",
"exchange_rate_fetch_failed": "Could not fetch the exchange rate.",
"title": "Review posting",
"description_template": "Review the journal entry before posting",
@@ -3177,17 +2929,10 @@
"no_vat_liability_account": "No VAT for liability/equity accounts",
"no_vat_default": "No VAT",
"change": "Change",
- "attached_doc_label": "Receipt attached",
- "attached_doc_source": "from document inbox",
- "opening": "Opening…",
- "view": "View",
"doc_label": "Receipt",
"doc_attached_count": "{count} attached",
- "doc_pick_existing": "Choose from the inbox",
"doc_pick_existing_inline": "or choose an existing document from the inbox",
"doc_picked_remove": "Remove document",
- "doc_link_failed_title": "Receipt could not be attached",
- "doc_link_failed_description": "{count} file(s) could not be linked to the journal entry.",
"doc_link_failed_booked_title": "Booked, but documents are missing",
"doc_link_failed_booked_description": "The transaction was booked, but {count} document(s) could not be attached to the journal entry: {files}. Open the entry to attach them again.",
"doc_link_open_entry": "Open the entry",
@@ -3200,8 +2945,6 @@
"tx_booking_dialog": {
"title": "Post transaction",
"description": "Create a journal entry for the transaction",
- "doc_label": "Receipt (optional)",
- "doc_attached_count": "{count} attached",
"doc_pick_existing": "Choose existing document",
"doc_pick_existing_inline": "or choose an existing document from the inbox",
"doc_picked_remove": "Remove document",
@@ -3257,54 +3000,11 @@
"income_label": "Income"
},
"tx_inbox_zero": {
- "empty_title": "No transactions",
- "empty_description": "Import a bank statement or add transactions manually to get started.",
- "import_btn": "Import transactions",
- "add_manual_btn": "Add manually",
"done_title": "All transactions posted!",
"done_description": "Nice work! All your transactions are posted. Import more or switch to history.",
"import_more_btn": "Import more",
"new_btn": "New transaction"
},
- "tx_swipe_view": {
- "doc_link_failed_title": "Receipt could not be attached",
- "doc_link_failed_description": "{count} file(s) could not be linked to the journal entry.",
- "booking_failed_skip": "Could not post. Tap \"Skip\" to continue.",
- "generic_error_skip": "An error occurred. Tap \"Skip\" to continue.",
- "match_failed_skip": "Could not match the invoice. Tap \"Skip\" to continue.",
- "done_title": "Done!",
- "done_subtitle": "All transactions are now posted",
- "back_to_transactions": "Back to transactions",
- "choose_template": "Choose template",
- "skip": "Skip",
- "review_title": "Review posting",
- "label_template": "Template",
- "label_category": "Category",
- "change_template": "Change template",
- "reverse_charge_warning": "Reverse charge requires the supplier's VAT registration number and country.",
- "label_account": "Account",
- "label_vat_treatment": "VAT treatment",
- "no_vat_liability_account": "No VAT for liability/equity accounts",
- "no_vat_default": "No VAT",
- "change": "Change",
- "doc_label": "Receipt",
- "doc_attached_count": "{count} attached",
- "booking": "Posting...",
- "book": "Post",
- "progress_label": "{current} of {total}",
- "instr_skip": "Skip",
- "instr_book": "Post",
- "indicator_skip": "Skip",
- "indicator_business": "Business",
- "badge_receipt": "Receipt",
- "badge_attachment": "Attachment",
- "invoice_match_title": "Invoice match found",
- "invoice_match_badge": "Match",
- "invoice_label": "Invoice {number}",
- "unknown_customer": "Unknown customer",
- "match_invoice_btn": "Match with invoice {number}",
- "suggested_categories": "Suggested categories"
- },
"tx_template_picker": {
"group_premises": "Premises",
"group_vehicle": "Vehicle",
@@ -3441,9 +3141,6 @@
"preview_truncated": "+ {remaining} more lines on booking",
"col_account": "Account",
"col_description": "Description",
- "accounts_loading": "Loading chart of accounts...",
- "accounts_load_failed": "The chart of accounts could not be loaded. Try again to change the account.",
- "accounts_retry": "Try again",
"col_debit": "Debit",
"col_credit": "Credit",
"total_label": "Total",
@@ -3704,7 +3401,6 @@
"skv_badge": "Skatteverket",
"duplicate_title_with_voucher": "Possible duplicate of voucher {label}",
"duplicate_title_draft": "Possible duplicate of voucher (draft)",
- "duplicate_body": "This looks like the same cash flow: link instead of re-posting.",
"link_to_voucher": "Link to voucher",
"book_anyway": "Post anyway",
"book": "Post",
@@ -3839,9 +3535,7 @@
"vat_none_desc": "Not VAT-liable (e.g. wages, private withdrawals)"
},
"tx_skattekonto_match": {
- "search_failed_default": "Could not search candidates",
"fetch_candidates_failed_title": "Could not fetch candidates",
- "match_failed_default": "Match failed",
"match_success_title": "Transaction linked to voucher",
"match_failed_title": "Could not link the transaction",
"title": "Match against existing voucher",
@@ -3943,7 +3637,6 @@
"load_customers_failed_title": "Could not load customers",
"load_customers_failed_description": "Check your connection and try again.",
"items_card_title": "Invoice lines",
- "more_references": "References & more",
"description_label": "Description",
"description_placeholder": "E.g. Instagram campaign",
"quantity_label": "Quantity",
@@ -4050,8 +3743,6 @@
"deduction_housing_label": "Property designation (fastighetsbeteckning)",
"deduction_housing_placeholder": "e.g. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Required for ROT deductions (not needed for RUT).",
- "deduction_cap_over": "The invoice's deduction exceeds the annual cap",
- "deduction_cap_check": "The customer needs to check their remaining allowance themselves.",
"deduction_summary_label": "Tax reduction ROT/RUT",
"deduction_work_type_required": "Choose a work type for the ROT/RUT row.",
"deduction_work_type_mismatch": "The work type does not belong to the selected tax reduction (ROT/RUT).",
@@ -4458,14 +4149,12 @@
"created_toast_title": "Credit note created",
"created_toast_description": "Credit note {number} was created as a draft.",
"create_failed_title": "Could not create credit note",
- "create_failed_fallback": "Failed to create credit note",
"try_again": "Try again."
},
"invoice_recurring": {
"title": "Recurring invoices",
"new_schedule": "New schedule",
"viewer_disabled_tooltip": "You only have read-only access to this company",
- "loading": "Loading...",
"load_failed_title": "Could not load recurring invoices",
"load_failed_description": "Check your connection and try again.",
"empty_title": "No recurring invoices",
@@ -4503,7 +4192,6 @@
"interval_every_n": "Every {n} months"
},
"invoice_recurring_new": {
- "back": "Back",
"title": "New recurring schedule",
"edit_title": "Edit schedule",
"save_changes": "Save changes",
@@ -4883,18 +4571,6 @@
"debit_short": "D {amount}",
"credit_short": "C {amount}",
"debit_credit_short": "D {debit} / C {credit}",
- "account_2440": "Accounts payable",
- "account_2641": "Input VAT",
- "account_2645": "Calculated input VAT, foreign purchase",
- "account_2647": "Calculated input VAT, domestic reverse charge",
- "account_2614": "Output VAT, reverse charge 25%",
- "account_2624": "Output VAT, reverse charge 12%",
- "account_2634": "Output VAT, reverse charge 6%",
- "account_1710": "Prepaid rent",
- "account_1720": "Prepaid leasing fees",
- "account_1730": "Prepaid insurance premiums",
- "account_1740": "Prepaid interest",
- "account_1790": "Other prepaid expenses",
"review_accrual_line_info": "Accrued {from} to {to}"
},
"supplier_invoice_detail": {
@@ -5017,7 +4693,6 @@
"type_swedish": "Swedish company or organisation",
"type_eu": "EU company",
"type_non_eu": "Outside EU",
- "org_number_inline": " | Org. no.: {number}",
"edit": "Edit",
"edit_dialog_title": "Edit supplier",
"load_failed_title": "Could not load supplier",
@@ -5034,17 +4709,7 @@
"total_paid": "Total paid",
"invoice_count": "Invoice count",
"contact_section_title": "Contact details",
- "email_inline": "Email: {email}",
- "phone_inline": "Phone: {phone}",
- "vat_inline": "VAT: {vat}",
"payment_section_title": "Payment details",
- "bankgiro_inline": "Bankgiro: {value}",
- "plusgiro_inline": "Plusgiro: {value}",
- "iban_inline": "IBAN: {value}",
- "bic_inline": "BIC: {value}",
- "payment_terms_inline": "Payment terms: {days} days",
- "currency_inline": "Currency: {currency}",
- "expense_account_inline": "Expense account: {account}",
"delete": "Delete",
"kicker_org": "Org. no. {number}",
"def_email": "Email",
@@ -5111,9 +4776,7 @@
"import_attn_action": "Show import history",
"mode_vouchers": "Vouchers",
"mode_drafts": "Drafts",
- "density_compact": "Compact view",
"show_correction_chain": "Show storno & corrected entries",
- "loading": "Loading journal entries...",
"empty_title": "No journal entries",
"empty_description": "Journal entries are created automatically from invoicing and transaction posting, or manually via the \"New entry\" tab.",
"empty_drafts_title": "No drafts",
@@ -5138,25 +4801,19 @@
"filter_dialog_title": "Filter journal entries",
"filter_clear_all": "Clear all filters",
"filter_done": "Done",
- "filter_section_period": "Fiscal year",
"filter_section_sort": "Sort order",
"filter_section_series": "Voucher series",
"filter_section_date": "Date range",
"clear_date_filter": "Clear date filter",
- "scope_label": "Showing:",
- "scope_all_years": "All fiscal years",
"out_of_period_label": "Subsequent",
"rattelse_badge": "Corrected",
"rattelse_badge_tooltip": "The voucher was corrected after posting: see the correction history on the voucher page",
"out_of_period_tooltip": "Posted in a later fiscal year, but relates to the selected year (e.g. payment of an invoice issued in the selected year).",
- "out_of_period_tooltip_mobile": "Posted in a later fiscal year, but relates to the selected year.",
"attachment_count_tooltip": "{count} documents",
"missing_attachment_tooltip": "Document missing",
"no_lines": "No entry lines found for this journal entry.",
"debit": "Debit",
"credit": "Credit",
- "sum_debit": "Total debit",
- "sum_credit": "Total credit",
"post": "Post",
"show_details": "Show details",
"create_correction": "Create correction entry",
@@ -5207,7 +4864,6 @@
"sum_label": "Total",
"batch_select_all": "Select all ({count})",
"batch_select_row": "Select entry",
- "batch_selected_count": "{count} selected",
"batch_mark_no_doc": "Mark as no document required",
"batch_clear_selection": "Clear",
"batch_no_doc_done_title": "Marked as no document required",
@@ -5265,9 +4921,6 @@
"download": "Download",
"remove": "Remove",
"replace": "Replace with new version",
- "remove_confirm_title": "Remove document",
- "remove_confirm_body": "Remove {file}? This cannot be undone.",
- "remove_confirm_cta": "Remove",
"remove_blocked_title": "Document cannot be removed",
"remove_blocked_body": "This document is attached to a verifikation and constitutes räkenskapsinformation under the Swedish Bookkeeping Act (BFL 7 kap 2§). Räkenskapsinformation must be retained for at least 7 years and cannot be deleted.",
"remove_blocked_hint": "If the document needs correction, upload a new version. The existing one is preserved in the version history.",
@@ -5278,7 +4931,6 @@
"detaching": "Detaching...",
"detach_failed": "Could not detach the document.",
"replace_uploading": "Replacing...",
- "remove_failed": "Could not remove the document.",
"replace_failed": "Could not upload new version.",
"choose_from_inbox": "Choose from inbox",
"picker_title": "Choose a document from the inbox",
@@ -5340,7 +4992,6 @@
"accounts_retry": "Try again",
"edit_draft": "Edit",
"back": "Back to bookkeeping",
- "loading": "Loading journal entry...",
"error_not_found": "Journal entry not found",
"error_load_failed": "Could not fetch journal entry",
"post": "Post",
@@ -5349,9 +5000,7 @@
"confirm_post_description_generic": "The voucher \"{description}\" will be posted with the next available voucher number and can then only be corrected or reversed.",
"delete_draft": "Delete draft",
"delete_entry": "Delete journal entry",
- "create_correction": "Create correction entry",
"copy_entry": "Copy journal entry",
- "edit_entry": "Edit",
"correct_menu": "Correct",
"correct_lines": "Correct lines (correction entry)",
"correct_date": "Correct date",
@@ -5731,12 +5380,10 @@
"col_audited": "Audited",
"col_audit_opinion": "Audit opinion",
"status_section": "Status",
- "status_no_entries": "No status entries on file.",
"fiscal_year_section": "Fiscal year",
"fiscal_year_current": "Current: {start}-{end}",
"fiscal_year_changed": "Fiscal year has been changed {n, plural, one {# time} other {# times}}.",
"signatory_section": "Authorised signatories",
- "signatory_empty": "No signatory rules on file at TIC.",
"board_section": "Board & officers",
"board_summary_members": "{n, plural, one {# board member} other {# board members}}",
"board_summary_deputies": "{n, plural, one {# deputy} other {# deputies}}",
@@ -5748,7 +5395,6 @@
"col_position": "Role",
"col_since": "Since",
"payroll_section": "Payroll history",
- "payroll_empty": "No payroll history reported to Skatteverket.",
"col_payroll_period": "Period",
"col_payroll_employees": "Employees",
"col_payroll_tax": "Tax withheld",
@@ -6132,30 +5778,17 @@
"counterparty_suggestion_gone_title": "That counterparty is no longer available",
"counterparty_suggestion_gone_description": "The suggestion was refreshed. Close the dialog, open it again and pick the counterparty once more.",
"page_title": "Transactions",
- "subtitle_to_post": "to post",
- "subtitle_matches": "{count} invoice matches",
- "history_subtitle": "All your transactions",
"action_import": "Import",
- "action_review_all": "Review all",
- "action_review_loading": "Loading...",
"action_new_transaction": "New transaction",
"viewer_disabled_tooltip": "You only have viewer access in this company",
"mode_inbox": "To record",
- "mode_history": "All transactions",
- "source_label": "Source:",
- "source_all": "All ({count})",
- "source_bank": "Bank ({count})",
- "source_skatteverket": "Skatteverket ({count})",
- "search_placeholder": "Search transactions...",
"no_search_results": "No transactions match your search.",
"source_empty": "The selected source has no transactions. Choose All to show the other sources.",
"period_empty": "The selected period has no transactions. Choose All fiscal years to show everything.",
"period_pending_outside": "{count, plural, one {# transaction to record outside the selected period.} other {# transactions to record outside the selected period.}}",
"period_show_all": "Show all",
- "skv_reconnect_title": "The Skatteverket connection needs to be renewed",
"skv_reconnect_body": "Tax account transactions are not fetched until you reconnect with BankID and approve all permissions.",
"skv_reconnect_cta": "Reconnect",
- "dialog_choose_template": "Choose template",
"dialog_match_invoice": "Match with invoice",
"dialog_add_transaction": "Add transaction",
"dialog_match_supplier_invoice": "Match with supplier invoice?",
@@ -6199,8 +5832,6 @@
"match_failed_transaction": "The transaction could not be matched. Please try again.",
"match_failed_with_invoice": "The transaction could not be matched with the invoice. Please try again.",
"voucher_link_failed_description": "The journal entry could not be linked. Please try again.",
- "login_required_title": "Sign in required",
- "login_required_description": "You must be signed in to add transactions.",
"deleted_title": "Deleted",
"deleted_description": "The transaction has been deleted",
"delete_failed_description": "The transaction could not be deleted. Please try again.",
@@ -6208,7 +5839,6 @@
"edit_title_failed": "Could not update the title",
"move_account_saved": "Transaction moved",
"move_account_failed": "Could not move the transaction",
- "review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping.",
"bank_sync_attention_one": "1 bank connection needs renewal",
"bank_sync_attention_many": "{count} bank connections need renewal",
"bank_sync_auto_nightly": "Synced automatically each night",
@@ -6335,10 +5965,6 @@
"toast_post_failed_generic": "Could not post journal entry",
"edit_draft_dialog_title": "Edit draft",
"title": "Bookkeeping",
- "year_end": "Year-end (Årsbokslut)",
- "tab_journal": "Journal entries",
- "tab_new_entry": "New journal entry",
- "tab_accounts": "Chart of accounts",
"new_entry_dialog_title": "New journal entry",
"create_with_assistant": "Create with assistant",
"loading_source_voucher": "Loading source voucher...",
@@ -6457,7 +6083,6 @@
},
"form_article": {
"type_label": "Type *",
- "type_placeholder": "Choose type",
"type_vara": "Goods",
"type_tjanst": "Service",
"number_label": "Article number",
@@ -6480,17 +6105,14 @@
"summary_booked_on": "Posted to",
"summary_unnamed": "Untitled article",
"revenue_account_label": "Posting account",
- "revenue_account_placeholder": "e.g. 2897",
"revenue_account_hint": "Leave empty to derive the sales account automatically from VAT. You may select an active class 1-3 account.",
"posting_account_invalid": "Enter a four-digit class 1-3 account.",
"cost_price_label": "Cost price",
"cost_price_hint": "Margin display only, never posted.",
"currency_label": "Currency",
- "currency_hint": "Pre-fills a new invoice's currency when the article is added.",
"ean_label": "EAN/barcode",
"ean_placeholder": "e.g. 7350000000000",
"housework_label": "ROT/RUT",
- "housework_placeholder": "Choose work type",
"housework_none": "None",
"housework_rot": "ROT",
"housework_rut": "RUT",
@@ -6529,52 +6151,6 @@
"col_created": "Created",
"count_summary": "{count, plural, one {1 customer} other {# customers}}"
},
- "products": {
- "title": "Products",
- "new_product": "New product",
- "back_to_list": "Back to products",
- "viewer_disabled_tooltip": "You only have viewer access in this company",
- "load_failed_title": "Could not load products",
- "load_failed_description": "Check your connection and try again.",
- "create_failed_title": "Could not create product",
- "update_failed_title": "Could not update product",
- "archive_failed_title": "Could not archive product",
- "created_title": "Product created",
- "created_description": "{name} was added",
- "updated_title": "Product updated",
- "archived_title": "Product archived",
- "archive": "Archive",
- "archive_confirm": "Archive this product? Existing invoices that reference it are unaffected.",
- "archived_badge": "Archived",
- "search_placeholder": "Search products",
- "no_search_results_title": "No matches",
- "no_search_results_description": "No products match \"{term}\".",
- "empty_title": "No products yet",
- "empty_description": "Create your first product so you can add it to invoices.",
- "section_basics": "Basics",
- "section_pricing": "Price & VAT",
- "type_goods": "Goods",
- "type_service": "Service",
- "field_name": "Product name",
- "field_name_placeholder": "e.g. LED bulb 9W",
- "field_sku": "SKU",
- "field_sku_placeholder": "e.g. LED-9W-E27",
- "field_description": "Description",
- "field_type": "Product type",
- "field_type_hint": "Goods update stock levels when invoiced. Services don't.",
- "field_category": "Category",
- "field_default_price": "Default price (excl. VAT)",
- "field_default_unit": "Unit",
- "field_default_vat": "Default VAT rate",
- "validation_name_required": "Product name is required",
- "save": "Save",
- "cancel": "Cancel",
- "col_name": "Name",
- "col_sku": "SKU",
- "col_type": "Type",
- "col_default_price": "Default price",
- "col_unit": "Unit"
- },
"webshop_orders": {
"title": "Orders",
"all_stores": "All stores",
@@ -6688,179 +6264,22 @@
"bulk_none_bookable": "None of the selected orders can be booked in a sweep. Book them individually.",
"bulk_skipped_unsupported_rate": "Skipped (non-Swedish VAT rate, book individually): {numbers}"
},
- "sales_orders": {
- "title": "Sales orders",
- "back_to_list": "Back to orders",
- "new_order": "New order",
- "viewer_disabled_tooltip": "You only have read access in this company",
- "load_failed_title": "Could not load orders",
- "load_failed_description": "Check your connection and try again.",
- "search_placeholder": "Search order number or customer",
- "empty_title": "No orders yet",
- "empty_description": "Create your first order when a customer accepts a quote, or start one from scratch.",
- "no_category_title": "No orders in this category",
- "no_category_description": "Try a different filter or create a new order.",
- "tab_all": "All",
- "tab_draft": "Drafts",
- "tab_confirmed": "Confirmed",
- "tab_partially_shipped": "Partially shipped",
- "tab_shipped": "Shipped",
- "tab_partially_invoiced": "Partially invoiced",
- "tab_invoiced": "Invoiced",
- "tab_cancelled": "Cancelled",
- "status_draft": "Draft",
- "status_confirmed": "Confirmed",
- "status_partially_shipped": "Partially shipped",
- "status_shipped": "Shipped",
- "status_partially_invoiced": "Partially invoiced",
- "status_invoiced": "Invoiced",
- "status_cancelled": "Cancelled",
- "col_number": "Number",
- "col_customer": "Customer",
- "col_date": "Date",
- "col_expected_delivery": "Expected delivery",
- "col_status": "Status",
- "col_amount": "Amount",
- "col_description": "Description",
- "col_quantity": "Quantity",
- "col_quantity_shipped": "Shipped",
- "col_quantity_invoiced": "Invoiced",
- "col_unit_price": "Unit price",
- "col_line_total": "Line total",
- "section_header": "Order details",
- "section_lines": "Order lines",
- "section_progress": "Shipment and invoicing progress",
- "field_customer": "Customer",
- "field_order_date": "Order date",
- "field_expected_delivery": "Expected delivery date",
- "field_currency": "Currency",
- "field_notes": "Notes",
- "field_our_reference": "Our reference",
- "field_your_reference": "Your reference",
- "field_product": "Product",
- "field_unit": "Unit",
- "field_vat_rate": "VAT",
- "action_confirm": "Confirm order",
- "action_ship": "Ship",
- "action_invoice": "Invoice",
- "action_cancel": "Cancel",
- "action_delete": "Delete draft",
- "confirm_dialog_title": "Confirm the order?",
- "confirm_dialog_description": "The customer commits to receiving the shipment. You can then create a delivery note and invoice.",
- "ship_dialog_title": "Ship the order",
- "ship_dialog_description": "A delivery note is created and stock levels are updated automatically for products that track inventory.",
- "invoice_dialog_title": "Invoice the order",
- "invoice_dialog_description": "An invoice is created for the remaining quantities. You can edit it before sending it to the customer.",
- "cancel_dialog_title": "Cancel the order?",
- "cancel_dialog_description": "An order can only be cancelled before the first shipment. Cancelled orders cannot be restored.",
- "delete_dialog_title": "Delete draft?",
- "delete_dialog_description": "Drafts can be removed without a trace: confirmed orders must be cancelled instead.",
- "confirmed_toast": "Order is confirmed",
- "shipped_toast": "Delivery note created",
- "invoiced_toast": "Invoice created",
- "cancelled_toast": "Order is cancelled",
- "deleted_toast": "Draft removed",
- "from_quote": "From quote",
- "summary_total": "Total",
- "summary_subtotal": "Subtotal",
- "summary_vat": "VAT",
- "stock_warnings_title": "Some stock movements could not be recorded",
- "validation_at_least_one_line": "At least one order line is required",
- "remaining_to_ship": "{remaining} left to ship",
- "remaining_to_invoice": "{remaining} left to invoice"
- },
- "inventory": {
- "title": "Inventory",
- "back_to_inventory": "Back to inventory",
- "view_movements": "View movements",
- "movements_title": "Stock movements",
- "load_failed_title": "Could not load inventory",
- "no_locations_title": "No location configured",
- "no_locations_description": "Create a primary location to start tracking stock levels.",
- "create_primary_location": "Create primary location",
- "default_location_name": "Main warehouse",
- "location_created": "Location created",
- "location_create_failed": "Could not create location",
- "locations_title": "Locations",
- "primary": "Primary",
- "inactive": "Inactive",
- "stock_levels_title": "Current stock",
- "no_levels_title": "No stock movements yet",
- "no_levels_description": "Stock movements are created automatically when you send invoices or approve supplier invoices that reference a 'Goods' product.",
- "no_movements_title": "No movements yet",
- "no_movements_description": "All inbound and outbound movements will appear here once recorded.",
- "col_location_name": "Name",
- "col_location_status": "Status",
- "col_product": "Product",
- "col_sku": "SKU",
- "col_location": "Location",
- "col_quantity": "Quantity",
- "col_unit": "Unit",
- "col_when": "When",
- "col_reason": "Reason",
- "col_delta": "Change",
- "col_reference": "Source",
- "reason_purchase": "Purchase",
- "reason_sale": "Sale",
- "reason_adjustment": "Adjustment",
- "reason_transfer_in": "Transfer in",
- "reason_transfer_out": "Transfer out",
- "reason_return": "Return",
- "reason_disposal": "Disposal",
- "reason_opening": "Opening balance",
- "ref_invoice": "Invoice",
- "ref_supplier_invoice": "Supplier invoice",
- "ref_transfer": "Stock transfer",
- "ref_transfer_rollback": "Rollback",
- "ref_manual": "Manual"
- },
"self_billing": {
"title": "Register self-billing invoice",
- "subtitle": "A self-billing invoice you received: the customer invoiced in your name. For you it is a sale with output VAT.",
- "back": "Back",
- "issuer_card_title": "Issuer and invoice reference",
"issuer_card_description": "The customer who issued the self-billing invoice, and the number they assigned.",
"customer_label": "Customer (issuer)",
- "select_customer_placeholder": "Select customer",
"external_number_label": "Invoice number (customer's)",
"external_number_placeholder": "e.g. SF-2026-014",
"agreement_ref_label": "Agreement reference",
"agreement_ref_placeholder": "Self-billing agreement",
- "items_card_title": "Lines",
- "items_card_description": "The amounts from the received self-billing invoice.",
- "description_label": "Description",
- "description_placeholder": "Description",
- "quantity_label": "Qty",
- "unit_label": "Unit",
- "unit_price_label": "Unit price",
- "vat_label": "VAT",
- "row_label": "Row {index}",
- "add_row": "Add row",
- "notes_card_title": "Notes",
- "notes_placeholder": "Internal notes (optional)",
- "details_card_title": "Details",
- "currency_label": "Currency",
"invoice_date_label": "Invoice date",
"received_date_label": "Received date",
- "due_date_label": "Due date",
- "summary_card_title": "Summary",
- "subtotal_label": "Net",
- "output_vat_label": "Output VAT",
- "total_label": "Total",
"register": "Register self-billing invoice",
- "viewer_disabled_tooltip": "You only have viewer access in this company",
- "load_customers_failed": "Could not load customers",
"created_title": "Self-billing invoice registered",
"created_description": "Self-billing invoice {number} has been booked as a sale",
"create_failed_title": "Could not register the self-billing invoice",
- "validation_customer_required": "Select a customer",
"validation_external_number_required": "Invoice number is required",
- "validation_invoice_date_required": "Invoice date is required",
- "validation_received_date_required": "Received date is required",
- "validation_due_date_required": "Due date is required",
- "validation_description_required": "Description is required",
- "validation_quantity_min": "Quantity must be at least 0.01",
- "validation_min_one_row": "At least one row is required"
+ "validation_received_date_required": "Received date is required"
},
"invoices": {
"rot_rut_payout_action": "ROT/RUT file",
@@ -7274,28 +6693,7 @@
"card_blockers_title": "Needs attention",
"card_blockers_detail": "{bank} missing bank account · {email} missing email",
"card_blockers_none": "No blockers — all employees are complete.",
- "card_vacation_title": "Vacation days left",
- "card_vacation_detail": "{employees} employees, {saved} saved days",
- "card_vacation_none": "Vacation balances are created on the first booked salary run.",
- "vacation_close_button": "Close vacation year",
- "vacation_close_title": "Vacation year close",
- "vacation_close_description": "Review the transition before confirming: saved days roll (max 5 years), expired days are flagged for payout, and the vacation pay liability is reconciled against the booked balance.",
- "vacation_close_previewing": "Preparing the review report…",
"vacation_close_preview_failed": "Could not prepare the report",
- "vacation_close_period": "Vacation year {from} to {to}",
- "vacation_close_col_employee": "Employee",
- "vacation_close_col_saved": "Saved",
- "vacation_close_col_flagged": "Flagged",
- "vacation_close_col_expiring": "Expiring",
- "vacation_close_col_next": "Next year",
- "vacation_close_computed": "Computed vacation pay liability: {amount}",
- "vacation_close_booked": "Booked on 2920: {amount}",
- "vacation_close_drift": "Adjustment to book: {amount}",
- "vacation_close_no_drift": "No adjustment needed (difference under 1 kr).",
- "vacation_close_cancel": "Cancel",
- "vacation_close_confirm": "Close the vacation year",
- "vacation_close_done": "Vacation year closed",
- "vacation_close_failed": "Vacation year close failed",
"status_draft": "Draft",
"status_review": "Review",
"status_approved": "Approved",
@@ -7315,7 +6713,6 @@
"not_found": "Payroll run not found",
"more_actions": "More actions",
"unknown_error": "Unknown error",
- "rail_title": "This month's steps",
"rail_calculate": "Calculate",
"rail_calculate_hint": "Calculate wages, tax, and contributions, then send for review.",
"rail_approve": "Approve",
@@ -7391,7 +6788,6 @@
"th_vacation": "Vacation",
"th_payslip": "Payslip",
"diff_new_employee": "New",
- "view_pdf": "View PDF",
"view_payslip_title": "View payslip",
"salary_input_aria": "Monthly salary for {name}",
"degree_hint": "× {degree} % = {amount}",
@@ -7863,19 +7259,6 @@
"source_unavailable": "Tax tables for {year} could not be fetched",
"recheck": "Check again"
},
- "time_tracking": {
- "title": "Time tracking",
- "billable_inbox": "Billable hours by customer",
- "create_invoice": "Create invoice",
- "hourly_rate": "Hourly rate",
- "project_required": "Select a project to bill",
- "no_project": "No project",
- "no_billable_hours": "No billable hours to invoice. Mark days as billable to collect them here.",
- "log_hours": "Log hours",
- "billable": "Billable",
- "invoiced": "Invoiced",
- "missing_rate": "Missing rate"
- },
"import": {
"title": "Import / export",
"subtitle": "Import bank transactions or bookkeeping data into your company",
@@ -8201,36 +7584,11 @@
"k3_draft_notice": "K3-dokumentet är ännu ett granskningsutkast och kan inte låsas eller lämnas in via Accounted. Upprätta och lämna in årsredovisningen på papper tills hela upplysningsmatrisen är implementerad och granskad."
},
"empty": {
- "invoices_title": "No invoices yet",
- "invoices_description": "Create your first invoice to get started.",
- "customers_title": "No customers yet",
- "customers_description": "Add your first customer to start invoicing.",
- "transactions_title": "No transactions",
- "transactions_description": "Transactions will appear here once your bank connection syncs.",
- "suppliers_title": "No suppliers yet",
- "suppliers_description": "Add your first supplier.",
- "no_results": "No results",
- "no_data": "No data to display",
"support_hint_subject": "Need help getting started",
"support_hint_label": "Need help? Contact support",
- "preset_invoices_title": "No invoices yet",
- "preset_invoices_description": "Create your first invoice in under 60 seconds. We fill in your details automatically.",
- "preset_invoices_action": "Create invoice",
"preset_customers_title": "No customers yet",
"preset_customers_description": "Add your customers to easily create invoices and track payments.",
- "preset_customers_action": "Add customer",
- "preset_transactions_title": "No transactions",
- "preset_transactions_description": "Import bank statements to automatically post entries and stay on top of your finances.",
- "preset_transactions_action": "Import transactions",
- "preset_deadlines_title": "No upcoming deadlines",
- "preset_deadlines_description": "Great work! You have no immediate deadlines to handle.",
- "preset_no_bank_title": "No transactions imported",
- "preset_no_bank_description": "Import bank statements to automatically post entries and get a better view of your finances.",
- "preset_no_bank_action": "Import transactions",
- "preset_reports_title": "No reports available",
- "preset_reports_description": "Reports are generated automatically once you have enough data. Start by creating invoices or importing transactions.",
- "preset_reports_action": "Create invoice",
- "preset_reports_secondary": "Import transactions"
+ "preset_customers_action": "Add customer"
},
"start_cards": {
"invoices_title": "Your first invoice takes two minutes.",
@@ -8432,7 +7790,6 @@
"action_show_voucher": "Show voucher",
"action_match": "Match",
"action_book": "Book",
- "action_booking": "Booking…",
"ignore_action": "Ignore",
"action_unignore": "Restore",
"band_ignored": "Ignored",
diff --git a/messages/sv.json b/messages/sv.json
index 1568d506..c2918999 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -7,69 +7,28 @@
"save": "Spara",
"saving": "Sparar...",
"cancel": "Avbryt",
- "create": "Skapa",
- "add": "Lägg till",
"delete": "Ta bort",
"edit": "Redigera",
"back": "Tillbaka",
- "next": "Nästa",
- "previous": "Föregående",
"pager_previous": "Föregående post",
"pager_next": "Nästa post",
"pager_position": "{index} av {total}",
"close": "Stäng",
- "search": "Sök",
- "filter": "Filtrera",
"loading": "Laddar...",
"load_more": "Ladda fler",
"retry": "Försök igen",
"load_error": "Kunde inte ladda data",
- "confirm": "Bekräfta",
- "yes": "Ja",
- "no": "Nej",
- "remove": "Ta bort",
- "submit": "Skicka",
- "send": "Skicka",
- "download": "Hämta",
- "upload": "Ladda upp",
- "copy": "Kopiera",
- "copied": "Kopierat",
"open": "Öppna",
- "select": "Välj",
- "all": "Alla",
- "none": "Inga",
- "more": "Mer",
- "less": "Mindre",
- "show": "Visa",
- "hide": "Dölj",
- "actions": "Åtgärder",
- "settings": "Inställningar",
- "language": "Språk",
"language_swedish": "Svenska",
"language_english": "Engelska",
- "appearance": "Utseende",
"theme_light": "Ljust",
"theme_dark": "Mörkt",
"theme_system": "System",
"logout": "Logga ut",
"logout_description": "Logga ut från ditt konto",
- "account_settings": "Kontoinställningar",
"status": {
- "posted": "Bokförd",
- "draft": "Utkast",
- "pending": "Väntande",
- "approved": "Godkänd",
- "archived": "Arkiverad",
- "paid": "Betald",
- "unpaid": "Obetald",
- "overdue": "Förfallen",
- "cancelled": "Annullerad",
- "sent": "Skickad",
- "matched": "Matchad",
- "unmatched": "Omatchad"
},
"more_options": "Fler alternativ",
- "popup_blocked_title": "Webbläsaren blockerade fliken",
"popup_blocked_description": "Tillåt popupfönster för {appName} i webbläsaren och försök igen.",
"source_code": "Källkod"
},
@@ -406,14 +365,9 @@
"team": "Medlemmar och roller",
"brand": "Varumärke",
"banking": "Bank (PSD2)",
- "skatteverket": "Skatteverket",
"salary": "Löner",
"templates": "Mallar",
- "agent_profile": "Företagsprofil",
- "agent_memory": "Assistentens minne",
- "agent_skills": "Assistentens kunskap",
"assistant": "Assistenten",
- "backup": "Säkerhetsbackup",
"account": "Konto",
"api": "API & MCP",
"billing": "Abonnemang",
@@ -583,7 +537,6 @@
},
"settings": {
"group_profile": "Profil",
- "section_name": "Namn",
"digest_group": "Aviseringar",
"digest_label": "Nytt att bokföra",
"digest_description": "Ett dagligt mejl när nya banktransaktioner eller underlag i inkorgen väntar på bokföring. Endast antal, aldrig belopp.",
@@ -801,12 +754,9 @@
"generate_none_title": "Inga deadlines skapades",
"generate_none_description": "Inga nya deadlines att skapa. Kontrollera att skatteinställningarna är ifyllda om du väntade dig fler.",
"generate_failed_title": "Kunde inte skapa deadlines",
- "generate_failed_description": "Det gick inte att skapa skattedeadlines just nu.",
"group_skattekonto_title": "Skattekonto",
"group_same_day": "{count} deadlines samma dag",
"group_mark_done": "Markera klar",
- "group_confirm_question": "Markera {title} som klar?",
- "group_cancel": "Avbryt",
"group_confirm": "Bekräfta",
"new_deadline": "Ny deadline",
"subscribe_calendar": "Prenumerera i din kalender",
@@ -836,68 +786,6 @@
"empty_filtered": "Inga deadlines matchar filtret.",
"confirm_done_title": "Markera som klar?"
},
- "bureau": {
- "title": "Byrå",
- "subtitle": "Översikt över alla klientbolag du har tillgång till",
- "section_title": "Byrå-läge",
- "enable_setting": "Aktivera byrå-läge",
- "enable_setting_description": "Visar en samlad översikt över alla klientbolag du har tillgång till",
- "toggle_saved": "Byrå-läge uppdaterat",
- "toggle_save_failed": "Kunde inte spara byrå-inställning",
- "tiles": {
- "clients": "Klienter totalt",
- "pending": "Väntar på godkännande",
- "deadlines": "Deadlines denna vecka",
- "overdue_ar": "Förfallna fakturor"
- },
- "action": {
- "open": "Öppna"
- },
- "pending_label": "Väntande operationer",
- "deadlines_label": "Kommande deadlines",
- "no_pending": "Inga väntande operationer",
- "no_deadlines": "Inga deadlines denna vecka",
- "show_pending": "Visa pending",
- "high_risk": "{count} hög risk",
- "kpi": {
- "revenue_mtd": "Intäkter MTD",
- "cash": "Bank/Kassa",
- "overdue_ar": "Förfallna fakt."
- },
- "role": {
- "owner": "ägare",
- "admin": "administratör",
- "member": "medlem",
- "viewer": "läsare"
- },
- "deadline_status": {
- "overdue": "förfallen"
- },
- "empty": {
- "title": "Inga klientbolag ännu",
- "description": "När du läggs till som medlem i ett bolag dyker det upp här."
- }
- },
- "insights": {
- "title": "Insikter",
- "empty": "Inga avvikelser hittade",
- "severity_flag": "Åtgärda",
- "severity_warn": "Granska",
- "severity_info": "Information",
- "action_dismiss": "Avfärda",
- "action_snooze": "Snooze 7d",
- "subject_open": "Öppna källan",
- "dashboard_tile_title": "Avvikelser",
- "dashboard_tile_subtitle": "{count} att åtgärda",
- "rules": {
- "outlier_amount": "Ovanligt belopp för motpart",
- "missing_recurring": "Återkommande leverantör saknas",
- "duplicate_suspect": "Möjlig dubblett",
- "stale_uncategorized": "Gammal okategoriserad transaktion",
- "vat_rate_outlier": "Avvikande momssats",
- "stale_customer_balance": "Förfallen kundfordran"
- }
- },
"supplier_invoices": {
"title": "Leverantörsfakturor",
"load_failed_title": "Kunde inte ladda leverantörsfakturor",
@@ -1010,34 +898,6 @@
"mark_all_result_failed": "{count} kunde inte bokföras. Öppna fakturorna och försök igen.",
"mark_all_result_settled": "{count} var redan betalda."
},
- "purchase_orders": {
- "title": "Inköpsorder",
- "new": "Ny inköpsorder",
- "empty_title": "Inga inköpsorder än",
- "empty_description": "Skapa en inköpsorder för att planera ett inköp, ta emot gods, och stämma av mot leverantörsfakturan.",
- "filter_open": "Öppna",
- "filter_all": "Alla",
- "th_number": "Nummer",
- "th_supplier": "Leverantör",
- "th_order_date": "Orderdatum",
- "th_status": "Status",
- "th_expected_delivery": "Beräknad lev.",
- "th_amount": "Belopp",
- "status_draft": "Utkast",
- "status_sent": "Skickad",
- "status_partially_received": "Delvis mottagen",
- "status_received": "Mottagen",
- "status_partially_invoiced": "Delvis fakturerad",
- "status_closed": "Stängd",
- "status_cancelled": "Annullerad",
- "send": "Markera som skickad",
- "receive": "Ta emot gods",
- "cancel": "Avbryt order",
- "three_way_match_failed": "Trevägs-matchningen misslyckades. Kontrollera kvantiteter och priser mot inköpsordern.",
- "po_link_required": "Företaget kräver att leverantörsfakturor länkas till en inköpsorder.",
- "match_within_tolerance": "Avvikelse inom tolerans",
- "match_out_of_tolerance": "Avvikelse utanför tolerans"
- },
"suppliers": {
"title": "Leverantörer",
"new_supplier": "Ny leverantör",
@@ -1258,26 +1118,11 @@
"password_signup_unavailable": "Lösenordsregistrering är inte tillgänglig på den här installationen."
},
"mfa": {
- "enroll_title": "Aktivera tvåstegsverifiering",
- "enroll_subtitle": "Skydda ditt konto med en autentiseringsapp",
"verify_title": "Tvåstegsverifiering",
- "verify_subtitle": "Ange koden från din autentiseringsapp",
"verify_code_label": "Verifieringskod",
"verify_button": "Verifiera",
"verifying": "Verifierar...",
- "enroll_step_qr": "Skanna QR-koden",
- "enroll_step_qr_hint": "Använd en autentiseringsapp som Google Authenticator, Authy eller 1Password.",
- "enroll_show_secret": "Visa nyckel manuellt",
- "enroll_step_verify": "Verifiera kod",
- "enroll_step_verify_hint": "Ange koden från din app för att slutföra registreringen.",
- "enroll_finish": "Aktivera",
- "enroll_finishing": "Aktiverar...",
- "enroll_failed_title": "Kunde inte aktivera tvåstegsverifiering",
"verify_failed_title": "Verifiering misslyckades",
- "verify_failed_description": "Koden är felaktig eller har gått ut.",
- "enrolled_title": "Tvåstegsverifiering aktiverad",
- "enrolled_description": "Ditt konto är nu skyddat.",
- "back": "Tillbaka",
"verify_subtitle_full": "Ange den 6-siffriga koden från din autentiseringsapp",
"verify_challenge_failed_description": "Kunde inte starta verifiering. Försök igen.",
"wrong_code_title": "Fel kod",
@@ -1449,7 +1294,6 @@
"type_swedish_business": "Svenskt företag eller organisation",
"type_eu_business": "EU-företag",
"type_non_eu_business": "Företag utanför EU",
- "type_hint": "Leverantörstypen påverkar hur moms hanteras på inköp",
"email_label": "E-post",
"email_placeholder": "kontakt@foretag.se",
"email_invalid": "Ogiltig e-postadress",
@@ -1463,14 +1307,7 @@
"org_number_label": "Organisationsnummer",
"org_number_placeholder": "XXXXXX-XXXX",
"vat_label": "VAT-nummer (momsreg.nr)",
- "vat_placeholder_eu": "DE123456789",
"vat_placeholder_se": "SE123456789001",
- "vat_verify": "Verifiera",
- "vat_verified_title": "VAT-nummer verifierat",
- "vat_verified_description": "Företag: {name}",
- "vat_failed_title": "Verifiering misslyckades",
- "vat_failed_default": "VAT-numret kunde inte verifieras",
- "vat_error_title": "Kunde inte verifiera VAT-nummer",
"payment_section": "Betalningsuppgifter",
"bankgiro_label": "Bankgiro",
"bankgiro_placeholder": "123-4567",
@@ -1478,12 +1315,10 @@
"iban_label": "IBAN",
"iban_placeholder": "SE45 5000 0000 0583 9825 7466",
"swift_label": "SWIFT/BIC",
- "bank_account_label": "Bankkonto",
"clearing_label": "Clearingnummer",
"account_number_label": "Kontonummer",
"default_payment_terms_label": "Betalningsvillkor (dagar)",
"default_account_label": "Standardkonto",
- "default_account_placeholder": "T.ex. 5410",
"default_account_clear": "Rensa standardkonto",
"default_currency_label": "Standardvaluta",
"notes_label": "Anteckningar",
@@ -1492,37 +1327,6 @@
"submit_saving": "Sparar...",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag"
},
- "upcoming_deadlines": {
- "title": "Kommande deadlines",
- "overdue_badge": "{count} försenad",
- "action_needed_badge": "{count} åtgärd",
- "today": "Idag",
- "tomorrow": "Imorgon",
- "time_prefix": "kl.",
- "tax_badge": "Skatt",
- "mark_as_submitted": "Markera som inskickad",
- "mark_as_confirmed": "Markera som bekräftad",
- "view_all": "Visa alla deadlines",
- "toast_status_updated": "Status uppdaterad",
- "toast_status_updated_description": "Deadline markerad som {status}",
- "toast_status_update_failed": "Kunde inte uppdatera status"
- },
- "tax_todo": {
- "title": "Att göra - Skatt",
- "overdue_badge": "{count} försenad",
- "action_needed_badge": "{count} snart",
- "today": "Idag",
- "tomorrow": "Imorgon",
- "days_ago": "{count} dagar sedan",
- "in_days": "Om {count} dagar",
- "start": "Påbörja",
- "submitted": "Inskickad",
- "more_tasks": "+{count} fler uppgifter",
- "view_all": "Visa alla deadlines",
- "toast_status_updated": "Status uppdaterad",
- "toast_status_updated_description": "Markerad som {status}",
- "toast_status_update_failed": "Kunde inte uppdatera status"
- },
"initial_setup": {
"completed_verdict": "Bokföringen är igång.",
"title": "{count} steg så är bokföringen igång",
@@ -1536,10 +1340,6 @@
"step_bank_description": "Hämta transaktioner automatiskt, eller importera ett kontoutdrag.",
"step_bank_sweep_note": "{matched, plural, one {# matchad mot importen} other {# matchade mot importen}}, {toReview, plural, one {# att granska} other {# att granska}}",
"step_bank_action": "Koppla banken",
- "step_assistant_title": "Bygg din bokföringsassistent",
- "step_assistant_beta": "Beta",
- "step_assistant_description": "Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.",
- "step_assistant_action": "Kom igång",
"dismiss": "Dölj",
"step_skv_title": "Anslut Skatteverket",
"step_skv_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Ansluts med BankID på ett par minuter.",
@@ -1812,7 +1612,6 @@
"phone_label": "Telefon",
"email_label": "E-post",
"website_label": "Webbplats",
- "members_invite_title": "Bjud in till {companyName}",
"members_invite_description": "Personen får tillgång till enbart detta företag.",
"members_invite_email_label": "E-postadress",
"members_invite_email_placeholder": "namn@example.com",
@@ -1843,7 +1642,6 @@
"members_remove_failed": "Kunde inte ta bort medlem.",
"members_invite_revoked": "Inbjudan återkallad",
"members_invite_revoke_failed": "Kunde inte återkalla inbjudan.",
- "invitations_pending_title": "Väntande inbjudningar",
"invitations_expires": "Går ut {date}",
"logo_heading": "Logotyp",
"logo_help": "Visas i sidhuvudet på dina fakturor. Max 10 MB, PNG/JPG/WebP.",
@@ -1968,9 +1766,6 @@
"wrapper_save_changes": "Spara ändringar",
"wrapper_readonly_tooltip": "Du har endast läsbehörighet i detta företag"
},
- "settings_invoicing": {
- "bank_validation_title": "Kontrollera bankuppgifter"
- },
"settings_invoicing_preview": {
"title": "Förhandsvisning",
"preview_button": "Förhandsvisa faktura",
@@ -2056,7 +1851,6 @@
"settings_team": {},
"settings_templates": {},
"settings_salary": {
- "title": "Löneinställningar",
"payments_heading": "Utbetalning",
"pay_day_label": "Utbetalningsdag",
"pay_day_help": "Dag i månaden då lönen betalas ut (1–28). Används som standard vid nya lönekörningar och för påminnelser.",
@@ -2076,21 +1870,11 @@
"vacation_info_link": "Hantera anställda",
"accounting_heading": "Bokföring",
"voucher_series_label": "Standard verifikationsserie för löner",
- "voucher_series_a": "A: Standard",
- "voucher_series_l": "L: Löner",
"voucher_series_help": "Kan ändras per lönekörning. Varje serie har obrutna verifikationsnummer per räkenskapsår.",
"tax_tables_heading": "Skattetabeller",
"tax_tables_help": "Skattetabeller och kommunala skattesatser hämtas automatiskt från Skatteverkets öppna data vid varje lönekörning. Ingen manuell uppdatering krävs. Om Skatteverkets API är otillgängligt används en inbäddad reservkopia tills tjänsten är uppe igen.",
"vacation_heading": "Semester",
"vacation_rule_label": "Standard semesterregel",
- "vacation_rule_percentage": "Procentregeln (12 %)",
- "vacation_rule_same_pay": "Sammalöneregeln",
- "vacation_rule_none": "Ingen semesteravsättning",
- "vacation_supplement_label": "Semestertillägg",
- "vacation_supplement_min": "0,43% (lagstadgat minimum)",
- "vacation_supplement_cba": "0,80% (vanligt kollektivavtalsbelopp)",
- "vacation_supplement_help": "Tillämpas vid sammalöneregeln. Kan ändras per anställd.",
- "info_heading": "Information",
"info_payroll_scope": "Lönemodulen hanterar löner till dina anställda: skatteavdrag, arbetsgivaravgifter, semesterlöneskuld och arbetsgivardeklaration (AGI). Driver du enskild firma tar du som ägare ut pengar via eget uttag (konto 2013), inte lön.",
"info_current_year": "Aktuellt år: 2026 (Arbetsgivaravgifter 31,42 %, prisbasbelopp 59 200 SEK)"
},
@@ -2147,10 +1931,6 @@
"tax_mark_paid_timeout": "Servern svarade inte i tid. Ladda om sidan och kontrollera om markeringen sparades.",
"tax_mark_paid_network": "Ingen kontakt med servern. Kontrollera din internetanslutning och försök igen."
},
- "settings_backup": {
- "heading": "Säkerhetsbackup",
- "intro": "Ladda ner en egen kopia av all räkenskapsinformation (SIE-filer, kvitton, underlag och behandlingshistorik) i en enda ZIP-fil. {appName} arkiverar all räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter inte vårt lagkrav; den kompletterar det."
- },
"settings_api": {},
"settings_banking": {
"connect_failed_title": "Anslutning misslyckades",
@@ -2199,28 +1979,6 @@
"blockers_load_failed": "Listan över företag du äger kunde inte läsas in, så kontot kan inte raderas just nu.",
"blockers_load_retry": "Försök igen"
},
- "settings_bank_details_form": {
- "heading": "Bankuppgifter",
- "subheading": "Visas på dina fakturor",
- "bank_label": "Bank",
- "clearing_label": "Clearing",
- "clearing_error": "Måste vara 4-5 siffror",
- "account_number_label": "Kontonummer",
- "account_number_error": "Måste vara 6-12 siffror",
- "bankgiro_label": "Bankgiro",
- "bankgiro_error": "Ogiltigt bankgironummer",
- "plusgiro_label": "Plusgiro",
- "plusgiro_error": "Ogiltigt plusgironummer",
- "swish_label": "Swish",
- "swish_placeholder": "123 XXX XX XX eller 07X XXX XX XX",
- "swish_error": "Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)",
- "iban_label": "IBAN",
- "iban_error": "Ogiltigt IBAN (SE följt av 22 siffror)",
- "iban_hint": "Krävs för betalfil (ISO 20022) vid löneutbetalning",
- "bic_label": "BIC/SWIFT",
- "bic_placeholder": "Fylls i automatiskt",
- "bic_error": "Ogiltig BIC/SWIFT (8 eller 11 tecken)"
- },
"settings_billing": {
"load_failed": "Abonnemangsuppgifterna kunde inte läsas in, så din status kan inte visas just nu.",
"load_retry": "Försök igen",
@@ -2369,7 +2127,6 @@
"access_enabled_send_only": "Aktiverat för att skicka e-fakturor. Mottagning ingår inte; skriv till support om ni vill ta emot."
},
"settings_pdf_print": {
- "coming_soon": "Kommer snart",
"heading": "Utskrift & PDF",
"toast_save_failed": "Kunde inte spara",
"font_label": "Typsnitt på fakturan",
@@ -2573,7 +2330,6 @@
"settings_voucher_series": {
"heading": "Verifikationsserier",
"empty_state": "Inga verifikationsserier ännu. Serie {series} skapas automatiskt vid första verifikationen.",
- "active_series_label": "Aktiva serier",
"series_prefix": "Serie",
"default_badge": "standard",
"latest_number": "Senaste nr",
@@ -3017,7 +2773,6 @@
"confirm_unlink": "Vill du koppla bort BankID från ditt konto?",
"toast_unlinked": "BankID bortkopplat",
"toast_unlink_failed": "Kunde inte koppla bort BankID",
- "link_bankid_title": "Koppla BankID",
"link_bankid_description": "Skanna QR-koden med BankID-appen",
"linked_description": "Ditt konto är kopplat till BankID.",
"not_linked_description": "Koppla BankID för säkrare inloggning.",
@@ -3051,7 +2806,6 @@
"toast_unenroll_failed_title": "Kunde inte inaktivera 2FA",
"toast_mfa_disabled_title": "Tvåfaktorsautentisering inaktiverad",
"toast_mfa_disabled_description": "2FA har tagits bort från ditt konto.",
- "change_password_title": "Ändra lösenord",
"change_password_description": "Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.",
"new_password_label": "Nytt lösenord",
"new_password_placeholder": "Minst 8 tecken",
@@ -3112,7 +2866,6 @@
"skv_counterpart_body": "Det finns en skattekonto-händelse den {date} som matchar: bokför detta verifikat först, koppla sedan skattekonto-raden mot samma verifikat istället för att bokföra två gånger.",
"match_invoice_btn": "Matcha Faktura {number}",
"match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}",
- "choose_template_btn": "Välj mall...",
"match_voucher_btn": "Matcha mot befintlig verifikation",
"attach_document_btn": "Matcha mot underlag",
"more_actions_aria": "Fler åtgärder",
@@ -3157,7 +2910,6 @@
"adjustment": "Justering"
},
"tx_quick_review": {
- "open_attached_failed": "Kunde inte öppna underlaget",
"exchange_rate_fetch_failed": "Kunde inte hämta växelkursen.",
"title": "Granska bokföring",
"description_template": "Granska verifikationen innan du bokför",
@@ -3177,17 +2929,10 @@
"no_vat_liability_account": "Ingen moms för skuld-/eget kapital-konton",
"no_vat_default": "Ingen moms",
"change": "Ändra",
- "attached_doc_label": "Underlag bifogat",
- "attached_doc_source": "från dokumentinkorgen",
- "opening": "Öppnar…",
- "view": "Visa",
"doc_label": "Underlag",
"doc_attached_count": "{count} bifogade",
- "doc_pick_existing": "Välj från inkorgen",
"doc_pick_existing_inline": "eller välj befintligt underlag från inkorgen",
"doc_picked_remove": "Ta bort underlag",
- "doc_link_failed_title": "Underlag kunde inte bifogas",
- "doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen.",
"doc_link_failed_booked_title": "Bokförd, men underlag saknas",
"doc_link_failed_booked_description": "Transaktionen bokfördes, men {count} underlag kunde inte bifogas verifikatet: {files}. Öppna verifikatet för att bifoga på nytt.",
"doc_link_open_entry": "Öppna verifikatet",
@@ -3200,8 +2945,6 @@
"tx_booking_dialog": {
"title": "Bokför transaktion",
"description": "Skapa en verifikation för transaktionen",
- "doc_label": "Underlag (valfritt)",
- "doc_attached_count": "{count} bifogade",
"doc_pick_existing": "Välj befintligt underlag",
"doc_pick_existing_inline": "eller välj befintligt underlag från inkorgen",
"doc_picked_remove": "Ta bort underlag",
@@ -3257,54 +3000,11 @@
"income_label": "Intäkter"
},
"tx_inbox_zero": {
- "empty_title": "Inga transaktioner",
- "empty_description": "Importera kontoutdrag från din bank eller lägg till transaktioner manuellt för att komma igång.",
- "import_btn": "Importera transaktioner",
- "add_manual_btn": "Lägg till manuellt",
"done_title": "Alla transaktioner bokförda!",
"done_description": "Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik.",
"import_more_btn": "Importera fler",
"new_btn": "Ny transaktion"
},
- "tx_swipe_view": {
- "doc_link_failed_title": "Underlag kunde inte bifogas",
- "doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen.",
- "booking_failed_skip": "Kunde inte bokföra. Tryck \"Hoppa över\" för att gå vidare.",
- "generic_error_skip": "Ett fel uppstod. Tryck \"Hoppa över\" för att gå vidare.",
- "match_failed_skip": "Kunde inte matcha faktura. Tryck \"Hoppa över\" för att gå vidare.",
- "done_title": "Klart!",
- "done_subtitle": "Alla transaktioner är nu bokförda",
- "back_to_transactions": "Tillbaka till transaktioner",
- "choose_template": "Välj mall",
- "skip": "Hoppa över",
- "review_title": "Granska bokföring",
- "label_template": "Mall",
- "label_category": "Kategori",
- "change_template": "Byt mall",
- "reverse_charge_warning": "Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.",
- "label_account": "Konto",
- "label_vat_treatment": "Momsbehandling",
- "no_vat_liability_account": "Ingen moms för skuld-/eget kapital-konton",
- "no_vat_default": "Ingen moms",
- "change": "Ändra",
- "doc_label": "Underlag",
- "doc_attached_count": "{count} bifogade",
- "booking": "Bokför...",
- "book": "Bokför",
- "progress_label": "{current} av {total}",
- "instr_skip": "Hoppa över",
- "instr_book": "Bokför",
- "indicator_skip": "Hoppa över",
- "indicator_business": "Företag",
- "badge_receipt": "Kvitto",
- "badge_attachment": "Bilaga",
- "invoice_match_title": "Fakturamatchning hittad",
- "invoice_match_badge": "Match",
- "invoice_label": "Faktura {number}",
- "unknown_customer": "Okänd kund",
- "match_invoice_btn": "Matcha med Faktura {number}",
- "suggested_categories": "Föreslagna kategorier"
- },
"tx_template_picker": {
"group_premises": "Lokalkostnader",
"group_vehicle": "Fordon",
@@ -3441,9 +3141,6 @@
"preview_truncated": "+ {remaining} fler rader visas vid bokföring",
"col_account": "Konto",
"col_description": "Beskrivning",
- "accounts_loading": "Laddar kontoplan...",
- "accounts_load_failed": "Kontoplanen kunde inte laddas. Försök igen för att ändra konto.",
- "accounts_retry": "Försök igen",
"col_debit": "Debet",
"col_credit": "Kredit",
"total_label": "Totalt",
@@ -3704,7 +3401,6 @@
"skv_badge": "Skatteverket",
"duplicate_title_with_voucher": "Möjlig dublett av verifikat {label}",
"duplicate_title_draft": "Möjlig dublett av verifikat (utkast)",
- "duplicate_body": "Det här ser ut som samma kassaflöde: koppla istället för att bokföra om.",
"link_to_voucher": "Koppla till verifikat",
"book_anyway": "Bokför ändå",
"book": "Bokför",
@@ -3839,9 +3535,7 @@
"vat_none_desc": "Ej momspliktigt (t.ex. lön, privata uttag)"
},
"tx_skattekonto_match": {
- "search_failed_default": "Kunde inte söka kandidater",
"fetch_candidates_failed_title": "Kunde inte hämta kandidater",
- "match_failed_default": "Matchning misslyckades",
"match_success_title": "Transaktion kopplad till verifikat",
"match_failed_title": "Kunde inte koppla transaktionen",
"title": "Matcha mot befintligt verifikat",
@@ -3943,7 +3637,6 @@
"load_customers_failed_title": "Kunde inte ladda kunder",
"load_customers_failed_description": "Kontrollera din anslutning och försök igen.",
"items_card_title": "Fakturarader",
- "more_references": "Referenser & mer",
"description_label": "Beskrivning",
"description_placeholder": "T.ex. Instagram-kampanj",
"quantity_label": "Antal",
@@ -4050,8 +3743,6 @@
"deduction_housing_label": "Fastighetsbeteckning",
"deduction_housing_placeholder": "t.ex. Stockholm Vasastan 1:23",
"deduction_housing_hint": "Krävs för ROT-avdrag (RUT behöver inte detta fält).",
- "deduction_cap_over": "Fakturans avdrag överstiger årstaket",
- "deduction_cap_check": "Kunden behöver kontrollera sitt återstående utrymme själv.",
"deduction_summary_label": "Skattereduktion ROT/RUT",
"deduction_work_type_required": "Välj arbetstyp för ROT/RUT-raden.",
"deduction_work_type_mismatch": "Arbetstypen hör inte till vald skattereduktion (ROT/RUT).",
@@ -4458,14 +4149,12 @@
"created_toast_title": "Kreditfaktura skapad",
"created_toast_description": "Kreditfaktura {number} har skapats som utkast.",
"create_failed_title": "Kunde inte skapa kreditfaktura",
- "create_failed_fallback": "Kunde inte skapa kreditfaktura",
"try_again": "Försök igen."
},
"invoice_recurring": {
"title": "Återkommande fakturor",
"new_schedule": "Nytt schema",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
- "loading": "Laddar...",
"load_failed_title": "Kunde inte ladda återkommande fakturor",
"load_failed_description": "Kontrollera din anslutning och försök igen.",
"empty_title": "Inga återkommande fakturor",
@@ -4503,7 +4192,6 @@
"interval_every_n": "Var {n}:e månad"
},
"invoice_recurring_new": {
- "back": "Tillbaka",
"title": "Nytt återkommande schema",
"edit_title": "Redigera schema",
"save_changes": "Spara ändringar",
@@ -4883,18 +4571,6 @@
"debit_short": "D {amount}",
"credit_short": "K {amount}",
"debit_credit_short": "D {debit} / K {credit}",
- "account_2440": "Leverantörsskulder",
- "account_2641": "Ingående moms",
- "account_2645": "Beräknad ing. moms förvärv utlandet",
- "account_2647": "Beräknad ing. moms omvänd i Sverige",
- "account_2614": "Utg. moms omvänd 25%",
- "account_2624": "Utg. moms omvänd 12%",
- "account_2634": "Utg. moms omvänd 6%",
- "account_1710": "Förutbetalda hyreskostnader",
- "account_1720": "Förutbetalda leasingavgifter",
- "account_1730": "Förutbetalda försäkringspremier",
- "account_1740": "Förutbetalda räntekostnader",
- "account_1790": "Övriga förutbetalda kostnader",
"review_accrual_line_info": "Periodiseras {from} till {to}"
},
"supplier_invoice_detail": {
@@ -5017,7 +4693,6 @@
"type_swedish": "Svenskt företag eller organisation",
"type_eu": "EU-företag",
"type_non_eu": "Utanför EU",
- "org_number_inline": " | Org.nr: {number}",
"edit": "Redigera",
"edit_dialog_title": "Redigera leverantör",
"load_failed_title": "Kunde inte ladda leverantör",
@@ -5034,17 +4709,7 @@
"total_paid": "Totalt betalt",
"invoice_count": "Antal fakturor",
"contact_section_title": "Kontaktuppgifter",
- "email_inline": "E-post: {email}",
- "phone_inline": "Telefon: {phone}",
- "vat_inline": "VAT: {vat}",
"payment_section_title": "Betalningsuppgifter",
- "bankgiro_inline": "Bankgiro: {value}",
- "plusgiro_inline": "Plusgiro: {value}",
- "iban_inline": "IBAN: {value}",
- "bic_inline": "BIC: {value}",
- "payment_terms_inline": "Betalningsvillkor: {days} dagar",
- "currency_inline": "Valuta: {currency}",
- "expense_account_inline": "Kostnadskonto: {account}",
"delete": "Ta bort",
"kicker_org": "Org.nr {number}",
"def_email": "E-post",
@@ -5111,9 +4776,7 @@
"import_attn_action": "Visa importhistorik",
"mode_vouchers": "Verifikat",
"mode_drafts": "Utkast",
- "density_compact": "Kompakt visning",
"show_correction_chain": "Visa storno- och rättade poster",
- "loading": "Laddar verifikationer...",
"empty_title": "Inga verifikationer",
"empty_description": "Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken \"Ny verifikation\".",
"empty_drafts_title": "Inga utkast",
@@ -5138,25 +4801,19 @@
"filter_dialog_title": "Filtrera verifikat",
"filter_clear_all": "Rensa alla filter",
"filter_done": "Klar",
- "filter_section_period": "Räkenskapsår",
"filter_section_sort": "Sortering",
"filter_section_series": "Verifikationsserie",
"filter_section_date": "Datumintervall",
"clear_date_filter": "Rensa datumfilter",
- "scope_label": "Visar:",
- "scope_all_years": "Alla räkenskapsår",
"out_of_period_label": "Efterföljande",
"rattelse_badge": "Rättad",
"rattelse_badge_tooltip": "Verifikatet har rättats i efterhand: se rättelsehistoriken på verifikatsidan",
"out_of_period_tooltip": "Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året).",
- "out_of_period_tooltip_mobile": "Bokförd i ett senare räkenskapsår, men avser det valda året.",
"attachment_count_tooltip": "{count} underlag",
"missing_attachment_tooltip": "Underlag saknas",
"no_lines": "Inga kontorader hittades för denna verifikation.",
"debit": "Debet",
"credit": "Kredit",
- "sum_debit": "Summa debet",
- "sum_credit": "Summa kredit",
"post": "Bokför",
"show_details": "Visa detaljer",
"create_correction": "Skapa ändringsverifikation",
@@ -5207,7 +4864,6 @@
"sum_label": "Summa",
"batch_select_all": "Markera alla ({count})",
"batch_select_row": "Markera verifikat",
- "batch_selected_count": "{count} markerade",
"batch_mark_no_doc": "Markera som inget underlag krävs",
"batch_clear_selection": "Avmarkera",
"batch_no_doc_done_title": "Markerade som inget underlag krävs",
@@ -5265,9 +4921,6 @@
"download": "Ladda ner",
"remove": "Ta bort",
"replace": "Ersätt med ny version",
- "remove_confirm_title": "Ta bort underlag",
- "remove_confirm_body": "Vill du ta bort {file}? Detta går inte att ångra.",
- "remove_confirm_cta": "Ta bort",
"remove_blocked_title": "Underlaget kan inte tas bort",
"remove_blocked_body": "Detta underlag är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation måste bevaras i minst 7 år och får inte raderas.",
"remove_blocked_hint": "Behöver underlaget korrigeras: ladda upp en ny version. Den befintliga bevaras då i versionshistoriken.",
@@ -5278,7 +4931,6 @@
"detaching": "Kopplar bort...",
"detach_failed": "Underlaget kunde inte kopplas bort.",
"replace_uploading": "Ersätter...",
- "remove_failed": "Kunde inte ta bort underlaget.",
"replace_failed": "Kunde inte ladda upp ny version.",
"choose_from_inbox": "Välj från inkorgen",
"picker_title": "Välj underlag från inkorgen",
@@ -5340,7 +4992,6 @@
"accounts_retry": "Försök igen",
"edit_draft": "Redigera",
"back": "Tillbaka till bokföring",
- "loading": "Laddar verifikation...",
"error_not_found": "Verifikation hittades inte",
"error_load_failed": "Kunde inte hämta verifikation",
"post": "Bokför",
@@ -5349,9 +5000,7 @@
"confirm_post_description_generic": "Verifikatet \"{description}\" bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
"delete_draft": "Radera utkast",
"delete_entry": "Radera verifikat",
- "create_correction": "Skapa ändringsverifikation",
"copy_entry": "Kopiera verifikat",
- "edit_entry": "Redigera",
"correct_menu": "Rätta",
"correct_lines": "Rätta rader (ändringsverifikat)",
"correct_date": "Rätta datum",
@@ -5731,12 +5380,10 @@
"col_audited": "Reviderad",
"col_audit_opinion": "Revisionsutlåtande",
"status_section": "Status",
- "status_no_entries": "Inga statusposter registrerade.",
"fiscal_year_section": "Räkenskapsår",
"fiscal_year_current": "Nuvarande: {start}-{end}",
"fiscal_year_changed": "Räkenskapsåret har ändrats {n, plural, one {# gång} other {# gånger}}.",
"signatory_section": "Firmateckning",
- "signatory_empty": "Ingen firmateckning registrerad hos TIC.",
"board_section": "Företrädare",
"board_summary_members": "{n, plural, one {# styrelseledamot} other {# styrelseledamöter}}",
"board_summary_deputies": "{n, plural, one {# suppleant} other {# suppleanter}}",
@@ -5748,7 +5395,6 @@
"col_position": "Roll",
"col_since": "Sedan",
"payroll_section": "Lönehistorik",
- "payroll_empty": "Ingen lönehistorik registrerad hos Skatteverket.",
"col_payroll_period": "Period",
"col_payroll_employees": "Anställda",
"col_payroll_tax": "Avdragen skatt",
@@ -6132,30 +5778,17 @@
"counterparty_suggestion_gone_title": "Motparten är inte längre tillgänglig",
"counterparty_suggestion_gone_description": "Förslaget hann uppdateras. Stäng rutan, öppna den igen och välj motparten på nytt.",
"page_title": "Transaktioner",
- "subtitle_to_post": "att bokföra",
- "subtitle_matches": "{count} fakturamatchningar",
- "history_subtitle": "Alla dina transaktioner",
"action_import": "Importera",
- "action_review_all": "Gå igenom alla",
- "action_review_loading": "Laddar...",
"action_new_transaction": "Ny transaktion",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"mode_inbox": "Att bokföra",
- "mode_history": "Alla transaktioner",
- "source_label": "Källa:",
- "source_all": "Alla ({count})",
- "source_bank": "Bank ({count})",
- "source_skatteverket": "Skatteverket ({count})",
- "search_placeholder": "Sök transaktion...",
"no_search_results": "Inga transaktioner matchar din sökning.",
"source_empty": "Den valda källan har inga transaktioner. Välj Alla för att visa övriga källor.",
"period_empty": "Den valda perioden har inga transaktioner. Välj Alla räkenskapsår för att visa allt.",
"period_pending_outside": "{count, plural, one {# transaktion att bokföra utanför vald period.} other {# transaktioner att bokföra utanför vald period.}}",
"period_show_all": "Visa alla",
- "skv_reconnect_title": "Anslutningen till Skatteverket behöver förnyas",
"skv_reconnect_body": "Skattekontots transaktioner hämtas inte förrän du anslutit igen med BankID och godkänt alla behörigheter.",
"skv_reconnect_cta": "Anslut igen",
- "dialog_choose_template": "Välj mall",
"dialog_match_invoice": "Matcha med faktura",
"dialog_add_transaction": "Lägg till transaktion",
"dialog_match_supplier_invoice": "Matcha mot leverantörsfaktura?",
@@ -6199,8 +5832,6 @@
"match_failed_transaction": "Transaktionen kunde inte matchas. Försök igen.",
"match_failed_with_invoice": "Transaktionen kunde inte matchas med fakturan. Försök igen.",
"voucher_link_failed_description": "Verifikationen kunde inte kopplas. Försök igen.",
- "login_required_title": "Inloggning krävs",
- "login_required_description": "Du måste vara inloggad för att lägga till transaktioner.",
"deleted_title": "Borttagen",
"deleted_description": "Transaktionen har tagits bort",
"delete_failed_description": "Transaktionen kunde inte tas bort. Försök igen.",
@@ -6208,7 +5839,6 @@
"edit_title_failed": "Kunde inte uppdatera titeln",
"move_account_saved": "Transaktionen flyttades",
"move_account_failed": "Kunde inte flytta transaktionen",
- "review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring.",
"bank_sync_attention_one": "1 bankanslutning behöver förnyas",
"bank_sync_attention_many": "{count} bankanslutningar behöver förnyas",
"bank_sync_auto_nightly": "Synkas automatiskt varje natt",
@@ -6335,10 +5965,6 @@
"toast_post_failed_generic": "Kunde inte bokföra verifikat",
"edit_draft_dialog_title": "Redigera utkast",
"title": "Bokföring",
- "year_end": "Årsbokslut",
- "tab_journal": "Verifikationer",
- "tab_new_entry": "Ny verifikation",
- "tab_accounts": "Kontoplan",
"new_entry_dialog_title": "Ny verifikation",
"create_with_assistant": "Skapa med assistent",
"loading_source_voucher": "Laddar källverifikat...",
@@ -6457,7 +6083,6 @@
},
"form_article": {
"type_label": "Typ *",
- "type_placeholder": "Välj typ",
"type_vara": "Vara",
"type_tjanst": "Tjänst",
"number_label": "Artikelnummer",
@@ -6480,17 +6105,14 @@
"summary_booked_on": "Bokförs på",
"summary_unnamed": "Namnlös artikel",
"revenue_account_label": "Bokföringskonto",
- "revenue_account_placeholder": "t.ex. 2897",
"revenue_account_hint": "Lämna tomt för att härleda försäljningskontot automatiskt utifrån momsen. Du kan välja ett aktivt konto i klass 1-3.",
"posting_account_invalid": "Ange ett fyrsiffrigt konto i klass 1-3.",
"cost_price_label": "Inköpspris",
"cost_price_hint": "Endast för marginalberäkning, bokförs aldrig.",
"currency_label": "Valuta",
- "currency_hint": "Förifyller en ny fakturas valuta när artikeln läggs till.",
"ean_label": "EAN/streckkod",
"ean_placeholder": "t.ex. 7350000000000",
"housework_label": "ROT/RUT",
- "housework_placeholder": "Välj arbetstyp",
"housework_none": "Ingen",
"housework_rot": "ROT",
"housework_rut": "RUT",
@@ -6529,52 +6151,6 @@
"col_created": "Skapad",
"count_summary": "{count, plural, one {1 kund} other {# kunder}}"
},
- "products": {
- "title": "Produkter",
- "new_product": "Ny produkt",
- "back_to_list": "Tillbaka till produkter",
- "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
- "load_failed_title": "Kunde inte ladda produkter",
- "load_failed_description": "Kontrollera din anslutning och försök igen.",
- "create_failed_title": "Kunde inte skapa produkt",
- "update_failed_title": "Kunde inte uppdatera produkt",
- "archive_failed_title": "Kunde inte arkivera produkt",
- "created_title": "Produkt skapad",
- "created_description": "{name} har lagts till",
- "updated_title": "Produkt uppdaterad",
- "archived_title": "Produkten arkiverades",
- "archive": "Arkivera",
- "archive_confirm": "Är du säker på att du vill arkivera produkten? Befintliga fakturor med produkten påverkas inte.",
- "archived_badge": "Arkiverad",
- "search_placeholder": "Sök produkter",
- "no_search_results_title": "Inga träffar",
- "no_search_results_description": "Inga produkter matchar \"{term}\".",
- "empty_title": "Inga produkter ännu",
- "empty_description": "Skapa din första produkt för att kunna lägga till den på fakturor.",
- "section_basics": "Grunduppgifter",
- "section_pricing": "Pris & moms",
- "type_goods": "Vara",
- "type_service": "Tjänst",
- "field_name": "Produktnamn",
- "field_name_placeholder": "T.ex. Glödlampa LED 9W",
- "field_sku": "Artikelnummer (SKU)",
- "field_sku_placeholder": "T.ex. LED-9W-E27",
- "field_description": "Beskrivning",
- "field_type": "Produkttyp",
- "field_type_hint": "Varor påverkar lagersaldot vid fakturering. Tjänster gör det inte.",
- "field_category": "Kategori",
- "field_default_price": "Standardpris (exkl. moms)",
- "field_default_unit": "Enhet",
- "field_default_vat": "Standardmomssats",
- "validation_name_required": "Produktnamn krävs",
- "save": "Spara",
- "cancel": "Avbryt",
- "col_name": "Namn",
- "col_sku": "SKU",
- "col_type": "Typ",
- "col_default_price": "Standardpris",
- "col_unit": "Enhet"
- },
"webshop_orders": {
"title": "Order",
"all_stores": "Alla butiker",
@@ -6688,179 +6264,22 @@
"bulk_none_bookable": "Inga av de markerade ordrarna kan bokföras i svep. Bokför dem enskilt.",
"bulk_skipped_unsupported_rate": "Hoppas över (momssats som inte är svensk, bokför enskilt): {numbers}"
},
- "sales_orders": {
- "title": "Order",
- "back_to_list": "Tillbaka till order",
- "new_order": "Ny order",
- "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
- "load_failed_title": "Kunde inte ladda ordrar",
- "load_failed_description": "Kontrollera din anslutning och försök igen.",
- "search_placeholder": "Sök ordernummer eller kund",
- "empty_title": "Inga ordrar ännu",
- "empty_description": "Skapa din första order när en kund godkänner en offert eller direkt från grunden.",
- "no_category_title": "Inga ordrar i denna kategori",
- "no_category_description": "Prova ett annat filter eller skapa en ny order.",
- "tab_all": "Alla",
- "tab_draft": "Utkast",
- "tab_confirmed": "Bekräftade",
- "tab_partially_shipped": "Delvis levererade",
- "tab_shipped": "Levererade",
- "tab_partially_invoiced": "Delvis fakturerade",
- "tab_invoiced": "Fakturerade",
- "tab_cancelled": "Avbrutna",
- "status_draft": "Utkast",
- "status_confirmed": "Bekräftad",
- "status_partially_shipped": "Delvis levererad",
- "status_shipped": "Levererad",
- "status_partially_invoiced": "Delvis fakturerad",
- "status_invoiced": "Fakturerad",
- "status_cancelled": "Avbruten",
- "col_number": "Nummer",
- "col_customer": "Kund",
- "col_date": "Datum",
- "col_expected_delivery": "Önskad leverans",
- "col_status": "Status",
- "col_amount": "Belopp",
- "col_description": "Beskrivning",
- "col_quantity": "Antal",
- "col_quantity_shipped": "Levererat",
- "col_quantity_invoiced": "Fakturerat",
- "col_unit_price": "À-pris",
- "col_line_total": "Radsumma",
- "section_header": "Orderuppgifter",
- "section_lines": "Orderrader",
- "section_progress": "Leverans- och faktureringsstatus",
- "field_customer": "Kund",
- "field_order_date": "Orderdatum",
- "field_expected_delivery": "Önskad leveransdatum",
- "field_currency": "Valuta",
- "field_notes": "Anteckningar",
- "field_our_reference": "Vår referens",
- "field_your_reference": "Er referens",
- "field_product": "Produkt",
- "field_unit": "Enhet",
- "field_vat_rate": "Moms",
- "action_confirm": "Bekräfta order",
- "action_ship": "Leverera",
- "action_invoice": "Fakturera",
- "action_cancel": "Avbryt",
- "action_delete": "Ta bort utkast",
- "confirm_dialog_title": "Bekräfta ordern?",
- "confirm_dialog_description": "Kunden förbinder sig att ta emot leveransen. Du kan därefter skapa följesedel och faktura.",
- "ship_dialog_title": "Leverera ordern",
- "ship_dialog_description": "En följesedel skapas och lagersaldot uppdateras automatiskt för produkter som spårar lager.",
- "invoice_dialog_title": "Fakturera ordern",
- "invoice_dialog_description": "En faktura skapas för återstående mängd. Du kan redigera den innan du skickar den till kund.",
- "cancel_dialog_title": "Avbryta ordern?",
- "cancel_dialog_description": "Ordern kan endast avbrytas innan första leverans. Avbrutna ordrar går inte att återställa.",
- "delete_dialog_title": "Ta bort utkast?",
- "delete_dialog_description": "Utkast kan tas bort utan spår: bekräftade ordrar måste avbrytas istället.",
- "confirmed_toast": "Ordern är bekräftad",
- "shipped_toast": "Följesedel skapad",
- "invoiced_toast": "Faktura skapad",
- "cancelled_toast": "Ordern är avbruten",
- "deleted_toast": "Utkastet är borttaget",
- "from_quote": "Från offert",
- "summary_total": "Total",
- "summary_subtotal": "Delsumma",
- "summary_vat": "Moms",
- "stock_warnings_title": "Vissa lagerrörelser kunde inte registreras",
- "validation_at_least_one_line": "Minst en orderrad krävs",
- "remaining_to_ship": "{remaining} kvar att leverera",
- "remaining_to_invoice": "{remaining} kvar att fakturera"
- },
- "inventory": {
- "title": "Lager",
- "back_to_inventory": "Tillbaka till lager",
- "view_movements": "Visa lagerrörelser",
- "movements_title": "Lagerrörelser",
- "load_failed_title": "Kunde inte ladda lagerdata",
- "no_locations_title": "Inget lager konfigurerat",
- "no_locations_description": "Skapa ett primärt lager för att börja registrera lagersaldon.",
- "create_primary_location": "Skapa primärt lager",
- "default_location_name": "Huvudlager",
- "location_created": "Lager skapat",
- "location_create_failed": "Kunde inte skapa lager",
- "locations_title": "Lagerplatser",
- "primary": "Primär",
- "inactive": "Inaktiv",
- "stock_levels_title": "Aktuellt lagersaldo",
- "no_levels_title": "Inga lagerrörelser ännu",
- "no_levels_description": "Lagerrörelser skapas automatiskt när du skickar fakturor eller godkänner leverantörsfakturor som länkar till produkter av typen 'Vara'.",
- "no_movements_title": "Inga lagerrörelser ännu",
- "no_movements_description": "Här visas alla in- och utleveranser så snart de börjar registreras.",
- "col_location_name": "Namn",
- "col_location_status": "Status",
- "col_product": "Produkt",
- "col_sku": "SKU",
- "col_location": "Plats",
- "col_quantity": "Saldo",
- "col_unit": "Enhet",
- "col_when": "När",
- "col_reason": "Anledning",
- "col_delta": "Förändring",
- "col_reference": "Källa",
- "reason_purchase": "Inköp",
- "reason_sale": "Försäljning",
- "reason_adjustment": "Justering",
- "reason_transfer_in": "Inflyttning",
- "reason_transfer_out": "Utflyttning",
- "reason_return": "Retur",
- "reason_disposal": "Kassation",
- "reason_opening": "Ingående saldo",
- "ref_invoice": "Faktura",
- "ref_supplier_invoice": "Leverantörsfaktura",
- "ref_transfer": "Lageröverföring",
- "ref_transfer_rollback": "Återställning",
- "ref_manual": "Manuell"
- },
"self_billing": {
"title": "Registrera självfaktura",
- "subtitle": "En självfaktura du tagit emot: kunden har fakturerat i ditt namn. För dig är det en försäljning med utgående moms.",
- "back": "Tillbaka",
- "issuer_card_title": "Utställare och fakturareferens",
"issuer_card_description": "Kunden som ställt ut självfakturan, och fakturanumret de tilldelat.",
"customer_label": "Kund (utställare)",
- "select_customer_placeholder": "Välj kund",
"external_number_label": "Fakturanummer (kundens)",
"external_number_placeholder": "t.ex. SF-2026-014",
"agreement_ref_label": "Avtalsreferens",
"agreement_ref_placeholder": "Självfaktureringsavtal",
- "items_card_title": "Rader",
- "items_card_description": "Beloppen från den mottagna självfakturan.",
- "description_label": "Beskrivning",
- "description_placeholder": "Beskrivning",
- "quantity_label": "Antal",
- "unit_label": "Enhet",
- "unit_price_label": "À-pris",
- "vat_label": "Moms",
- "row_label": "Rad {index}",
- "add_row": "Lägg till rad",
- "notes_card_title": "Anteckningar",
- "notes_placeholder": "Interna anteckningar (valfritt)",
- "details_card_title": "Uppgifter",
- "currency_label": "Valuta",
"invoice_date_label": "Fakturadatum",
"received_date_label": "Mottaget datum",
- "due_date_label": "Förfallodatum",
- "summary_card_title": "Sammanfattning",
- "subtotal_label": "Netto",
- "output_vat_label": "Utgående moms",
- "total_label": "Totalt",
"register": "Registrera självfaktura",
- "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
- "load_customers_failed": "Kunde inte ladda kunder",
"created_title": "Självfaktura registrerad",
"created_description": "Självfaktura {number} har bokförts som försäljning",
"create_failed_title": "Kunde inte registrera självfakturan",
- "validation_customer_required": "Välj en kund",
"validation_external_number_required": "Fakturanummer krävs",
- "validation_invoice_date_required": "Fakturadatum krävs",
- "validation_received_date_required": "Mottaget datum krävs",
- "validation_due_date_required": "Förfallodatum krävs",
- "validation_description_required": "Beskrivning krävs",
- "validation_quantity_min": "Antal måste vara minst 0,01",
- "validation_min_one_row": "Minst en rad krävs"
+ "validation_received_date_required": "Mottaget datum krävs"
},
"invoices": {
"rot_rut_payout_action": "ROT/RUT-fil",
@@ -7274,28 +6693,7 @@
"card_blockers_title": "Att åtgärda",
"card_blockers_detail": "{bank} saknar bankkonto · {email} saknar e-post",
"card_blockers_none": "Inga blockerare — alla anställda är kompletta.",
- "card_vacation_title": "Semesterdagar kvar",
- "card_vacation_detail": "{employees} anställda, {saved} sparade dagar",
- "card_vacation_none": "Semestersaldon skapas vid första bokförda lönekörningen.",
- "vacation_close_button": "Stäng semesterår",
- "vacation_close_title": "Semesterårsavslut",
- "vacation_close_description": "Granska övergången innan du bekräftar: sparade dagar rullas (max 5 år), utgångna dagar flaggas för utbetalning och semesterlöneskulden stäms av mot bokfört saldo.",
- "vacation_close_previewing": "Förbereder granskningsrapport…",
"vacation_close_preview_failed": "Kunde inte förbereda rapporten",
- "vacation_close_period": "Semesterår {from} till {to}",
- "vacation_close_col_employee": "Anställd",
- "vacation_close_col_saved": "Sparas",
- "vacation_close_col_flagged": "Flaggade",
- "vacation_close_col_expiring": "Utgår",
- "vacation_close_col_next": "Nästa år",
- "vacation_close_computed": "Beräknad semesterlöneskuld: {amount}",
- "vacation_close_booked": "Bokfört på 2920: {amount}",
- "vacation_close_drift": "Justering bokförs: {amount}",
- "vacation_close_no_drift": "Ingen justering behövs (differens under 1 kr).",
- "vacation_close_cancel": "Avbryt",
- "vacation_close_confirm": "Stäng semesteråret",
- "vacation_close_done": "Semesteråret stängt",
- "vacation_close_failed": "Semesterårsavslutet misslyckades",
"status_draft": "Utkast",
"status_review": "Granskning",
"status_approved": "Godkänd",
@@ -7315,7 +6713,6 @@
"not_found": "Lönekörning hittades inte",
"more_actions": "Fler åtgärder",
"unknown_error": "Okänt fel",
- "rail_title": "Månadens steg",
"rail_calculate": "Beräkna",
"rail_calculate_hint": "Beräkna löner, skatt och avgifter, och skicka sedan till granskning.",
"rail_approve": "Godkänn",
@@ -7391,7 +6788,6 @@
"th_vacation": "Semester",
"th_payslip": "Lönespec",
"diff_new_employee": "Ny",
- "view_pdf": "Visa PDF",
"view_payslip_title": "Visa lönespecifikation",
"salary_input_aria": "Månadslön för {name}",
"degree_hint": "× {degree} % = {amount}",
@@ -7863,19 +7259,6 @@
"source_unavailable": "Skattetabeller för {year} kunde inte hämtas",
"recheck": "Kontrollera igen"
},
- "time_tracking": {
- "title": "Tidrapportering",
- "billable_inbox": "Fakturerbar tid per kund",
- "create_invoice": "Skapa faktura",
- "hourly_rate": "Timpris",
- "project_required": "Välj projekt för att fakturera",
- "no_project": "Inget projekt",
- "no_billable_hours": "Ingen fakturerbar tid att fakturera. Markera dagar som fakturerbara för att samla dem här.",
- "log_hours": "Logga tid",
- "billable": "Fakturerbar",
- "invoiced": "Fakturerad",
- "missing_rate": "Saknar timpris"
- },
"import": {
"title": "Importera / exportera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
@@ -8201,36 +7584,11 @@
"k3_draft_notice": "K3-dokumentet är ännu ett granskningsutkast och kan inte låsas eller lämnas in via Accounted. Upprätta och lämna in årsredovisningen på papper tills hela upplysningsmatrisen är implementerad och granskad."
},
"empty": {
- "invoices_title": "Inga fakturor ännu",
- "invoices_description": "Skapa din första faktura för att komma igång.",
- "customers_title": "Inga kunder ännu",
- "customers_description": "Lägg till din första kund för att börja fakturera.",
- "transactions_title": "Inga transaktioner",
- "transactions_description": "När bankkopplingen synkar dyker transaktionerna upp här.",
- "suppliers_title": "Inga leverantörer ännu",
- "suppliers_description": "Lägg till din första leverantör.",
- "no_results": "Inga resultat",
- "no_data": "Inga uppgifter att visa",
"support_hint_subject": "Behöver hjälp att komma igång",
"support_hint_label": "Behöver du hjälp? Kontakta support",
- "preset_invoices_title": "Inga fakturor ännu",
- "preset_invoices_description": "Skapa din första faktura på under 60 sekunder. Vi fyller i dina uppgifter automatiskt.",
- "preset_invoices_action": "Skapa faktura",
"preset_customers_title": "Inga kunder ännu",
"preset_customers_description": "Lägg till dina kunder för att enkelt skapa fakturor och hålla koll på betalningar.",
- "preset_customers_action": "Lägg till kund",
- "preset_transactions_title": "Inga transaktioner",
- "preset_transactions_description": "Importera kontoutdrag från din bank för att automatiskt bokföra och få koll på ekonomin.",
- "preset_transactions_action": "Importera transaktioner",
- "preset_deadlines_title": "Inga kommande deadlines",
- "preset_deadlines_description": "Bra jobbat! Du har inga omedelbara deadlines att ta hand om.",
- "preset_no_bank_title": "Inga transaktioner importerade",
- "preset_no_bank_description": "Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin.",
- "preset_no_bank_action": "Importera transaktioner",
- "preset_reports_title": "Inga rapporter tillgängliga",
- "preset_reports_description": "Rapporter genereras automatiskt när du har tillräckligt med data. Börja med att skapa fakturor eller importera transaktioner.",
- "preset_reports_action": "Skapa faktura",
- "preset_reports_secondary": "Importera transaktioner"
+ "preset_customers_action": "Lägg till kund"
},
"start_cards": {
"invoices_title": "Din första faktura tar två minuter.",
@@ -8432,7 +7790,6 @@
"action_show_voucher": "Visa verifikat",
"action_match": "Matcha",
"action_book": "Bokför",
- "action_booking": "Bokför…",
"ignore_action": "Ignorera",
"action_unignore": "Återställ",
"band_ignored": "Ignorerade",
diff --git a/package-lock.json b/package-lock.json
index 0040ff13..dd82c27c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,10 +28,8 @@
"@supabase/ssr": "^0.12.1",
"@supabase/supabase-js": "^2.110.1",
"@tailwindcss/typography": "^0.5.20",
- "@types/qrcode": "^1.5.6",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
- "@use-gesture/react": "^10.3.1",
"@vercel/speed-insights": "^2.0.0",
"ai": "6.0.259",
"class-variance-authority": "^0.7.1",
@@ -62,7 +60,6 @@
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"stripe": "^22.3.1",
- "svix": "^1.85.0",
"swr": "^2.4.2",
"tailwind-merge": "^3.6.0",
"web-push": "^3.6.7",
@@ -76,9 +73,9 @@
"@types/node": "^20",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/sharp": "^0.32.0",
"@types/web-push": "^3.6.4",
"dotenv": "^17.4.2",
"eslint": "^9",
@@ -7779,6 +7776,7 @@
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -7810,6 +7808,7 @@
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -7834,17 +7833,6 @@
"@types/react": "^19.2.0"
}
},
- "node_modules/@types/sharp": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.32.0.tgz",
- "integrity": "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==",
- "deprecated": "This is a stub types definition. sharp provides its own type definitions, so you do not need this installed.",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "sharp": "*"
- }
- },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -8451,24 +8439,6 @@
"uncrypto": "^0.1.3"
}
},
- "node_modules/@use-gesture/core": {
- "version": "10.3.1",
- "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
- "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
- "license": "MIT"
- },
- "node_modules/@use-gesture/react": {
- "version": "10.3.1",
- "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
- "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
- "license": "MIT",
- "dependencies": {
- "@use-gesture/core": "10.3.1"
- },
- "peerDependencies": {
- "react": ">= 16.8.0"
- }
- },
"node_modules/@vercel/oidc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
@@ -15943,15 +15913,6 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
- "node_modules/svix": {
- "version": "1.96.1",
- "resolved": "https://registry.npmjs.org/svix/-/svix-1.96.1.tgz",
- "integrity": "sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==",
- "license": "MIT",
- "dependencies": {
- "standardwebhooks": "1.0.0"
- }
- },
"node_modules/swr": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz",
@@ -16876,6 +16837,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unicode-properties": {
diff --git a/package.json b/package.json
index ec1beabd..b06812ea 100644
--- a/package.json
+++ b/package.json
@@ -49,10 +49,8 @@
"@supabase/ssr": "^0.12.1",
"@supabase/supabase-js": "^2.110.1",
"@tailwindcss/typography": "^0.5.20",
- "@types/qrcode": "^1.5.6",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
- "@use-gesture/react": "^10.3.1",
"@vercel/speed-insights": "^2.0.0",
"ai": "6.0.259",
"class-variance-authority": "^0.7.1",
@@ -83,7 +81,6 @@
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"stripe": "^22.3.1",
- "svix": "^1.85.0",
"swr": "^2.4.2",
"tailwind-merge": "^3.6.0",
"web-push": "^3.6.7",
@@ -97,9 +94,9 @@
"@types/node": "^20",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.20.0",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/sharp": "^0.32.0",
"@types/web-push": "^3.6.4",
"dotenv": "^17.4.2",
"eslint": "^9",
@@ -112,7 +109,6 @@
},
"overrides": {
"html-to-text": "10.0.1",
- "ws": "^8.21.0",
"sharp": "^0.35.3",
"postcss": "^8.5.18"
}
diff --git a/scripts/backfill-tax-deadlines.ts b/scripts/backfill-tax-deadlines.ts
index c7493371..b5c1daa0 100644
--- a/scripts/backfill-tax-deadlines.ts
+++ b/scripts/backfill-tax-deadlines.ts
@@ -4,7 +4,7 @@
*
* Root cause (bug): tax deadlines only ever regenerated when a tax-relevant
* settings field CHANGED value (app/api/settings/route.ts, gated on
- * didTaxFieldsChange). Companies fill these fields once at onboarding, so a
+ * the since-removed didTaxFieldsChange). Companies fill these fields once at onboarding, so a
* later save changed nothing and generated nothing. The annual cron
* (generateNewYearDeadlines) is the only unconditional trigger and runs Jan 2,
* so the installed base sat empty. Result before this backfill: only ~5 of ~776
diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json
index 29336a5b..814276df 100644
--- a/scripts/checks/antipatterns-baseline.json
+++ b/scripts/checks/antipatterns-baseline.json
@@ -7,10 +7,10 @@
]
},
"naiveOreRound": {
- "count": 622
+ "count": 620
},
"handRolledInvariants": {
- "count": 115
+ "count": 113
},
"ledgerScanningReports": {
"count": 4,
diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs
index fc7c6a56..fbeb0b24 100644
--- a/scripts/checks/no-new-antipatterns.mjs
+++ b/scripts/checks/no-new-antipatterns.mjs
@@ -547,8 +547,6 @@ function findFoldedPublicFlags() {
// Files whose whitespace-nowrap cells are fixed-width numeric/tabular columns
// living inside their OWN overflow-x-auto scroll container, so they cannot
// widen the dialog itself:
-// - MockDataImportDialog: CSV preview built on the Table primitive, which
-// self-wraps in overflow-auto (components/ui/table.tsx).
// - PaymentFileDialog: payment-line table wrapped in an overflow-x-auto div.
// 11. direct-ai-client. Every model call goes through the job-shaped service
// in lib/ai (getAiService): that is what lets hosted stay on Bedrock while a
@@ -596,7 +594,6 @@ function findDirectAiClients() {
}
const DIALOG_NOWRAP_ALLOWED = new Set([
- 'components/extensions/shared/MockDataImportDialog.tsx',
'components/supplier-invoices/PaymentFileDialog.tsx',
])
diff --git a/tests/helpers.ts b/tests/helpers.ts
index c7dd7590..639dbb8f 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -9,17 +9,13 @@ import type {
JournalEntry,
JournalEntryLine,
DocumentAttachment,
- TaxCode,
Invoice,
- InvoicePayment,
Customer,
Supplier,
SupplierInvoice,
CompanySettings,
InvoiceInboxItem,
CategorizationTemplate,
- Company,
- CompanyMember,
} from '@/types'
import type { SIEVoucher, SIETransactionLine } from '@/lib/import/types'
@@ -105,37 +101,6 @@ export function createMockSupabase() {
let _counter = 0
const nextId = () => `test-${++_counter}`
-export function makeCompany(overrides: Partial = {}): Company {
- const { team_id = null, ...rest } = overrides
- return {
- id: 'company-1',
- name: 'Test Company',
- org_number: null,
- entity_type: 'enskild_firma',
- accounting_framework: 'k2',
- created_by: 'user-1',
- team_id,
- archived_at: null,
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- ...rest,
- }
-}
-
-export function makeCompanyMember(overrides: Partial = {}): CompanyMember {
- return {
- id: 'member-1',
- company_id: 'company-1',
- user_id: 'user-1',
- role: 'owner',
- invited_by: null,
- joined_at: '2024-01-01T00:00:00Z',
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- ...overrides,
- }
-}
-
export function makeReceipt(overrides: Partial = {}): Receipt {
return {
id: nextId(),
@@ -319,28 +284,6 @@ export function makeDocumentAttachment(
}
}
-export function makeTaxCode(overrides: Partial = {}): TaxCode {
- return {
- id: nextId(),
- user_id: null,
- code: 'MP1',
- description: 'Utgående moms 25%',
- rate: 25,
- moms_basis_boxes: ['05'],
- moms_tax_boxes: ['10'],
- moms_input_boxes: [],
- is_output_vat: true,
- is_reverse_charge: false,
- is_eu: false,
- is_export: false,
- is_oss: false,
- is_system: true,
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- ...overrides,
- }
-}
-
export function makeInvoice(overrides: Partial = {}): Invoice {
return {
id: nextId(),
@@ -381,27 +324,6 @@ export function makeInvoice(overrides: Partial = {}): Invoice {
}
}
-export function makeInvoicePayment(
- overrides: Partial = {}
-): InvoicePayment {
- return {
- id: nextId(),
- user_id: 'user-1',
- company_id: 'company-1',
- invoice_id: 'invoice-1',
- payment_date: '2024-07-01',
- amount: 12500,
- currency: 'SEK',
- exchange_rate: null,
- exchange_rate_difference: 0,
- journal_entry_id: null,
- transaction_id: null,
- notes: null,
- created_at: '2024-07-01T00:00:00Z',
- ...overrides,
- }
-}
-
export function makeCustomer(overrides: Partial = {}): Customer {
return {
id: nextId(),
diff --git a/types/chat.ts b/types/chat.ts
deleted file mode 100644
index 337a71e6..00000000
--- a/types/chat.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-// Chat types for AI chatbot
-
-export interface ChatMessage {
- id: string
- session_id: string
- user_id: string
- role: 'user' | 'assistant'
- content: string
- sources: SourceReference[]
- artifact?: ArtifactSpec | null
- created_at: string
-}
-
-export interface ChatSession {
- id: string
- user_id: string
- title: string | null
- created_at: string
-}
-
-export interface SourceReference {
- id: string
- source_file: string
- title: string
- section_title: string | null
- similarity: number
-}
-
-export interface KnowledgeDocument {
- id: string
- source_file: string
- title: string
- section_title: string | null
- content: string
- content_hash: string
- embedding: number[] | null
- metadata: Record
- created_at: string
-}
-
-export interface ChatRequest {
- message: string
- session_id?: string
-}
-
-export interface ChatResponse {
- message: ChatMessage
- session_id: string
-}
-
-export interface StreamChunk {
- type: 'content' | 'sources' | 'done' | 'error' | 'tool_start' | 'artifact'
- content?: string
- sources?: SourceReference[]
- error?: string
- toolName?: string
- artifact?: ArtifactSpec
-}
-
-// ── Artifact Types ──────────────────────────────────────────────
-
-export type ArtifactSpec =
- | ChartArtifact
- | TableArtifact
- | KpiCardsArtifact
- | AgingBucketsArtifact
-
-export interface ChartArtifact {
- type: 'bar_chart' | 'line_chart' | 'pie_chart' | 'stacked_bar'
- title: string
- data: { label: string; value: number; color?: string }[]
- unit?: string
- subtitle?: string
-}
-
-export interface TableArtifact {
- type: 'table'
- title: string
- columns: { key: string; label: string; align?: 'left' | 'right' }[]
- rows: Record[]
- summary_row?: Record
-}
-
-export interface KpiCardsArtifact {
- type: 'kpi_cards'
- title?: string
- cards: { label: string; value: string; trend?: 'up' | 'down' | 'flat'; change?: string }[]
-}
-
-export interface AgingBucketsArtifact {
- type: 'aging_buckets'
- title: string
- buckets: { label: string; amount: number; count: number }[]
- total: number
-}
diff --git a/types/index.ts b/types/index.ts
index 0e18f83c..8a5acdbe 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -10,10 +10,6 @@ export type AccountingFramework = 'k2' | 'k3'
// Company role for multi-tenant access
export type CompanyRole = 'owner' | 'admin' | 'member' | 'viewer'
-// Team (consulting firm) roles and source tracking
-export type TeamRole = 'owner' | 'admin' | 'member'
-export type MemberSource = 'direct' | 'team'
-
// Team (consulting firm grouping). 'personal' teams are the implicit
// one-per-user grouping; 'byra' teams are ops-created accounting-firm
// tenants (WL-08) with invites, a brand, and cockpit access.
@@ -163,18 +159,6 @@ export interface FiscalYearResetRpcResult {
period_name?: string
}
-// User preferences (cross-company)
-export interface UserPreferences {
- id: string
- user_id: string
- active_company_id: string | null
- // Client-driven UI preferences (nav collapse/fold state, last-used create
- // modes). jsonb DEFAULT '{}'. Cosmetic only, never load-bearing.
- ui_state?: UserUiState
- created_at: string
- updated_at: string
-}
-
// Shape of user_preferences.ui_state. All fields optional: the bag grows
// as UI surfaces add preferences (UI migration plan PR 2/3).
export interface UserUiState {
@@ -296,23 +280,6 @@ export type ProcessingHistoryAggregateType =
| 'Migration'
| 'System'
-export interface ProcessingHistoryEvent {
- event_id: string
- seq: number
- company_id: string
- correlation_id: string
- causation_id: string | null
- aggregate_type: ProcessingHistoryAggregateType
- aggregate_id: string
- event_type: string // open type: validated at runtime against processing_event_types registry
- payload: Record
- payload_schema_version: number
- actor: ProcessingHistoryActor
- rubric_version: string | null
- occurred_at: string
- appended_at: string
-}
-
// Bank connection status
// 'pending_selection' = PSD2 consent granted, awaiting user to pick which
// accounts to actually sync. No transactions are pulled in this state.
@@ -340,16 +307,6 @@ export interface InvoicePaymentAccount {
foreign_account_number?: string | null
}
-// Profile (extends auth.users)
-export interface Profile {
- id: string
- email: string
- full_name: string | null
- avatar_url: string | null
- created_at: string
- updated_at: string
-}
-
// Editable invoice email texts (standard invoices only; sv + en).
// Missing / whitespace-only fields fall back to the hardcoded defaults in
// lib/email/invoice-templates.ts. Supports the fixed placeholder set
@@ -694,17 +651,6 @@ export interface CashAccount {
updated_at: string
}
-// Import source identifiers
-export type ImportSource =
- | 'enable_banking'
- | 'csv_nordea'
- | 'csv_seb'
- | 'csv_swedbank'
- | 'csv_handelsbanken'
- | 'csv_generic'
- | 'camt053'
- | 'manual'
-
/**
* Closed vocabulary for HOW money moved (the payment rail), classified at
* ingest by classifyTransactionMethod() (lib/transactions/transaction-method.ts).
@@ -845,25 +791,6 @@ export interface Transaction {
// (upsert on company_id + file_hash) and moves it back to 'processing'.
export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'undone'
-export interface BankFileImport {
- id: string
- user_id: string
- company_id: string
- filename: string
- file_hash: string
- file_format: string
- transaction_count: number
- imported_count: number
- duplicate_count: number
- matched_count: number
- date_from: string | null
- date_to: string | null
- status: BankFileImportStatus
- error_message: string | null
- created_at: string
- updated_at: string
-}
-
// Customer
export interface Customer {
id: string
@@ -1508,41 +1435,6 @@ export type RotRutPayoutRequestStatus =
| 'rejected'
| 'cancelled'
-export interface RotRutPayoutRequest {
- id: string
- company_id: string
- user_id: string
- deduction_type: 'rot' | 'rut'
- /** NamnPaBegaran in the file: 1-16 chars, shown in Skatteverkets e-tjänst. */
- name: string
- status: RotRutPayoutRequestStatus
- requested_total: number
- decided_total: number | null
- file_name: string
- file_document_id: string | null
- settlement_journal_entry_id: string | null
- submitted_at: string | null
- decided_at: string | null
- created_at: string
- updated_at: string
-
- // Relations (populated when fetched)
- items?: RotRutPayoutRequestItem[]
-}
-
-export interface RotRutPayoutRequestItem {
- id: string
- request_id: string
- invoice_id: string
- requested_amount: number
- decided_amount: number | null
- created_at: string
- updated_at: string
-
- // Relations (populated when fetched)
- invoice?: Invoice
-}
-
// Recurring Invoice Schedule (template + monthly cadence)
export type RecurringInvoiceScheduleStatus = 'active' | 'paused'
@@ -1607,24 +1499,6 @@ export interface RecurringInvoiceScheduleItem {
created_at: string
}
-// Tax Rates (reference table)
-export interface TaxRate {
- id: string
-
- // Type
- rate_type: 'egenavgifter' | 'bolagsskatt' | 'arbetsgivaravgifter' | 'vat' | 'municipal'
-
- // Rate
- rate: number
-
- // Validity
- valid_from: string
- valid_to: string | null
-
- // Description
- description: string
-}
-
// Form types for creating/updating
export interface CreateCustomerInput {
@@ -1674,91 +1548,6 @@ export interface CreateSupplierInput {
notes?: string
}
-export interface CreateSupplierInvoiceInput {
- supplier_id: string
- supplier_invoice_number: string
- invoice_date: string
- due_date: string
- delivery_date?: string
- currency?: string
- exchange_rate?: number
- vat_treatment?: VatTreatment
- reverse_charge?: boolean
- payment_reference?: string
- notes?: string
- /** Per-invoice öresavrundning override (display-only). Omitted = null (off). */
- ore_rounding?: boolean
- items: CreateSupplierInvoiceItemInput[]
-}
-
-export interface CreateSupplierInvoiceItemInput {
- description: string
- amount: number
- account_number: string
- vat_rate?: number
- // Manual override. See CreateSupplierInvoiceItemSchema for rationale.
- vat_amount?: number
- // Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). When
- // set, the engine books fiktiv moms at this rate while vat_rate stays 0.
- reverse_charge_rate?: number
- // Särskild löneskatt på pensionskostnader: injects 7533 D / 2514 K at
- // 24.26 % of the line amount. Only valid on 741x pension accounts.
- apply_slp?: boolean
- vat_code?: string
- // Legacy fields (backward compat, ignored when amount is set)
- quantity?: number
- unit?: string
- unit_price?: number
-}
-
-export interface CreateInvoiceInput {
- customer_id: string
- invoice_date: string
- due_date: string
- currency: Currency
- document_type?: InvoiceDocumentType
- your_reference?: string
- our_reference?: string
- /** Fakturamärkning: buyer-required marking, separate from your_reference. */
- invoice_marking?: string
- notes?: string
- /** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */
- payment_link_url?: string
- /** Plaintext personnummer: encrypted server-side before storage. */
- deduction_personnummer?: string
- /** Fastighetsbeteckning. Required when any item carries deduction_type === 'rot'. */
- deduction_housing_designation?: string
- /** Save as an unnumbered draft (no F-number, no invoice.created) until the
- * user finalizes via "Granska & skapa". Lets the draft be hard-deleted. */
- save_as_draft?: boolean
- /** Per-invoice öresavrundning override (display-only). Omitted = null (inherit company setting). */
- ore_rounding?: boolean
- items: CreateInvoiceItemInput[]
-}
-
-export interface CreateInvoiceItemInput {
- /** 'text' rows carry only a description (may be empty for a spacer) and are
- * excluded from totals and bookkeeping. Defaults to 'product'. */
- line_type?: 'product' | 'text'
- description: string
- quantity: number
- unit: string
- unit_price: number
- /** Percentage discount on the line (0-100). Omitted/null = 0. */
- discount_percent?: number | null
- vat_rate?: number
- /** Source article (optional). Free-text lines omit it. */
- article_id?: string | null
- /** BAS class 1-3 posting account override copied from the article. null = derive from VAT treatment. */
- revenue_account?: string | null
- /** ROT/RUT toggle. null/undefined = no deduction. */
- deduction_type?: 'rot' | 'rut' | null
- labor_hours?: number | null
- work_type?: string | null
- housing_designation?: string | null
- apartment_number?: string | null
-}
-
export interface CreateTransactionInput {
date: string
description: string
@@ -1783,14 +1572,6 @@ export interface ArchiveEstimate {
within_limit: boolean
}
-export interface PaginatedResponse {
- data: T[]
- count: number
- page: number
- pageSize: number
- totalPages: number
-}
-
// VAT validation response
export interface VatValidationResult {
valid: boolean
@@ -1808,54 +1589,6 @@ export interface ExchangeRate {
date: string
}
-// Dashboard summary types
-export interface DashboardSummary {
- // Income
- total_income_ytd: number
- total_income_mtd: number
-
- // Expenses
- total_expenses_ytd: number
- total_expenses_mtd: number
-
- // Net
- net_income_ytd: number
- net_income_mtd: number
-
- // Tax estimates
- estimated_tax: TaxEstimate
-
- // Alerts
- uncategorized_count: number
- unpaid_invoices_count: number
- unpaid_invoices_total: number
- overdue_invoices_count: number
-
- // Bank
- bank_balance: number | null
- available_balance: number | null // After tax reservations
-}
-
-export interface TaxEstimate {
- // For EF
- egenavgifter?: number
- income_tax?: number // Municipal tax (kommunalskatt)
- state_tax?: number // State tax (statlig skatt) - 20% on high incomes
- grundavdrag?: number // Basic deduction applied
-
- // For AB
- bolagsskatt?: number
-
- // Common
- moms_to_pay: number
- total_tax_liability: number
-
- // Comparison with preliminary
- preliminary_paid_ytd: number
- difference: number // Positive = underpaying
-
-}
-
// ============================================================
// BAS Kontoplan & Bookkeeping Types
// ============================================================
@@ -2245,24 +1978,6 @@ export interface BookingTemplateLibrary {
updated_at: string
}
-// Account Balance (cached)
-export interface AccountBalance {
- id: string
- user_id: string
- company_id: string
- fiscal_period_id: string
- account_number: string
- account_id: string | null
- opening_debit: number
- opening_credit: number
- period_debit: number
- period_credit: number
- closing_debit: number
- closing_credit: number
- created_at: string
- updated_at: string
-}
-
// Report types
export interface TrialBalanceRow {
account_number: string
@@ -2477,12 +2192,6 @@ export interface CreateJournalEntryLineInput {
project?: string
}
-export interface CreateFiscalPeriodInput {
- name: string
- period_start: string
- period_end: string
-}
-
// ── Pending Operations ────────────────────────────────────────
export type PendingOperationType =
@@ -2703,44 +2412,6 @@ export interface InitialSetupState {
dismissedAt: string | null
}
-// Onboarding step data
-export interface OnboardingStepData {
- step1?: {
- entity_type: EntityType
- }
- step2?: {
- company_name: string
- org_number?: string
- address_line1?: string
- postal_code?: string
- city?: string
- }
- step3?: {
- f_skatt: boolean
- fiscal_year_start_month: number
- is_first_fiscal_year?: boolean
- first_year_start?: string
- first_year_end?: string
- vat_registered: boolean
- vat_number?: string
- moms_period?: MomsPeriod
- }
- step4?: {
- preliminary_tax_monthly?: number
- }
- step5?: {
- bank_name?: string
- clearing_number?: string
- account_number?: string
- iban?: string
- bic?: string
- }
- step6?: {
- bank_connected: boolean
- bank_connection_id?: string
- }
-}
-
// ============================================================
// Calendar & Deadline Types
// ============================================================
@@ -2846,40 +2517,10 @@ export interface Deadline {
customer?: Customer
}
-// Input for creating a deadline
-export interface CreateDeadlineInput {
- title: string
- due_date: string
- due_time?: string
- deadline_type: DeadlineType
- priority?: DeadlinePriority
- customer_id?: string
- notes?: string
- // Tax deadline fields
- tax_deadline_type?: TaxDeadlineType
- tax_period?: string
- source?: DeadlineSource
- linked_report_type?: string
- linked_report_period?: Record
-}
-
// ============================================================
// Push Notification Types
// ============================================================
-// Push subscription for Web Push API
-export interface PushSubscription {
- id: string
- user_id: string
- endpoint: string
- p256dh: string
- auth: string
- user_agent: string | null
- is_active: boolean
- last_used_at: string | null
- created_at: string
-}
-
// Notification settings per user
export interface NotificationSettings {
id: string
@@ -2916,18 +2557,6 @@ export type NotificationType =
| 'skv_connection_expired'
| 'bookkeeping_digest'
-// Notification log entry
-export interface NotificationLog {
- id: string
- user_id: string
- company_id: string | null
- notification_type: NotificationType
- reference_id: string
- days_before: number
- sent_at: string
- delivery_status: 'pending' | 'sent' | 'delivered' | 'failed'
-}
-
// ============================================================
// Calendar Feed Types (ICS)
// ============================================================
@@ -2947,90 +2576,6 @@ export interface CalendarFeed {
updated_at: string
}
-// Input for creating/updating calendar feed
-export interface UpdateCalendarFeedInput {
- include_tax_deadlines?: boolean
- include_invoices?: boolean
-}
-
-// Swedish labels for deadline status
-export const DEADLINE_STATUS_LABELS: Record = {
- upcoming: 'Kommande',
- action_needed: 'Åtgärd krävs',
- in_progress: 'Pågår',
- submitted: 'Inskickad',
- confirmed: 'Bekräftad',
- overdue: 'Försenad'
-}
-
-// Swedish labels for tax deadline types
-export const TAX_DEADLINE_TYPE_LABELS: Record = {
- moms_monthly: 'Momsdeklaration (månad)',
- moms_quarterly: 'Momsdeklaration (kvartal)',
- moms_yearly: 'Momsdeklaration (år)',
- f_skatt: 'Preliminärskatt (F-skatt)',
- arbetsgivardeklaration: 'Arbetsgivardeklaration',
- skatteinbetalning: 'Skatteinbetalning (storföretag)',
- inkomstdeklaration_ef: 'Inkomstdeklaration EF',
- inkomstdeklaration_ab: 'Inkomstdeklaration AB',
- arsredovisning: 'Årsredovisning',
- arsstamma: 'Årsstämma',
- periodisk_sammanstallning: 'Periodisk sammanställning',
- kontrolluppgifter: 'Kontrolluppgifter (KU)',
- rot_rut_begaran: 'ROT/RUT-begäran om utbetalning',
- oss_quarterly: 'OSS-deklaration',
- ioss_monthly: 'IOSS-deklaration',
- intrastat_monthly: 'Intrastat',
- punktskatt_monthly: 'Punktskattedeklaration',
- fyllnadsinbetalning: 'Fyllnadsinbetalning',
- kvarskatt: 'Kvarskatt'
-}
-
-// ============================================================
-// SIE Import Types
-// ============================================================
-
-// SIE import status
-export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
-
-// SIE import record
-export interface SIEImport {
- id: string
- user_id: string
- company_id: string
- filename: string
- file_hash: string
- org_number: string | null
- company_name: string | null
- sie_type: number
- fiscal_year_start: string | null
- fiscal_year_end: string | null
- accounts_count: number
- transactions_count: number
- opening_balance_total: number | null
- status: SIEImportStatus
- error_message: string | null
- fiscal_period_id: string | null
- opening_balance_entry_id: string | null
- imported_at: string | null
- created_at: string
- updated_at: string
-}
-
-// SIE account mapping record
-export interface SIEAccountMapping {
- id: string
- user_id: string
- company_id: string
- source_account: string
- source_name: string | null
- target_account: string
- confidence: number
- match_type: 'exact' | 'name' | 'class' | 'manual'
- created_at: string
- updated_at: string
-}
-
// ============================================================
// Invoice Inbox Types
// ============================================================
@@ -3411,68 +2956,12 @@ export interface ExtractedLineItem {
confidence?: number
}
-// Match candidate for receipt-to-transaction matching
-export interface ReceiptMatchCandidate {
- transaction: Transaction
- confidence: number
- matchReasons: string[]
- dateVariance: number
- amountVariance: number
-}
-
-// Input for creating a receipt
-export interface CreateReceiptInput {
- image_url: string
- image_thumbnail_url?: string
-}
-
-// Input for confirming receipt line items
-export interface ConfirmReceiptInput {
- line_items: ConfirmLineItemInput[]
- matched_transaction_id?: string
- representation_persons?: number
- representation_purpose?: string
-}
-
-export interface ConfirmLineItemInput {
- id: string
- is_business: boolean
- category?: TransactionCategory
- bas_account?: string
-}
-
-// Receipt queue summary
-export interface ReceiptQueueSummary {
- unmatched_receipts_count: number
- unmatched_transactions_count: number
- pending_review_count: number
- streak_count: number
-}
-
-// Camera quality feedback
-export interface CameraQualityFeedback {
- lightingOk: boolean
- distanceOk: boolean
- focusOk: boolean
- readyToCapture: boolean
- message?: string
-}
-
-// Swedish labels for receipt status
-export const RECEIPT_STATUS_LABELS: Record = {
- pending: 'Väntar',
- processing: 'Analyserar',
- extracted: 'Extraherat',
- confirmed: 'Bekräftat',
- error: 'Fel'
-}
-
// ============================================================
// VAT Declaration Types (Momsdeklaration)
// ============================================================
// VAT period type
-export type VatPeriodType = 'monthly' | 'quarterly' | 'yearly'
+export type VatPeriodType = MomsPeriod
// VAT declaration rutor (boxes) according to SKV 4700
// Complete set of all 30 boxes in the momsdeklaration form.
@@ -3606,13 +3095,6 @@ export interface VatDeclaration {
}
}
-// VAT declaration request parameters
-export interface VatDeclarationRequest {
- periodType: VatPeriodType
- year: number
- period: number
-}
-
// Labels for VAT rutor
export const VAT_RUTA_LABELS: Record = {
ruta05: 'Momspliktig försäljning',
@@ -3655,52 +3137,6 @@ export interface CreditNote extends Invoice {
credited_invoice_id: string
}
-/** Generic key-value store record for extensions */
-export interface ExtensionDataRecord {
- id: string
- user_id: string
- company_id: string
- extension_id: string
- key: string
- value: Record
- created_at: string
- updated_at: string
-}
-
-// ============================================================
-// Tax Code Types
-// ============================================================
-
-// Tax code identifiers (standard Swedish codes)
-export type TaxCodeId =
- | 'MP1' | 'MP2' | 'MP3' // Output VAT 25%, 12%, 6%
- | 'MPI' | 'MPI12' | 'MPI6' // Input VAT 25%, 12%, 6%
- | 'IV' // Intra-EU acquisition
- | 'EUS' // EU sale (reverse charge)
- | 'IP' // Import
- | 'EXP' // Export outside EU
- | 'OSS' // One Stop Shop
- | 'NONE' // VAT exempt
-
-export interface TaxCode {
- id: string
- user_id: string | null
- code: string
- description: string
- rate: number
- moms_basis_boxes: string[]
- moms_tax_boxes: string[]
- moms_input_boxes: string[]
- is_output_vat: boolean
- is_reverse_charge: boolean
- is_eu: boolean
- is_export: boolean
- is_oss: boolean
- is_system: boolean
- created_at: string
- updated_at: string
-}
-
// ============================================================
// Document Archive Types
// ============================================================
@@ -3741,17 +3177,6 @@ export interface DocumentAttachment {
updated_at: string
}
-export interface CreateDocumentAttachmentInput {
- storage_path: string
- file_name: string
- file_size_bytes?: number
- mime_type?: string
- sha256_hash: string
- upload_source?: DocumentUploadSource
- journal_entry_id?: string
- journal_entry_line_id?: string
-}
-
// ============================================================
// Audit Log Types
// ============================================================
@@ -3791,32 +3216,6 @@ export interface AuditLogEntry {
created_at: string
}
-// ============================================================
-// Dimension Types (Kostnadsställen & Projekt)
-// ============================================================
-
-export interface CostCenter {
- id: string
- company_id: string
- code: string
- name: string
- is_active: boolean
- created_at: string
- updated_at: string
-}
-
-export interface Project {
- id: string
- company_id: string
- code: string
- name: string
- is_active: boolean
- start_date: string | null
- end_date: string | null
- created_at: string
- updated_at: string
-}
-
// ============================================================
// Voucher Gap Detection
// ============================================================
@@ -3827,19 +3226,6 @@ export interface VoucherGap {
series: string
}
-export interface VoucherGapExplanation {
- id: string
- company_id: string
- user_id: string
- fiscal_period_id: string
- voucher_series: string
- gap_start: number
- gap_end: number
- explanation: string
- created_at: string
- updated_at: string
-}
-
export interface SequenceMismatch {
series: string
sequenceCounter: number
@@ -4057,19 +3443,6 @@ export interface Asset {
updated_at: string
}
-export interface DepreciationSchedule {
- id: string
- user_id: string
- company_id: string
- asset_id: string
- fiscal_period_id: string
- planned_depreciation: number
- journal_entry_id: string | null
- posted_at: string | null
- created_at: string
- updated_at: string
-}
-
// ============================================================
// IB/UB Continuity Check Types (Avstämning ingående/utgående balans)
// ============================================================
@@ -4121,15 +3494,6 @@ export interface CurrencyRevaluationResult {
preview: CurrencyRevaluationPreview
}
-export interface PeriodStatus {
- is_locked: boolean
- is_closed: boolean
- has_closing_entry: boolean
- has_opening_balances: boolean
- draft_count: number
- next_period_exists: boolean
-}
-
// ============================================================
// Invoice Reminder Types (Betalningspåminnelser)
// ============================================================
@@ -4160,20 +3524,6 @@ export interface InvoiceReminder {
fee_journal_entry_id: string | null
}
-// Swedish labels for reminder levels
-export const REMINDER_LEVEL_LABELS: Record<1 | 2 | 3, string> = {
- 1: 'Vänlig påminnelse',
- 2: 'Andra påminnelsen',
- 3: 'Slutlig påminnelse'
-}
-
-// Reminder level descriptions
-export const REMINDER_LEVEL_DESCRIPTIONS: Record<1 | 2 | 3, string> = {
- 1: '15 dagar efter förfallodatum',
- 2: '30 dagar efter förfallodatum',
- 3: '45 dagar efter förfallodatum'
-}
-
// ============================================================
// Transaction Ingestion Types (re-exported for extension use)
// ============================================================
@@ -4504,13 +3854,6 @@ export type SalaryType = 'monthly' | 'hourly'
export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified'
export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected'
-export type AGIStatus =
- | 'generated' // XML built from a salary run; nothing sent to SKV yet
- | 'pending_signature' // underlag accepted into Eget utrymme; awaiting BankID
- | 'exported' // legacy: manual XML download path
- | 'submitted' // kvittens received; AGI is filed
- | 'accepted' // reserved (SKV does not currently expose this)
- | 'rejected' // reserved (kontrollresultat DONE_REJECTED could land here)
export type SalaryLineItemType =
| 'monthly_salary' | 'hourly_salary'
@@ -4722,31 +4065,6 @@ export interface SalaryLineItem {
updated_at: string
}
-export interface AGIDeclaration {
- id: string
- company_id: string
- user_id: string
- salary_run_id: string | null
- period_year: number
- period_month: number
- xml_content: string
- status: AGIStatus
- individuppgifter: Record[]
- total_gross: number
- total_tax: number
- total_avgifter_basis: number
- total_avgifter: number
- employee_count: number
- kvittensnummer: string | null
- submitted_at: string | null
- submitted_by: string | null
- response_data: Record | null
- is_correction: boolean
- corrects_agi_id: string | null
- created_at: string
- updated_at: string
-}
-
/**
* A `pending_operations` row a chat conversation staged and nobody has answered
* yet, as returned by GET /api/agent/conversations/[id] and by the /chat/[id]
diff --git a/types/skatteverket.ts b/types/skatteverket.ts
index 683292b0..d4d2cc1d 100644
--- a/types/skatteverket.ts
+++ b/types/skatteverket.ts
@@ -41,28 +41,6 @@ export interface StoredSkattekontoTransaction {
suggested_at?: string | null
}
-/** Row shape for the `skattekonto_file_imports` tracking table (DB → app). */
-export interface SkattekontoFileImportRecord {
- id: string
- company_id: string
- /** Importing user; null after that user's account is deleted. */
- user_id: string | null
- filename: string
- file_hash: string
- file_variant: 'csv' | 'skv'
- row_count: number
- imported_count: number
- duplicate_count: number
- promoted_count: number
- date_from: string | null
- date_to: string | null
- closing_saldo: number | null
- status: 'pending' | 'processing' | 'completed' | 'failed'
- error_message: string | null
- created_at: string
- updated_at: string
-}
-
/**
* Single best candidate verifikat for an unmatched SKV row. Attached by
* the `/skattekonto/transaktioner` endpoint when exactly one strong match