Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect Verified against the Swedish Common Interpretation of ISO 20022 (Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4: Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22), and XSD-validated against the official pain.001.001.03 schema: - drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl gets the domestic NURG default) - drop RmtInf (not allowed for SALA salary payments; the beneficiary statement text comes from the Dataclearing LON code) - address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA, account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN - share the clearing/account split (Swedbank 5-digit shift, Nordea personkonto prefix dedup) between the LB and pain.001 generators via splitDomesticBankAccount, fixing pain.001 duplicating the personkonto clearing - clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx counter surviving truncation; carry the org number on Dbtr - return 400 from the pain001 route on an invalid clearing instead of emitting a broken file Also includes two unrelated decision-log lines from the parallel revisor-review session (DECISIONS.md is a shared append-only log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(nav): surface the year-end chain in the sidebar Add Periodiseringar, Arsredovisning (aktiebolag only) and Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt & bokslut group, in workflow order. Entity gating via a new entityOnly flag on NavItem; isActive carve-outs extended so exactly one row lights up for the new routes. Driven by an external revisor review that concluded these features did not exist because none of them were reachable from the nav. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): Stripe Connect integration behind config gate Connect OAuth per company (only the acct_ id is stored), automatic single-use Payment Links on invoice send, deterministic payment settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686), payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614), and a 15-minute sync cron. Non-deterministic events land as needs_review, never guessed at. Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the send hook and cron no-op, and the settings page shows 'Kommer snart' (hosted) until the Connect platform is verified. Self-hosted keeps the honest not-configured message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete generate-declaration.ts has updated non-existent columns (type/period/ status) since inception, so the arbetsgivardeklaration deadline was never auto-completed. Replace with a shared helper targeting the real schema (tax_deadline_type/tax_period/is_completed), also used by the kvittens crons and moms handlers in the follow-up commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record godkant belopp on the matching begaran: matched by stored skv_referensnummer first, then exact name among active undecided requests; arenden by fakturanummer then personnummer, exactly-one or the beslut errors (all-or-nothing). Never auto-settles: recording the beslut and booking the payout are separate acts. Exposed as an API route and the gnubok_import_rot_rut_beslut MCP tool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications Hybrid auth program: system CCG (org certificate) for background reads while personal BankID stays for interactive submissions, since SKV per-flow refresh tokens live 65 min and crons structurally cannot run on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE (default off) with a stub transport until the Expisoft cert and CCG avtal land; auth resolution is centralized in resolve-auth.ts. Also in this change: - One-click VAT submit chaining kontrollera -> utkast -> las server-side with a stage discriminator; step-by-step buttons demoted to the overflow menu. - Kvittens crons (AGI + new VAT schedule) with email-only notifications, deduped in notification_log under the new skv_kvittens type. - Ombud grant probe + verification UI in the connect panel, and a dashboard promo card for unconnected companies. - skatteverket_company_connections table with pg-real coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card The "Skatt att betala" card only cleared via the manual mark-paid button on the run detail page; the promised automatic flip from the Skattekonto sync was never implemented, so paid periods stayed red. - settleAgiTaxPayments: during every skattekonto sync, a booked "Arbetsgivardeklaration YYYYMM" debit row settles the matching agi_declarations.tax_paid_at, but only when the amount equals the declared total to the ore and the account is not in deficit (deterministic; drift or deficit falls back to manual). - Salary overview card: reconnect hint when the SKV token needs re-consent (link to /settings/tax, silent when the extension is off), plus an inline "Markera som betald" button reusing the existing endpoint and salary_payments strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add cloud backup scheduling and alerting features - Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due. - Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures. - Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours. - Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats. - Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files. - Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content. - Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup. * fix(stripe): correct invoice clearing reference and improve type safety in sync logic * fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType settleInvoicePayment takes accountingMethod as a raw settings string, but resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union. Normalize at the call site (anything but 'cash' books as accrual), matching the existing useCashEntry semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review findings and nitpicks on PR #1004 Review findings: - backup settings redirect: always force view=export over incoming params - AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the signed-state persist error, guard recovery calls in catch blocks so one company cannot abort the rest; surface grant_revoked in the run summary - kvittens notifications: atomic claim-first dedup with a partial unique index; map non-uuid reference keys to deterministic uuids - grant probe: record the actual 2xx status; mTLS transport: handle response-stream errors - stripe: amount-aware idempotency keys for payment links; emit stripe.disconnected on upstream revocations - ROT/RUT beslut import: mutate in-memory request state after apply, move item + header writes into an atomic apply_rot_rut_beslut RPC, add rot_rut_payout to JournalEntrySourceTypeSchema - migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on journal_entries, notification_log and rot_rut_payout_requests - cloud backup: hour_utc-only schedule updates clear stale hour_local Nitpicks: - stripe sync: enforce the cron time budget inside per-connection event processing with idempotent cursor progress; maybeSingle for settings; honest partial-customer DTO shared with the settlement boundary - shared applyPaymentLinkToInvoice helper for both invoice send routes, v1 docblock documents step 6b and PAYMENT_LINK_FAILED - settings panel: drop redundant decodeURIComponent - cloud backup: document worst-case archive memory headroom Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ee3c33c7a4
commit
98d0c7f2d0
@@ -207,7 +207,9 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
@@ -384,6 +386,9 @@ export const CreateInvoiceSchema = z.object({
|
||||
])
|
||||
.transform((v) => v || undefined)
|
||||
.optional(),
|
||||
// Per-invoice opt-out for the automatic Stripe payment link on send.
|
||||
// Omitted → true (create) / kept as sent by the form (edit).
|
||||
payment_link_auto: z.boolean().optional(),
|
||||
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
|
||||
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
|
||||
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
|
||||
@@ -484,6 +489,34 @@ export const RotRutSettleSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// The beslutsfil JSON downloaded from Skatteverkets rot/rut e-tjänst
|
||||
// (dev_docs/skatteverket/husavdrag/exempel_beslut.json + ht.raml).
|
||||
export const RotRutBeslutFileSchema = z.object({
|
||||
version: z.string(),
|
||||
// Utförarens orgnr, 12 digits with 16-prefix in SKV's file.
|
||||
utforare: z.string().regex(/^\d{10,12}$/),
|
||||
beslut: z
|
||||
.array(
|
||||
z.object({
|
||||
// NamnPaBegaran as submitted (1-16 chars); the primary match key
|
||||
// against rot_rut_payout_requests.name.
|
||||
namn: z.string().min(1),
|
||||
referensnummer: z.string().regex(/^\d{11}(-\d+)?$/),
|
||||
arenden: z
|
||||
.array(
|
||||
z.object({
|
||||
personnummer: z.string().regex(/^\d{12}$/),
|
||||
fakturanummer: z.string().max(20).optional(),
|
||||
// Whole kronor; 0 = avslag for the ärende.
|
||||
godkantBelopp: z.number().int().min(0),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Articles (artikelregister)
|
||||
// ============================================================
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
export const INVOICE_FULL_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
|
||||
export const INVOICE_ITEM_FULL_COLUMNS =
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
|
||||
@@ -445,6 +445,11 @@ export async function createInvoiceJournalEntry(
|
||||
*
|
||||
* Debit 1930 Företagskonto [total]
|
||||
* Credit 1510 Kundfordringar [total]
|
||||
*
|
||||
* `settlementAccountNumber` overrides the debit side for payments that land
|
||||
* somewhere other than the bank account: e.g. '1686' (Fordringar för
|
||||
* kontokort) when a Stripe payment settles into the PSP balance and only
|
||||
* reaches 1930 with the later payout.
|
||||
*/
|
||||
export async function createInvoicePaymentJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
@@ -454,7 +459,8 @@ export async function createInvoicePaymentJournalEntry(
|
||||
paymentDate: string,
|
||||
exchangeRateDifference?: number,
|
||||
customerName?: string,
|
||||
paymentAmount?: number
|
||||
paymentAmount?: number,
|
||||
settlementAccountNumber: string = '1930'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -487,9 +493,9 @@ export async function createInvoicePaymentJournalEntry(
|
||||
// For receivables: positive diff = gain (received more), negative = loss (received less)
|
||||
const actualSekReceived = bookedSekAmount + exchangeRateDifference
|
||||
|
||||
// Debit: Bank at actual SEK received
|
||||
// Debit: settlement account (bank by default) at actual SEK received
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: Math.round(actualSekReceived * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
@@ -525,7 +531,7 @@ export async function createInvoicePaymentJournalEntry(
|
||||
// Standard SEK payment or no exchange rate difference
|
||||
lines.push(
|
||||
{
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: Math.round(bookedSekAmount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
@@ -682,7 +688,8 @@ export async function createInvoiceCashEntry(
|
||||
invoice: Invoice,
|
||||
paymentDate: string,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
customerName?: string
|
||||
customerName?: string,
|
||||
settlementAccountNumber: string = '1930'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -748,7 +755,7 @@ export async function createInvoiceCashEntry(
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const bankAmount = Math.round((cashDebit - rotRut.totalSek) * 100) / 100
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: bankAmount,
|
||||
credit_amount: 0,
|
||||
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { completeTaxDeadline } from '../complete-tax-deadline'
|
||||
|
||||
/**
|
||||
* Chain mock that records every builder call and resolves to the given
|
||||
* result when awaited (the helper ends the chain with .select('id')).
|
||||
*/
|
||||
function makeSupabase(result: { data?: unknown; error?: unknown }) {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['update', 'eq', 'in', 'select']) {
|
||||
chain[method] = vi.fn((...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
|
||||
const from = vi.fn(() => chain)
|
||||
return { supabase: { from } as unknown as SupabaseClient, from, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('completeTaxDeadline', () => {
|
||||
it('completes matching open deadlines and returns the count', async () => {
|
||||
const { supabase, from, calls } = makeSupabase({ data: [{ id: 'd1' }, { id: 'd2' }] })
|
||||
|
||||
const result = await completeTaxDeadline(
|
||||
supabase, 'company-1', ['moms_monthly', 'moms_quarterly'], '2026-06', 'submitted',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ completed: 2 })
|
||||
expect(from).toHaveBeenCalledWith('deadlines')
|
||||
|
||||
const updateCall = calls.find((c) => c.method === 'update')
|
||||
expect(updateCall?.args[0]).toMatchObject({
|
||||
is_completed: true,
|
||||
status: 'submitted',
|
||||
})
|
||||
expect((updateCall?.args[0] as Record<string, unknown>).completed_at).toBeTruthy()
|
||||
|
||||
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqArgs).toContainEqual(['tax_period', '2026-06'])
|
||||
expect(eqArgs).toContainEqual(['is_completed', false])
|
||||
|
||||
const inCall = calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['tax_deadline_type', ['moms_monthly', 'moms_quarterly']])
|
||||
})
|
||||
|
||||
it('quarterly period string matches the generator format', async () => {
|
||||
const { supabase, calls } = makeSupabase({ data: [{ id: 'd1' }] })
|
||||
|
||||
await completeTaxDeadline(supabase, 'company-1', ['moms_quarterly'], '2026-Q2', 'confirmed')
|
||||
|
||||
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqArgs).toContainEqual(['tax_period', '2026-Q2'])
|
||||
const updateCall = calls.find((c) => c.method === 'update')
|
||||
expect(updateCall?.args[0]).toMatchObject({ status: 'confirmed' })
|
||||
})
|
||||
|
||||
it('is a no-op returning 0 when nothing matches', async () => {
|
||||
const { supabase } = makeSupabase({ data: [] })
|
||||
const result = await completeTaxDeadline(
|
||||
supabase, 'company-1', ['arbetsgivardeklaration'], '2026-01', 'submitted',
|
||||
)
|
||||
expect(result).toEqual({ completed: 0 })
|
||||
})
|
||||
|
||||
it('swallows DB errors: logs and returns 0, never throws', async () => {
|
||||
const { supabase } = makeSupabase({ error: { message: 'permission denied' } })
|
||||
await expect(
|
||||
completeTaxDeadline(supabase, 'company-1', ['moms_monthly'], '2026-06', 'submitted'),
|
||||
).resolves.toEqual({ completed: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('complete-tax-deadline')
|
||||
|
||||
/**
|
||||
* Mark a system-generated tax deadline as completed from an external signal
|
||||
* (a declaration was generated, filed, or decided).
|
||||
*
|
||||
* Deadline completion is a courtesy on top of the filing flow, never part of
|
||||
* it: this helper logs failures and returns instead of throwing, so a broken
|
||||
* deadline row can never block a submission to Skatteverket.
|
||||
*
|
||||
* `taxPeriod` must use the deadline generator's format
|
||||
* (lib/tax/deadline-generator.ts): `YYYY-MM` for monthly, `YYYY-QN` for
|
||||
* quarterly, `YYYY` for annual. Build it from the caller's own period params;
|
||||
* do not reverse-parse Skatteverket's redovisningsperiod strings.
|
||||
*
|
||||
* Pass every deadline type that can represent the filing (e.g. both
|
||||
* `moms_monthly` and `moms_quarterly` for a VAT filing): the company's
|
||||
* settings decide which one exists, and the `IN` filter makes the wrong one
|
||||
* a no-op.
|
||||
*/
|
||||
export async function completeTaxDeadline(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
taxDeadlineTypes: string[],
|
||||
taxPeriod: string,
|
||||
newStatus: 'submitted' | 'confirmed'
|
||||
): Promise<{ completed: number }> {
|
||||
const now = new Date().toISOString()
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_at: now,
|
||||
status: newStatus,
|
||||
status_changed_at: now,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.in('tax_deadline_type', taxDeadlineTypes)
|
||||
.eq('tax_period', taxPeriod)
|
||||
.eq('is_completed', false)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
log.warn('Failed to auto-complete tax deadline', {
|
||||
companyId,
|
||||
taxDeadlineTypes,
|
||||
taxPeriod,
|
||||
error: error.message,
|
||||
})
|
||||
return { completed: 0 }
|
||||
}
|
||||
|
||||
return { completed: data?.length ?? 0 }
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export const CAPABILITY = {
|
||||
migration: 'migration',
|
||||
/** Bolagsverket iXBRL årsredovisning filing. Reserved (extension not yet enabled). */
|
||||
bolagsverket: 'bolagsverket',
|
||||
/** Stripe Connect: auto payment links on invoices + payment/payout sync. */
|
||||
stripe_payments: 'stripe_payments',
|
||||
} as const
|
||||
|
||||
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
||||
@@ -52,6 +54,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
CAPABILITY.bank_sync,
|
||||
CAPABILITY.skatteverket,
|
||||
CAPABILITY.email_send,
|
||||
CAPABILITY.stripe_payments,
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -712,6 +712,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Filen kunde inte skapas.',
|
||||
message_en: 'The payout file could not be created.',
|
||||
},
|
||||
ROT_RUT_BESLUT_WRONG_COMPANY: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Beslutsfilens utförare matchar inte företagets organisationsnummer. Kontrollera att filen laddades ner för rätt företag.',
|
||||
message_en:
|
||||
"The decision file's utförare does not match the company's organisation number. Check that the file was downloaded for the right company.",
|
||||
},
|
||||
INVOICE_CREATE_INSERT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte sparas.',
|
||||
|
||||
@@ -73,6 +73,11 @@ export type CoreEvent =
|
||||
userId: string
|
||||
companyId: string
|
||||
} }
|
||||
// Stripe Connect lifecycle: connect/disconnect are outward-facing consent
|
||||
// transitions (a third party gains/loses access to payment data), so they
|
||||
// land in event_log for the audit trail, mirroring bank_connection.*.
|
||||
| { type: 'stripe.connected'; payload: { connectionId: string; stripeAccountId: string; livemode: boolean; userId: string; companyId: string } }
|
||||
| { type: 'stripe.disconnected'; payload: { connectionId: string; stripeAccountId: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import {
|
||||
maybeCreatePaymentLinkForInvoice,
|
||||
applyPaymentLinkToInvoice,
|
||||
} from '@/lib/extensions/payment-links'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
const supabase = {} as SupabaseClient
|
||||
const log = { warn: vi.fn() }
|
||||
|
||||
function eligibleInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
||||
return makeInvoice({
|
||||
payment_link_url: null,
|
||||
document_type: 'invoice',
|
||||
credited_invoice_id: null,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function registerProvider(create: (...args: unknown[]) => Promise<unknown>) {
|
||||
extensionRegistry.register({
|
||||
id: 'fake-psp',
|
||||
name: 'Fake PSP',
|
||||
version: '1.0.0',
|
||||
services: { createInvoicePaymentLink: create as (...args: unknown[]) => Promise<unknown> },
|
||||
})
|
||||
}
|
||||
|
||||
describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
extensionRegistry.unregister('fake-psp')
|
||||
})
|
||||
|
||||
it('returns null when no provider extension is registered', async () => {
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a manually pasted link exists', { payment_link_url: 'https://buy.stripe.com/x' }],
|
||||
['the per-invoice toggle is off', { payment_link_auto: false }],
|
||||
['the document is a proforma', { document_type: 'proforma' as const }],
|
||||
['the document is a credit note', { credited_invoice_id: 'orig-1' }],
|
||||
])('skips the provider when %s', async (_label, overrides) => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(overrides as Partial<Invoice>), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the provider link on success', async () => {
|
||||
const create = vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
registerProvider(create)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', invoice)
|
||||
})
|
||||
|
||||
it('treats a provider null (not eligible) as no link', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue(null))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('never throws: a provider failure becomes { ok: false, reason }', async () => {
|
||||
registerProvider(vi.fn().mockRejectedValue(new Error('stripe down')))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toEqual({ ok: false, reason: 'stripe down' })
|
||||
expect(log.warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPaymentLinkToInvoice', () => {
|
||||
function mockPersist(error: { message: string } | null) {
|
||||
const eqCompany = vi.fn().mockResolvedValue({ error })
|
||||
const eqId = vi.fn().mockReturnValue({ eq: eqCompany })
|
||||
const update = vi.fn().mockReturnValue({ eq: eqId })
|
||||
const from = vi.fn().mockReturnValue({ update })
|
||||
return { client: { from } as unknown as SupabaseClient, from, update, eqId, eqCompany }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
extensionRegistry.unregister('fake-psp')
|
||||
})
|
||||
|
||||
it('is a no-op when no provider is registered', async () => {
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: null })
|
||||
expect(persist.from).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the link, then mirrors it onto the invoice object', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
}))
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: null })
|
||||
expect(persist.update).toHaveBeenCalledWith({
|
||||
payment_link_url: 'https://buy.stripe.com/test_abc',
|
||||
stripe_payment_link_id: 'plink_1',
|
||||
})
|
||||
expect(persist.eqId).toHaveBeenCalledWith('id', invoice.id)
|
||||
expect(persist.eqCompany).toHaveBeenCalledWith('company_id', 'company-1')
|
||||
expect(invoice.payment_link_url).toBe('https://buy.stripe.com/test_abc')
|
||||
expect(invoice.stripe_payment_link_id).toBe('plink_1')
|
||||
})
|
||||
|
||||
it('persist failure returns { failure } and does NOT mutate the invoice', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
}))
|
||||
const persist = mockPersist({ message: 'row is locked' })
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
{ logPrefix: 'invoices.send: ', logContext: { invoiceId: invoice.id } },
|
||||
)
|
||||
expect(result).toEqual({ failure: 'row is locked' })
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
expect(invoice.stripe_payment_link_id).toBeUndefined()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'invoices.send: payment link created but not persisted; sending without it',
|
||||
{ invoiceId: invoice.id, err: { message: 'row is locked' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('provider failure returns { failure: reason } without touching the DB', async () => {
|
||||
registerProvider(vi.fn().mockRejectedValue(new Error('stripe down')))
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: 'stripe down' })
|
||||
expect(persist.from).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the invoiceNumber override to the provider without mutating the invoice', async () => {
|
||||
const create = vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
registerProvider(create)
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice({ invoice_number: null })
|
||||
await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
{ invoiceNumber: 'F-2026-0042' },
|
||||
)
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
persist.client,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ invoice_number: 'F-2026-0042' }),
|
||||
)
|
||||
expect(invoice.invoice_number).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 13 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(13)
|
||||
it('should have 14 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(14)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(13)
|
||||
expect(extensions.length).toBe(14)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
+1
@@ -10,4 +10,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'skatteverket',
|
||||
'invoice-inbox',
|
||||
'document-extraction',
|
||||
'stripe',
|
||||
])
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
||||
import { skatteverketExtension } from '@/extensions/general/skatteverket'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { documentExtractionExtension } from '@/extensions/general/document-extraction'
|
||||
import { stripeExtension } from '@/extensions/general/stripe'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
@@ -20,4 +21,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
skatteverketExtension,
|
||||
invoiceInboxExtension,
|
||||
documentExtractionExtension,
|
||||
stripeExtension,
|
||||
]
|
||||
|
||||
+12
@@ -119,5 +119,17 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"invoice_inbox_items"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "stripe",
|
||||
"name": "Stripe-betalningar",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "CreditCard",
|
||||
"dataPattern": "manual",
|
||||
"description": "Betalningslänkar på fakturor och automatisk avprickning via Stripe",
|
||||
"longDescription": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura. Betalningar prickas av mot rätt faktura och Stripe-utbetalningar bokförs med avgifter och moms.",
|
||||
"hasOwnData": true,
|
||||
"subscriptionNotice": "Denna integration kräver ett eget Stripe-konto. Stripes transaktionsavgifter tillkommer enligt ditt avtal med Stripe."
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* Core-side bridge to an extension-provided invoice payment link service.
|
||||
*
|
||||
* The send routes call this to auto-fill `invoices.payment_link_url` before
|
||||
* the email/PDF render. Core must never import extension code, so the
|
||||
* provider is resolved through the registry: an extension that exposes
|
||||
* `services.createInvoicePaymentLink` (today: the Stripe extension) becomes
|
||||
* the provider. With zero extensions enabled this is a no-op, keeping the
|
||||
* extension-free core build and its tests untouched.
|
||||
*
|
||||
* The provider itself decides eligibility that only it can know (active
|
||||
* connection, capability, sandbox, live/test mode) and returns null when it
|
||||
* declines. This bridge handles the invoice-shaped eligibility that is true
|
||||
* for ANY provider, and never throws: a payment link is a convenience, an
|
||||
* invoice send must not fail because a PSP is down.
|
||||
*/
|
||||
|
||||
export const CREATE_INVOICE_PAYMENT_LINK_SERVICE = 'createInvoicePaymentLink'
|
||||
|
||||
export interface ProvidedPaymentLink {
|
||||
url: string
|
||||
externalId: string
|
||||
}
|
||||
|
||||
export type PaymentLinkOutcome =
|
||||
| { ok: true; url: string; externalId: string }
|
||||
| { ok: false; reason: string }
|
||||
| null
|
||||
|
||||
interface MinimalLog {
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
export interface ApplyPaymentLinkOptions {
|
||||
/**
|
||||
* Invoice number to use for link creation when the caller holds a fresher
|
||||
* value than the invoice object (the v1 route re-reads the number after
|
||||
* atomic allocation). The invoice object itself is not touched.
|
||||
*/
|
||||
invoiceNumber?: string | null
|
||||
/** Route-specific prefix for the persist-failure warning message. */
|
||||
logPrefix?: string
|
||||
/** Extra structured context attached to the persist-failure warning. */
|
||||
logContext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function maybeCreatePaymentLinkForInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
log?: MinimalLog,
|
||||
): Promise<PaymentLinkOutcome> {
|
||||
// Provider-agnostic eligibility. A manually pasted link always wins; only
|
||||
// real, non-credit invoices carry a payment request; the per-invoice
|
||||
// toggle opts out entirely.
|
||||
if (invoice.payment_link_url) return null
|
||||
if (invoice.payment_link_auto === false) return null
|
||||
if (invoice.document_type && invoice.document_type !== 'invoice') return null
|
||||
if (invoice.credited_invoice_id) return null
|
||||
|
||||
const provider = extensionRegistry
|
||||
.getAll()
|
||||
.find((ext) => typeof ext.services?.[CREATE_INVOICE_PAYMENT_LINK_SERVICE] === 'function')
|
||||
if (!provider) return null
|
||||
|
||||
try {
|
||||
const result = (await provider.services) as ProvidedPaymentLink | null
|
||||
if (result && typeof result.url === 'string' && typeof result.externalId === 'string') {
|
||||
return { ok: true, url: result.url, externalId: result.externalId }
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
log?.warn('payment link provider failed; sending without a link', {
|
||||
provider: provider.id,
|
||||
invoiceId: invoice.id,
|
||||
reason,
|
||||
})
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full send-route flow: create a payment link (if a provider extension is
|
||||
* enabled), persist it on the invoices row, and only then mirror it onto the
|
||||
* in-memory invoice object so the email button and PDF QR carry it.
|
||||
*
|
||||
* Shared by the dashboard and v1 send routes. Never throws and never blocks
|
||||
* the send: any failure (provider error or persist error) is returned as
|
||||
* `{ failure }` for the caller to surface as a non-blocking warning. A link
|
||||
* that was created but could not be persisted is deliberately NOT copied onto
|
||||
* the invoice: a link rendered on the PDF but missing from the DB could never
|
||||
* be matched back to this invoice when the payment event arrives.
|
||||
*/
|
||||
export async function applyPaymentLinkToInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
log: MinimalLog,
|
||||
opts: ApplyPaymentLinkOptions = {},
|
||||
): Promise<{ failure: string | null }> {
|
||||
const linkInvoice =
|
||||
opts.invoiceNumber !== undefined
|
||||
? { ...invoice, invoice_number: opts.invoiceNumber }
|
||||
: invoice
|
||||
|
||||
const outcome = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
linkInvoice,
|
||||
log,
|
||||
)
|
||||
if (!outcome) return { failure: null }
|
||||
if (!outcome.ok) return { failure: outcome.reason }
|
||||
|
||||
const { error: persistError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
payment_link_url: outcome.url,
|
||||
stripe_payment_link_id: outcome.externalId,
|
||||
})
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId)
|
||||
if (persistError) {
|
||||
log.warn(
|
||||
`${opts.logPrefix ?? ''}payment link created but not persisted; sending without it`,
|
||||
opts.logContext ? { ...opts.logContext, err: persistError } : persistError,
|
||||
)
|
||||
return { failure: persistError.message }
|
||||
}
|
||||
|
||||
invoice.payment_link_url = outcome.url
|
||||
invoice.stripe_payment_link_id = outcome.externalId
|
||||
return { failure: null }
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
|
||||
'cloud-backup': dynamic(
|
||||
() => import('@/extensions/general/cloud-backup/components/CloudBackupCard')
|
||||
),
|
||||
stripe: dynamic(
|
||||
() => import('@/extensions/general/stripe/components/StripeSettingsPanel')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { importRotRutBeslutFile, type RotRutBeslutFile } from '../rot-rut-beslut-import'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const db = supabase as unknown as SupabaseClient
|
||||
|
||||
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
|
||||
// Skatteverket official example personnummer (synthetic).
|
||||
const PNR = '193610058590'
|
||||
const PNR2 = '193204029064'
|
||||
|
||||
const ORG_SETTINGS = { data: { org_number: '878000-3656' } }
|
||||
|
||||
function makeRequestRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: REQUEST_ID,
|
||||
name: 'ROT 2026-07-02',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
decided_at: null,
|
||||
skv_referensnummer: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeItemRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
requested_amount: 3000,
|
||||
invoice: {
|
||||
invoice_number: '96458',
|
||||
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeFile(overrides: Partial<RotRutBeslutFile> = {}): RotRutBeslutFile {
|
||||
return {
|
||||
version: '1',
|
||||
utforare: '168780003656',
|
||||
beslut: [
|
||||
{
|
||||
namn: 'ROT 2026-07-02',
|
||||
referensnummer: '20260000185-01',
|
||||
arenden: [{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 2000 }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
describe('importRotRutBeslutFile', () => {
|
||||
it('rejects a file whose utforare is another company', async () => {
|
||||
enqueue({ data: { org_number: '556123-4567' } })
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'ROT_RUT_BESLUT_WRONG_COMPANY' })
|
||||
})
|
||||
|
||||
it('records the beslut on a name-matched request (fakturanummer match)', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
enqueue({ data: null }) // apply_rot_rut_beslut rpc
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
request_id: REQUEST_ID,
|
||||
decided_total: 2000,
|
||||
items_updated: 1,
|
||||
rejected: false,
|
||||
})
|
||||
expect(result.results[0].next).toContain(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`)
|
||||
// All writes go through the atomic RPC: items + header in one transaction.
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('apply_rot_rut_beslut', {
|
||||
p_request_id: REQUEST_ID,
|
||||
p_items: [{ item_id: 'item-1', decided_amount: 2000 }],
|
||||
p_decided_total: 2000,
|
||||
p_skv_referensnummer: '20260000185-01',
|
||||
p_new_status: 'submitted',
|
||||
})
|
||||
})
|
||||
|
||||
it('matches by personnummer when fakturanummer is absent', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
enqueue({ data: null }) // apply_rot_rut_beslut rpc
|
||||
|
||||
const file = makeFile()
|
||||
file.beslut[0].arenden = [{ personnummer: PNR, godkantBelopp: 1500 }]
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.results[0]).toMatchObject({ status: 'imported', decided_total: 1500 })
|
||||
})
|
||||
|
||||
it('flags a 0-kr beslut as rejected (avslag)', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
enqueue({ data: null }) // apply_rot_rut_beslut rpc
|
||||
|
||||
const file = makeFile()
|
||||
file.beslut[0].arenden = [{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 0 }]
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
decided_total: 0,
|
||||
rejected: true,
|
||||
})
|
||||
expect(result.results[0].next).toBeUndefined()
|
||||
expect(supabase.rpc).toHaveBeenCalledWith(
|
||||
'apply_rot_rut_beslut',
|
||||
expect.objectContaining({ p_new_status: 'rejected' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent: a request with the same referensnummer already decided reports already_imported', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({
|
||||
data: [
|
||||
makeRequestRow({
|
||||
skv_referensnummer: '20260000185-01',
|
||||
decided_at: '2026-07-10T00:00:00Z',
|
||||
decided_total: 2000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.already_imported).toBe(1)
|
||||
expect(result.results[0]).toMatchObject({ status: 'already_imported', request_id: REQUEST_ID })
|
||||
})
|
||||
|
||||
it('errors on ambiguous names instead of guessing', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow(), makeRequestRow({ id: 'other-request' })] })
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.results[0].status).toBe('error')
|
||||
expect(result.results[0].error).toContain('entydigt')
|
||||
})
|
||||
|
||||
it('errors when no active request carries the name', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow({ name: 'Annat namn' })] })
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.results[0]).toMatchObject({ status: 'error' })
|
||||
expect(result.results[0].error).toContain('Ingen aktiv begäran')
|
||||
})
|
||||
|
||||
it('applies nothing when an ärende matches no item (all-or-nothing)', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
|
||||
const file = makeFile()
|
||||
file.beslut[0].arenden = [
|
||||
{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 2000 },
|
||||
// Second ärende matches nothing in the single-item request.
|
||||
{ personnummer: PNR2, fakturanummer: '9265412', godkantBelopp: 2000 },
|
||||
]
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.results[0].status).toBe('error')
|
||||
// Only the three reads happened: settings, requests, items. No writes.
|
||||
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps an RPC failure to an error outcome (nothing partially applied)', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
enqueue({ error: { message: 'apply_rot_rut_beslut: item item-1 not found on request' } })
|
||||
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', makeFile())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.results[0]).toMatchObject({
|
||||
status: 'error',
|
||||
request_id: REQUEST_ID,
|
||||
error: expect.stringContaining('not found'),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not re-decide a request when a later beslut shares the name but not the referensnummer', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] }) // items for the first beslut
|
||||
enqueue({ data: null }) // apply_rot_rut_beslut rpc for the first beslut
|
||||
|
||||
const file = makeFile()
|
||||
file.beslut.push({
|
||||
namn: 'ROT 2026-07-02',
|
||||
referensnummer: '20260000186-01',
|
||||
arenden: [{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 3000 }],
|
||||
})
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.results[0]).toMatchObject({ status: 'imported', decided_total: 2000 })
|
||||
// The second beslut must NOT silently overwrite the first decision: the
|
||||
// in-memory request is now decided, so name matching finds nothing.
|
||||
expect(result.results[1]).toMatchObject({ status: 'error' })
|
||||
expect(result.results[1].error).toContain('Ingen aktiv begäran')
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports already_imported when the same referensnummer repeats within one file', async () => {
|
||||
enqueue(ORG_SETTINGS)
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
enqueue({ data: [makeItemRow()] })
|
||||
enqueue({ data: null }) // apply_rot_rut_beslut rpc for the first beslut
|
||||
|
||||
const file = makeFile()
|
||||
file.beslut.push({ ...file.beslut[0] })
|
||||
const result = await importRotRutBeslutFile(db, 'company-1', file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.already_imported).toBe(1)
|
||||
expect(result.results[1]).toMatchObject({
|
||||
status: 'already_imported',
|
||||
request_id: REQUEST_ID,
|
||||
})
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoicePaymentJournalEntry: vi.fn(),
|
||||
createInvoiceCashEntry: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
findFiscalPeriod: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
cancelOrphanedPaymentEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
function payableInvoice(overrides: Partial<Invoice> = {}) {
|
||||
return {
|
||||
...makeInvoice({ id: 'inv-1', status: 'sent', total: 1250, currency: 'SEK' }),
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
customer: { name: 'Kund AB' },
|
||||
...overrides,
|
||||
} as Invoice & { customer?: { name?: string | null } | null }
|
||||
}
|
||||
|
||||
const BASE_PARAMS = {
|
||||
paymentAmountInInvoiceCurrency: 1250,
|
||||
paymentDate: '2026-07-12',
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'aktiebolag' as const,
|
||||
}
|
||||
|
||||
describe('settleInvoicePayment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.mocked(createInvoicePaymentJournalEntry).mockResolvedValue({ id: 'je-1' } as never)
|
||||
vi.mocked(createInvoiceCashEntry).mockResolvedValue({ id: 'je-2' } as never)
|
||||
})
|
||||
|
||||
it('books via the payment entry and forwards the settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
const invoice = payableInvoice({ journal_entry_id: 'je-orig' } as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ ...BASE_PARAMS, invoice, settlementAccountNumber: '1686' },
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, newStatus: 'paid', journalEntryId: 'je-1' })
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
invoice,
|
||||
'2026-07-12',
|
||||
undefined,
|
||||
'Kund AB',
|
||||
undefined,
|
||||
'1686',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the cash entry for unbooked kontantmetoden invoices', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] })
|
||||
|
||||
const invoice = payableInvoice({ journal_entry_id: null } as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ ...BASE_PARAMS, invoice, accountingMethod: 'cash', settlementAccountNumber: '1686' },
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(vi.mocked(createInvoiceCashEntry)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
invoice,
|
||||
'2026-07-12',
|
||||
'aktiebolag',
|
||||
'Kund AB',
|
||||
'1686',
|
||||
)
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects overpayment before creating any journal entry', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ ...BASE_PARAMS, invoice: payableInvoice(), paymentAmountInInvoiceCurrency: 9999 },
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when no journal entry is produced', async () => {
|
||||
vi.mocked(createInvoicePaymentJournalEntry).mockResolvedValue(null)
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ ...BASE_PARAMS, invoice: payableInvoice() },
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, code: 'INVOICE_PAID_BOOK_FAILED' })
|
||||
})
|
||||
|
||||
it('cancels the orphaned voucher when the CAS update loses the race', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // CAS update matched nothing (concurrent settle)
|
||||
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{ ...BASE_PARAMS, invoice: payableInvoice() },
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: 'INVOICE_PAID_RACE' })
|
||||
expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits invoice.paid with the settled state', async () => {
|
||||
const handler = vi.fn()
|
||||
eventBus.on('invoice.paid', handler)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] })
|
||||
|
||||
await settleInvoicePayment(supabase as unknown as SupabaseClient, 'company-1', 'user-1', {
|
||||
...BASE_PARAMS,
|
||||
invoice: payableInvoice(),
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: 'company-1',
|
||||
paymentAmount: 1250,
|
||||
invoice: expect.objectContaining({ id: 'inv-1', status: 'paid' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -70,6 +70,8 @@ export interface InvoiceWriteInput {
|
||||
notes?: string
|
||||
/** Optional https payment link (schema-validated). Omitted/empty → null. */
|
||||
payment_link_url?: string
|
||||
/** Per-invoice opt-out for the automatic Stripe payment link. Omitted → true. */
|
||||
payment_link_auto?: boolean
|
||||
/** Per-invoice öresavrundning override (display-only). Omitted → null (inherit company setting). */
|
||||
ore_rounding?: boolean
|
||||
deduction_personnummer?: string
|
||||
@@ -108,6 +110,7 @@ export type InvoiceWriteFields = {
|
||||
our_reference: string | null | undefined
|
||||
notes: string | null | undefined
|
||||
payment_link_url: string | null
|
||||
payment_link_auto: boolean
|
||||
ore_rounding: boolean | null
|
||||
document_type: InvoiceDocumentType
|
||||
deduction_total: number
|
||||
@@ -371,6 +374,9 @@ export async function buildInvoiceWriteData(params: {
|
||||
// Always a concrete value (never undefined) so a draft edit that cleared
|
||||
// the field actually NULLs the column: supabase-js drops undefined keys.
|
||||
payment_link_url: input.payment_link_url?.trim() || null,
|
||||
// Automation opt-out for the Stripe payment link; default on. The form
|
||||
// always sends the field, so a draft edit that unticked it persists false.
|
||||
payment_link_auto: input.payment_link_auto ?? true,
|
||||
// Display-only öresavrundning override; null inherits company_settings.ore_rounding.
|
||||
ore_rounding: input.ore_rounding ?? null,
|
||||
document_type: documentType,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { z } from 'zod'
|
||||
import type { RotRutBeslutFileSchema } from '@/lib/api/schemas'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
/**
|
||||
* Import of Skatteverkets beslutsfil for rot/rut (the decision JSON the user
|
||||
* downloads from the e-tjänst after SKV has processed a begäran om
|
||||
* utbetalning: dev_docs/skatteverket/husavdrag/exempel_beslut.json).
|
||||
*
|
||||
* Records godkänt belopp per ärende on the matching payout request so the
|
||||
* existing settle flow can book the actual utbetalning (including partial
|
||||
* approvals) without the user re-typing SKV's numbers.
|
||||
*
|
||||
* Matching is deterministic, never fuzzy (core principle: act only on exact
|
||||
* matches):
|
||||
* - request: by previously stored skv_referensnummer (idempotent
|
||||
* re-import), else by exact name (NamnPaBegaran round-trips through
|
||||
* SKV's file unchanged). Ambiguity is an error, not a guess.
|
||||
* - ärende -> item: by fakturanummer against the invoice number, else by
|
||||
* the buyer's personnummer. Exactly one candidate or the beslut errors.
|
||||
*
|
||||
* A beslut applies all-or-nothing: if any ärende fails to match, nothing in
|
||||
* that beslut is written. Different beslut in the same file are independent.
|
||||
*
|
||||
* Settlement is deliberately NOT booked here: recording the beslut and
|
||||
* booking the bank payout are separate acts (the payout may not have landed
|
||||
* yet). The result carries a `next` hint pointing at the settle endpoint.
|
||||
*/
|
||||
|
||||
export type RotRutBeslutFile = z.infer<typeof RotRutBeslutFileSchema>
|
||||
|
||||
export interface RotRutBeslutOutcome {
|
||||
namn: string
|
||||
referensnummer: string
|
||||
status: 'imported' | 'already_imported' | 'error'
|
||||
request_id?: string
|
||||
decided_total?: number
|
||||
items_updated?: number
|
||||
/** True when SKV approved 0 kr for every ärende (avslag). */
|
||||
rejected?: boolean
|
||||
error?: string
|
||||
next?: string
|
||||
}
|
||||
|
||||
export type RotRutBeslutImportResult =
|
||||
| {
|
||||
ok: true
|
||||
results: RotRutBeslutOutcome[]
|
||||
imported: number
|
||||
already_imported: number
|
||||
errors: number
|
||||
}
|
||||
| { ok: false; code: 'ROT_RUT_BESLUT_WRONG_COMPANY' | 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
|
||||
interface RequestRow {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
requested_total: number
|
||||
decided_total: number | null
|
||||
decided_at: string | null
|
||||
skv_referensnummer: string | null
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
id: string
|
||||
invoice_id: string
|
||||
requested_amount: number
|
||||
invoice: {
|
||||
invoice_number: string | number | null
|
||||
deduction_personnummer_encrypted: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Normalize an org number to SKV's 12-digit form (16-prefixed). */
|
||||
function normalizeOrgNumber(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
return digits.length === 10 ? `16${digits}` : digits
|
||||
}
|
||||
|
||||
const SETTLE_HINT = 'POST /api/rot-rut/payout-requests/{request_id}/settle bokför utbetalningen när den landat på banken.'
|
||||
|
||||
export async function importRotRutBeslutFile(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
file: RotRutBeslutFile
|
||||
): Promise<RotRutBeslutImportResult> {
|
||||
// Hard company check: a beslutsfil for another utförare must never touch
|
||||
// this company's requests.
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (settingsError) return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
|
||||
const orgNumber = (settings?.org_number as string | null) ?? null
|
||||
if (!orgNumber || normalizeOrgNumber(orgNumber) !== normalizeOrgNumber(file.utforare)) {
|
||||
return { ok: false, code: 'ROT_RUT_BESLUT_WRONG_COMPANY' }
|
||||
}
|
||||
|
||||
const { data: requests, error: requestsError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('id, name, status, requested_total, decided_total, decided_at, skv_referensnummer')
|
||||
.eq('company_id', companyId)
|
||||
if (requestsError) return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
|
||||
const allRequests = (requests ?? []) as RequestRow[]
|
||||
const results: RotRutBeslutOutcome[] = []
|
||||
|
||||
for (const beslut of file.beslut) {
|
||||
results.push(await applyBeslut(supabase, allRequests, beslut))
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
results,
|
||||
imported: results.filter((r) => r.status === 'imported').length,
|
||||
already_imported: results.filter((r) => r.status === 'already_imported').length,
|
||||
errors: results.filter((r) => r.status === 'error').length,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBeslut(
|
||||
supabase: SupabaseClient,
|
||||
allRequests: RequestRow[],
|
||||
beslut: RotRutBeslutFile['beslut'][number]
|
||||
): Promise<RotRutBeslutOutcome> {
|
||||
const base = { namn: beslut.namn, referensnummer: beslut.referensnummer }
|
||||
|
||||
// 1. Resolve the request. Referensnummer wins (set by a previous import of
|
||||
// the same beslut); name matching only considers undecided, active
|
||||
// requests without a stored referensnummer.
|
||||
const byRef = allRequests.filter((r) => r.skv_referensnummer === beslut.referensnummer)
|
||||
let request: RequestRow | null = byRef[0] ?? null
|
||||
|
||||
if (request?.decided_at) {
|
||||
return { ...base, status: 'already_imported', request_id: request.id }
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
const byName = allRequests.filter(
|
||||
(r) =>
|
||||
r.name === beslut.namn &&
|
||||
r.skv_referensnummer === null &&
|
||||
['generated', 'submitted'].includes(r.status)
|
||||
)
|
||||
if (byName.length === 0) {
|
||||
return {
|
||||
...base,
|
||||
status: 'error',
|
||||
error: `Ingen aktiv begäran med namnet "${beslut.namn}" hittades.`,
|
||||
}
|
||||
}
|
||||
if (byName.length > 1) {
|
||||
return {
|
||||
...base,
|
||||
status: 'error',
|
||||
error: `Flera begäran har namnet "${beslut.namn}": beslutet kan inte matchas entydigt.`,
|
||||
}
|
||||
}
|
||||
request = byName[0]
|
||||
if (request.decided_at) {
|
||||
return { ...base, status: 'already_imported', request_id: request.id }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load the request's items with the invoice keys used for matching.
|
||||
const { data: itemRows, error: itemsError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select(
|
||||
'id, invoice_id, requested_amount, invoice:invoices(invoice_number, deduction_personnummer_encrypted)'
|
||||
)
|
||||
.eq('request_id', request.id)
|
||||
if (itemsError) {
|
||||
return { ...base, status: 'error', request_id: request.id, error: itemsError.message }
|
||||
}
|
||||
|
||||
const items = ((itemRows ?? []) as unknown as ItemRow[]).map((item) => {
|
||||
let personnummer: string | null = null
|
||||
if (item.invoice?.deduction_personnummer_encrypted) {
|
||||
try {
|
||||
personnummer = decryptPersonnummer(item.invoice.deduction_personnummer_encrypted).replace(/\D/g, '')
|
||||
} catch {
|
||||
personnummer = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
invoiceNumber: item.invoice?.invoice_number != null ? String(item.invoice.invoice_number) : null,
|
||||
personnummer,
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Match every ärende to exactly one item (all-or-nothing per beslut).
|
||||
const consumed = new Set<string>()
|
||||
const matches: Array<{ itemId: string; godkantBelopp: number }> = []
|
||||
for (const arende of beslut.arenden) {
|
||||
const available = items.filter((i) => !consumed.has(i.id))
|
||||
let candidates = arende.fakturanummer
|
||||
? available.filter((i) => i.invoiceNumber === arende.fakturanummer)
|
||||
: []
|
||||
if (candidates.length !== 1) {
|
||||
candidates = available.filter((i) => i.personnummer === arende.personnummer)
|
||||
}
|
||||
if (candidates.length !== 1) {
|
||||
return {
|
||||
...base,
|
||||
status: 'error',
|
||||
request_id: request.id,
|
||||
error:
|
||||
`Ärendet (personnummer ...${arende.personnummer.slice(-4)}` +
|
||||
`${arende.fakturanummer ? `, faktura ${arende.fakturanummer}` : ''}) ` +
|
||||
`matchar ${candidates.length === 0 ? 'ingen' : 'flera'} fakturor i begäran. Inget har importerats.`,
|
||||
}
|
||||
}
|
||||
consumed.add(candidates[0].id)
|
||||
matches.push({ itemId: candidates[0].id, godkantBelopp: arende.godkantBelopp })
|
||||
}
|
||||
|
||||
// 4. Apply atomically: per-item decided amounts plus the request header go
|
||||
// through one RPC (apply_rot_rut_beslut) so a mid-sequence failure can
|
||||
// never leave some items decided but the header untouched (or vice
|
||||
// versa). The function raises on any missing row, rolling back the
|
||||
// whole beslut.
|
||||
const decidedTotal = matches.reduce((sum, m) => sum + m.godkantBelopp, 0)
|
||||
const rejected = decidedTotal === 0
|
||||
// A beslut proves the file was submitted; 0 kr approved on every ärende is
|
||||
// an avslag. Settle (paid/partially_paid) stays a separate, explicit act.
|
||||
const newStatus = rejected
|
||||
? 'rejected'
|
||||
: request.status === 'generated'
|
||||
? 'submitted'
|
||||
: request.status
|
||||
const { error: applyError } = await supabase.rpc('apply_rot_rut_beslut', {
|
||||
p_request_id: request.id,
|
||||
p_items: matches.map((m) => ({ item_id: m.itemId, decided_amount: m.godkantBelopp })),
|
||||
p_decided_total: decidedTotal,
|
||||
p_skv_referensnummer: beslut.referensnummer,
|
||||
p_new_status: newStatus,
|
||||
})
|
||||
if (applyError) {
|
||||
return { ...base, status: 'error', request_id: request.id, error: applyError.message }
|
||||
}
|
||||
|
||||
// Keep the shared in-memory request list in sync with what was just
|
||||
// written: a later beslut in the same file (same name, different
|
||||
// referensnummer) must see this request as decided instead of re-applying
|
||||
// a second decision on top of the first.
|
||||
request.skv_referensnummer = beslut.referensnummer
|
||||
request.decided_at = new Date().toISOString()
|
||||
request.decided_total = decidedTotal
|
||||
request.status = newStatus
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'imported',
|
||||
request_id: request.id,
|
||||
decided_total: decidedTotal,
|
||||
items_updated: matches.length,
|
||||
rejected,
|
||||
next: rejected ? undefined : SETTLE_HINT.replace('{request_id}', request.id),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* The core "apply a payment to an invoice" operation, extracted from the
|
||||
* mark-paid route so the Stripe payment sync (and any future automated payment
|
||||
* channel) shares the exact same booking, status transition, orphan handling
|
||||
* and event emission as the manual flow:
|
||||
*
|
||||
* 1. planInvoicePayment: ledger math + overpayment guard
|
||||
* 2. journal entry: custom lines | cash entry (kontantmetoden, unbooked) |
|
||||
* payment entry (clears 1510), fail-closed for real invoices
|
||||
* 3. CAS-guarded invoice status update; a lost race or failed update cancels
|
||||
* the just-posted voucher so GL and sub-ledger never diverge
|
||||
* 4. invoice.paid event (best-effort)
|
||||
*
|
||||
* `settlementAccountNumber` routes the debit side: default 1930 (bank), 1686
|
||||
* for PSP-balance settlements (Stripe) where the money reaches the bank only
|
||||
* with the later payout.
|
||||
*
|
||||
* The function performs the write path only. Caller-owned concerns stay in
|
||||
* the callers: fetching the invoice, payable-status guards, request parsing,
|
||||
* the duplicate-payment guard (a UX advisory: the Stripe sync skips it
|
||||
* because the payment event IS the authoritative payment), and mapping the
|
||||
* result to a transport-specific response.
|
||||
*/
|
||||
|
||||
export interface SettleCustomLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice shape at the settlement boundary. Callers typically join only the
|
||||
* customer's name (`customer:customers(name)`), so the relation is modelled
|
||||
* as exactly that: reading any other Customer field here would be undefined
|
||||
* at runtime. A fully joined Customer still satisfies this structurally.
|
||||
*/
|
||||
export type InvoiceWithCustomerName = Omit<Invoice, 'customer'> & {
|
||||
customer?: Pick<Customer, 'name'> | null
|
||||
}
|
||||
|
||||
export interface SettleInvoicePaymentParams {
|
||||
invoice: InvoiceWithCustomerName
|
||||
/** Payment amount in the INVOICE currency (caller converts if needed). */
|
||||
paymentAmountInInvoiceCurrency: number
|
||||
/** Booking date (YYYY-MM-DD). */
|
||||
paymentDate: string
|
||||
accountingMethod: string
|
||||
entityType: EntityType
|
||||
/** FX difference in SEK (manual flow only). */
|
||||
exchangeRateDifference?: number
|
||||
/** Caller-supplied booking lines (manual dialog only); must balance. */
|
||||
customLines?: SettleCustomLine[]
|
||||
/** Debit-side account; default '1930'. Stripe settlements pass '1686'. */
|
||||
settlementAccountNumber?: string
|
||||
}
|
||||
|
||||
export type SettleInvoicePaymentResult =
|
||||
| {
|
||||
ok: true
|
||||
newStatus: 'paid' | 'partially_paid'
|
||||
newPaidAmount: number
|
||||
newRemaining: number
|
||||
journalEntryId: string | null
|
||||
paidAt: string | null
|
||||
}
|
||||
| { ok: false; code: 'MATCH_AMOUNT_EXCEEDS_REMAINING'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_LINES_UNBALANCED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_NO_FISCAL_PERIOD'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_BOOK_FAILED'; details: Record<string, unknown> }
|
||||
| { ok: false; code: 'INVOICE_PAID_RACE' }
|
||||
| { ok: false; code: 'BOOKKEEPING_ERROR'; error: unknown }
|
||||
| { ok: false; code: 'UPDATE_FAILED'; error: unknown }
|
||||
|
||||
export async function settleInvoicePayment(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
params: SettleInvoicePaymentParams,
|
||||
): Promise<SettleInvoicePaymentResult> {
|
||||
const {
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
accountingMethod,
|
||||
entityType,
|
||||
exchangeRateDifference,
|
||||
customLines,
|
||||
settlementAccountNumber,
|
||||
} = params
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
// the current accounting_method setting. If the invoice was booked at
|
||||
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510:
|
||||
// otherwise the receivable orphans and 30xx + VAT double-count. Only
|
||||
// when there is no prior JE (pure kontantmetoden) do we recognise
|
||||
// revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null })
|
||||
.journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Ledger math + overpayment guard. Runs BEFORE any journal entry is
|
||||
// created so a doomed overpayment never burns a voucher number.
|
||||
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
|
||||
if (!payment.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING',
|
||||
details: payment.details as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
const { newPaidAmount, newRemaining, newStatus } = payment.plan
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_LINES_UNBALANCED',
|
||||
details: { totalDebit, totalCredit },
|
||||
}
|
||||
}
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_NO_FISCAL_PERIOD',
|
||||
details: { paymentDate },
|
||||
}
|
||||
}
|
||||
const sourceType = resolveInvoicePaymentSourceType({
|
||||
invoiceAlreadyBooked,
|
||||
// Settings store a raw string; anything but 'cash' books as accrual,
|
||||
// matching the useCashEntry check above.
|
||||
accountingMethod: accountingMethod === 'cash' ? 'cash' : 'accrual',
|
||||
})
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
}
|
||||
const journalEntry = await createJournalEntry(supabase, companyId, userId, input)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
// The entry helpers never read invoice.customer (the display name is
|
||||
// passed explicitly), so the partial customer relation is safe here.
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType,
|
||||
invoice.customer?.name ?? undefined,
|
||||
settlementAccountNumber,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
exchangeRateDifference,
|
||||
invoice.customer?.name ?? undefined,
|
||||
undefined,
|
||||
settlementAccountNumber,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return { ok: false, code: 'BOOKKEEPING_ERROR', error: err }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_BOOK_FAILED',
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed: a real invoice must produce a payment voucher. If a helper
|
||||
// returned null without throwing (e.g. a closed/locked fiscal period),
|
||||
// refuse to mark the invoice paid: flipping status with no journal entry
|
||||
// orphans the receivable and diverges the GL from the sub-ledger.
|
||||
if (!journalEntryId) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_PAID_BOOK_FAILED',
|
||||
details: { reason: 'no_journal_entry_created' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CAS guard: only update if status is still in a payable state.
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
...(newStatus === 'paid' ? { paid_at: now } : {}),
|
||||
})
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
// The payment voucher already posted but the invoice row did not flip to
|
||||
// paid; cancel the orphan so the GL doesn't diverge from the sub-ledger.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning',
|
||||
)
|
||||
}
|
||||
return { ok: false, code: 'UPDATE_FAILED', error: updateError }
|
||||
}
|
||||
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
// Status changed between read and write (concurrent settle): cancel the
|
||||
// orphaned payment voucher; the trigger documents the voucher gap.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
}
|
||||
return { ok: false, code: 'INVOICE_PAID_RACE' }
|
||||
}
|
||||
|
||||
// Notify subscribers: invoice.paid fans out to registered webhooks and the
|
||||
// Stripe extension's link-deactivation handler. Best-effort: the payment is
|
||||
// already committed, so an emit failure must not fail the operation.
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.paid',
|
||||
payload: {
|
||||
invoice: {
|
||||
...invoice,
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: newStatus === 'paid' ? now : invoice.paid_at,
|
||||
} as Invoice,
|
||||
companyId,
|
||||
userId,
|
||||
paymentAmount: paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Swallowed by design; the DB state is the source of truth.
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
newStatus,
|
||||
newPaidAmount,
|
||||
newRemaining,
|
||||
journalEntryId,
|
||||
paidAt: newStatus === 'paid' ? now : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
trialBalanceToCsv,
|
||||
incomeStatementToCsv,
|
||||
balanceSheetToCsv,
|
||||
generalLedgerToCsv,
|
||||
} from '../archive-csv'
|
||||
import type { IncomeStatementReport, BalanceSheetReport } from '@/types'
|
||||
import type { GeneralLedgerReport } from '../general-ledger'
|
||||
|
||||
describe('trialBalanceToCsv', () => {
|
||||
const report = {
|
||||
rows: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
opening_debit: 1000,
|
||||
opening_credit: 0,
|
||||
period_debit: 500.5,
|
||||
period_credit: 200,
|
||||
closing_debit: 1300.5,
|
||||
closing_credit: 0,
|
||||
},
|
||||
],
|
||||
totalDebit: 500.5,
|
||||
totalCredit: 200,
|
||||
isBalanced: false,
|
||||
}
|
||||
|
||||
it('starts with a UTF-8 BOM and uses semicolons + decimal commas', () => {
|
||||
const csv = trialBalanceToCsv(report)
|
||||
expect(csv.charCodeAt(0)).toBe(0xfeff)
|
||||
const lines = csv.slice(1).split('\r\n')
|
||||
expect(lines[0]).toBe(
|
||||
'Konto;Benämning;IB debet;IB kredit;Period debet;Period kredit;UB debet;UB kredit'
|
||||
)
|
||||
expect(lines[1]).toBe('1930;Företagskonto;1000,00;0,00;500,50;200,00;1300,50;0,00')
|
||||
expect(lines[2]).toContain('Summa')
|
||||
expect(lines[2]).toContain('500,50;200,00')
|
||||
})
|
||||
|
||||
it('quotes fields containing separators or quotes', () => {
|
||||
const csv = trialBalanceToCsv({
|
||||
...report,
|
||||
rows: [
|
||||
{
|
||||
...report.rows[0],
|
||||
account_name: 'Kassa; "extra" konto',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(csv).toContain('"Kassa; ""extra"" konto"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('incomeStatementToCsv', () => {
|
||||
it('renders sections with subtotals and the net result', () => {
|
||||
const report: IncomeStatementReport = {
|
||||
revenue_sections: [
|
||||
{
|
||||
title: 'Nettoomsättning',
|
||||
rows: [{ account_number: '3001', account_name: 'Försäljning 25%', amount: 100 }],
|
||||
subtotal: 100,
|
||||
},
|
||||
],
|
||||
total_revenue: 100,
|
||||
expense_sections: [
|
||||
{
|
||||
title: 'Övriga externa kostnader',
|
||||
rows: [{ account_number: '6110', account_name: 'Kontorsmateriel', amount: 40 }],
|
||||
subtotal: 40,
|
||||
},
|
||||
],
|
||||
total_expenses: 40,
|
||||
financial_sections: [],
|
||||
total_financial: 0,
|
||||
net_result: 60,
|
||||
period: { start: '2024-01-01', end: '2024-12-31' },
|
||||
}
|
||||
const csv = incomeStatementToCsv(report)
|
||||
expect(csv).toContain('Nettoomsättning;3001;Försäljning 25%;100,00')
|
||||
expect(csv).toContain('Summa nettoomsättning;;;100,00')
|
||||
expect(csv).toContain('Summa intäkter;;;100,00')
|
||||
expect(csv).toContain('Summa kostnader;;;40,00')
|
||||
expect(csv).toContain('Årets resultat;;;60,00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('balanceSheetToCsv', () => {
|
||||
it('renders assets and equity/liabilities with totals', () => {
|
||||
const report: BalanceSheetReport = {
|
||||
asset_sections: [
|
||||
{
|
||||
title: 'Kassa och bank',
|
||||
rows: [{ account_number: '1930', account_name: 'Företagskonto', amount: 5000 }],
|
||||
subtotal: 5000,
|
||||
},
|
||||
],
|
||||
total_assets: 5000,
|
||||
equity_liability_sections: [
|
||||
{
|
||||
title: 'Eget kapital',
|
||||
rows: [{ account_number: '2010', account_name: 'Eget kapital', amount: 5000 }],
|
||||
subtotal: 5000,
|
||||
},
|
||||
],
|
||||
total_equity_liabilities: 5000,
|
||||
period: { start: '2024-01-01', end: '2024-12-31' },
|
||||
}
|
||||
const csv = balanceSheetToCsv(report)
|
||||
expect(csv).toContain('Kassa och bank;1930;Företagskonto;5000,00')
|
||||
expect(csv).toContain('Summa tillgångar;;;5000,00')
|
||||
expect(csv).toContain('Summa eget kapital och skulder;;;5000,00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generalLedgerToCsv', () => {
|
||||
it('renders opening balance, lines with voucher refs, and closing balance', () => {
|
||||
const report: GeneralLedgerReport = {
|
||||
accounts: [
|
||||
{
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
opening_balance: 1000,
|
||||
lines: [
|
||||
{
|
||||
date: '2024-03-15',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 17,
|
||||
journal_entry_id: 'e-1',
|
||||
description: 'Kundbetalning',
|
||||
source_type: 'manual',
|
||||
debit: 500,
|
||||
credit: 0,
|
||||
balance: 1500,
|
||||
},
|
||||
],
|
||||
closing_balance: 1500,
|
||||
total_debit: 500,
|
||||
total_credit: 0,
|
||||
},
|
||||
],
|
||||
period: { start: '2024-01-01', end: '2024-12-31' },
|
||||
}
|
||||
const csv = generalLedgerToCsv(report)
|
||||
expect(csv).toContain('1930;Företagskonto;;;Ingående balans;;;1000,00')
|
||||
expect(csv).toContain('1930;Företagskonto;2024-03-15;A17;Kundbetalning;500,00;0,00;1500,00')
|
||||
expect(csv).toContain('1930;Företagskonto;;;Utgående balans;500,00;0,00;1500,00')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { generateFullArchive, estimateArchiveSize } from '../full-archive-export'
|
||||
import {
|
||||
generateFullArchive,
|
||||
generateBaseDataArchive,
|
||||
estimateArchiveSize,
|
||||
MASTER_DATA_DUMP_TABLES,
|
||||
} from '../full-archive-export'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
|
||||
@@ -17,7 +22,10 @@ vi.mock('../trial-balance', () => ({
|
||||
|
||||
vi.mock('../income-statement', () => ({
|
||||
generateIncomeStatement: vi.fn().mockResolvedValue({
|
||||
sections: [], netResult: 0, period: { start: '2024-01-01', end: '2024-12-31' },
|
||||
revenue_sections: [], total_revenue: 0,
|
||||
expense_sections: [], total_expenses: 0,
|
||||
financial_sections: [], total_financial: 0,
|
||||
net_result: 0, period: { start: '2024-01-01', end: '2024-12-31' },
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -122,6 +130,16 @@ describe('generateFullArchive', () => {
|
||||
expect(zip.file('dokument/manifest.json')).not.toBeNull()
|
||||
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
|
||||
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
|
||||
// Human-readable layer: CSV twins + the Swedish README.
|
||||
expect(zip.file('rapporter/saldobalans.csv')).not.toBeNull()
|
||||
expect(zip.file('rapporter/resultatrakning.csv')).not.toBeNull()
|
||||
expect(zip.file('rapporter/balansrakning.csv')).not.toBeNull()
|
||||
expect(zip.file('rapporter/huvudbok.csv')).not.toBeNull()
|
||||
const readme = zip.file('LÄSMIG.txt')
|
||||
expect(readme).not.toBeNull()
|
||||
const readmeText = await readme!.async('text')
|
||||
expect(readmeText).toContain('Test AB')
|
||||
expect(readmeText).toContain('Räkenskapsår')
|
||||
})
|
||||
|
||||
it('handles missing documents gracefully', async () => {
|
||||
@@ -247,8 +265,12 @@ describe('generateFullArchive', () => {
|
||||
expect(zip.file('sie/2024-01-01_2024-12-31.se')).not.toBeNull()
|
||||
expect(zip.file('rapporter/2023-01-01_2023-12-31/saldobalans.json')).not.toBeNull()
|
||||
expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.json')).not.toBeNull()
|
||||
expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.csv')).not.toBeNull()
|
||||
expect(zip.file('rapporter/2024-01-01_2024-12-31/huvudbok.csv')).not.toBeNull()
|
||||
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
|
||||
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
|
||||
const readmeText = await zip.file('LÄSMIG.txt')!.async('text')
|
||||
expect(readmeText).toContain('Hela bokföringen')
|
||||
// No root bokforing.se in all-mode
|
||||
expect(zip.file('bokforing.se')).toBeNull()
|
||||
})
|
||||
@@ -335,6 +357,15 @@ describe('generateFullArchive', () => {
|
||||
{ data: [PERIOD_2024] },
|
||||
{
|
||||
data: [
|
||||
// True orphan: uploaded but never linked to any entry. Backups
|
||||
// must still carry it (inbox items are räkenskapsinformation).
|
||||
{
|
||||
id: 'doc-orphan',
|
||||
file_name: 'inbox.pdf',
|
||||
storage_path: 'p/inbox.pdf',
|
||||
journal_entry_id: null,
|
||||
journal_entries: null,
|
||||
},
|
||||
// Draft entry: journal_entry_id present but voucher_number is null
|
||||
{
|
||||
id: 'doc-draft',
|
||||
@@ -381,6 +412,10 @@ describe('generateFullArchive', () => {
|
||||
}>
|
||||
const byId = Object.fromEntries(manifest.map((m) => [m.document_id, m]))
|
||||
|
||||
// Unlinked orphan -> _okopplade, included in the backup
|
||||
expect(byId['doc-orphan'].voucher_number).toBeNull()
|
||||
expect(byId['doc-orphan'].zip_path).toBe('dokument/_okopplade/inbox.pdf')
|
||||
|
||||
// Draft -> _okopplade, no voucher prefix
|
||||
expect(byId['doc-draft'].voucher_number).toBeNull()
|
||||
expect(byId['doc-draft'].zip_path).toBe('dokument/_okopplade/invoice.pdf')
|
||||
@@ -392,7 +427,8 @@ describe('generateFullArchive', () => {
|
||||
'dokument/2024/A5_kvitto_doc-coll.pdf'
|
||||
)
|
||||
|
||||
// Both files exist in the ZIP
|
||||
// All files exist in the ZIP
|
||||
expect(zip.file('dokument/_okopplade/inbox.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/_okopplade/invoice.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/2024/A5_kvitto.pdf')).not.toBeNull()
|
||||
expect(zip.file('dokument/2024/A5_kvitto_doc-coll.pdf')).not.toBeNull()
|
||||
@@ -428,26 +464,31 @@ describe('generateFullArchive', () => {
|
||||
created_at: '2024-11-01T09:55:00Z',
|
||||
}
|
||||
|
||||
// Master-data dump runs sequentially over MASTER_DATA_DUMP_TABLES.
|
||||
// Direct tables issue one query; via-tables issue a parent-id query and,
|
||||
// when parents exist, one chunked child query.
|
||||
const masterDataQueue = MASTER_DATA_DUMP_TABLES.flatMap((t): { data: unknown }[] => {
|
||||
if (t.name === 'customers') {
|
||||
return [{ data: [{ id: 'cust-1', name: 'Acme AB' }] }]
|
||||
}
|
||||
if (t.name === 'invoice_items') {
|
||||
return [
|
||||
{ data: [{ id: 'inv-1' }] }, // parent ids (invoices)
|
||||
{ data: [{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' }] },
|
||||
]
|
||||
}
|
||||
if (t.via) return [{ data: [] }] // no parents -> no child query
|
||||
if (t.name === 'company_settings') return [{ data: [COMPANY_ROW] }]
|
||||
return [{ data: [] }]
|
||||
})
|
||||
|
||||
enqueueMany([
|
||||
{ data: COMPANY_ROW }, // fetchCompany
|
||||
{ data: [PERIOD_2024] }, // fetchAllPeriods
|
||||
{ data: [] }, // document_attachments
|
||||
{ data: [importRow] }, // sie_imports
|
||||
{ data: [{ source_account: '9999', target_account: '1510' }] }, // sie_account_mappings
|
||||
{ data: [{ id: 'cust-1', name: 'Acme AB' }] }, // customers
|
||||
{ data: [{ id: 'sup-1', name: 'Supplier AB' }] }, // suppliers
|
||||
{ data: [{ id: 'inv-1', invoice_number: 'F-001' }] }, // invoices
|
||||
{ data: [] }, // invoice_items
|
||||
{ data: [] }, // invoice_payments
|
||||
{ data: [] }, // supplier_invoices
|
||||
{ data: [] }, // supplier_invoice_items
|
||||
{ data: [] }, // receipts
|
||||
{ data: [] }, // receipt_line_items
|
||||
{ data: [] }, // transactions
|
||||
{ data: [] }, // mapping_rules
|
||||
{ data: [] }, // categorization_templates
|
||||
{ data: [] }, // bank_file_imports
|
||||
{ data: [COMPANY_ROW] }, // company_settings
|
||||
...masterDataQueue,
|
||||
])
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
@@ -468,23 +509,19 @@ describe('generateFullArchive', () => {
|
||||
expect(imports[0].filename).toBe('original.se')
|
||||
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
|
||||
|
||||
expect(zip.file('data/customers.json')).not.toBeNull()
|
||||
expect(zip.file('data/suppliers.json')).not.toBeNull()
|
||||
expect(zip.file('data/invoices.json')).not.toBeNull()
|
||||
expect(zip.file('data/invoice_items.json')).not.toBeNull()
|
||||
expect(zip.file('data/invoice_payments.json')).not.toBeNull()
|
||||
expect(zip.file('data/supplier_invoices.json')).not.toBeNull()
|
||||
expect(zip.file('data/supplier_invoice_items.json')).not.toBeNull()
|
||||
expect(zip.file('data/receipts.json')).not.toBeNull()
|
||||
expect(zip.file('data/receipt_line_items.json')).not.toBeNull()
|
||||
expect(zip.file('data/transactions.json')).not.toBeNull()
|
||||
expect(zip.file('data/mapping_rules.json')).not.toBeNull()
|
||||
expect(zip.file('data/categorization_templates.json')).not.toBeNull()
|
||||
expect(zip.file('data/bank_file_imports.json')).not.toBeNull()
|
||||
expect(zip.file('data/company_settings.json')).not.toBeNull()
|
||||
// Every table in the dump contract gets a file, even when empty.
|
||||
for (const t of MASTER_DATA_DUMP_TABLES) {
|
||||
expect(zip.file(`data/${t.file}`), `data/${t.file}`).not.toBeNull()
|
||||
}
|
||||
|
||||
const customers = JSON.parse(await zip.file('data/customers.json')!.async('text'))
|
||||
expect(customers).toEqual([{ id: 'cust-1', name: 'Acme AB' }])
|
||||
|
||||
// Child table fetched via parent ids (invoice_items has no company_id).
|
||||
const items = JSON.parse(await zip.file('data/invoice_items.json')!.async('text'))
|
||||
expect(items).toEqual([
|
||||
{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
|
||||
@@ -522,6 +559,75 @@ describe('generateFullArchive', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateBaseDataArchive', () => {
|
||||
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
|
||||
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
})
|
||||
|
||||
it('bundles unlinked documents, master data, SIE sources and the audit trail', async () => {
|
||||
const masterDataQueue = MASTER_DATA_DUMP_TABLES.flatMap((t): { data: unknown }[] => {
|
||||
if (t.name === 'customers') return [{ data: [{ id: 'cust-1', name: 'Acme AB' }] }]
|
||||
if (t.via) return [{ data: [] }]
|
||||
return [{ data: [] }]
|
||||
})
|
||||
|
||||
enqueueMany([
|
||||
{ data: COMPANY_ROW }, // fetchCompany
|
||||
{ data: [PERIOD_2024] }, // fetchAllPeriods
|
||||
{
|
||||
data: [
|
||||
// Orphan: goes into Grunddata.
|
||||
{
|
||||
id: 'doc-orphan',
|
||||
file_name: 'inbox.pdf',
|
||||
storage_path: 'p/inbox.pdf',
|
||||
journal_entry_id: null,
|
||||
journal_entries: null,
|
||||
},
|
||||
// Linked to a posted entry: belongs to the period archive, not here.
|
||||
{
|
||||
id: 'doc-linked',
|
||||
file_name: 'kvitto.pdf',
|
||||
storage_path: 'p/kvitto.pdf',
|
||||
journal_entry_id: 'e-1',
|
||||
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
|
||||
},
|
||||
],
|
||||
}, // document_attachments
|
||||
{ data: [{ id: 'e-1', fiscal_period_id: PERIOD_2024.id }] }, // entry->period map
|
||||
{ data: [] }, // sie_imports
|
||||
{ data: [] }, // sie_account_mappings
|
||||
...masterDataQueue,
|
||||
])
|
||||
|
||||
const buffer = await generateBaseDataArchive(supabase as any, 'company-1')
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
|
||||
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text'))
|
||||
expect(manifest).toHaveLength(1)
|
||||
expect(manifest[0].document_id).toBe('doc-orphan')
|
||||
expect(zip.file('dokument/_okopplade/inbox.pdf')).not.toBeNull()
|
||||
|
||||
expect(zip.file('data/customers.json')).not.toBeNull()
|
||||
expect(zip.file('sie/imports.json')).not.toBeNull()
|
||||
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
|
||||
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
|
||||
|
||||
const readme = await zip.file('LÄSMIG.txt')!.async('text')
|
||||
expect(readme).toContain('Grunddata')
|
||||
// Period-scoped content stays out of Grunddata.
|
||||
expect(zip.file('bokforing.se')).toBeNull()
|
||||
expect(zip.file('rapporter/saldobalans.json')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateArchiveSize', () => {
|
||||
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
|
||||
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* CSV renditions of the key reports for the full archive.
|
||||
*
|
||||
* The JSON files in the archive are complete but hostile to humans: a user,
|
||||
* their revisor, or a Skatteverket auditor opening the backup years later
|
||||
* needs something Excel can read. Conventions target Swedish Excel:
|
||||
* semicolon separator (comma is the decimal sign in sv-SE), decimal comma,
|
||||
* CRLF line endings and a UTF-8 BOM so å/ä/ö render correctly.
|
||||
*/
|
||||
import type {
|
||||
TrialBalanceRow,
|
||||
IncomeStatementReport,
|
||||
IncomeStatementSection,
|
||||
BalanceSheetReport,
|
||||
} from '@/types'
|
||||
import type { GeneralLedgerReport } from './general-ledger'
|
||||
|
||||
export interface TrialBalanceLike {
|
||||
rows: TrialBalanceRow[]
|
||||
totalDebit: number
|
||||
totalCredit: number
|
||||
}
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const SEP = ';'
|
||||
const EOL = '\r\n'
|
||||
|
||||
function csvField(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'number') {
|
||||
// Swedish Excel: decimal comma, no thousands separator.
|
||||
return value.toFixed(2).replace('.', ',')
|
||||
}
|
||||
if (/[";\r\n]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function csv(rows: (string | number | null | undefined)[][]): string {
|
||||
return BOM + rows.map((r) => r.map(csvField).join(SEP)).join(EOL) + EOL
|
||||
}
|
||||
|
||||
export function trialBalanceToCsv(report: TrialBalanceLike): string {
|
||||
const rows: (string | number | null)[][] = [
|
||||
[
|
||||
'Konto',
|
||||
'Benämning',
|
||||
'IB debet',
|
||||
'IB kredit',
|
||||
'Period debet',
|
||||
'Period kredit',
|
||||
'UB debet',
|
||||
'UB kredit',
|
||||
],
|
||||
]
|
||||
for (const r of report.rows) {
|
||||
rows.push([
|
||||
r.account_number,
|
||||
r.account_name,
|
||||
r.opening_debit,
|
||||
r.opening_credit,
|
||||
r.period_debit,
|
||||
r.period_credit,
|
||||
r.closing_debit,
|
||||
r.closing_credit,
|
||||
])
|
||||
}
|
||||
rows.push(['Summa', '', null, null, report.totalDebit, report.totalCredit, null, null])
|
||||
return csv(rows)
|
||||
}
|
||||
|
||||
function sectionRows(
|
||||
sections: IncomeStatementSection[],
|
||||
out: (string | number | null)[][]
|
||||
): void {
|
||||
for (const section of sections) {
|
||||
for (const row of section.rows) {
|
||||
out.push([section.title, row.account_number, row.account_name, row.amount])
|
||||
}
|
||||
out.push([`Summa ${section.title.toLowerCase()}`, '', '', section.subtotal])
|
||||
}
|
||||
}
|
||||
|
||||
export function incomeStatementToCsv(report: IncomeStatementReport): string {
|
||||
const rows: (string | number | null)[][] = [
|
||||
['Rubrik', 'Konto', 'Benämning', 'Belopp'],
|
||||
]
|
||||
sectionRows(report.revenue_sections, rows)
|
||||
rows.push(['Summa intäkter', '', '', report.total_revenue])
|
||||
sectionRows(report.expense_sections, rows)
|
||||
rows.push(['Summa kostnader', '', '', report.total_expenses])
|
||||
sectionRows(report.financial_sections, rows)
|
||||
rows.push(['Summa finansiella poster', '', '', report.total_financial])
|
||||
rows.push(['Årets resultat', '', '', report.net_result])
|
||||
return csv(rows)
|
||||
}
|
||||
|
||||
export function balanceSheetToCsv(report: BalanceSheetReport): string {
|
||||
const rows: (string | number | null)[][] = [
|
||||
['Rubrik', 'Konto', 'Benämning', 'Belopp'],
|
||||
]
|
||||
sectionRows(report.asset_sections, rows)
|
||||
rows.push(['Summa tillgångar', '', '', report.total_assets])
|
||||
sectionRows(report.equity_liability_sections, rows)
|
||||
rows.push(['Summa eget kapital och skulder', '', '', report.total_equity_liabilities])
|
||||
return csv(rows)
|
||||
}
|
||||
|
||||
export function generalLedgerToCsv(report: GeneralLedgerReport): string {
|
||||
const rows: (string | number | null)[][] = [
|
||||
[
|
||||
'Konto',
|
||||
'Benämning',
|
||||
'Datum',
|
||||
'Verifikat',
|
||||
'Beskrivning',
|
||||
'Debet',
|
||||
'Kredit',
|
||||
'Saldo',
|
||||
],
|
||||
]
|
||||
for (const account of report.accounts) {
|
||||
rows.push([
|
||||
account.account_number,
|
||||
account.account_name,
|
||||
'',
|
||||
'',
|
||||
'Ingående balans',
|
||||
null,
|
||||
null,
|
||||
account.opening_balance,
|
||||
])
|
||||
for (const line of account.lines) {
|
||||
rows.push([
|
||||
account.account_number,
|
||||
account.account_name,
|
||||
line.date,
|
||||
`${line.voucher_series}${line.voucher_number}`,
|
||||
line.description,
|
||||
line.debit,
|
||||
line.credit,
|
||||
line.balance,
|
||||
])
|
||||
}
|
||||
rows.push([
|
||||
account.account_number,
|
||||
account.account_name,
|
||||
'',
|
||||
'',
|
||||
'Utgående balans',
|
||||
account.total_debit,
|
||||
account.total_credit,
|
||||
account.closing_balance,
|
||||
])
|
||||
}
|
||||
return csv(rows)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* LÄSMIG.txt for the full archive: a Swedish, human-readable map of the ZIP.
|
||||
* The archive outlives the product subscription; whoever opens it (the user
|
||||
* years later, a revisor, a Skatteverket auditor) must understand it without
|
||||
* Accounted running.
|
||||
*/
|
||||
|
||||
export interface ArchiveReadmeParams {
|
||||
companyName: string
|
||||
orgNumber: string | null
|
||||
generatedAt: string
|
||||
scope: 'all' | 'period'
|
||||
periodLabel?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
export interface DriveFolderReadmeParams {
|
||||
companyName: string
|
||||
orgNumber: string | null
|
||||
generatedAt: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* README for the Drive backup folder layout: one `Arkiv <år>.zip` per
|
||||
* räkenskapsår plus `Grunddata.zip`. Uploaded both as a standalone
|
||||
* LÄSMIG.txt in the Drive folder and inside Grunddata.zip.
|
||||
*/
|
||||
export function buildDriveFolderReadme(params: DriveFolderReadmeParams): string {
|
||||
const app = params.appName || 'Accounted'
|
||||
return [
|
||||
`SÄKERHETSKOPIA FRÅN ${app.toUpperCase()}`,
|
||||
'='.repeat(30),
|
||||
'',
|
||||
`Företag: ${params.companyName}${params.orgNumber ? ` (${params.orgNumber})` : ''}`,
|
||||
`Senast uppdaterad: ${params.generatedAt}`,
|
||||
'',
|
||||
'Den här mappen innehåller företagets löpande säkerhetskopia:',
|
||||
'',
|
||||
'Arkiv <år>.zip Ett komplett arkiv per räkenskapsår: bokföringen som',
|
||||
' SIE4-fil, rapporter (JSON och CSV), underlag döpta',
|
||||
' efter verifikat samt behandlingshistorik för året.',
|
||||
'Grunddata.zip Register (kunder, leverantörer, fakturor, anställda,',
|
||||
' löner, tillgångar med mera), ursprungliga SIE-filer,',
|
||||
' dokument som ännu inte kopplats till verifikat samt',
|
||||
' fullständig behandlingshistorik.',
|
||||
'',
|
||||
'Filerna uppdateras på plats vid varje säkerhetskopiering: bara år med',
|
||||
'ändringar laddas upp på nytt. Google Drive sparar tidigare versioner i',
|
||||
'cirka 30 dagar (högerklicka på filen och välj Hantera versioner).',
|
||||
'',
|
||||
'Varje zip innehåller en egen LÄSMIG.txt som beskriver innehållet.',
|
||||
`${app} är öppen källkod (AGPL): bokföringen förblir läsbar utan tjänsten.`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildArchiveReadme(params: ArchiveReadmeParams): string {
|
||||
const app = params.appName || 'Accounted'
|
||||
const scopeLine =
|
||||
params.scope === 'all'
|
||||
? 'Hela bokföringen (samtliga räkenskapsår)'
|
||||
: `Räkenskapsår ${params.periodLabel ?? ''}`.trim()
|
||||
|
||||
const lines: string[] = [
|
||||
`SÄKERHETSKOPIA FRÅN ${app.toUpperCase()}`,
|
||||
'='.repeat(30),
|
||||
'',
|
||||
`Företag: ${params.companyName}${params.orgNumber ? ` (${params.orgNumber})` : ''}`,
|
||||
`Skapad: ${params.generatedAt}`,
|
||||
`Omfattning: ${scopeLine}`,
|
||||
'',
|
||||
'Det här arkivet innehåller företagets räkenskapsinformation i öppna',
|
||||
'format (SIE4, JSON, CSV). Det är läsbart utan särskild programvara och',
|
||||
'är avsett att uppfylla arkiveringskraven i Bokföringslagen (7 år).',
|
||||
'',
|
||||
'INNEHÅLL',
|
||||
'--------',
|
||||
]
|
||||
|
||||
if (params.scope === 'all') {
|
||||
lines.push(
|
||||
'sie/ Bokföringen som SIE4-filer, en per räkenskapsår.',
|
||||
' Kan importeras i de flesta svenska bokföringsprogram.',
|
||||
'sie/original/ Ursprungliga SIE-filer som importerats, bevarade',
|
||||
' byte för byte. manifest.json beskriver varje fil.',
|
||||
'rapporter/<år>/ Rapporter per räkenskapsår: saldobalans, resultat-',
|
||||
' och balansräkning, huvudbok, grundbok och moms.',
|
||||
' JSON för maskiner, CSV för Excel (semikolonavgränsad,',
|
||||
' svensk teckenkodning).',
|
||||
'dokument/<år>/ Underlag (kvitton, fakturor) döpta efter verifikat,',
|
||||
' t.ex. A17_kvitto.pdf. dokument/_okopplade/ innehåller',
|
||||
' dokument som ännu inte kopplats till något verifikat.',
|
||||
' dokument/manifest.json listar alla filer med SHA-256.',
|
||||
'data/ Register som JSON: kunder, leverantörer, fakturor,',
|
||||
' anställda, löner, tillgångar, med mera. En fil per',
|
||||
' tabell, radernas fältnamn följer databasen.',
|
||||
'revision/ behandlingshistorik.json (alla ändringar, BFL 5 kap',
|
||||
' 11 §) och systemdokumentation.json (kontoplan,',
|
||||
' verifikationsserier, arkiveringsprinciper).'
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
'bokforing.se Räkenskapsårets bokföring som SIE4-fil.',
|
||||
'rapporter/ Saldobalans, resultat- och balansräkning, huvudbok,',
|
||||
' grundbok och momsdeklaration. JSON för maskiner,',
|
||||
' CSV för Excel (semikolonavgränsad).',
|
||||
'dokument/ Underlag (kvitton, fakturor) döpta efter verifikat.',
|
||||
' dokument/manifest.json listar alla filer med SHA-256.',
|
||||
'revision/ behandlingshistorik.json (alla ändringar under året)',
|
||||
' och systemdokumentation.json.'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'ÅTERSTÄLLNING',
|
||||
'-------------',
|
||||
`SIE-filerna kan importeras i ${app} eller annat bokföringsprogram.`,
|
||||
'Dokumentens koppling till verifikat framgår av dokument/manifest.json',
|
||||
'(fältet journal_entry_id samt verifikatnumret i filnamnet).',
|
||||
'',
|
||||
`${app} är öppen källkod (AGPL): programvaran kan även köras självhostad,`,
|
||||
'så bokföringen förblir åtkomlig oavsett tjänstens framtid.',
|
||||
''
|
||||
)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -10,7 +10,20 @@ import { calculateVatDeclaration } from './vat-declaration'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import type { AuditLogEntry } from '@/types'
|
||||
import {
|
||||
trialBalanceToCsv,
|
||||
incomeStatementToCsv,
|
||||
balanceSheetToCsv,
|
||||
generalLedgerToCsv,
|
||||
type TrialBalanceLike,
|
||||
} from './archive-csv'
|
||||
import { buildArchiveReadme, buildDriveFolderReadme } from './archive-readme'
|
||||
import type { GeneralLedgerReport } from './general-ledger'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
BalanceSheetReport,
|
||||
IncomeStatementReport,
|
||||
} from '@/types'
|
||||
|
||||
export type FullArchiveOptions =
|
||||
| { scope: 'period'; period_id: string; include_documents?: boolean }
|
||||
@@ -85,7 +98,10 @@ const REPORT_CONCURRENCY = 3
|
||||
// 5 MB for SIE + reports + audit + system doc, +3 MB headroom for master-data
|
||||
// JSON dumps and raw imported SIE files (the bucket caps each file at 50 MB,
|
||||
// but typical SIE4 files are tens of KB so a few MB covers most companies).
|
||||
const ARCHIVE_OVERHEAD_BYTES = 8 * 1024 * 1024
|
||||
export const ARCHIVE_OVERHEAD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
/** Documents included in an archive: per-period, everything, or only the rest. */
|
||||
type DocumentMode = ArchiveScope | 'unlinked'
|
||||
|
||||
/**
|
||||
* Generate a full archive ZIP for a company.
|
||||
@@ -175,6 +191,62 @@ export async function generateFullArchive(
|
||||
const systemDoc = await buildSystemDoc(supabase, companyId, periods, options.scope)
|
||||
revision.file('systemdokumentation.json', JSON.stringify(systemDoc, null, 2))
|
||||
|
||||
zip.file(
|
||||
'LÄSMIG.txt',
|
||||
buildArchiveReadme({
|
||||
companyName: company.company_name || 'Okänt företag',
|
||||
orgNumber: company.org_number,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: options.scope,
|
||||
periodLabel: options.scope === 'period' ? periodLabel(periods[0]) : undefined,
|
||||
appName: getBranding().appName,
|
||||
})
|
||||
)
|
||||
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the "Grunddata" archive for the per-fiscal-year Drive backup:
|
||||
* everything that is not tied to a single fiscal year. Master-data JSON
|
||||
* dumps, original imported SIE files, documents no period archive carries
|
||||
* (unlinked/draft), the full behandlingshistorik and the system
|
||||
* documentation. Complements one `generateFullArchive(scope='period')` ZIP
|
||||
* per räkenskapsår.
|
||||
*/
|
||||
export async function generateBaseDataArchive(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: { include_documents?: boolean } = {}
|
||||
): Promise<ArrayBuffer> {
|
||||
const company = await fetchCompany(supabase, companyId)
|
||||
const periods = await fetchAllPeriods(supabase, companyId)
|
||||
const includeDocuments = options.include_documents !== false
|
||||
|
||||
const zip = new JSZip()
|
||||
|
||||
if (includeDocuments) {
|
||||
await writeDocuments(zip, supabase, companyId, periods, 'unlinked')
|
||||
}
|
||||
await writeSieSourceFiles(zip, supabase, companyId, includeDocuments)
|
||||
await writeMasterData(zip, supabase, companyId)
|
||||
|
||||
const revision = zip.folder('revision')!
|
||||
const auditEntries = await fetchAllAuditEntries(supabase, companyId, {})
|
||||
revision.file('behandlingshistorik.json', JSON.stringify(auditEntries, null, 2))
|
||||
const systemDoc = await buildSystemDoc(supabase, companyId, periods, 'all')
|
||||
revision.file('systemdokumentation.json', JSON.stringify(systemDoc, null, 2))
|
||||
|
||||
zip.file(
|
||||
'LÄSMIG.txt',
|
||||
buildDriveFolderReadme({
|
||||
companyName: company.company_name || 'Okänt företag',
|
||||
orgNumber: company.org_number,
|
||||
generatedAt: new Date().toISOString(),
|
||||
appName: getBranding().appName,
|
||||
})
|
||||
)
|
||||
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
}
|
||||
|
||||
@@ -192,11 +264,11 @@ export async function estimateArchiveSize(
|
||||
scope: ArchiveScope,
|
||||
periodId?: string
|
||||
): Promise<{ total_bytes: number; document_bytes: number; document_count: number }> {
|
||||
// Scope=all counts every document (linked or not), mirroring writeDocuments.
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
.select('file_size_bytes, journal_entry_id', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
|
||||
if (scope === 'period') {
|
||||
if (!periodId) {
|
||||
@@ -326,6 +398,24 @@ function writeReports(folder: JSZip, reports: PeriodReports): void {
|
||||
if (reports.vatDeclaration) {
|
||||
folder.file('momsdeklaration.json', JSON.stringify(reports.vatDeclaration, null, 2))
|
||||
}
|
||||
// CSV twins for humans: the JSON is complete but unreadable in Excel.
|
||||
// Never let a formatting bug take down the archive (the JSON stays
|
||||
// canonical), and never let one broken report take down the other CSVs.
|
||||
const tryCsv = (file: string, make: () => string) => {
|
||||
try {
|
||||
folder.file(file, make())
|
||||
} catch {
|
||||
// Skip this CSV on shape mismatch.
|
||||
}
|
||||
}
|
||||
tryCsv('saldobalans.csv', () => trialBalanceToCsv(reports.trialBalance as TrialBalanceLike))
|
||||
tryCsv('resultatrakning.csv', () =>
|
||||
incomeStatementToCsv(reports.incomeStatement as IncomeStatementReport)
|
||||
)
|
||||
tryCsv('balansrakning.csv', () =>
|
||||
balanceSheetToCsv(reports.balanceSheet as BalanceSheetReport)
|
||||
)
|
||||
tryCsv('huvudbok.csv', () => generalLedgerToCsv(reports.generalLedger as GeneralLedgerReport))
|
||||
}
|
||||
|
||||
async function writeDocuments(
|
||||
@@ -333,32 +423,48 @@ async function writeDocuments(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[],
|
||||
scope: ArchiveScope
|
||||
scope: DocumentMode
|
||||
): Promise<void> {
|
||||
const dokument = zip.folder('dokument')!
|
||||
const manifest: DocumentManifestEntry[] = []
|
||||
|
||||
try {
|
||||
const documents = await fetchAllRows<DocumentRow>(({ from, to }) =>
|
||||
supabase
|
||||
const documents = await fetchAllRows<DocumentRow>(({ from, to }) => {
|
||||
let q = supabase
|
||||
.from('document_attachments')
|
||||
.select(
|
||||
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes, journal_entries:journal_entry_id(voucher_number, voucher_series, entry_date)'
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
// Backups (scope=all/unlinked) include every document, even those not
|
||||
// yet linked to an entry: inbox items and unbooked receipts are
|
||||
// räkenskapsinformation too. The per-period archive keeps the
|
||||
// linked-only filter.
|
||||
if (scope === 'period') {
|
||||
q = q.not('journal_entry_id', 'is', null)
|
||||
}
|
||||
// Stable total order for correct paging (see fetch-all.ts).
|
||||
return q.order('id', { ascending: true }).range(from, to)
|
||||
})
|
||||
|
||||
if (documents.length > 0) {
|
||||
const entryIdToPeriodId = await buildEntryToPeriodMap(supabase, companyId, periods, scope)
|
||||
const entryIdToPeriodId = await buildEntryToPeriodMap(
|
||||
supabase,
|
||||
companyId,
|
||||
periods,
|
||||
scope === 'period' ? 'period' : 'all'
|
||||
)
|
||||
|
||||
const inScopeDocuments =
|
||||
scope === 'period'
|
||||
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
|
||||
: documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc
|
||||
: scope === 'unlinked'
|
||||
? // Grunddata mode: only what no period archive carries (orphans
|
||||
// and docs linked to draft/unposted entries).
|
||||
documents.filter(
|
||||
(d) => !d.journal_entry_id || !entryIdToPeriodId.has(d.journal_entry_id)
|
||||
)
|
||||
: documents // all-mode: keep every doc, linked or not
|
||||
|
||||
// Track used paths so we can disambiguate collisions (two documents with
|
||||
// identical voucher prefix + filename) by appending a short id suffix.
|
||||
@@ -654,6 +760,197 @@ async function writeSieSourceFiles(
|
||||
}
|
||||
}
|
||||
|
||||
export interface MasterDataTableSpec {
|
||||
name: string
|
||||
file: string
|
||||
orderBy?: string
|
||||
/**
|
||||
* Unique column used as the paging/dedupe key. Defaults to 'id'; override
|
||||
* for tables whose PK has another name (e.g. journal_entry_no_doc_required).
|
||||
*/
|
||||
pageKey?: string
|
||||
/**
|
||||
* Child tables without a company_id column: rows are fetched by first
|
||||
* collecting the parent table's ids for the company, then paging the child
|
||||
* table through `fk IN (...)` chunks.
|
||||
*/
|
||||
via?: { parent: string; fk: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables dumped as JSON under `data/` in the scope='all' backup.
|
||||
*
|
||||
* This list is a contract enforced by tests/pg/full-archive-coverage.pg.test.ts:
|
||||
* every public table with a company_id column must appear here, in
|
||||
* ARCHIVE_COVERED_ELSEWHERE_TABLES, or in ARCHIVE_EXCLUDED_TABLES. A migration
|
||||
* that adds a company-scoped table fails that test until the table is
|
||||
* classified, so the backup can never silently fall behind the schema again.
|
||||
*/
|
||||
export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
// Counterparties and articles
|
||||
{ name: 'customers', file: 'customers.json', orderBy: 'created_at' },
|
||||
{ name: 'suppliers', file: 'suppliers.json', orderBy: 'created_at' },
|
||||
{ name: 'articles', file: 'articles.json', orderBy: 'created_at' },
|
||||
// Customer invoicing
|
||||
{ name: 'invoices', file: 'invoices.json', orderBy: 'invoice_date' },
|
||||
{ name: 'invoice_items', file: 'invoice_items.json', via: { parent: 'invoices', fk: 'invoice_id' } },
|
||||
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
|
||||
{ name: 'invoice_reminders', file: 'invoice_reminders.json' },
|
||||
{ name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' },
|
||||
// Supplier invoicing
|
||||
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
|
||||
{ name: 'supplier_invoice_items', file: 'supplier_invoice_items.json', via: { parent: 'supplier_invoices', fk: 'supplier_invoice_id' } },
|
||||
{ name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' },
|
||||
// Receipts
|
||||
{ name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' },
|
||||
{ name: 'receipt_line_items', file: 'receipt_line_items.json', via: { parent: 'receipts', fk: 'receipt_id' } },
|
||||
// Bank and categorization
|
||||
// NOTE: the date column on transactions is `date` (a previous spec said
|
||||
// booking_date, which does not exist: every backup got an error stub).
|
||||
{ name: 'transactions', file: 'transactions.json', orderBy: 'date' },
|
||||
{ name: 'transaction_voucher_links', file: 'transaction_voucher_links.json' },
|
||||
{ name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' },
|
||||
{ name: 'cash_accounts', file: 'cash_accounts.json' },
|
||||
{ name: 'mapping_rules', file: 'mapping_rules.json' },
|
||||
{ name: 'categorization_templates', file: 'categorization_templates.json' },
|
||||
{ name: 'booking_template_library', file: 'booking_template_library.json' },
|
||||
{ name: 'skattekonto_rules', file: 'skattekonto_rules.json' },
|
||||
// Salary (räkenskapsinformation with 7-year retention)
|
||||
{ name: 'employees', file: 'employees.json', orderBy: 'created_at' },
|
||||
{ name: 'employee_benefits', file: 'employee_benefits.json', orderBy: 'created_at' },
|
||||
{ name: 'salary_runs', file: 'salary_runs.json', orderBy: 'created_at' },
|
||||
{ name: 'salary_run_employees', file: 'salary_run_employees.json', orderBy: 'created_at' },
|
||||
{ name: 'salary_line_items', file: 'salary_line_items.json', orderBy: 'created_at' },
|
||||
{ name: 'salary_absence_days', file: 'salary_absence_days.json' },
|
||||
{ name: 'salary_worked_days', file: 'salary_worked_days.json' },
|
||||
{ name: 'salary_payslip_links', file: 'salary_payslip_links.json' },
|
||||
{ name: 'shift_premium_rules', file: 'shift_premium_rules.json' },
|
||||
{ name: 'agi_declarations', file: 'agi_declarations.json', orderBy: 'created_at' },
|
||||
// Assets and accruals
|
||||
{ name: 'assets', file: 'assets.json', orderBy: 'created_at' },
|
||||
{ name: 'depreciation_schedules', file: 'depreciation_schedules.json', orderBy: 'created_at' },
|
||||
{ name: 'accrual_schedules', file: 'accrual_schedules.json', orderBy: 'created_at' },
|
||||
{ name: 'accrual_schedule_installments', file: 'accrual_schedule_installments.json', orderBy: 'created_at' },
|
||||
// Dimensions
|
||||
{ name: 'dimensions', file: 'dimensions.json', orderBy: 'created_at' },
|
||||
{ name: 'dimension_values', file: 'dimension_values.json', orderBy: 'created_at' },
|
||||
{ name: 'cost_centers', file: 'cost_centers.json', orderBy: 'created_at' },
|
||||
{ name: 'projects', file: 'projects.json', orderBy: 'created_at' },
|
||||
{ name: 'account_dimension_rules', file: 'account_dimension_rules.json' },
|
||||
// Compliance records
|
||||
{ name: 'voucher_gap_explanations', file: 'voucher_gap_explanations.json', orderBy: 'created_at' },
|
||||
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
|
||||
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
|
||||
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
|
||||
{ name: 'arsredovisning_narratives', file: 'arsredovisning_narratives.json' },
|
||||
{ name: 'arsredovisning_submissions', file: 'arsredovisning_submissions.json' },
|
||||
// Settings
|
||||
{ name: 'company_settings', file: 'company_settings.json' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Company-scoped tables whose content reaches the archive through another
|
||||
* section, so they are deliberately not part of the `data/` dump.
|
||||
*/
|
||||
export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record<string, string> = {
|
||||
journal_entries: 'sie/<period>.se + rapporter/<period>/grundbok.json',
|
||||
fiscal_periods: 'revision/systemdokumentation.json + SIE #RAR',
|
||||
chart_of_accounts: 'revision/systemdokumentation.json (kontoplan)',
|
||||
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
|
||||
audit_log: 'revision/behandlingshistorik.json',
|
||||
document_attachments: 'dokument/ + dokument/manifest.json',
|
||||
sie_imports: 'sie/imports.json + sie/original/',
|
||||
sie_account_mappings: 'sie/account_mappings.json',
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scoped tables deliberately kept out of the archive, with the reason.
|
||||
* Platform state, secrets, telemetry and re-fetchable mirrors do not belong in
|
||||
* a portable räkenskapsinformation backup.
|
||||
*/
|
||||
export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
agent_conversations: 'AI assistant state, not räkenskapsinformation',
|
||||
agent_memory: 'AI assistant state, not räkenskapsinformation',
|
||||
agent_profiles: 'AI assistant state, not räkenskapsinformation',
|
||||
api_keys: 'secrets',
|
||||
arsredovisning_signature_requests: 'signing workflow state',
|
||||
bank_connections: 'PSD2 connection state and tokens, not portable',
|
||||
bolagsverket_avtal_acceptances: 'service agreement acceptance state',
|
||||
bolagsverket_subscriptions: 'integration subscription state',
|
||||
booking_template_usage: 'usage telemetry',
|
||||
calendar_feeds: 'feed tokens (secrets)',
|
||||
capability_grants: 'entitlement state',
|
||||
chat_messages: 'AI assistant state, not räkenskapsinformation',
|
||||
chat_sessions: 'AI assistant state, not räkenskapsinformation',
|
||||
company_capability_config: 'entitlement state',
|
||||
company_inbound_domains: 'inbound-mail infrastructure',
|
||||
company_inboxes: 'inbound-mail infrastructure',
|
||||
company_invitations: 'membership state, meaningless outside the platform',
|
||||
company_members: 'membership state, meaningless outside the platform',
|
||||
company_subscriptions: 'billing state',
|
||||
deadlines: 'regenerable operational calendar state',
|
||||
dimension_retag_log: 'operation log',
|
||||
event_log: '30-day TTL event bus log',
|
||||
extension_data: 'extension runtime state (includes this backup\'s own state)',
|
||||
graph_counterparties: 'derived AI context graph, regenerable',
|
||||
graph_transaction_counterparties: 'derived AI context graph, regenerable',
|
||||
idempotency_keys: 'infrastructure',
|
||||
inbox_rate_counters: 'infrastructure',
|
||||
invoice_inbox_items: 'inbox workflow state; the files live in document_attachments',
|
||||
metered_events: 'billing telemetry',
|
||||
notification_log: 'notification dedup log',
|
||||
operations: 'staged-operation workflow state',
|
||||
payment_match_log: 'derived matching log',
|
||||
pending_operations: 'staged-operation workflow state',
|
||||
processing_history: 'internal processing log; behandlingshistorik exports from audit_log',
|
||||
provider_consents: 'consent tokens, not portable',
|
||||
salary_payslip_deliveries: 'delivery log',
|
||||
skattekonto_transactions: 'mirror of Skatteverket skattekonto, re-fetchable at source',
|
||||
skatteverket_api_audit_log: 'integration audit log',
|
||||
skatteverket_company_connections: 'integration connection state',
|
||||
skatteverket_tokens: 'secrets',
|
||||
stripe_connections: 'Stripe OAuth state (secrets)',
|
||||
stripe_payment_events: 'mirror of Stripe data, re-fetchable at source',
|
||||
stripe_payouts: 'mirror of Stripe data, re-fetchable at source',
|
||||
webhook_deliveries: 'automation delivery log',
|
||||
webhooks: 'automation config with signing secrets',
|
||||
}
|
||||
|
||||
/** Max parent ids per `IN (...)` chunk: keeps the PostgREST URL well under limits. */
|
||||
const CHILD_FK_CHUNK = 100
|
||||
|
||||
async function fetchChildTableRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
spec: MasterDataTableSpec
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const via = spec.via!
|
||||
const parents = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from(via.parent)
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const pageKey = spec.pageKey ?? 'id'
|
||||
const rows: Record<string, unknown>[] = []
|
||||
for (let i = 0; i < parents.length; i += CHILD_FK_CHUNK) {
|
||||
const chunk = parents.slice(i, i + CHILD_FK_CHUNK).map((p) => p.id)
|
||||
const chunkRows = await fetchAllRows<Record<string, unknown>>(
|
||||
({ from, to }) => {
|
||||
let q = supabase.from(spec.name).select('*').in(via.fk, chunk)
|
||||
if (spec.orderBy) q = q.order(spec.orderBy, { ascending: true })
|
||||
return q.order(pageKey, { ascending: true }).range(from, to)
|
||||
},
|
||||
{ dedupeBy: (r) => String(r[pageKey]) }
|
||||
)
|
||||
rows.push(...chunkRows)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump structured master data as JSON under `data/`. These records are implicit
|
||||
* in the SIE export (as journal entries) but not recoverable as domain objects
|
||||
@@ -666,51 +963,38 @@ async function writeMasterData(
|
||||
): Promise<void> {
|
||||
const data = zip.folder('data')!
|
||||
|
||||
const tables: Array<{ name: string; file: string; orderBy?: string }> = [
|
||||
{ name: 'customers', file: 'customers.json', orderBy: 'created_at' },
|
||||
{ name: 'suppliers', file: 'suppliers.json', orderBy: 'created_at' },
|
||||
{ name: 'invoices', file: 'invoices.json', orderBy: 'invoice_date' },
|
||||
{ name: 'invoice_items', file: 'invoice_items.json' },
|
||||
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
|
||||
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
|
||||
{ name: 'supplier_invoice_items', file: 'supplier_invoice_items.json' },
|
||||
{ name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' },
|
||||
{ name: 'receipt_line_items', file: 'receipt_line_items.json' },
|
||||
{ name: 'transactions', file: 'transactions.json', orderBy: 'booking_date' },
|
||||
{ name: 'mapping_rules', file: 'mapping_rules.json' },
|
||||
{ name: 'categorization_templates', file: 'categorization_templates.json' },
|
||||
{ name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' },
|
||||
{ name: 'company_settings', file: 'company_settings.json' },
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
tables.map(async (t) => {
|
||||
try {
|
||||
const rows = await fetchAllRows<{ id: string } & Record<string, unknown>>(({ from, to }) => {
|
||||
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
|
||||
if (t.orderBy) {
|
||||
q = q.order(t.orderBy, { ascending: true })
|
||||
}
|
||||
// Always end on the unique PK so paging has a stable TOTAL order. A
|
||||
// non-unique display order (e.g. created_at) or no order at all
|
||||
// silently SKIPS/DUPLICATES rows across page boundaries: data loss in
|
||||
// a statutory 7-year retention archive. (All these tables have an
|
||||
// `id uuid` PK.) dedupeBy is defense-in-depth against the duplicate case.
|
||||
return q.order('id', { ascending: true }).range(from, to)
|
||||
}, { dedupeBy: (r) => r.id })
|
||||
data.file(t.file, JSON.stringify(rows, null, 2))
|
||||
} catch (err) {
|
||||
data.file(
|
||||
t.file,
|
||||
JSON.stringify(
|
||||
{ error: err instanceof Error ? err.message : 'Fetch failed', rows: [] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
// Sequential on purpose: ~50 fast queries in series are gentler on
|
||||
// PostgREST than 50 concurrent ones, and the deterministic order keeps the
|
||||
// queued-mock tests stable.
|
||||
for (const t of MASTER_DATA_DUMP_TABLES) {
|
||||
const pageKey = t.pageKey ?? 'id'
|
||||
try {
|
||||
const rows = t.via
|
||||
? await fetchChildTableRows(supabase, companyId, t)
|
||||
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
|
||||
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
|
||||
if (t.orderBy) {
|
||||
q = q.order(t.orderBy, { ascending: true })
|
||||
}
|
||||
// Always end on the unique PK so paging has a stable TOTAL order. A
|
||||
// non-unique display order (e.g. created_at) or no order at all
|
||||
// silently SKIPS/DUPLICATES rows across page boundaries: data loss in
|
||||
// a statutory 7-year retention archive. dedupeBy is defense-in-depth
|
||||
// against the duplicate case.
|
||||
return q.order(pageKey, { ascending: true }).range(from, to)
|
||||
}, { dedupeBy: (r) => String(r[pageKey]) })
|
||||
data.file(t.file, JSON.stringify(rows, null, 2))
|
||||
} catch (err) {
|
||||
data.file(
|
||||
t.file,
|
||||
JSON.stringify(
|
||||
{ error: err instanceof Error ? err.message : 'Fetch failed', rows: [] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
|
||||
@@ -43,18 +43,27 @@ describe('generatePain001', () => {
|
||||
expect(xml).toContain('ESSESESS')
|
||||
})
|
||||
|
||||
it('identifies the debtor (company) by its own IBAN', () => {
|
||||
it('identifies the debtor (company) by its own IBAN and org number', () => {
|
||||
const xml = generatePain001(company, employees, options)
|
||||
|
||||
// The payer is the company's IBAN, inside DbtrAcct.
|
||||
expect(xml).toContain('<IBAN>SE1234567890123456789012</IBAN>')
|
||||
// Employees are still domestic BBAN (clearing+account), never IBAN.
|
||||
expect(xml).toContain('<Othr><Id>56781234567890</Id></Othr>')
|
||||
// Org number digits on InitgPty and Dbtr.
|
||||
expect(xml).toContain('<Othr><Id>5561234567</Id></Othr>')
|
||||
})
|
||||
|
||||
it('includes SALA category purpose for salary', () => {
|
||||
it('omits the org id blocks when the org number is missing', () => {
|
||||
const xml = generatePain001({ ...company, orgNumber: '' }, employees, options)
|
||||
expect(xml).not.toContain('<OrgId>')
|
||||
})
|
||||
|
||||
it('marks the batch as salary with SALA and nothing else in PmtTpInf', () => {
|
||||
const xml = generatePain001(company, employees, options)
|
||||
expect(xml).toContain('<Cd>SALA</Cd>')
|
||||
expect(xml).toContain('<CtgyPurp><Cd>SALA</Cd></CtgyPurp>')
|
||||
// SvcLvl SEPA means an EUR SEPA credit transfer: never valid for domestic
|
||||
// SEK salaries. The element must be absent (banks default to NURG).
|
||||
expect(xml).not.toContain('<SvcLvl>')
|
||||
expect(xml).not.toContain('SEPA')
|
||||
})
|
||||
|
||||
it('includes per-employee credit transfers', () => {
|
||||
@@ -66,16 +75,82 @@ describe('generatePain001', () => {
|
||||
expect(xml).toContain('<InstdAmt Ccy="SEK">32000.00</InstdAmt>')
|
||||
})
|
||||
|
||||
it('includes bank account details per employee', () => {
|
||||
it('addresses employees with SESBA clearing + BBAN account without clearing', () => {
|
||||
const xml = generatePain001(company, employees, options)
|
||||
|
||||
expect(xml).toContain('56781234567890') // Anna's clearing + account
|
||||
expect(xml).toContain('12349876543210') // Erik's clearing + account
|
||||
// Clearing goes in CdtrAgt as a SESBA clearing-system member id...
|
||||
expect(xml).toContain('<ClrSysId><Cd>SESBA</Cd></ClrSysId>')
|
||||
expect(xml).toContain('<MmbId>5678</MmbId>')
|
||||
expect(xml).toContain('<MmbId>1234</MmbId>')
|
||||
// ...and the account (WITHOUT clearing) in CdtrAcct with SchmeNm BBAN.
|
||||
expect(xml).toContain('<Id>1234567890</Id>')
|
||||
expect(xml).toContain('<Id>9876543210</Id>')
|
||||
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
|
||||
// The old concatenated clearing+account form must be gone.
|
||||
expect(xml).not.toContain('56781234567890')
|
||||
expect(xml).not.toContain('12349876543210')
|
||||
})
|
||||
|
||||
it('includes remittance info with period label', () => {
|
||||
it('places CdtrAgt before Cdtr within each transaction (XSD order)', () => {
|
||||
const xml = generatePain001(company, [employees[0]], options)
|
||||
const agtIndex = xml.indexOf('<CdtrAgt>')
|
||||
const cdtrIndex = xml.indexOf('<Cdtr>')
|
||||
expect(agtIndex).toBeGreaterThan(-1)
|
||||
expect(cdtrIndex).toBeGreaterThan(-1)
|
||||
expect(agtIndex).toBeLessThan(cdtrIndex)
|
||||
})
|
||||
|
||||
it('splits a Swedbank 5-digit clearing the same way as the LB file', () => {
|
||||
const swedbankEmployee = [
|
||||
{ name: 'Sara Svensson', clearingNumber: '83279', bankAccountNumber: '1234567890', netSalary: 30000 },
|
||||
]
|
||||
const xml = generatePain001(company, swedbankEmployee, options)
|
||||
|
||||
expect(xml).toContain('<MmbId>8327</MmbId>')
|
||||
expect(xml).toContain('<Id>91234567890</Id>')
|
||||
})
|
||||
|
||||
it('strips the duplicated clearing prefix from a Nordea personkonto', () => {
|
||||
const personkontoEmployee = [
|
||||
{ name: 'Nils Nilsson', clearingNumber: '1708', bankAccountNumber: '17082042825', netSalary: 25000 },
|
||||
]
|
||||
const xml = generatePain001(company, personkontoEmployee, options)
|
||||
|
||||
expect(xml).toContain('<MmbId>1708</MmbId>')
|
||||
expect(xml).toContain('<Id>2042825</Id>')
|
||||
expect(xml).not.toContain('<Id>17082042825</Id>')
|
||||
})
|
||||
|
||||
it('throws on an invalid clearing number instead of emitting a broken file', () => {
|
||||
const badEmployee = [
|
||||
{ name: 'Fel Felsson', clearingNumber: '123', bankAccountNumber: '1234567', netSalary: 20000 },
|
||||
]
|
||||
expect(() => generatePain001(company, badEmployee, options)).toThrow('Ogiltigt clearingnummer')
|
||||
})
|
||||
|
||||
it('omits remittance info entirely (not allowed for salary payments)', () => {
|
||||
const xml = generatePain001(company, employees, options)
|
||||
expect(xml).toContain('Lon 2026-04')
|
||||
expect(xml).not.toContain('<RmtInf>')
|
||||
expect(xml).not.toContain('<Ustrd>')
|
||||
})
|
||||
|
||||
it('keeps MsgId, PmtInfId and transaction ids within Max35Text', () => {
|
||||
const longOptions = {
|
||||
...options,
|
||||
messageId: 'A-VERY-LONG-WHITELABEL-NAME-5561234567-2026-04',
|
||||
}
|
||||
const xml = generatePain001(company, employees, longOptions)
|
||||
|
||||
const ids = [
|
||||
...xml.matchAll(/<(MsgId|PmtInfId|InstrId|EndToEndId)>([^<]+)<\/\1>/g),
|
||||
].map((m) => m[2])
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
for (const id of ids) {
|
||||
expect(id.length).toBeLessThanOrEqual(35)
|
||||
}
|
||||
// The per-transaction counter must survive truncation intact.
|
||||
expect(xml).toMatch(/<EndToEndId>[^<]*-TX0001<\/EndToEndId>/)
|
||||
expect(xml).toMatch(/<EndToEndId>[^<]*-TX0002<\/EndToEndId>/)
|
||||
})
|
||||
|
||||
it('includes payment date', () => {
|
||||
@@ -92,7 +167,7 @@ describe('generatePain001', () => {
|
||||
|
||||
it('formats amounts with 2 decimal places', () => {
|
||||
const empWithDecimals = [
|
||||
{ name: 'Test', clearingNumber: '1234', bankAccountNumber: '5678', netSalary: 28333.33 },
|
||||
{ name: 'Test', clearingNumber: '1234', bankAccountNumber: '56789', netSalary: 28333.33 },
|
||||
]
|
||||
const xml = generatePain001(company, empWithDecimals, options)
|
||||
expect(xml).toContain('28333.33')
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './xml-generator'
|
||||
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
// Strict runtime validation of the joined salary_run_employees row. Without
|
||||
@@ -660,19 +661,15 @@ export async function generateAgiDeclaration(
|
||||
|
||||
// 11. Auto-complete the arbetsgivardeklaration deadline for this period
|
||||
// (Skatteförfarandelagen: AGI generation satisfies the filing
|
||||
// obligation). Optimistic-lock on status='pending'.
|
||||
// obligation).
|
||||
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString(),
|
||||
completed_by: userId,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('type', 'arbetsgivardeklaration')
|
||||
.eq('period', period)
|
||||
.eq('status', 'pending')
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['arbetsgivardeklaration'],
|
||||
period,
|
||||
'submitted'
|
||||
)
|
||||
|
||||
opLog.info('AGI declaration generated', {
|
||||
requestId,
|
||||
|
||||
@@ -7,8 +7,51 @@ import {
|
||||
lookupBankByClearing,
|
||||
lookupBicByClearing,
|
||||
lookupBicByBankName,
|
||||
splitDomesticBankAccount,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
|
||||
describe('splitDomesticBankAccount', () => {
|
||||
it('passes a 4-digit clearing + account through unchanged', () => {
|
||||
expect(splitDomesticBankAccount('6000', '1234567')).toEqual({
|
||||
clearing4: '6000',
|
||||
accountDigits: '1234567',
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the 5th digit of a Swedbank clearing into the account field', () => {
|
||||
expect(splitDomesticBankAccount('83279', '1234567890')).toEqual({
|
||||
clearing4: '8327',
|
||||
accountDigits: '91234567890',
|
||||
})
|
||||
})
|
||||
|
||||
it('strips the redundant clearing prefix from an 11-digit Nordea personkonto', () => {
|
||||
expect(splitDomesticBankAccount('1708', '17082042825')).toEqual({
|
||||
clearing4: '1708',
|
||||
accountDigits: '2042825',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an 11-digit account that does not start with the clearing', () => {
|
||||
expect(splitDomesticBankAccount('3300', '19850101234')).toEqual({
|
||||
clearing4: '3300',
|
||||
accountDigits: '19850101234',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes hyphens and spaces before splitting', () => {
|
||||
expect(splitDomesticBankAccount('8327-9', '123 456 789 0')).toEqual({
|
||||
clearing4: '8327',
|
||||
accountDigits: '91234567890',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on an invalid clearing number', () => {
|
||||
expect(() => splitDomesticBankAccount('123', '1234567')).toThrow('Ogiltigt clearingnummer')
|
||||
expect(() => splitDomesticBankAccount('12345', '1234567')).toThrow('Ogiltigt clearingnummer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBankNumber', () => {
|
||||
it('strips spaces and hyphens', () => {
|
||||
expect(normalizeBankNumber('8327-9')).toBe('83279')
|
||||
|
||||
@@ -121,6 +121,54 @@ export function validateEmployeeBankAccount(
|
||||
return issues
|
||||
}
|
||||
|
||||
export interface DomesticBankAccountParts {
|
||||
/** 4-digit clearing: the Bankgirot LB clearing field and the pain.001 SESBA member id. */
|
||||
clearing4: string
|
||||
/** Account digits without the clearing prefix (see the special cases below). */
|
||||
accountDigits: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an employee's clearing/account pair into the 4-digit clearing and the
|
||||
* account digits used by BOTH payout formats. This is the single source of
|
||||
* truth: the Bankgirot LB file (fixed 4-digit clearing field, TK 54) and the
|
||||
* pain.001 file (CdtrAgt ClrSysMmbId SESBA + CdtrAcct BBAN without clearing)
|
||||
* must present identical routing for the same employee, or one format pays a
|
||||
* different account than the other.
|
||||
*
|
||||
* Special cases, per the Bankgirot LB spec and kept format-identical here:
|
||||
* - Swedbank 5-digit clearings (8xxx-y): the first 4 digits are the clearing,
|
||||
* the 5th digit is carried as the leading digit of the account field.
|
||||
* - Nordea personkonto entered as an 11-digit account that repeats the
|
||||
* 4-digit clearing as its prefix: the redundant prefix is stripped so the
|
||||
* account field holds only the account itself.
|
||||
*/
|
||||
export function splitDomesticBankAccount(
|
||||
clearingInput: string,
|
||||
accountInput: string
|
||||
): DomesticBankAccountParts {
|
||||
const clearing = (clearingInput ?? '').replace(/\D/g, '')
|
||||
const account = (accountInput ?? '').replace(/\D/g, '')
|
||||
|
||||
if (clearing.length === 4) {
|
||||
if (account.length === 11 && account.startsWith(clearing)) {
|
||||
return { clearing4: clearing, accountDigits: account.slice(4) }
|
||||
}
|
||||
return { clearing4: clearing, accountDigits: account }
|
||||
}
|
||||
|
||||
if (clearing.length === 5 && clearing.startsWith('8')) {
|
||||
return {
|
||||
clearing4: clearing.slice(0, 4),
|
||||
accountDigits: clearing.slice(4) + account,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Ogiltigt clearingnummer: ${clearingInput} (förväntat 4 siffror, eller 5 siffror som börjar med 8 för Swedbank)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative clearing-number -> bank-name lookup, for reassurance next to the
|
||||
* field. Only the major, long-stable, unambiguous ranges are included; any
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* the salary journal entry. Subject to 7-year retention.
|
||||
*/
|
||||
|
||||
import { splitDomesticBankAccount } from './bank-account'
|
||||
|
||||
export interface BgLbCompanyData {
|
||||
name: string
|
||||
/** Sender bankgiro number, with or without dash. e.g. "123-4567" or "1234567" */
|
||||
@@ -112,7 +114,7 @@ export function generateBgLb(
|
||||
// Pos 54-59: Payment date YYMMDD
|
||||
// Pos 60-80: Free reference / period label (21 chars)
|
||||
for (const emp of positivePayments) {
|
||||
const { clearing4, accountWithSwedbankPrefix } = encodeReceiverAccount(
|
||||
const { clearing4, accountDigits } = splitDomesticBankAccount(
|
||||
emp.clearingNumber,
|
||||
emp.bankAccountNumber
|
||||
)
|
||||
@@ -122,7 +124,7 @@ export function generateBgLb(
|
||||
records.push(
|
||||
pad('54', 2) +
|
||||
padNumber(clearing4, 4) +
|
||||
padNumber(accountWithSwedbankPrefix, 10) +
|
||||
padNumber(accountDigits, 10) +
|
||||
padText(emp.name, 25) +
|
||||
padNumber(String(amountOre), 12) +
|
||||
paymentDateYyMmDd +
|
||||
@@ -302,46 +304,3 @@ function pad(value: string, length: number): string {
|
||||
if (value.length > length) return value.slice(0, length)
|
||||
return value.padEnd(length, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Bankgirot encodes 4-digit clearings directly. Swedbank uses 5-digit clearings
|
||||
* (8xxx-x); the 5th digit is moved to the leading position of the account field.
|
||||
*
|
||||
* For 5-digit clearings starting with "8": digits 1-4 go to the clearing field,
|
||||
* the 5th digit becomes the first digit of the 10-position account field.
|
||||
*
|
||||
* Nordea Personkonto with 11-digit account number: the displayed account
|
||||
* already includes the 4-digit clearing as its leading digits (e.g. clearing
|
||||
* 1708 + account 17082042825). The 10-digit BG-LB account field cannot fit
|
||||
* 11 digits, so we strip the redundant clearing prefix and zero-pad the
|
||||
* remaining 7 digits to 10. The receiving bank reconstructs the full
|
||||
* personkonto from clearing + account.
|
||||
*/
|
||||
function encodeReceiverAccount(
|
||||
clearingInput: string,
|
||||
accountInput: string
|
||||
): { clearing4: string; accountWithSwedbankPrefix: string } {
|
||||
const clearing = clearingInput.replace(/\D/g, '')
|
||||
const account = accountInput.replace(/\D/g, '')
|
||||
|
||||
if (clearing.length === 4) {
|
||||
// Nordea Personkonto: account is 11 digits and starts with the clearing.
|
||||
// Strip the redundant clearing prefix so it fits the 10-digit account field.
|
||||
if (account.length === 11 && account.startsWith(clearing)) {
|
||||
return { clearing4: clearing, accountWithSwedbankPrefix: account.slice(4) }
|
||||
}
|
||||
return { clearing4: clearing, accountWithSwedbankPrefix: account }
|
||||
}
|
||||
|
||||
if (clearing.length === 5 && clearing.startsWith('8')) {
|
||||
// Swedbank: keep 4 leading digits in clearing field, prepend 5th digit to account.
|
||||
return {
|
||||
clearing4: clearing.slice(0, 4),
|
||||
accountWithSwedbankPrefix: clearing.slice(4) + account,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Ogiltigt clearingnummer: ${clearingInput} (förväntat 4 siffror, eller 5 siffror som börjar med 8 för Swedbank)`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
/**
|
||||
* pain.001 (ISO 20022) payment file generator for salary batch payments.
|
||||
*
|
||||
* Swedish banks (SEB, Handelsbanken, Swedbank, Nordea) accept
|
||||
* pain.001.001.03 for credit transfer initiation.
|
||||
* Dialect: Swedish DOMESTIC salary credit transfers per the Swedish Common
|
||||
* Interpretation of ISO 20022 payment messages (Svenska Bankföreningen,
|
||||
* "Common Payment Types in Sweden", Appendix 1, Example 4: Salaries),
|
||||
* cross-checked against Nordea Corporate Access Payables pain.001 examples
|
||||
* v2.6 (2026-06-22). Target banks: Swedbank, SEB, Handelsbanken, Nordea
|
||||
* (pain.001.001.03 uploaded in the bank's corporate portal).
|
||||
*
|
||||
* The generated file is uploaded to the bank's corporate portal.
|
||||
* Wire-format constraints this file encodes (do not "improve" without a
|
||||
* bank implementation guide in hand):
|
||||
*
|
||||
* - Salary batches are marked ONLY with CtgyPurp SALA. No SvcLvl element:
|
||||
* SvcLvl SEPA means a SEPA credit transfer, which is EUR-only and wrong
|
||||
* for SEK; the domestic default (NURG) applies when SvcLvl is omitted.
|
||||
* - Remittance information is NOT allowed on Swedish salary payments
|
||||
* (Nordea MIG: "No remittance info allowed"). The text on the employee's
|
||||
* statement comes from the Dataclearing LÖN code derived from SALA.
|
||||
* - The employee account is addressed domestically: the clearing number
|
||||
* goes in CdtrAgt as a ClrSysMmbId under clearing system SESBA, and the
|
||||
* account number WITHOUT the clearing goes in CdtrAcct/Id/Othr with
|
||||
* SchmeNm BBAN. The clearing/account split is shared with the Bankgirot
|
||||
* LB generator (splitDomesticBankAccount) so both formats present
|
||||
* identical routing for Swedbank 5-digit clearings and Nordea personkonto.
|
||||
* - MsgId, PmtInfId, InstrId and EndToEndId are Max35Text: bases are
|
||||
* truncated so suffixes always fit.
|
||||
*
|
||||
* Per BFL: the generated file is räkenskapsinformation (underlag) linked to
|
||||
* the salary journal entry. Subject to 7-year retention.
|
||||
*/
|
||||
|
||||
import { splitDomesticBankAccount } from './bank-account'
|
||||
|
||||
export interface Pain001CompanyData {
|
||||
name: string
|
||||
orgNumber: string // NNNNNN-NNNN
|
||||
@@ -22,20 +47,19 @@ export interface Pain001Employee {
|
||||
}
|
||||
|
||||
export interface Pain001Options {
|
||||
messageId: string // Unique message ID
|
||||
messageId: string // Unique message ID (truncated to Max35Text)
|
||||
paymentDate: string // YYYY-MM-DD requested execution date
|
||||
periodLabel: string // e.g. "2026-04" for remittance info
|
||||
periodLabel: string // e.g. "2026-04" (kept out of the file body: salary
|
||||
// payments carry no remittance info; used by callers
|
||||
// for filenames only)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pain.001.001.03 XML for salary batch payment.
|
||||
* Generate pain.001.001.03 XML for a Swedish domestic salary batch.
|
||||
*
|
||||
* Structure:
|
||||
* Document > CstmrCdtTrfInitn > GrpHdr + PmtInf (one per batch)
|
||||
* Document > CstmrCdtTrfInitn > GrpHdr + PmtInf (one batch)
|
||||
* PmtInf contains CdtTrfTxInf per employee
|
||||
*
|
||||
* Per BFL: The generated file is räkenskapsinformation (underlag)
|
||||
* linked to the salary journal entry. Subject to 7-year retention.
|
||||
*/
|
||||
export function generatePain001(
|
||||
company: Pain001CompanyData,
|
||||
@@ -45,6 +69,8 @@ export function generatePain001(
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
const totalAmount = employees.reduce((sum, e) => sum + e.netSalary, 0)
|
||||
const formattedTotal = formatDecimal(totalAmount)
|
||||
const msgId = max35(options.messageId)
|
||||
const orgDigits = company.orgNumber.replace(/\D/g, '')
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
@@ -54,29 +80,30 @@ export function generatePain001(
|
||||
|
||||
// ─── Group Header ───
|
||||
lines.push(' <GrpHdr>')
|
||||
lines.push(` <MsgId>${escapeXml(options.messageId)}</MsgId>`)
|
||||
lines.push(` <MsgId>${escapeXml(msgId)}</MsgId>`)
|
||||
lines.push(` <CreDtTm>${now}</CreDtTm>`)
|
||||
lines.push(` <NbOfTxs>${employees.length}</NbOfTxs>`)
|
||||
lines.push(` <CtrlSum>${formattedTotal}</CtrlSum>`)
|
||||
lines.push(' <InitgPty>')
|
||||
lines.push(` <Nm>${escapeXml(company.name)}</Nm>`)
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(company.orgNumber.replace('-', ''))}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
lines.push(' </InitgPty>')
|
||||
lines.push(' </GrpHdr>')
|
||||
|
||||
// ─── Payment Information ───
|
||||
lines.push(' <PmtInf>')
|
||||
lines.push(` <PmtInfId>${escapeXml(options.messageId)}-PMT</PmtInfId>`)
|
||||
lines.push(` <PmtInfId>${escapeXml(suffixId(msgId, '-PMT'))}</PmtInfId>`)
|
||||
lines.push(' <PmtMtd>TRF</PmtMtd>') // Transfer
|
||||
lines.push(' <BtchBookg>true</BtchBookg>') // Batch booking
|
||||
lines.push(' <BtchBookg>true</BtchBookg>') // One debit for the batch
|
||||
lines.push(` <NbOfTxs>${employees.length}</NbOfTxs>`)
|
||||
lines.push(` <CtrlSum>${formattedTotal}</CtrlSum>`)
|
||||
lines.push(' <PmtTpInf>')
|
||||
lines.push(' <SvcLvl><Cd>SEPA</Cd></SvcLvl>')
|
||||
lines.push(' <CtgyPurp><Cd>SALA</Cd></CtgyPurp>') // Salary payment
|
||||
lines.push(' </PmtTpInf>')
|
||||
lines.push(` <ReqdExctnDt>${options.paymentDate}</ReqdExctnDt>`)
|
||||
@@ -84,6 +111,13 @@ export function generatePain001(
|
||||
// Debtor (company)
|
||||
lines.push(' <Dbtr>')
|
||||
lines.push(` <Nm>${escapeXml(company.name)}</Nm>`)
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
lines.push(' </Dbtr>')
|
||||
lines.push(' <DbtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
@@ -103,7 +137,12 @@ export function generatePain001(
|
||||
// ─── Per-employee credit transfers ───
|
||||
for (let i = 0; i < employees.length; i++) {
|
||||
const emp = employees[i]
|
||||
const txId = `${options.messageId}-TX${String(i + 1).padStart(4, '0')}`
|
||||
const txSuffix = `-TX${String(i + 1).padStart(4, '0')}`
|
||||
const txId = suffixId(msgId, txSuffix)
|
||||
const { clearing4, accountDigits } = splitDomesticBankAccount(
|
||||
emp.clearingNumber,
|
||||
emp.bankAccountNumber
|
||||
)
|
||||
|
||||
lines.push(' <CdtTrfTxInf>')
|
||||
lines.push(' <PmtId>')
|
||||
@@ -113,18 +152,28 @@ export function generatePain001(
|
||||
lines.push(' <Amt>')
|
||||
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(emp.netSalary)}</InstdAmt>`)
|
||||
lines.push(' </Amt>')
|
||||
// Creditor bank identified by clearing number in the Swedish clearing
|
||||
// system (SESBA). XSD order: CdtrAgt before Cdtr/CdtrAcct.
|
||||
lines.push(' <CdtrAgt>')
|
||||
lines.push(' <FinInstnId>')
|
||||
lines.push(' <ClrSysMmbId>')
|
||||
lines.push(' <ClrSysId><Cd>SESBA</Cd></ClrSysId>')
|
||||
lines.push(` <MmbId>${clearing4}</MmbId>`)
|
||||
lines.push(' </ClrSysMmbId>')
|
||||
lines.push(' </FinInstnId>')
|
||||
lines.push(' </CdtrAgt>')
|
||||
lines.push(' <Cdtr>')
|
||||
lines.push(` <Nm>${escapeXml(emp.name)}</Nm>`)
|
||||
lines.push(' </Cdtr>')
|
||||
lines.push(' <CdtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
// Swedish domestic: clearing + account number (not IBAN for domestic)
|
||||
lines.push(` <Othr><Id>${escapeXml(emp.clearingNumber)}${escapeXml(emp.bankAccountNumber)}</Id></Othr>`)
|
||||
lines.push(' <Othr>')
|
||||
lines.push(` <Id>${accountDigits}</Id>`)
|
||||
lines.push(' <SchmeNm><Cd>BBAN</Cd></SchmeNm>')
|
||||
lines.push(' </Othr>')
|
||||
lines.push(' </Id>')
|
||||
lines.push(' </CdtrAcct>')
|
||||
lines.push(' <RmtInf>')
|
||||
lines.push(` <Ustrd>Lon ${escapeXml(options.periodLabel)}</Ustrd>`)
|
||||
lines.push(' </RmtInf>')
|
||||
// No RmtInf: not allowed for Swedish salary payments (SALA).
|
||||
lines.push(' </CdtTrfTxInf>')
|
||||
}
|
||||
|
||||
@@ -152,3 +201,15 @@ function escapeXml(str: string): string {
|
||||
function formatDecimal(amount: number): string {
|
||||
return (Math.round(amount * 100) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** Truncate to Max35Text. */
|
||||
function max35(value: string): string {
|
||||
return value.slice(0, 35)
|
||||
}
|
||||
|
||||
/** Append a suffix to a base id, truncating the BASE so the result stays
|
||||
* Max35Text: the suffix carries the uniqueness (per-tx counter) and must
|
||||
* survive intact. */
|
||||
function suffixId(base: string, suffix: string): string {
|
||||
return base.slice(0, Math.max(1, 35 - suffix.length)) + suffix
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('applySubscriptionState', () => {
|
||||
const grantUpsert = calls.find((c) => c.table === 'capability_grants')
|
||||
expect(grantUpsert?.op).toBe('upsert')
|
||||
const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }>
|
||||
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket'])
|
||||
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket', 'stripe_payments'])
|
||||
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user