Files
accounted/extensions/general/skatteverket/lib/skattekonto-sync.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

277 lines
8.8 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { getSaldo, getTransaktioner } from './skattekonto-client'
import { SkatteverketAuthError } from './api-client'
import type {
SkatteverketBookedTransaction,
SkatteverketUpcomingTransaction,
SkatteverketSaldoResponse,
SkattekontoBalanceSnapshot,
StoredSkattekontoTransaction,
} from '../types'
const log = createLogger('skattekonto-sync')
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
const LAST_SYNCED_AT_KEY = 'skattekonto_last_synced_at'
export interface SkattekontoSyncResult {
/** Number of new or status-promoted booked rows */
booked: number
/** Number of new or updated upcoming rows */
upcoming: number
/** Saldo at end of sync (mirrors snapshot) */
saldoSkatteverket: number
saldoKronofogden: number
/** Sync timestamp */
syncedAt: string
}
/**
* Compute the dedup key for a transaction.
*
* - When `transaktionsidentitet` is present (always on tidigare, sometimes
* on kommande), use it directly. It's stable across syncs.
* - Otherwise compute a sha256 hex over (date|amount|text): stable enough
* for kommande, which graduate to tidigare with the same content.
*
* The point of this function is reproducibility: the same logical
* transaction must always produce the same dedup_key.
*/
export function computeDedupKey(tx: {
transaktionsidentitet?: number | null
transaktionsdatum: string
beloppSkatteverket: number
transaktionstext: string
}): string {
if (tx.transaktionsidentitet != null) {
return `id:${tx.transaktionsidentitet}`
}
const material = `${tx.transaktionsdatum}|${tx.beloppSkatteverket}|${tx.transaktionstext}`
return `h:${crypto.createHash('sha256').update(material).digest('hex')}`
}
/**
* Resolve the org/personnummer to send to Skatteverket as `omfragad`.
* Reads from company_settings: same source the existing momsdeklaration
* flow uses.
*/
async function resolveOmfragad(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
return formatRedovisare(settings.org_number, settings.entity_type)
}
/**
* Build the row to insert/upsert into skattekonto_transactions.
*/
function bookedToRow(
companyId: string,
tx: SkatteverketBookedTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: null,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'booked',
}
}
function upcomingToRow(
companyId: string,
tx: SkatteverketUpcomingTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet ?? null,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'upcoming',
}
}
/**
* Sync skattekonto data for the active company in `ctx`.
*
* Steps:
* 1. Resolve omfragad from company_settings.
* 2. Fetch saldo + transaktioner in parallel (rate-limited).
* 3. Upsert rows by (company_id, dedup_key). When a kommande row graduates
* to tidigare it's updated in place: same dedup_key, status flips,
* transaktionsidentitet populated.
* 4. Cache the saldo response in extension_data with a fetched-at timestamp.
* 5. Emit skattekonto.synced and (when applicable) other events.
*/
export async function syncSkattekonto(
ctx: ExtensionContext,
): Promise<SkattekontoSyncResult> {
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
let saldo: SkatteverketSaldoResponse
let transaktioner: Awaited<ReturnType<typeof getTransaktioner>>
try {
;[saldo, transaktioner] = await Promise.all([
getSaldo(ctx.supabase, ctx.userId, omfragad),
getTransaktioner(ctx.supabase, ctx.userId, omfragad),
])
} catch (err) {
if (err instanceof SkatteverketAuthError) {
if (
err.code === 'REFRESH_EXHAUSTED' ||
err.code === 'SESSION_EXPIRED' ||
err.code === 'TOKEN_CORRUPTED'
) {
await eventBus.emit({
type: 'skattekonto.connection.expired',
payload: {
reason: err.code,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
}
throw err
}
const previousSnapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(
BALANCE_SNAPSHOT_KEY,
)
const previousBalance = previousSnapshot?.saldo.saldoSkatteverket ?? null
const bookedRows = transaktioner.tidigareTransaktioner.map(tx =>
bookedToRow(ctx.companyId, tx),
)
const upcomingRows = transaktioner.kommandeTransaktioner.map(tx =>
upcomingToRow(ctx.companyId, tx),
)
// Upsert in two steps to keep the conflict target consistent. We rely on
// the (company_id, dedup_key) unique constraint defined in the migration.
const allRows = [...bookedRows, ...upcomingRows]
// Find which dedup_keys are NEW (not yet in the table) so we can fire
// the `skattekonto.transaction.upcoming` event only for first-appearance
// upcoming rows.
const dedupKeys = allRows.map(r => r.dedup_key)
const { data: existingRows } = dedupKeys.length
? await ctx.supabase
.from('skattekonto_transactions')
.select('dedup_key, status')
.eq('company_id', ctx.companyId)
.in('dedup_key', dedupKeys)
: { data: [] }
const existingMap = new Map<string, { status: 'booked' | 'upcoming' }>()
for (const row of existingRows ?? []) {
existingMap.set(row.dedup_key as string, {
status: row.status as 'booked' | 'upcoming',
})
}
if (allRows.length > 0) {
const { error } = await ctx.supabase
.from('skattekonto_transactions')
.upsert(allRows, {
onConflict: 'company_id,dedup_key',
// Don't return rows: we already know what we wrote.
ignoreDuplicates: false,
})
if (error) {
log.error('upsert failed', { companyId: ctx.companyId, message: error.message })
throw new Error(`Kunde inte spara skattekonto-transaktioner: ${error.message}`)
}
}
// Cache balance snapshot.
const snapshot: SkattekontoBalanceSnapshot = {
saldo,
fetchedAt: Date.now(),
}
await ctx.settings.set(BALANCE_SNAPSHOT_KEY, snapshot)
await ctx.settings.set(LAST_SYNCED_AT_KEY, new Date().toISOString())
// Emit events.
await ctx.emit({
type: 'skattekonto.synced',
payload: {
booked: bookedRows.length,
upcoming: upcomingRows.length,
balanceSkv: saldo.saldoSkatteverket,
balanceKfm: saldo.saldoKronofogden,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
// Sign flip → fire balance.changed.
if (
previousBalance !== null &&
Math.sign(previousBalance) !== Math.sign(saldo.saldoSkatteverket)
) {
await ctx.emit({
type: 'skattekonto.balance.changed',
payload: {
previousBalance,
currentBalance: saldo.saldoSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
// First-appearance upcoming transactions.
for (const tx of transaktioner.kommandeTransaktioner) {
const key = computeDedupKey(tx)
if (existingMap.has(key)) continue
await ctx.emit({
type: 'skattekonto.transaction.upcoming',
payload: {
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
transaktionstext: tx.transaktionstext,
beloppSkatteverket: tx.beloppSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
return {
booked: bookedRows.length,
upcoming: upcomingRows.length,
saldoSkatteverket: saldo.saldoSkatteverket,
saldoKronofogden: saldo.saldoKronofogden,
syncedAt: new Date().toISOString(),
}
}
export const SKATTEKONTO_BALANCE_SNAPSHOT_KEY = BALANCE_SNAPSHOT_KEY
export const SKATTEKONTO_LAST_SYNCED_AT_KEY = LAST_SYNCED_AT_KEY