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:
Mattsson
2026-07-12 19:14:12 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ee3c33c7a4
commit 98d0c7f2d0
136 changed files with 15312 additions and 1133 deletions
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { cloudBackupExtension } from '../index'
import { isScheduleDue } from '../lib/schedule'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { GoogleDriveSchedule } from '../types'
function findRoute(method: string, path: string) {
const route = cloudBackupExtension.apiRoutes?.find(
(r) => r.method === method && r.path === path
)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(body: unknown): Request {
return new Request('https://test.local/api/extensions/ext/cloud-backup/schedule', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function makeContext(existing: GoogleDriveSchedule | null): {
ctx: ExtensionContext
set: ReturnType<typeof vi.fn>
} {
const set = vi.fn().mockResolvedValue(undefined)
const ctx = {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'cloud-backup',
requestId: 'req_test',
supabase: {},
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(existing),
set,
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as ExtensionContext
return { ctx, set }
}
describe('PUT /schedule', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('clears a stale hour_local on an hour_utc-only update so the UTC hour wins', async () => {
// Existing schedule fires at 05:00 Stockholm (03:00 UTC in summer).
const existing: GoogleDriveSchedule = {
enabled: true,
hour_utc: 3,
hour_local: 5,
last_auto_sync_at: null,
last_auto_sync_status: null,
last_auto_sync_error: null,
}
const { ctx, set } = makeContext(existing)
// Legacy UTC-only client moves the schedule to 14:00 UTC.
const res = await findRoute('PUT', '/schedule').handler(
makeRequest({ enabled: true, hour_utc: 14 }),
ctx
)
expect(res.status).toBe(200)
expect(set).toHaveBeenCalledTimes(1)
const stored = set.mock.calls[0][1] as GoogleDriveSchedule
expect(stored.hour_utc).toBe(14)
// The stale local hour must not survive: the scheduler prefers
// hour_local, so keeping 5 would make the schedule ignore 14:00 UTC.
expect(stored.hour_local).toBeUndefined()
// The stored schedule no longer resolves to the stale 05:00 Stockholm
// slot (03:00 UTC in summer): not due before 14:00 UTC, due after.
expect(isScheduleDue(stored, new Date('2026-07-12T03:30:00.000Z'))).toBe(false)
expect(isScheduleDue(stored, new Date('2026-07-12T14:01:00.000Z'))).toBe(true)
})
it('stores hour_local and mirrors hour_utc on an hour_local update', async () => {
const { ctx, set } = makeContext(null)
const res = await findRoute('PUT', '/schedule').handler(
makeRequest({ enabled: true, hour_local: 5 }),
ctx
)
expect(res.status).toBe(200)
const stored = set.mock.calls[0][1] as GoogleDriveSchedule
expect(stored.hour_local).toBe(5)
expect([3, 4]).toContain(stored.hour_utc) // CEST vs CET mirror
})
it('rejects a request without a valid hour', async () => {
const { ctx, set } = makeContext(null)
const res = await findRoute('PUT', '/schedule').handler(
makeRequest({ enabled: true }),
ctx
)
expect(res.status).toBe(400)
expect(set).not.toHaveBeenCalled()
})
})
@@ -1,14 +1,22 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { AlertTriangle, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import type { CloudBackupStatus, GoogleDriveSchedule } from '../types'
import type {
CloudBackupStatus,
GoogleDriveLastSync,
GoogleDriveSchedule,
} from '../types'
const API_BASE = '/api/extensions/ext/cloud-backup'
@@ -16,6 +24,7 @@ export default function CloudBackupCard() {
const { toast } = useToast()
const t = useTranslations('extensions')
const searchParams = useSearchParams()
const { dialogProps, confirm } = useDestructiveConfirm()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -26,28 +35,53 @@ export default function CloudBackupCard() {
const loadStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/status`)
if (!res.ok) throw new Error('Kunde inte hämta status')
if (!res.ok) throw new Error(t('ext_cloud_backup_status_failed'))
const { data } = (await res.json()) as { data: CloudBackupStatus }
setStatus(data)
} finally {
setIsLoading(false)
}
}, [])
}, [t])
useEffect(() => {
loadStatus()
}, [loadStatus])
// After a connect redirect the first backup builds in the background: poll
// the status a few times so the finished sync shows up without a reload.
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
// Handle OAuth callback redirect params.
useEffect(() => {
const result = searchParams.get('cloud_backup')
if (!result) return
if (result === 'connected') {
toast({ title: 'Google Drive kopplat', description: 'Du kan nu synka till din Drive.' })
} else if (result === 'error') {
const reason = searchParams.get('reason') || 'Okänt fel'
if (result === 'connected' || result === 'connected_first') {
toast({
title: 'Kunde inte koppla Google Drive',
title: t('ext_cloud_backup_connected_title'),
description: t(
result === 'connected_first'
? 'ext_cloud_backup_connected_first_description'
: 'ext_cloud_backup_connected_description'
),
})
let attempts = 0
pollRef.current = setInterval(() => {
attempts += 1
loadStatus()
if (attempts >= 6 && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}, 10_000)
} else if (result === 'error') {
const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error')
toast({
title: t('ext_cloud_backup_connect_failed'),
description: reason,
variant: 'destructive',
})
@@ -57,7 +91,7 @@ export default function CloudBackupCard() {
url.searchParams.delete('cloud_backup')
url.searchParams.delete('reason')
window.history.replaceState({}, '', url.toString())
}, [searchParams, toast])
}, [loadStatus, searchParams, t, toast])
const handleConnect = useCallback(async () => {
setIsConnecting(true)
@@ -65,19 +99,19 @@ export default function CloudBackupCard() {
const res = await fetch(`${API_BASE}/connect`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte starta anslutning')
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
}
const { url } = (await res.json()) as { url: string }
window.location.href = url
} catch (err) {
toast({
title: 'Kunde inte koppla Google Drive',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('ext_cloud_backup_connect_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
setIsConnecting(false)
}
}, [toast])
}, [t, toast])
const handleDisconnect = useCallback(async () => {
setIsDisconnecting(true)
@@ -85,69 +119,118 @@ export default function CloudBackupCard() {
const res = await fetch(`${API_BASE}/disconnect`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte koppla bort')
throw new Error(body.error || t('ext_cloud_backup_disconnect_failed'))
}
toast({ title: 'Google Drive bortkopplat' })
toast({ title: t('ext_cloud_backup_disconnected') })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla bort',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('ext_cloud_backup_disconnect_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsDisconnecting(false)
}
}, [loadStatus, toast])
}, [loadStatus, t, toast])
type SyncOutcome =
| { result: 'ok' | 'error' }
| { result: 'too_large'; sizeMb: number | null; limitMb: number | null }
const syncOnce = useCallback(
async (allowDocumentFallback: boolean): Promise<SyncOutcome> => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_documents: true,
allow_document_fallback: allowDocumentFallback,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
if (res.status === 413 && !allowDocumentFallback) {
// Handled by the caller: a dialog offers syncing the oversized
// archives without their document blobs.
return {
result: 'too_large',
sizeMb: body.size_bytes
? Math.round(body.size_bytes / (1024 * 1024))
: null,
limitMb: body.size_limit_bytes
? Math.round(body.size_limit_bytes / (1024 * 1024))
: null,
}
}
if (body.error === 'needs_reauth') {
// Refresh status so the card switches to the reconnect state.
await loadStatus()
throw new Error(t('ext_cloud_backup_reauth_description'))
}
throw new Error(body.error || t('ext_cloud_backup_sync_failed'))
}
const { data } = (await res.json()) as {
data: GoogleDriveLastSync & {
web_view_link: string
uploaded_count?: number
skipped_count?: number
}
}
if (data.uploaded_count === 0) {
toast({
title: t('ext_cloud_backup_up_to_date'),
description: t('ext_cloud_backup_no_changes'),
})
} else {
const anyNoDocs = (data.files ?? []).some(
(f) => f.kind !== 'readme' && f.included_documents === false
)
toast({
title: t('ext_cloud_backup_uploaded'),
description: `${t('ext_cloud_backup_files_updated', {
count: data.uploaded_count ?? 0,
})} (${formatMb(data.total_size_bytes ?? data.file_size_bytes ?? 0)})${
anyNoDocs ? ` · ${t('ext_cloud_backup_no_documents_note')}` : ''
}`,
})
}
await loadStatus()
return { result: 'ok' }
} catch (err) {
toast({
title: t('ext_cloud_backup_sync_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
return { result: 'error' }
} finally {
setIsSyncing(false)
}
},
[loadStatus, t, toast]
)
const handleSync = useCallback(async () => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include_documents: true }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
if (res.status === 413) {
const mb = body.size_bytes
? Math.round(body.size_bytes / (1024 * 1024))
: null
throw new Error(
mb
? `Arkivet är ${mb} MB, större än nuvarande gräns. Minska omfattning eller avvakta bakgrundssynk.`
: 'Arkivet är för stort för direktsynk.'
)
}
if (body.error === 'needs_reauth') {
// Refresh status so the card switches to the reconnect state.
await loadStatus()
throw new Error(t('ext_cloud_backup_reauth_description'))
}
throw new Error(body.error || 'Synkningen misslyckades')
}
const { data } = (await res.json()) as {
data: { file_name: string; file_size_bytes: number; web_view_link: string }
}
toast({
title: 'Uppladdad till Google Drive',
description: `${data.file_name} (${formatMb(data.file_size_bytes)})`,
})
await loadStatus()
} catch (err) {
toast({
title: 'Synkningen misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
variant: 'destructive',
})
} finally {
setIsSyncing(false)
}
}, [loadStatus, t, toast])
const first = await syncOnce(false)
if (first.result !== 'too_large') return
const ok = await confirm({
title: t('ext_cloud_backup_too_large_title'),
description: t('ext_cloud_backup_too_large_description', {
size: first.sizeMb != null ? String(first.sizeMb) : '?',
limit: first.limitMb != null ? String(first.limitMb) : '?',
}),
confirmLabel: t('ext_cloud_backup_too_large_confirm'),
variant: 'warning',
})
if (ok) await syncOnce(true)
}, [confirm, syncOnce, t])
return (
<div className="rounded-lg border border-border bg-card p-6">
<DestructiveConfirmDialog {...dialogProps} />
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Identity */}
<div className="flex items-start gap-3">
@@ -157,7 +240,7 @@ export default function CloudBackupCard() {
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Google Drive</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
Säkerhetskopia till din egen Drive.
{t('ext_cloud_backup_card_tagline')}
</p>
</div>
</div>
@@ -165,7 +248,7 @@ export default function CloudBackupCard() {
{/* Controls */}
<div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
) : status?.connected ? (
<>
{status.needs_reauth && (
@@ -186,7 +269,7 @@ export default function CloudBackupCard() {
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Omdirigerar…
{t('ext_cloud_backup_redirecting')}
</>
) : (
<>
@@ -200,29 +283,22 @@ export default function CloudBackupCard() {
)}
<dl className="space-y-3 text-sm">
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">Konto</dt>
<dt className="shrink-0 text-muted-foreground">
{t('ext_cloud_backup_account_label')}
</dt>
<dd className="min-w-0 truncate font-medium">{status.account_email}</dd>
</div>
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">Senaste synk</dt>
<dt className="shrink-0 text-muted-foreground">
{t('ext_cloud_backup_last_sync_label')}
</dt>
<dd className="min-w-0 text-right">
{status.last_sync ? (
<>
<a
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
>
{formatDate(status.last_sync.at)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
<p className="text-xs text-muted-foreground tabular-nums">
{formatMb(status.last_sync.file_size_bytes)}
</p>
</>
<LastSyncSummary lastSync={status.last_sync} />
) : (
<span className="text-muted-foreground">Aldrig</span>
<span className="text-muted-foreground">
{t('ext_cloud_backup_never')}
</span>
)}
</dd>
</div>
@@ -241,12 +317,12 @@ export default function CloudBackupCard() {
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Synkar…
{t('ext_cloud_backup_syncing')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Synka nu
{t('ext_cloud_backup_sync_now')}
</>
)}
</Button>
@@ -259,12 +335,12 @@ export default function CloudBackupCard() {
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar bort…
{t('ext_cloud_backup_disconnecting')}
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
Koppla bort
{t('ext_cloud_backup_disconnect')}
</>
)}
</Button>
@@ -273,21 +349,19 @@ export default function CloudBackupCard() {
) : (
<>
<p className="text-sm text-muted-foreground leading-relaxed">
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen Drive.
Accounted får bara tillgång till filer som appen själv skapar (scope{' '}
<span className="font-mono text-xs">drive.file</span>).
{t('ext_cloud_backup_connect_description')}
</p>
<div className="mt-4">
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Omdirigerar…
{t('ext_cloud_backup_redirecting')}
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
Koppla Google Drive
{t('ext_cloud_backup_connect')}
</>
)}
</Button>
@@ -300,12 +374,57 @@ export default function CloudBackupCard() {
)
}
/**
* Last-sync cell. New records list the per-fiscal-year files and link to the
* Drive folder; legacy single-ZIP records link to the file.
*/
function LastSyncSummary({ lastSync }: { lastSync: GoogleDriveLastSync }) {
const t = useTranslations('extensions')
const files = lastSync.files
const href = files
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
const sizeBytes = files
? lastSync.total_size_bytes ?? 0
: lastSync.file_size_bytes ?? 0
const anyNoDocs = files
? files.some((f) => f.kind !== 'readme' && f.included_documents === false)
: lastSync.included_documents === false
const archiveCount = files ? files.filter((f) => f.kind !== 'readme').length : null
const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256)
return (
<>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
>
{formatDateTime(lastSync.at)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
<p className="text-xs text-muted-foreground tabular-nums">
{formatMb(sizeBytes)}
{archiveCount !== null &&
` · ${t('ext_cloud_backup_files_count', { count: archiveCount })}`}
{verified && ` · ${t('ext_cloud_backup_verified')}`}
</p>
{anyNoDocs && (
<p className="text-xs text-muted-foreground">
{t('ext_cloud_backup_last_sync_no_documents')}
</p>
)}
</>
)
}
function formatMb(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
}
function formatDate(iso: string): string {
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('sv-SE', {
year: 'numeric',
@@ -326,23 +445,22 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
const { toast } = useToast()
const t = useTranslations('extensions')
// Convert stored UTC hour to the user's local hour for display.
const initialLocalHour =
schedule && typeof schedule.hour_utc === 'number'
? utcHourToLocalHour(schedule.hour_utc)
: utcHourToLocalHour(3)
// Prefer the DST-stable Stockholm hour; fall back to converting the legacy
// UTC hour through the browser's clock (Swedish users: same thing).
const scheduleHour = (s: GoogleDriveSchedule | null): number =>
typeof s?.hour_local === 'number'
? s.hour_local
: utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3)
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
const [localHour, setLocalHour] = useState(initialLocalHour)
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
setEnabled(schedule?.enabled ?? false)
setLocalHour(
schedule && typeof schedule.hour_utc === 'number'
? utcHourToLocalHour(schedule.hour_utc)
: utcHourToLocalHour(3)
)
}, [schedule?.enabled, schedule?.hour_utc])
setLocalHour(scheduleHour(schedule))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule?.enabled, schedule?.hour_utc, schedule?.hour_local])
const save = useCallback(
async (nextEnabled: boolean, nextLocalHour: number) => {
@@ -353,25 +471,25 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: nextEnabled,
hour_utc: localHourToUtcHour(nextLocalHour),
hour_local: nextLocalHour,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte spara schema')
throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed'))
}
await onUpdated()
} catch (err) {
toast({
title: 'Kunde inte spara schema',
description: err instanceof Error ? err.message : 'Försök igen.',
title: t('ext_cloud_backup_schedule_save_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
},
[onUpdated, toast]
[onUpdated, t, toast]
)
const handleToggle = useCallback(
@@ -396,10 +514,10 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
Automatisk synkronisering
{t('ext_cloud_backup_auto_sync_title')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Daglig säkerhetsbackup till din Drive.
{t('ext_cloud_backup_auto_sync_description')}
</p>
</div>
<Switch
@@ -413,7 +531,7 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
{enabled && (
<div className="flex items-center gap-2">
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
Tid (lokal)
{t('ext_cloud_backup_time_label')}
</Label>
<select
id="auto-sync-hour"
@@ -434,12 +552,12 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
{schedule?.last_auto_sync_at && (
<p className="text-xs text-muted-foreground">
Senaste automatiska synk: {formatDate(schedule.last_auto_sync_at)}{' '}
{t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '}
{schedule.last_auto_sync_status === 'success' ? (
<span className="text-success">· lyckades</span>
<span className="text-success">· {t('ext_cloud_backup_auto_sync_success')}</span>
) : schedule.last_auto_sync_status === 'error' ? (
<span className="text-destructive">
· misslyckades
· {t('ext_cloud_backup_auto_sync_error')}
{needsReauth
? ` (${t('ext_cloud_backup_reauth_needed_short')})`
: schedule.last_auto_sync_error
@@ -459,10 +577,3 @@ function utcHourToLocalHour(hourUtc: number): number {
d.setUTCHours(hourUtc, 0, 0, 0)
return d.getHours()
}
/** Convert a local hour (0-23) to UTC. */
function localHourToUtcHour(localHour: number): number {
const d = new Date()
d.setHours(localHour, 0, 0, 0)
return d.getUTCHours()
}
+68 -14
View File
@@ -1,5 +1,5 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import {
buildAuthorizationUrl,
exchangeCodeForTokens,
@@ -19,6 +19,7 @@ import {
LAST_SYNC_KEY,
SCHEDULE_KEY,
} from './lib/sync'
import { stockholmHourToUtcHour } from './lib/schedule'
import type {
CloudBackupStatus,
GoogleDriveConnection,
@@ -38,7 +39,8 @@ async function loadConnection(
const DEFAULT_SCHEDULE: GoogleDriveSchedule = {
enabled: false,
hour_utc: 3, // 05:00 Swedish summer time / 04:00 winter: low-traffic default
hour_utc: 3,
hour_local: 5, // 05:00 Swedish time, DST-stable: low-traffic default
last_auto_sync_at: null,
last_auto_sync_status: null,
last_auto_sync_error: null,
@@ -128,7 +130,40 @@ export const cloudBackupExtension: Extension = {
company_folder_id: null,
}
await ctx.settings.set(CONNECTION_KEY, connection)
return redirect('connected')
// First-time connections get daily auto-sync on by default: a
// backup that defaults to off protects nobody. Reconnects keep
// whatever schedule the user had.
const existingSchedule =
await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
const firstConnect = !existingSchedule
if (firstConnect) {
await ctx.settings.set(SCHEDULE_KEY, {
...DEFAULT_SCHEDULE,
enabled: true,
})
}
// Kick off the first backup after the redirect response is sent, so
// the user lands back on the card immediately while the archive
// builds in the background.
const syncOrigin = process.env.NEXT_PUBLIC_APP_URL || origin
after(async () => {
try {
await performSync({
supabase: ctx.supabase,
companyId: ctx.companyId,
userId: ctx.userId,
origin: syncOrigin,
includeDocuments: true,
allowDocumentFallback: true,
})
} catch (err) {
ctx.log.error('initial sync after connect failed', err)
}
})
return redirect(firstConnect ? 'connected_first' : 'connected')
} catch (err) {
ctx.log.error('oauth callback failed', err)
return redirect(
@@ -202,8 +237,9 @@ export const cloudBackupExtension: Extension = {
},
},
// Update the auto-sync schedule. Preserves `last_auto_sync_*` fields the
// cron writes: those are not user-editable.
// Update the auto-sync schedule. Preserves the fields the cron writes
// (`last_auto_sync_*`, failure counter, alert throttle): those are not
// user-editable.
{
method: 'PUT',
path: '/schedule',
@@ -212,24 +248,38 @@ export const cloudBackupExtension: Extension = {
try {
const body = (await request.json()) as {
enabled?: boolean
hour_local?: number
hour_utc?: number
}
if (typeof body.enabled !== 'boolean') {
return jsonError('enabled must be a boolean', 400)
}
if (
typeof body.hour_utc !== 'number' ||
!Number.isInteger(body.hour_utc) ||
body.hour_utc < 0 ||
body.hour_utc > 23
) {
return jsonError('hour_utc must be an integer between 0 and 23', 400)
const validHour = (h: unknown): h is number =>
typeof h === 'number' && Number.isInteger(h) && h >= 0 && h <= 23
let hourLocal: number | undefined
let hourUtc: number
if (validHour(body.hour_local)) {
// Preferred: Stockholm wall-clock hour, DST-stable. hour_utc is
// mirrored (today's offset) so legacy readers keep a sane value.
hourLocal = body.hour_local
hourUtc = stockholmHourToUtcHour(body.hour_local)
} else if (validHour(body.hour_utc)) {
// Legacy UTC-only request: leave hourLocal undefined so any
// stored hour_local is cleared below. The scheduler prefers
// hour_local, so keeping a stale value would make the schedule
// ignore the requested UTC hour.
hourUtc = body.hour_utc
} else {
return jsonError('hour_local must be an integer between 0 and 23', 400)
}
const existing = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
const updated: GoogleDriveSchedule = {
...existing,
enabled: body.enabled,
hour_utc: body.hour_utc,
hour_utc: hourUtc,
hour_local: hourLocal,
last_auto_sync_at: existing?.last_auto_sync_at ?? null,
last_auto_sync_status: existing?.last_auto_sync_status ?? null,
last_auto_sync_error: existing?.last_auto_sync_error ?? null,
@@ -246,7 +296,7 @@ export const cloudBackupExtension: Extension = {
},
},
// Generate an archive and upload it to Drive. Returns the Drive file info.
// Generate the archive set and sync it to Drive. Returns the sync summary.
{
method: 'POST',
path: '/sync',
@@ -255,6 +305,7 @@ export const cloudBackupExtension: Extension = {
try {
const body = (await request.json().catch(() => ({}))) as {
include_documents?: boolean
allow_document_fallback?: boolean
}
const origin =
process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin
@@ -264,6 +315,7 @@ export const cloudBackupExtension: Extension = {
userId: ctx.userId,
origin,
includeDocuments: body.include_documents !== false,
allowDocumentFallback: body.allow_document_fallback === true,
})
if (!result.ok) {
@@ -290,6 +342,8 @@ export const cloudBackupExtension: Extension = {
data: {
...result.lastSync,
web_view_link: result.webViewLink,
uploaded_count: result.uploadedCount,
skipped_count: result.skippedCount,
},
})
} catch (err) {
@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
const sendEmail = vi.fn()
const isConfigured = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ sendEmail, isConfigured }),
}))
import {
shouldSendBackupAlert,
sendBackupFailureAlert,
ALERT_FAILURE_THRESHOLD,
ALERT_THROTTLE_MS,
} from '../backup-alert'
const NOW = new Date('2026-07-12T04:00:00.000Z')
describe('shouldSendBackupAlert', () => {
it('alerts immediately on needs_reauth regardless of failure count', () => {
expect(
shouldSendBackupAlert({
kind: 'needs_reauth',
consecutiveFailures: 1,
lastAlertAt: null,
now: NOW,
})
).toBe(true)
})
it('requires the failure threshold for repeated_failures', () => {
expect(
shouldSendBackupAlert({
kind: 'repeated_failures',
consecutiveFailures: ALERT_FAILURE_THRESHOLD - 1,
lastAlertAt: null,
now: NOW,
})
).toBe(false)
expect(
shouldSendBackupAlert({
kind: 'repeated_failures',
consecutiveFailures: ALERT_FAILURE_THRESHOLD,
lastAlertAt: null,
now: NOW,
})
).toBe(true)
})
it('throttles both kinds against last_alert_at', () => {
const recent = new Date(NOW.getTime() - ALERT_THROTTLE_MS + 60_000).toISOString()
expect(
shouldSendBackupAlert({
kind: 'needs_reauth',
consecutiveFailures: 0,
lastAlertAt: recent,
now: NOW,
})
).toBe(false)
expect(
shouldSendBackupAlert({
kind: 'repeated_failures',
consecutiveFailures: 10,
lastAlertAt: recent,
now: NOW,
})
).toBe(false)
const stale = new Date(NOW.getTime() - ALERT_THROTTLE_MS - 60_000).toISOString()
expect(
shouldSendBackupAlert({
kind: 'needs_reauth',
consecutiveFailures: 0,
lastAlertAt: stale,
now: NOW,
})
).toBe(true)
})
})
/**
* Supabase stub: company_members lookup resolves a member with an email,
* company_settings resolves a company name.
*/
function makeSupabase(options: { member?: unknown; companyName?: string | null } = {}) {
const from = vi.fn().mockImplementation((table: string) => {
const chain: any = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockImplementation(() => {
if (table === 'company_members') {
return Promise.resolve({
data:
options.member !== undefined
? options.member
: { user_id: 'u-1', profiles: { email: 'emil@example.com' } },
})
}
return Promise.resolve({
data: { company_name: options.companyName ?? 'Testbolag AB' },
})
}),
}
return chain
})
return { from } as any
}
const baseInput = {
companyId: 'c-1',
userId: 'u-1',
consecutiveFailures: 3,
errorMessage: 'Drive quota exceeded',
origin: 'https://app.test',
} as const
describe('sendBackupFailureAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
isConfigured.mockReturnValue(true)
sendEmail.mockResolvedValue({ success: true })
})
it('does nothing when the email service is not configured', async () => {
isConfigured.mockReturnValue(false)
const result = await sendBackupFailureAlert(makeSupabase(), {
...baseInput,
kind: 'repeated_failures',
})
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
expect(sendEmail).not.toHaveBeenCalled()
})
it('does not email a user who is no longer a company member', async () => {
const result = await sendBackupFailureAlert(makeSupabase({ member: null }), {
...baseInput,
kind: 'repeated_failures',
})
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
expect(sendEmail).not.toHaveBeenCalled()
})
it('sends a repeated-failures email with count, error and link', async () => {
const result = await sendBackupFailureAlert(makeSupabase(), {
...baseInput,
kind: 'repeated_failures',
})
expect(result).toEqual({ sent: true })
expect(sendEmail).toHaveBeenCalledTimes(1)
const options = sendEmail.mock.calls[0][0]
expect(options.to).toBe('emil@example.com')
expect(options.subject).toContain('misslyckas')
expect(options.text).toContain('3 nätter i rad')
expect(options.text).toContain('Drive quota exceeded')
expect(options.text).toContain('https://app.test/import#cloud-backup')
expect(options.html).toContain('Testbolag AB')
})
it('sends a needs_reauth email pointing at the reconnect flow', async () => {
const result = await sendBackupFailureAlert(makeSupabase(), {
...baseInput,
kind: 'needs_reauth',
errorMessage: null,
})
expect(result).toEqual({ sent: true })
const options = sendEmail.mock.calls[0][0]
expect(options.subject).toContain('pausad')
expect(options.text).toContain('Koppla om Google Drive')
expect(options.text).toContain('https://app.test/import#cloud-backup')
})
it('reports send failures without throwing', async () => {
sendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
const result = await sendBackupFailureAlert(makeSupabase(), {
...baseInput,
kind: 'repeated_failures',
})
expect(result).toEqual({ sent: false, reason: 'send_failed' })
})
it('escapes HTML in the error message', async () => {
await sendBackupFailureAlert(makeSupabase(), {
...baseInput,
kind: 'repeated_failures',
errorMessage: '<script>alert(1)</script>',
})
const options = sendEmail.mock.calls[0][0]
expect(options.html).not.toContain('<script>')
expect(options.html).toContain('&lt;script&gt;')
})
})
@@ -1,5 +1,12 @@
import { createHash } from 'node:crypto'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ensureFolder, uploadFile } from '../google-drive'
import {
DriveFileGoneError,
ensureFolder,
getFileMeta,
updateFile,
uploadFile,
} from '../google-drive'
beforeEach(() => {
vi.restoreAllMocks()
@@ -65,40 +72,110 @@ describe('ensureFolder', () => {
})
})
describe('uploadFile', () => {
it('posts multipart body with metadata + binary and returns parsed result', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
/** Resumable init response: 200 with a session URL in the Location header. */
function initResponse(): Response {
return new Response(null, {
status: 200,
headers: { Location: 'https://upload.session/xyz' },
})
}
function fileResponse(
file: Record<string, unknown>,
status = 200
): Response {
return new Response(JSON.stringify(file), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function md5Of(data: ArrayBuffer): string {
return createHash('md5').update(Buffer.from(data)).digest('hex')
}
describe('uploadFile (resumable)', () => {
it('initiates a resumable session with metadata, then uploads the bytes', async () => {
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(
fileResponse({
id: 'file-123',
name: 'arkiv.zip',
size: '2048',
webViewLink: 'https://drive.google.com/file/d/file-123/view',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
md5Checksum: md5Of(data),
})
)
)
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
const result = await uploadFile('access-tok', 'folder-1', 'arkiv.zip', data)
expect(result.id).toBe('file-123')
expect(result.size_bytes).toBe(2048)
expect(result.web_view_link).toContain('file-123')
expect(result.md5_checksum).toBe(md5Of(data))
const [url, init] = fetchMock.mock.calls[0]
expect(url).toContain('uploadType=multipart')
const contentType = (init as RequestInit).headers as Record<string, string>
expect(contentType.Authorization).toBe('Bearer access-tok')
expect(contentType['Content-Type']).toContain('multipart/related')
// Body must be a Buffer that contains the metadata JSON.
const body = (init as RequestInit).body as Buffer
expect(Buffer.isBuffer(body)).toBe(true)
expect(body.toString('utf8')).toContain('"name":"arkiv.zip"')
expect(body.toString('utf8')).toContain('"parents":["folder-1"]')
const [initUrl, initInit] = fetchMock.mock.calls[0]
expect(initUrl).toContain('uploadType=resumable')
expect((initInit as RequestInit).method).toBe('POST')
const initHeaders = (initInit as RequestInit).headers as Record<string, string>
expect(initHeaders.Authorization).toBe('Bearer access-tok')
expect(initHeaders['X-Upload-Content-Length']).toBe('5')
const metadata = JSON.parse(String((initInit as RequestInit).body))
expect(metadata.name).toBe('arkiv.zip')
expect(metadata.parents).toEqual(['folder-1'])
const [chunkUrl, chunkInit] = fetchMock.mock.calls[1]
expect(chunkUrl).toBe('https://upload.session/xyz')
expect((chunkInit as RequestInit).method).toBe('PUT')
const chunkHeaders = (chunkInit as RequestInit).headers as Record<string, string>
expect(chunkHeaders['Content-Range']).toBe('bytes 0-4/5')
})
it('throws with Drive error body when upload fails', async () => {
it('splits large payloads into chunks and follows 308 continuations', async () => {
const CHUNK = 8 * 1024 * 1024
const data = new Uint8Array(CHUNK + 10).buffer
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(
new Response(null, {
status: 308,
headers: { Range: `bytes=0-${CHUNK - 1}` },
})
)
.mockResolvedValueOnce(
fileResponse({ id: 'f-1', name: 'big.zip', md5Checksum: md5Of(data) })
)
const result = await uploadFile('at', 'folder', 'big.zip', data)
expect(result.id).toBe('f-1')
expect(fetchMock).toHaveBeenCalledTimes(3)
const firstRange = (fetchMock.mock.calls[1][1] as RequestInit)
.headers as Record<string, string>
expect(firstRange['Content-Range']).toBe(`bytes 0-${CHUNK - 1}/${CHUNK + 10}`)
const secondRange = (fetchMock.mock.calls[2][1] as RequestInit)
.headers as Record<string, string>
expect(secondRange['Content-Range']).toBe(`bytes ${CHUNK}-${CHUNK + 9}/${CHUNK + 10}`)
})
it('retries a chunk on 5xx before giving up', async () => {
const data = new Uint8Array([7, 7]).buffer
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(new Response('flaky', { status: 502 }))
.mockResolvedValueOnce(
fileResponse({ id: 'f-1', name: 'a.zip', md5Checksum: md5Of(data) })
)
const result = await uploadFile('at', 'folder', 'a.zip', data)
expect(result.id).toBe('f-1')
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('throws with Drive error body when the init fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('quota exceeded', { status: 403 })
)
@@ -106,4 +183,115 @@ describe('uploadFile', () => {
uploadFile('at', 'folder', 'a.zip', new Uint8Array(1).buffer)
).rejects.toThrow(/403/)
})
it('retries the whole upload once on checksum mismatch, then succeeds', async () => {
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(
fileResponse({ id: 'f-1', name: 'a.zip', md5Checksum: 'corrupted' })
)
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(
fileResponse({ id: 'f-2', name: 'a.zip', md5Checksum: md5Of(data) })
)
const result = await uploadFile('at', 'folder', 'a.zip', data)
expect(result.id).toBe('f-2')
expect(fetchMock).toHaveBeenCalledTimes(4)
})
it('throws after two checksum mismatches', async () => {
let call = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
call++
return call % 2 === 1
? initResponse()
: fileResponse({ id: 'f-1', name: 'a.zip', md5Checksum: 'corrupted' })
})
await expect(
uploadFile('at', 'folder', 'a.zip', new Uint8Array([9]).buffer)
).rejects.toThrow(/checksum mismatch/)
})
it('trusts the upload when Drive omits md5Checksum', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(fileResponse({ id: 'f-1', name: 'a.zip' }))
const result = await uploadFile('at', 'folder', 'a.zip', new Uint8Array([9]).buffer)
expect(result.id).toBe('f-1')
expect(result.md5_checksum).toBeUndefined()
})
})
describe('updateFile (resumable, in place)', () => {
it('PATCHes an existing file id and uploads the new content', async () => {
const data = new Uint8Array([4, 5, 6]).buffer
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(initResponse())
.mockResolvedValueOnce(
fileResponse({ id: 'file-9', name: 'Arkiv 2024.zip', md5Checksum: md5Of(data) })
)
const result = await updateFile('at', 'file-9', data)
expect(result.id).toBe('file-9')
const [initUrl, initInit] = fetchMock.mock.calls[0]
expect(initUrl).toContain('/file-9?uploadType=resumable')
expect((initInit as RequestInit).method).toBe('PATCH')
// No metadata body on a content-only update.
expect((initInit as RequestInit).body).toBeUndefined()
})
it('throws DriveFileGoneError when the file id no longer exists', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('not found', { status: 404 })
)
await expect(
updateFile('at', 'gone-id', new Uint8Array([1]).buffer)
).rejects.toBeInstanceOf(DriveFileGoneError)
})
})
describe('getFileMeta', () => {
it('returns metadata with trashed flag', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'f-1', name: 'gnubok', trashed: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const meta = await getFileMeta('at', 'f-1')
expect(meta).toEqual({ id: 'f-1', name: 'gnubok', trashed: true })
const url = fetchMock.mock.calls[0][0] as string
expect(url).toContain('/files/f-1')
expect(url).toContain('fields=id,name,trashed')
})
it('defaults trashed to false when the field is absent', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'f-1', name: 'gnubok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const meta = await getFileMeta('at', 'f-1')
expect(meta?.trashed).toBe(false)
})
it('returns null on 404 (deleted or invisible to the scope)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('not found', { status: 404 })
)
expect(await getFileMeta('at', 'gone')).toBeNull()
})
it('throws on other errors', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('server error', { status: 500 })
)
await expect(getFileMeta('at', 'f-1')).rejects.toThrow(/500/)
})
})
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { isScheduleDue, stockholmHourToUtcHour } from '../schedule'
import type { GoogleDriveSchedule } from '../../types'
function makeSchedule(overrides: Partial<GoogleDriveSchedule> = {}): GoogleDriveSchedule {
return {
enabled: true,
hour_utc: 3,
last_auto_sync_at: null,
last_auto_sync_status: null,
last_auto_sync_error: null,
...overrides,
}
}
// Fixed reference: 2026-07-12 12:30 UTC.
const NOW = new Date('2026-07-12T12:30:00.000Z')
describe('isScheduleDue', () => {
it('is false when the schedule is missing or disabled', () => {
expect(isScheduleDue(null, NOW)).toBe(false)
expect(isScheduleDue(undefined, NOW)).toBe(false)
expect(isScheduleDue(makeSchedule({ enabled: false }), NOW)).toBe(false)
})
it('is false when hour_utc is invalid', () => {
expect(isScheduleDue(makeSchedule({ hour_utc: -1 }), NOW)).toBe(false)
expect(isScheduleDue(makeSchedule({ hour_utc: 24 }), NOW)).toBe(false)
expect(isScheduleDue(makeSchedule({ hour_utc: 3.5 }), NOW)).toBe(false)
expect(
isScheduleDue(makeSchedule({ hour_utc: 'x' as unknown as number }), NOW)
).toBe(false)
})
it('is false before the daily slot has passed', () => {
expect(isScheduleDue(makeSchedule({ hour_utc: 13 }), NOW)).toBe(false)
expect(isScheduleDue(makeSchedule({ hour_utc: 23 }), NOW)).toBe(false)
})
it('is true at or after the slot when never synced', () => {
expect(isScheduleDue(makeSchedule({ hour_utc: 12 }), NOW)).toBe(true)
// Catch-up: a 03:00 company missed by an overrun batch is still due at 12:30.
expect(isScheduleDue(makeSchedule({ hour_utc: 3 }), NOW)).toBe(true)
expect(isScheduleDue(makeSchedule({ hour_utc: 0 }), NOW)).toBe(true)
})
it('is false when an attempt already ran since today\'s slot', () => {
expect(
isScheduleDue(
makeSchedule({ hour_utc: 3, last_auto_sync_at: '2026-07-12T03:05:00.000Z' }),
NOW
)
).toBe(false)
expect(
isScheduleDue(
makeSchedule({ hour_utc: 12, last_auto_sync_at: '2026-07-12T12:10:00.000Z' }),
NOW
)
).toBe(false)
})
it('is true when the last attempt predates today\'s slot', () => {
// Yesterday's run.
expect(
isScheduleDue(
makeSchedule({ hour_utc: 3, last_auto_sync_at: '2026-07-11T03:05:00.000Z' }),
NOW
)
).toBe(true)
// A late catch-up yesterday evening does not swallow today's slot.
expect(
isScheduleDue(
makeSchedule({ hour_utc: 3, last_auto_sync_at: '2026-07-11T23:00:00.000Z' }),
NOW
)
).toBe(true)
})
it('treats an unparsable last_auto_sync_at as never synced', () => {
expect(
isScheduleDue(
makeSchedule({ hour_utc: 3, last_auto_sync_at: 'not-a-date' }),
NOW
)
).toBe(true)
})
})
describe('isScheduleDue with hour_local (Europe/Stockholm, DST-stable)', () => {
it('computes the slot in Stockholm time during CEST (UTC+2)', () => {
// 05:00 Swedish summer time = 03:00 UTC.
const schedule = makeSchedule({ hour_local: 5 })
expect(isScheduleDue(schedule, new Date('2026-07-12T02:59:00.000Z'))).toBe(false)
expect(isScheduleDue(schedule, new Date('2026-07-12T03:01:00.000Z'))).toBe(true)
})
it('computes the slot in Stockholm time during CET (UTC+1)', () => {
// 05:00 Swedish winter time = 04:00 UTC: same wall-clock, shifted UTC.
const schedule = makeSchedule({ hour_local: 5 })
expect(isScheduleDue(schedule, new Date('2026-01-12T03:30:00.000Z'))).toBe(false)
expect(isScheduleDue(schedule, new Date('2026-01-12T04:01:00.000Z'))).toBe(true)
})
it('hour_local wins over the legacy hour_utc', () => {
// hour_utc says 23 (not yet due), hour_local says 5 (due at 12:30 UTC).
const schedule = makeSchedule({ hour_utc: 23, hour_local: 5 })
expect(isScheduleDue(schedule, NOW)).toBe(true)
})
it('already-ran-today respects the Stockholm slot', () => {
const ranToday = makeSchedule({
hour_local: 5,
last_auto_sync_at: '2026-07-12T03:05:00.000Z',
})
expect(isScheduleDue(ranToday, new Date('2026-07-12T12:00:00.000Z'))).toBe(false)
const ranYesterday = makeSchedule({
hour_local: 5,
last_auto_sync_at: '2026-07-11T03:05:00.000Z',
})
expect(isScheduleDue(ranYesterday, new Date('2026-07-12T12:00:00.000Z'))).toBe(true)
})
})
describe('stockholmHourToUtcHour', () => {
it('maps 05:00 Stockholm to 03 UTC in summer and 04 UTC in winter', () => {
expect(stockholmHourToUtcHour(5, new Date('2026-07-12T12:00:00.000Z'))).toBe(3)
expect(stockholmHourToUtcHour(5, new Date('2026-01-12T12:00:00.000Z'))).toBe(4)
})
it('handles midnight without h24 artifacts', () => {
expect(stockholmHourToUtcHour(0, new Date('2026-07-12T12:00:00.000Z'))).toBe(22)
expect(stockholmHourToUtcHour(0, new Date('2026-01-12T12:00:00.000Z'))).toBe(23)
})
})
@@ -1,9 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createHash } from 'node:crypto'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/reports/full-archive-export', () => ({
estimateArchiveSize: vi.fn(),
generateFullArchive: vi.fn(),
generateBaseDataArchive: vi.fn(),
ARCHIVE_OVERHEAD_BYTES: 8 * 1024 * 1024,
}))
vi.mock('@/lib/reports/archive-readme', () => ({
buildDriveFolderReadme: vi.fn().mockReturnValue('README TEXT'),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.test' }),
}))
vi.mock('../google-oauth', async (importOriginal) => {
@@ -19,29 +29,50 @@ vi.mock('../google-oauth', async (importOriginal) => {
}
})
vi.mock('../google-drive', () => ({
ensureFolder: vi.fn(),
uploadFile: vi.fn(),
}))
vi.mock('../google-drive', () => {
class DriveFileGoneError extends Error {}
return {
ensureFolder: vi.fn(),
uploadFile: vi.fn(),
updateFile: vi.fn(),
getFileMeta: vi.fn(),
DriveFileGoneError,
}
})
vi.mock('../crypto', () => ({
decryptToken: vi.fn().mockReturnValue('plain-refresh-token'),
}))
import { performSync, CONNECTION_KEY, LAST_SYNC_KEY } from '../sync'
import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth'
import { ensureFolder, uploadFile } from '../google-drive'
import {
estimateArchiveSize,
performSync,
CONNECTION_KEY,
LAST_SYNC_KEY,
ARCHIVE_FORMAT_VERSION,
SIZE_LIMIT_BYTES,
arkivFileName,
} from '../sync'
import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth'
import {
DriveFileGoneError,
ensureFolder,
getFileMeta,
updateFile,
uploadFile,
} from '../google-drive'
import {
generateFullArchive,
generateBaseDataArchive,
} from '@/lib/reports/full-archive-export'
import type { GoogleDriveConnection } from '../../types'
import type { GoogleDriveConnection, GoogleDriveLastSync } from '../../types'
const mockRefreshAccessToken = vi.mocked(refreshAccessToken)
const mockEnsureFolder = vi.mocked(ensureFolder)
const mockGetFileMeta = vi.mocked(getFileMeta)
const mockUploadFile = vi.mocked(uploadFile)
const mockEstimateArchiveSize = vi.mocked(estimateArchiveSize)
const mockUpdateFile = vi.mocked(updateFile)
const mockGenerateFullArchive = vi.mocked(generateFullArchive)
const mockGenerateBaseDataArchive = vi.mocked(generateBaseDataArchive)
function makeConnection(
overrides: Partial<GoogleDriveConnection> = {}
@@ -56,54 +87,170 @@ function makeConnection(
}
}
const PERIOD = { id: 'p-2024', period_start: '2024-01-01', period_end: '2024-12-31' }
const ENTRY = { id: 'e-1', fiscal_period_id: 'p-2024', updated_at: '2024-06-01T00:00:00Z' }
const AUDIT_AT = '2026-07-01T00:00:00Z'
interface MockData {
connection?: GoogleDriveConnection | null
lastSync?: GoogleDriveLastSync | null
periods?: (typeof PERIOD)[]
entries?: (typeof ENTRY)[]
docs?: {
id: string
journal_entry_id: string | null
file_size_bytes: number | null
created_at: string | null
}[]
auditAt?: string
}
/**
* Minimal supabase stub covering what performSync touches:
* - extension_data select ... maybeSingle() (connection load)
* - extension_data upsert (connection/last-sync save)
* - company_settings select ... maybeSingle() (folder label)
* Table-routed supabase stub: performSync touches extension_data (connection,
* last-sync), company_settings, fiscal_periods, journal_entries,
* document_attachments and audit_log. Thenable chains resolve per table.
*/
function makeSupabase(connection: GoogleDriveConnection | null) {
function makeSupabase(data: MockData) {
const upsert = vi.fn().mockResolvedValue({ error: null })
const from = vi.fn().mockImplementation((table: string) => {
let key: string | null = null
const chain: any = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data:
table === 'extension_data'
? connection
? { value: connection }
: null
: { company_name: 'Testbolag AB', org_number: '556000-0000' },
}),
upsert,
maybeSingle: vi.fn().mockImplementation(() => {
if (table === 'extension_data') {
if (key === 'google_drive_last_sync') {
return Promise.resolve({
data: data.lastSync ? { value: data.lastSync } : null,
})
}
return Promise.resolve({
data: data.connection ? { value: data.connection } : null,
})
}
if (table === 'company_settings') {
return Promise.resolve({
data: { company_name: 'Testbolag AB', org_number: '556000-0000' },
})
}
return Promise.resolve({ data: null })
}),
then: (resolve: (v: unknown) => void) => {
if (table === 'fiscal_periods') {
return resolve({ data: data.periods ?? [PERIOD], error: null })
}
if (table === 'journal_entries') {
return resolve({ data: data.entries ?? [ENTRY], error: null })
}
if (table === 'document_attachments') {
return resolve({ data: data.docs ?? [], error: null })
}
if (table === 'audit_log') {
return resolve({
data: [{ created_at: data.auditAt ?? AUDIT_AT }],
error: null,
})
}
return resolve({ data: [], error: null })
},
}
const passthrough = ['select', 'eq', 'in', 'order', 'range', 'limit']
for (const method of passthrough) {
chain[method] = vi.fn().mockImplementation((col?: string, val?: string) => {
if (method === 'eq' && col === 'key') key = val ?? null
return chain
})
}
return chain
})
return { supabase: { from } as any, upsert }
}
function syncParams(supabase: any) {
function syncParams(supabase: any, overrides: Record<string, unknown> = {}) {
return {
supabase,
companyId: 'company-1',
userId: 'user-1',
origin: 'https://app.test',
includeDocuments: true,
...overrides,
}
}
function sha256(data: ArrayBuffer | string): string {
return createHash('sha256')
.update(typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data))
.digest('hex')
}
// Fingerprints performSync derives from the fixture above.
const PERIOD_FP = `v${ARCHIVE_FORMAT_VERSION}|1|${ENTRY.updated_at}|0||docs:1`
const BASE_FP = `v${ARCHIVE_FORMAT_VERSION}|${AUDIT_AT}|0||docs:1`
const README_FP = `v${ARCHIVE_FORMAT_VERSION}|${sha256('README TEXT').slice(0, 16)}`
function upToDateLastSync(): GoogleDriveLastSync {
return {
at: '2026-07-11T03:00:00.000Z',
folder_id: 'company-1',
files: [
{
kind: 'period',
period_id: PERIOD.id,
file_id: 'drive-period',
file_name: 'Arkiv 2024.zip',
size_bytes: 100,
fingerprint: PERIOD_FP,
sha256: 'x',
included_documents: true,
uploaded_at: '2026-07-11T03:00:00.000Z',
},
{
kind: 'base',
file_id: 'drive-base',
file_name: 'Grunddata.zip',
size_bytes: 50,
fingerprint: BASE_FP,
sha256: 'y',
included_documents: true,
uploaded_at: '2026-07-11T03:00:00.000Z',
},
{
kind: 'readme',
file_id: 'drive-readme',
file_name: 'LÄSMIG.txt',
size_bytes: 11,
fingerprint: README_FP,
sha256: 'z',
included_documents: true,
uploaded_at: '2026-07-11T03:00:00.000Z',
},
],
total_size_bytes: 161,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockEstimateArchiveSize.mockResolvedValue({
total_bytes: 1_000,
document_bytes: 100,
} as any)
mockGetFileMeta.mockResolvedValue({ id: 'company-1', name: 'Testbolag', trashed: false })
mockRefreshAccessToken.mockResolvedValue({ access_token: 'fresh-token', expires_in: 3600 })
mockGenerateFullArchive.mockResolvedValue(new ArrayBuffer(8))
mockGenerateBaseDataArchive.mockResolvedValue(new ArrayBuffer(16))
mockUploadFile.mockImplementation(async (_t, _folder, name, data) => ({
id: `created-${name}`,
name,
size_bytes: (data as ArrayBuffer).byteLength,
web_view_link: `https://drive.google.com/file/d/created-${name}/view`,
}))
mockUpdateFile.mockImplementation(async (_t, fileId, data) => ({
id: fileId,
name: 'updated',
size_bytes: (data as ArrayBuffer).byteLength,
web_view_link: `https://drive.google.com/file/d/${fileId}/view`,
}))
})
describe('performSync needs_reauth handling', () => {
it('flags the connection needs_reauth when Google returns 400 invalid_grant', async () => {
const { supabase, upsert } = makeSupabase(makeConnection())
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
mockRefreshAccessToken.mockRejectedValueOnce(
new GoogleTokenRefreshError(
400,
@@ -119,14 +266,13 @@ describe('performSync needs_reauth handling', () => {
expect(payload.key).toBe(CONNECTION_KEY)
expect(payload.value.status).toBe('needs_reauth')
expect(payload.value.needs_reauth_at).toEqual(expect.any(String))
// Token, email etc. stay intact so the UI can still show the account.
expect(payload.value.account_email).toBe('user@example.com')
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('rethrows transient refresh failures (5xx) without flagging', async () => {
const { supabase, upsert } = makeSupabase(makeConnection())
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
mockRefreshAccessToken.mockRejectedValueOnce(
new GoogleTokenRefreshError(500, 'Internal Server Error')
)
@@ -135,66 +281,232 @@ describe('performSync needs_reauth handling', () => {
expect(upsert).not.toHaveBeenCalled()
})
it('rethrows a 400 that is not invalid_grant without flagging', async () => {
const { supabase, upsert } = makeSupabase(makeConnection())
mockRefreshAccessToken.mockRejectedValueOnce(
new GoogleTokenRefreshError(400, '{"error":"invalid_request"}')
)
await expect(performSync(syncParams(supabase))).rejects.toThrow(/400/)
expect(upsert).not.toHaveBeenCalled()
})
it('rethrows non-refresh errors untouched', async () => {
const { supabase, upsert } = makeSupabase(makeConnection())
mockRefreshAccessToken.mockRejectedValueOnce(new Error('network down'))
await expect(performSync(syncParams(supabase))).rejects.toThrow('network down')
expect(upsert).not.toHaveBeenCalled()
})
it('clears a stale needs_reauth flag after a successful refresh', async () => {
const { supabase, upsert } = makeSupabase(
makeConnection({
const { supabase, upsert } = makeSupabase({
connection: makeConnection({
status: 'needs_reauth',
needs_reauth_at: '2026-07-01T03:00:00.000Z',
})
)
mockRefreshAccessToken.mockResolvedValueOnce({
access_token: 'fresh-token',
expires_in: 3600,
}),
})
mockGenerateFullArchive.mockResolvedValueOnce(new ArrayBuffer(8))
mockUploadFile.mockResolvedValueOnce({
id: 'file-1',
name: 'arkiv.zip',
size_bytes: 8,
web_view_link: 'https://drive.google.com/file/d/file-1/view',
} as any)
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
// First upsert rewrites the connection with the flag cleared.
const connectionSave = upsert.mock.calls.find(
([payload]) => payload.key === CONNECTION_KEY
)
expect(connectionSave).toBeDefined()
expect(connectionSave![0].value.status).toBe('active')
// Last-sync state is still persisted as usual.
const lastSyncSave = upsert.mock.calls.find(
([payload]) => payload.key === LAST_SYNC_KEY
)
expect(lastSyncSave).toBeDefined()
})
it('still returns not_connected when no connection exists', async () => {
const { supabase } = makeSupabase(null)
it('returns not_connected when no connection exists', async () => {
const { supabase } = makeSupabase({ connection: null })
const result = await performSync(syncParams(supabase))
expect(result).toMatchObject({ ok: false, reason: 'not_connected' })
expect(mockRefreshAccessToken).not.toHaveBeenCalled()
})
})
describe('performSync per-fiscal-year layout', () => {
it('uploads one archive per period plus Grunddata and the folder README on first sync', async () => {
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.uploadedCount).toBe(3)
expect(result.skippedCount).toBe(0)
expect(result.webViewLink).toBe('https://drive.google.com/drive/folders/company-1')
expect(mockUploadFile).toHaveBeenCalledTimes(3)
const names = mockUploadFile.mock.calls.map((c) => c[2])
expect(names).toEqual(['Arkiv 2024.zip', 'Grunddata.zip', 'LÄSMIG.txt'])
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
// Progressive persistence: one snapshot per upload + the final one.
expect(lastSyncSaves.length).toBe(4)
const final = lastSyncSaves[lastSyncSaves.length - 1][0].value as GoogleDriveLastSync
expect(final.files).toHaveLength(3)
expect(final.total_size_bytes).toBe(8 + 16 + Buffer.from('README TEXT').length)
const periodFile = final.files!.find((f) => f.kind === 'period')!
expect(periodFile.fingerprint).toBe(PERIOD_FP)
expect(periodFile.sha256).toBe(sha256(new ArrayBuffer(8)))
expect(periodFile.included_documents).toBe(true)
})
it('skips everything when fingerprints are unchanged', async () => {
const { supabase, upsert } = makeSupabase({
connection: makeConnection(),
lastSync: upToDateLastSync(),
})
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.uploadedCount).toBe(0)
expect(result.skippedCount).toBe(3)
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
expect(mockGenerateBaseDataArchive).not.toHaveBeenCalled()
expect(mockUploadFile).not.toHaveBeenCalled()
expect(mockUpdateFile).not.toHaveBeenCalled()
// The final snapshot still refreshes `at` so the card shows recency.
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
expect(lastSyncSaves.length).toBe(1)
})
it('updates only the changed period file, in place', async () => {
const stale = upToDateLastSync()
stale.files![0].fingerprint = 'v2|0||0||docs:1' // period data changed since
const { supabase } = makeSupabase({
connection: makeConnection(),
lastSync: stale,
})
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.uploadedCount).toBe(1)
expect(result.skippedCount).toBe(2)
expect(mockUpdateFile).toHaveBeenCalledTimes(1)
expect(mockUpdateFile.mock.calls[0][1]).toBe('drive-period')
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('recreates a file the user deleted in Drive', async () => {
const stale = upToDateLastSync()
stale.files![0].fingerprint = 'changed'
const { supabase } = makeSupabase({
connection: makeConnection(),
lastSync: stale,
})
mockUpdateFile.mockRejectedValueOnce(new DriveFileGoneError('gone'))
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
expect(mockUploadFile).toHaveBeenCalledTimes(1)
expect(mockUploadFile.mock.calls[0][2]).toBe('Arkiv 2024.zip')
})
it('re-uploads everything for legacy single-file last_sync records', async () => {
const { supabase } = makeSupabase({
connection: makeConnection(),
lastSync: {
at: '2026-07-01T00:00:00Z',
folder_id: 'company-1',
file_id: 'legacy',
file_name: 'arkiv_full_x.zip',
file_size_bytes: 123,
},
})
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.uploadedCount).toBe(3)
expect(mockUpdateFile).not.toHaveBeenCalled()
})
})
describe('performSync size limits and document fallback', () => {
const hugeDoc = {
id: 'doc-huge',
journal_entry_id: 'e-1',
file_size_bytes: SIZE_LIMIT_BYTES,
created_at: '2024-07-01T00:00:00Z',
}
it('fails with archive_too_large when a period cannot fit and fallback is off', async () => {
const { supabase } = makeSupabase({
connection: makeConnection(),
docs: [hugeDoc],
})
const result = await performSync(syncParams(supabase))
expect(result).toMatchObject({
ok: false,
reason: 'archive_too_large',
size_limit_bytes: SIZE_LIMIT_BYTES,
})
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('builds the oversized period without documents when fallback is allowed', async () => {
const { supabase, upsert } = makeSupabase({
connection: makeConnection(),
docs: [hugeDoc],
})
const result = await performSync(
syncParams(supabase, { allowDocumentFallback: true })
)
expect(result.ok).toBe(true)
expect(mockGenerateFullArchive).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ scope: 'period', include_documents: false })
)
// Grunddata is unaffected: it still carries its (small) documents.
expect(mockGenerateBaseDataArchive).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ include_documents: true })
)
const lastSyncSaves = upsert.mock.calls.filter(([p]) => p.key === LAST_SYNC_KEY)
const final = lastSyncSaves[lastSyncSaves.length - 1][0].value as GoogleDriveLastSync
const periodFile = final.files!.find((f) => f.kind === 'period')!
expect(periodFile.included_documents).toBe(false)
expect(periodFile.fingerprint).toContain('docs:0')
})
})
describe('performSync folder revalidation', () => {
it('recreates the folder hierarchy when cached folders are trashed', async () => {
const { supabase, upsert } = makeSupabase({ connection: makeConnection() })
mockGetFileMeta
.mockResolvedValueOnce({ id: 'company-1', name: 'x', trashed: true })
.mockResolvedValueOnce({ id: 'root-1', name: 'gnubok', trashed: true })
mockEnsureFolder
.mockResolvedValueOnce({ id: 'root-2', name: 'gnubok' })
.mockResolvedValueOnce({ id: 'company-2', name: 'Testbolag AB (556000-0000)' })
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
expect(mockEnsureFolder).toHaveBeenCalledTimes(2)
const connectionSave = upsert.mock.calls.find(([p]) => p.key === CONNECTION_KEY)
expect(connectionSave![0].value.root_folder_id).toBe('root-2')
expect(connectionSave![0].value.company_folder_id).toBe('company-2')
// Uploads target the recreated company folder.
expect(mockUploadFile.mock.calls[0][1]).toBe('company-2')
})
it('does not touch folders when the cached company folder is alive', async () => {
const { supabase } = makeSupabase({ connection: makeConnection() })
const result = await performSync(syncParams(supabase))
expect(result.ok).toBe(true)
expect(mockGetFileMeta).toHaveBeenCalledTimes(1)
expect(mockEnsureFolder).not.toHaveBeenCalled()
})
})
describe('arkivFileName', () => {
it('uses the year alone for calendar years', () => {
expect(arkivFileName(PERIOD)).toBe('Arkiv 2024.zip')
})
it('uses full dates for broken fiscal years', () => {
expect(
arkivFileName({ id: 'p', period_start: '2024-07-01', period_end: '2025-06-30' })
).toBe('Arkiv 2024-07-01_2025-06-30.zip')
})
})
@@ -0,0 +1,152 @@
/**
* Failure-alert email for the cloud backup auto-sync.
*
* A backup that silently stops running is worse than no backup: the user
* believes they are covered. The cron calls this when a connection goes
* needs_reauth (retrying can never succeed) or when several consecutive
* auto-syncs have failed. Manual syncs never alert: the user is watching.
*
* Best-effort by design: an alert failure must never fail the sync loop.
* Throttling state (`last_alert_at`) lives on the schedule object and is
* persisted by the caller. The body carries no bookkeeping data, only the
* company name and the error summary.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
const log = createLogger('cloud-backup-alert')
/** At most one alert email per company per throttle window. */
export const ALERT_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000
/** Consecutive auto-sync failures before a repeated-failures alert fires. */
export const ALERT_FAILURE_THRESHOLD = 3
export type BackupAlertKind = 'needs_reauth' | 'repeated_failures'
export function shouldSendBackupAlert(params: {
kind: BackupAlertKind
consecutiveFailures: number
lastAlertAt: string | null | undefined
now?: Date
}): boolean {
const now = params.now ?? new Date()
if (params.lastAlertAt) {
const last = new Date(params.lastAlertAt).getTime()
if (Number.isFinite(last) && now.getTime() - last < ALERT_THROTTLE_MS) {
return false
}
}
if (params.kind === 'needs_reauth') return true
return params.consecutiveFailures >= ALERT_FAILURE_THRESHOLD
}
export interface BackupAlertInput {
companyId: string
/** The user who configured the schedule: recipient candidate. */
userId: string
kind: BackupAlertKind
consecutiveFailures: number
errorMessage: string | null
/** App origin used to build the reconnect link. */
origin: string
}
export async function sendBackupFailureAlert(
supabase: SupabaseClient,
input: BackupAlertInput
): Promise<{ sent: boolean; reason?: string }> {
try {
const email = getEmailService()
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
if (!recipient) {
log.info('no authorised recipient for backup alert', { companyId: input.companyId })
return { sent: false, reason: 'no_recipient' }
}
const companyName = await fetchCompanyName(supabase, input.companyId)
const link = `${input.origin}/import#cloud-backup`
let subject: string
let paragraphs: string[]
if (input.kind === 'needs_reauth') {
subject = 'Säkerhetskopieringen till Google Drive är pausad'
paragraphs = [
`Den automatiska säkerhetskopieringen för ${companyName} är pausad: åtkomsten till ditt Google-konto har gått ut eller återkallats.`,
'Koppla om Google Drive för att återuppta säkerhetskopieringen.',
]
} else {
subject = 'Säkerhetskopieringen till Google Drive misslyckas'
paragraphs = [
`Den automatiska säkerhetskopieringen för ${companyName} har misslyckats ${input.consecutiveFailures} nätter i rad.`,
input.errorMessage ? `Senaste fel: ${input.errorMessage}` : '',
'Kontrollera anslutningen under Importera/Exportera.',
].filter(Boolean)
}
const text = [...paragraphs, '', link].join('\n\n')
const html = [
...paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`),
`<p><a href="${escapeHtml(link)}">Öppna säkerhetskopiering</a></p>`,
].join('')
const result = await email.sendEmail({ to: recipient, subject, text, html })
if (!result.success) {
log.warn('backup alert send failed', { companyId: input.companyId, error: result.error })
return { sent: false, reason: 'send_failed' }
}
return { sent: true }
} catch (err) {
log.warn('backup alert failed', {
companyId: input.companyId,
error: err instanceof Error ? err.message : String(err),
})
return { sent: false, reason: 'error' }
}
}
/**
* The recipient must still be an active member of the company: a schedule
* owner who has since been removed must not receive alerts for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
async function fetchCompanyName(
supabase: SupabaseClient,
companyId: string
): Promise<string> {
const { data } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.maybeSingle()
return (data?.company_name as string) || 'ditt företag'
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -1,21 +1,43 @@
/**
* Minimal Google Drive v3 client: just enough to:
* - find or create a named folder,
* - upload a file via multipart.
* - revalidate a cached file/folder id,
* - upload a file (create) or update one in place, via resumable uploads.
*
* We operate on `drive.file` scope, so we can only see files we created.
* Queries by name return only app-created folders with that name.
*
* Uploads use Drive's resumable protocol in 8 MB chunks (a multiple of the
* required 256 KiB): no multipart body concatenation (which doubles memory
* for large archives), per-chunk retries on transient failures, and every
* upload is verified against the md5Checksum Drive reports. A silently
* corrupted backup is worse than a failed one.
*/
import { createHash } from 'node:crypto'
const DRIVE_API = 'https://www.googleapis.com/drive/v3'
const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'
const FOLDER_MIME = 'application/vnd.google-apps.folder'
/** 8 MB: resumable chunks must be multiples of 256 KiB. */
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024
const CHUNK_MAX_ATTEMPTS = 3
const RESULT_FIELDS = 'id,name,size,webViewLink,md5Checksum'
interface DriveFile {
id: string
name: string
}
/** Thrown when an update targets a file that no longer exists (404). */
export class DriveFileGoneError extends Error {
constructor(fileId: string) {
super(`Drive file ${fileId} no longer exists`)
this.name = 'DriveFileGoneError'
}
}
async function driveFetch(
accessToken: string,
path: string,
@@ -85,16 +107,48 @@ export async function ensureFolder(
return createFolder(accessToken, name, parentId)
}
export interface FileMeta {
id: string
name: string
/** True also when the file sits inside a trashed parent folder. */
trashed: boolean
}
/**
* Fetch metadata for a file/folder by id. Returns null on 404 (deleted or
* never visible to this scope). Used to revalidate cached folder ids: a file
* created inside a trashed folder is purged with it, so uploads must never
* target a trashed parent.
*/
export async function getFileMeta(
accessToken: string,
fileId: string
): Promise<FileMeta | null> {
const res = await fetch(
`${DRIVE_API}/files/${encodeURIComponent(fileId)}?fields=id,name,trashed`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (res.status === 404) return null
if (!res.ok) {
const body = await res.text()
throw new Error(`Drive API ${res.status}: ${body.slice(0, 200)}`)
}
const json = (await res.json()) as { id: string; name: string; trashed?: boolean }
return { id: json.id, name: json.name, trashed: json.trashed === true }
}
export interface UploadResult {
id: string
name: string
size_bytes: number
web_view_link: string
/** MD5 reported by Drive; present when the API returned it. */
md5_checksum?: string
}
/**
* Multipart upload: metadata + bytes in one request. Suitable for files
* up to ~100 MB; beyond that Drive recommends resumable uploads.
* Create a new file in a folder (resumable, md5-verified, one full retry on
* checksum mismatch).
*/
export async function uploadFile(
accessToken: string,
@@ -103,56 +157,155 @@ export async function uploadFile(
data: ArrayBuffer,
contentType = 'application/zip'
): Promise<UploadResult> {
const boundary = `gnubok-${crypto.randomUUID().replace(/-/g, '')}`
const metadata = JSON.stringify({
name: fileName,
parents: [folderId],
})
const head =
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${metadata}\r\n` +
`--${boundary}\r\n` +
`Content-Type: ${contentType}\r\n\r\n`
const tail = `\r\n--${boundary}--`
const body = Buffer.concat([
Buffer.from(head, 'utf8'),
Buffer.from(data),
Buffer.from(tail, 'utf8'),
])
const res = await fetch(
`${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id,name,size,webViewLink`,
{
return verifiedUpload(data, () =>
resumableUpload(accessToken, data, contentType, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
}
url: `${DRIVE_UPLOAD_API}?uploadType=resumable&fields=${RESULT_FIELDS}`,
metadata: { name: fileName, parents: [folderId] },
})
)
}
if (!res.ok) {
const errText = await res.text()
throw new Error(`Drive upload failed: ${res.status} ${errText.slice(0, 200)}`)
/**
* Replace the content of an existing file in place (resumable, md5-verified).
* Drive keeps prior versions of updated files for ~30 days, which gives the
* backup rolling history without accumulating one file per day.
* Throws DriveFileGoneError when the file id no longer exists.
*/
export async function updateFile(
accessToken: string,
fileId: string,
data: ArrayBuffer,
contentType = 'application/zip'
): Promise<UploadResult> {
return verifiedUpload(data, () =>
resumableUpload(accessToken, data, contentType, {
method: 'PATCH',
url: `${DRIVE_UPLOAD_API}/${encodeURIComponent(fileId)}?uploadType=resumable&fields=${RESULT_FIELDS}`,
fileId,
})
)
}
async function verifiedUpload(
data: ArrayBuffer,
run: () => Promise<UploadResult>
): Promise<UploadResult> {
const expectedMd5 = createHash('md5').update(Buffer.from(data)).digest('hex')
let lastError: Error | null = null
for (let attempt = 0; attempt < 2; attempt++) {
const result = await run()
if (!result.md5_checksum || result.md5_checksum === expectedMd5) {
return result
}
lastError = new Error(
`Drive upload checksum mismatch: expected ${expectedMd5}, got ${result.md5_checksum}`
)
}
throw lastError
}
interface ResumableTarget {
method: 'POST' | 'PATCH'
url: string
metadata?: Record<string, unknown>
fileId?: string
}
async function resumableUpload(
accessToken: string,
data: ArrayBuffer,
contentType: string,
target: ResumableTarget
): Promise<UploadResult> {
// 1) Initiate the session. Metadata (name/parents) rides on the initiation
// request; the byte chunks follow against the returned session URL.
const initRes = await fetch(target.url, {
method: target.method,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
'X-Upload-Content-Type': contentType,
'X-Upload-Content-Length': String(data.byteLength),
},
body: target.metadata ? JSON.stringify(target.metadata) : undefined,
})
if (initRes.status === 404 && target.fileId) {
throw new DriveFileGoneError(target.fileId)
}
if (!initRes.ok) {
const body = await initRes.text()
throw new Error(`Drive upload init failed: ${initRes.status} ${body.slice(0, 200)}`)
}
const sessionUrl = initRes.headers.get('location')
if (!sessionUrl) {
throw new Error('Drive upload init returned no session URL')
}
const json = (await res.json()) as {
id: string
name: string
size?: string
webViewLink?: string
}
// 2) Send the bytes in chunks. 308 = chunk accepted, keep going;
// 200/201 = upload complete with the file resource as body.
const total = data.byteLength
const buffer = Buffer.from(data)
let offset = 0
while (true) {
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, total)
const chunk = buffer.subarray(offset, end)
return {
id: json.id,
name: json.name,
size_bytes: json.size ? Number(json.size) : data.byteLength,
web_view_link: json.webViewLink || `https://drive.google.com/file/d/${json.id}/view`,
let res: Response | null = null
let lastErr: unknown = null
for (let attempt = 0; attempt < CHUNK_MAX_ATTEMPTS; attempt++) {
try {
res = await fetch(sessionUrl, {
method: 'PUT',
headers: {
'Content-Length': String(chunk.length),
'Content-Range': `bytes ${offset}-${end - 1}/${total}`,
},
body: chunk,
})
if (res.status >= 500) {
lastErr = new Error(`Drive chunk upload ${res.status}`)
res = null
continue
}
break
} catch (err) {
lastErr = err
res = null
}
}
if (!res) {
throw lastErr instanceof Error
? lastErr
: new Error('Drive chunk upload failed after retries')
}
if (res.status === 308) {
// Advance to wherever the session says it is (defensive: usually `end`).
const range = res.headers.get('range')
const match = range?.match(/bytes=0-(\d+)/)
offset = match ? Number(match[1]) + 1 : end
continue
}
if (res.ok) {
const json = (await res.json()) as {
id: string
name: string
size?: string
webViewLink?: string
md5Checksum?: string
}
return {
id: json.id,
name: json.name,
size_bytes: json.size ? Number(json.size) : total,
web_view_link:
json.webViewLink || `https://drive.google.com/file/d/${json.id}/view`,
md5_checksum: json.md5Checksum,
}
}
const body = await res.text()
throw new Error(`Drive upload failed: ${res.status} ${body.slice(0, 200)}`)
}
}
@@ -0,0 +1,83 @@
import type { GoogleDriveSchedule } from '../types'
const STOCKHOLM_TZ = 'Europe/Stockholm'
function isValidHour(hour: unknown): hour is number {
return (
typeof hour === 'number' && Number.isInteger(hour) && hour >= 0 && hour <= 23
)
}
function stockholmDateString(now: Date): string {
// sv-SE with a timeZone yields YYYY-MM-DD.
return new Intl.DateTimeFormat('sv-SE', { timeZone: STOCKHOLM_TZ }).format(now)
}
function stockholmHourOf(date: Date): number {
return Number(
new Intl.DateTimeFormat('sv-SE', {
timeZone: STOCKHOLM_TZ,
hour: '2-digit',
hourCycle: 'h23',
}).format(date)
)
}
/**
* Absolute timestamp of today's HH:00 in Europe/Stockholm. Tries the two
* offsets Sweden uses (CEST +02:00, CET +01:00) and keeps the one that lands
* on the requested wall-clock hour, so DST transitions resolve correctly
* without a timezone library.
*/
export function stockholmSlotForDay(now: Date, hour: number): Date {
const dateStr = stockholmDateString(now)
const hh = String(hour).padStart(2, '0')
for (const offset of ['+02:00', '+01:00']) {
const candidate = new Date(`${dateStr}T${hh}:00:00${offset}`)
if (stockholmHourOf(candidate) === hour) return candidate
}
return new Date(`${dateStr}T${hh}:00:00+01:00`)
}
/** UTC hour that corresponds to a Stockholm wall-clock hour today. */
export function stockholmHourToUtcHour(hour: number, now: Date = new Date()): number {
return stockholmSlotForDay(now, hour).getUTCHours()
}
/**
* Today's scheduled slot as an absolute timestamp, or null when the schedule
* carries no valid hour. `hour_local` (Europe/Stockholm wall-clock, stable
* across DST) wins over the legacy `hour_utc`.
*/
function scheduleSlotForDay(schedule: GoogleDriveSchedule, now: Date): Date | null {
if (isValidHour(schedule.hour_local)) {
return stockholmSlotForDay(now, schedule.hour_local)
}
if (isValidHour(schedule.hour_utc)) {
const slot = new Date(now)
slot.setUTCHours(schedule.hour_utc, 0, 0, 0)
return slot
}
return null
}
/**
* A schedule is due when its daily slot for today has passed and no auto-sync
* attempt has run since that slot. Hour equality is deliberately NOT
* required: if the popular 03:00 batch overruns the cron's time budget, the
* leftover companies stay due and get picked up by the next hourly run
* instead of silently losing the whole day.
*/
export function isScheduleDue(
schedule: GoogleDriveSchedule | null | undefined,
now: Date
): boolean {
if (!schedule?.enabled) return false
const slot = scheduleSlotForDay(schedule, now)
if (!slot) return false
if (now.getTime() < slot.getTime()) return false
if (!schedule.last_auto_sync_at) return true
const lastAttempt = new Date(schedule.last_auto_sync_at).getTime()
return Number.isFinite(lastAttempt) ? lastAttempt < slot.getTime() : true
}
+392 -46
View File
@@ -1,22 +1,55 @@
import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateFullArchive,
estimateArchiveSize,
generateBaseDataArchive,
ARCHIVE_OVERHEAD_BYTES,
} from '@/lib/reports/full-archive-export'
import { buildDriveFolderReadme } from '@/lib/reports/archive-readme'
import { getBranding } from '@/lib/branding/service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
getOAuthEnv,
refreshAccessToken,
GoogleTokenRefreshError,
} from './google-oauth'
import { ensureFolder, uploadFile } from './google-drive'
import {
DriveFileGoneError,
ensureFolder,
getFileMeta,
updateFile,
uploadFile,
type UploadResult,
} from './google-drive'
import { decryptToken } from './crypto'
import type { GoogleDriveConnection, GoogleDriveLastSync } from '../types'
import type {
DriveFileState,
GoogleDriveConnection,
GoogleDriveLastSync,
} from '../types'
export const CONNECTION_KEY = 'google_drive_connection'
export const LAST_SYNC_KEY = 'google_drive_last_sync'
export const SCHEDULE_KEY = 'google_drive_schedule'
export const ROOT_FOLDER_NAME = 'gnubok'
export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
/**
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
* bound left is JSZip building each archive in memory on a serverless
* function, hence 300 MB rather than "unlimited".
*
* Memory budget: worst-case transient usage is ~3x this limit (~900 MB):
* JSZip holds the input blobs while generateAsync accumulates output chunks
* and then concatenates them into the final ArrayBuffer. The upload path
* adds no further copies (md5/sha256 hash via zero-copy Buffer views,
* resumable 8 MB chunk views, no multipart concatenation), and files are
* generated and uploaded one at a time. That fits the Vercel default
* function memory (2048 MB) with ~2x headroom; raise this limit only
* together with an explicit memory bump in vercel.json.
*/
export const SIZE_LIMIT_BYTES = 300 * 1024 * 1024
/** Bump to force a re-upload of every file when the archive format changes. */
export const ARCHIVE_FORMAT_VERSION = 2
export type SyncFailureReason =
| 'not_connected'
@@ -29,7 +62,10 @@ export type PerformSyncResult =
| {
ok: true
lastSync: GoogleDriveLastSync
/** Link to the company's backup folder in Drive. */
webViewLink: string
uploadedCount: number
skippedCount: number
}
| {
ok: false
@@ -45,18 +81,50 @@ interface PerformSyncParams {
userId: string
origin: string
includeDocuments: boolean
/**
* When a single archive file with documents exceeds the size limit, fall
* back to building THAT file without document blobs instead of failing.
* The cron and the post-connect sync set this; the manual flow leaves it
* off and lets the user choose via a dialog.
*/
allowDocumentFallback?: boolean
}
interface PlannedFile {
key: string
kind: DriveFileState['kind']
periodId?: string
name: string
contentType: string
fingerprint: string
includeDocuments: boolean
generate: () => Promise<ArrayBuffer>
}
interface PeriodRow {
id: string
period_start: string
period_end: string
}
/**
* Run the end-to-end cloud backup sync: estimate size → refresh token →
* ensure Drive folder hierarchy → generate archive → upload → persist
* last_sync. Shared by the manual HTTP handler and the scheduled cron.
* Run the end-to-end cloud backup sync against the per-fiscal-year layout:
*
* gnubok/<company>/Arkiv <år>.zip one per räkenskapsår
* gnubok/<company>/Grunddata.zip registers, SIE originals, audit trail
* gnubok/<company>/LÄSMIG.txt folder map
*
* Every file carries a fingerprint (entry/document counts + latest
* timestamps); only files whose fingerprint changed are regenerated and
* uploaded, in place (Drive keeps ~30 days of prior versions). State is
* persisted after every upload, so an interrupted run resumes where it
* stopped instead of re-uploading finished years.
*
* Settings ops use raw `extension_data` queries (not the extension context
* wrapper) because the cron runs under the service role with no user session.
*/
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
const { supabase, companyId, userId, origin, includeDocuments } = params
const { supabase, companyId, userId, origin } = params
const connection = await loadExtensionData<GoogleDriveConnection>(
supabase,
@@ -67,20 +135,6 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
return { ok: false, reason: 'not_connected', message: 'Google Drive not connected' }
}
const estimate = await estimateArchiveSize(supabase, companyId, 'all')
const effectiveBytes = includeDocuments
? estimate.total_bytes
: estimate.total_bytes - estimate.document_bytes
if (effectiveBytes > SIZE_LIMIT_BYTES) {
return {
ok: false,
reason: 'archive_too_large',
message: 'Archive exceeds size limit',
size_bytes: effectiveBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
}
}
const env = getOAuthEnv(origin)
const refreshToken = decryptToken(connection.refresh_token_encrypted)
let accessToken: string
@@ -108,15 +162,29 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
throw err
}
const company = await fetchCompanyInfo(supabase, companyId)
let rootFolderId = connection.root_folder_id
let companyFolderId = connection.company_folder_id
// Revalidate cached folder ids: files created inside a trashed folder are
// purged with it, so a folder the user trashed or deleted must never
// receive uploads. `trashed` is inherited from parents, so checking the
// company folder covers a trashed root too; the root is only re-checked
// when the company folder needs recreating.
if (companyFolderId) {
const meta = await getFileMeta(accessToken, companyFolderId)
if (!meta || meta.trashed) companyFolderId = null
}
if (!companyFolderId && rootFolderId) {
const rootMeta = await getFileMeta(accessToken, rootFolderId)
if (!rootMeta || rootMeta.trashed) rootFolderId = null
}
if (!rootFolderId) {
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
rootFolderId = root.id
}
if (!companyFolderId) {
const companyName = await fetchCompanyLabel(supabase, companyId)
const companyFolder = await ensureFolder(accessToken, companyName, rootFolderId)
const companyFolder = await ensureFolder(accessToken, company.label, rootFolderId)
companyFolderId = companyFolder.id
}
if (
@@ -134,29 +202,306 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
})
}
const archive = await generateFullArchive(supabase, companyId, {
scope: 'all',
include_documents: includeDocuments,
// ---- Fingerprint basis: three paged reads + one point read. ----
const periods = await fetchAllRows<PeriodRow>(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
.range(from, to)
)
const entries = await fetchAllRows<{
id: string
fiscal_period_id: string
updated_at: string | null
}>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, fiscal_period_id, updated_at')
.eq('company_id', companyId)
.in('status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to)
)
const docs = await fetchAllRows<{
id: string
journal_entry_id: string | null
file_size_bytes: number | null
created_at: string | null
}>(({ from, to }) =>
supabase
.from('document_attachments')
.select('id, journal_entry_id, file_size_bytes, created_at')
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to)
)
const latestAuditAt = await fetchLatestAuditAt(supabase, companyId)
const entryToPeriod = new Map(entries.map((e) => [e.id, e.fiscal_period_id]))
interface Stats {
entryCount: number
maxEntryUpdated: string
docCount: number
docBytes: number
maxDocCreated: string
}
const emptyStats = (): Stats => ({
entryCount: 0,
maxEntryUpdated: '',
docCount: 0,
docBytes: 0,
maxDocCreated: '',
})
const statsByPeriod = new Map<string, Stats>()
for (const period of periods) statsByPeriod.set(period.id, emptyStats())
for (const entry of entries) {
const stats = statsByPeriod.get(entry.fiscal_period_id)
if (!stats) continue
stats.entryCount++
if (entry.updated_at && entry.updated_at > stats.maxEntryUpdated) {
stats.maxEntryUpdated = entry.updated_at
}
}
const baseStats = emptyStats()
for (const doc of docs) {
const periodId = doc.journal_entry_id ? entryToPeriod.get(doc.journal_entry_id) : undefined
const stats = (periodId && statsByPeriod.get(periodId)) || baseStats
stats.docCount++
stats.docBytes += Number(doc.file_size_bytes) || 0
if (doc.created_at && doc.created_at > stats.maxDocCreated) {
stats.maxDocCreated = doc.created_at
}
}
// ---- Plan the file set. ----
const planned: PlannedFile[] = []
const decideDocuments = (
docBytes: number
): { includeDocuments: boolean } | { tooLargeBytes: number } => {
let includeDocuments = params.includeDocuments
if (includeDocuments && ARCHIVE_OVERHEAD_BYTES + docBytes > SIZE_LIMIT_BYTES) {
if (params.allowDocumentFallback) {
includeDocuments = false
} else {
return { tooLargeBytes: ARCHIVE_OVERHEAD_BYTES + docBytes }
}
}
return { includeDocuments }
}
for (const period of periods) {
const stats = statsByPeriod.get(period.id)!
const decision = decideDocuments(stats.docBytes)
if ('tooLargeBytes' in decision) {
return {
ok: false,
reason: 'archive_too_large',
message: `Archive for ${period.period_start}..${period.period_end} exceeds size limit`,
size_bytes: decision.tooLargeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
}
}
const includeDocuments = decision.includeDocuments
planned.push({
key: `period:${period.id}`,
kind: 'period',
periodId: period.id,
name: arkivFileName(period),
contentType: 'application/zip',
fingerprint: [
`v${ARCHIVE_FORMAT_VERSION}`,
stats.entryCount,
stats.maxEntryUpdated,
stats.docCount,
stats.maxDocCreated,
`docs:${includeDocuments ? 1 : 0}`,
].join('|'),
includeDocuments,
generate: () =>
generateFullArchive(supabase, companyId, {
scope: 'period',
period_id: period.id,
include_documents: includeDocuments,
}),
})
}
const baseDecision = decideDocuments(baseStats.docBytes)
if ('tooLargeBytes' in baseDecision) {
return {
ok: false,
reason: 'archive_too_large',
message: 'Grunddata archive exceeds size limit',
size_bytes: baseDecision.tooLargeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
}
}
planned.push({
key: 'base',
kind: 'base',
name: 'Grunddata.zip',
contentType: 'application/zip',
fingerprint: [
`v${ARCHIVE_FORMAT_VERSION}`,
// Any data change writes the audit log, so this covers master data.
latestAuditAt,
baseStats.docCount,
baseStats.maxDocCreated,
`docs:${baseDecision.includeDocuments ? 1 : 0}`,
].join('|'),
includeDocuments: baseDecision.includeDocuments,
generate: () =>
generateBaseDataArchive(supabase, companyId, {
include_documents: baseDecision.includeDocuments,
}),
})
const stamp = new Date()
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
const fileName = `arkiv_full_${stamp}.zip`
// Folder README: fingerprinted on its content (no timestamp inside), so it
// uploads once and again only when the text or company name changes.
const readmeText = buildDriveFolderReadme({
companyName: company.name,
orgNumber: company.orgNumber,
generatedAt: '',
appName: getBranding().appName,
})
planned.push({
key: 'readme',
kind: 'readme',
name: 'LÄSMIG.txt',
contentType: 'text/plain',
fingerprint: `v${ARCHIVE_FORMAT_VERSION}|${sha256Hex(textToArrayBuffer(readmeText)).slice(0, 16)}`,
includeDocuments: true,
generate: async () => textToArrayBuffer(readmeText),
})
const uploaded = await uploadFile(accessToken, companyFolderId, fileName, archive)
const lastSync: GoogleDriveLastSync = {
at: new Date().toISOString(),
file_id: uploaded.id,
file_name: uploaded.name,
file_size_bytes: uploaded.size_bytes,
folder_id: companyFolderId,
// ---- Execute: regenerate + upload only what changed. ----
const previous = await loadExtensionData<GoogleDriveLastSync>(
supabase,
companyId,
LAST_SYNC_KEY
)
const stateByKey = new Map<string, DriveFileState>()
for (const file of previous?.files ?? []) {
stateByKey.set(fileKey(file), file)
}
await saveExtensionData(supabase, companyId, userId, LAST_SYNC_KEY, lastSync)
return { ok: true, lastSync, webViewLink: uploaded.web_view_link }
const persistSnapshot = async (): Promise<GoogleDriveLastSync> => {
const files = [...stateByKey.values()]
const lastSync: GoogleDriveLastSync = {
at: new Date().toISOString(),
folder_id: companyFolderId!,
files,
total_size_bytes: files.reduce((sum, f) => sum + f.size_bytes, 0),
}
await saveExtensionData(supabase, companyId, userId, LAST_SYNC_KEY, lastSync)
return lastSync
}
let uploadedCount = 0
let skippedCount = 0
for (const plan of planned) {
const prev = stateByKey.get(plan.key)
if (prev && prev.fingerprint === plan.fingerprint && prev.file_name === plan.name) {
skippedCount++
continue
}
const bytes = await plan.generate()
let uploaded: UploadResult
if (prev?.file_id && prev.file_name === plan.name) {
try {
uploaded = await updateFile(accessToken, prev.file_id, bytes, plan.contentType)
} catch (err) {
if (err instanceof DriveFileGoneError) {
// The user deleted the file in Drive: recreate it.
uploaded = await uploadFile(
accessToken,
companyFolderId,
plan.name,
bytes,
plan.contentType
)
} else {
throw err
}
}
} else {
uploaded = await uploadFile(accessToken, companyFolderId, plan.name, bytes, plan.contentType)
}
stateByKey.set(plan.key, {
kind: plan.kind,
period_id: plan.periodId,
file_id: uploaded.id,
file_name: uploaded.name,
size_bytes: uploaded.size_bytes,
fingerprint: plan.fingerprint,
sha256: sha256Hex(bytes),
included_documents: plan.includeDocuments,
uploaded_at: new Date().toISOString(),
})
uploadedCount++
// Persist progressively: an interrupted run (time budget, crash) resumes
// from the finished files instead of re-uploading them.
await persistSnapshot()
}
// Drop state for files no longer planned (e.g. a deleted fiscal period).
const plannedKeys = new Set(planned.map((p) => p.key))
for (const key of [...stateByKey.keys()]) {
if (!plannedKeys.has(key)) stateByKey.delete(key)
}
const lastSync = await persistSnapshot()
return {
ok: true,
lastSync,
webViewLink: `https://drive.google.com/drive/folders/${companyFolderId}`,
uploadedCount,
skippedCount,
}
}
function fileKey(file: DriveFileState): string {
return file.kind === 'period' ? `period:${file.period_id}` : file.kind
}
/** `Arkiv 2024.zip` for calendar years, full dates for broken years. */
export function arkivFileName(period: PeriodRow): string {
const year = period.period_start.slice(0, 4)
const isCalendarYear =
period.period_start === `${year}-01-01` && period.period_end === `${year}-12-31`
return isCalendarYear
? `Arkiv ${year}.zip`
: `Arkiv ${period.period_start}_${period.period_end}.zip`
}
function sha256Hex(data: ArrayBuffer): string {
return createHash('sha256').update(Buffer.from(data)).digest('hex')
}
function textToArrayBuffer(text: string): ArrayBuffer {
const bytes = new TextEncoder().encode(text)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
async function fetchLatestAuditAt(
supabase: SupabaseClient,
companyId: string
): Promise<string> {
const { data } = await supabase
.from('audit_log')
.select('created_at')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.limit(1)
const rows = (data as { created_at: string }[] | null) ?? []
return rows[0]?.created_at ?? ''
}
export async function loadExtensionData<T>(
@@ -194,16 +539,17 @@ export async function saveExtensionData<T>(
if (error) throw new Error(`Failed to save extension data: ${error.message}`)
}
async function fetchCompanyLabel(
async function fetchCompanyInfo(
supabase: SupabaseClient,
companyId: string
): Promise<string> {
): Promise<{ name: string; orgNumber: string | null; label: string }> {
const { data } = await supabase
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', companyId)
.maybeSingle()
const name = (data?.company_name as string) || 'företag'
const org = (data?.org_number as string) || companyId.slice(0, 8)
return `${name} (${org})`.replace(/[\\/]/g, '-')
const orgNumber = (data?.org_number as string) || null
const label = `${name} (${orgNumber || companyId.slice(0, 8)})`.replace(/[\\/]/g, '-')
return { name, orgNumber, label }
}
+54 -4
View File
@@ -22,15 +22,49 @@ export interface GoogleDriveConnection {
needs_reauth_at?: string
}
/**
* State of one file in the company's Drive backup folder: an `Arkiv <år>.zip`
* per räkenskapsår, `Grunddata.zip`, and the folder LÄSMIG.txt. Files are
* updated in place; `fingerprint` decides whether a sync re-uploads them.
*/
export interface DriveFileState {
kind: 'period' | 'base' | 'readme'
/** Set when kind = 'period'. */
period_id?: string
file_id: string
file_name: string
size_bytes: number
/** Change-detection key: the file re-uploads only when this differs. */
fingerprint: string
/**
* SHA-256 of the uploaded bytes. The upload itself is verified against
* Drive's md5Checksum; this hash is recorded for evidentiary value (the
* user can prove the file in their Drive is the one Accounted produced).
*/
sha256: string
/** False when the file was built without document blobs (size fallback). */
included_documents: boolean
uploaded_at: string
}
/**
* Last-sync snapshot stored under key `google_drive_last_sync`.
*
* Current records carry `files` (per-fiscal-year layout). The flat
* `file_id`/`file_name`/`file_size_bytes` fields are the legacy single-ZIP
* layout, kept optional so old records still render.
*/
export interface GoogleDriveLastSync {
at: string
file_id: string
file_name: string
file_size_bytes: number
folder_id: string
files?: DriveFileState[]
total_size_bytes?: number
// Legacy single-file layout fields.
file_id?: string
file_name?: string
file_size_bytes?: number
included_documents?: boolean
sha256?: string
}
/**
@@ -40,14 +74,30 @@ export interface GoogleDriveLastSync {
*/
export interface GoogleDriveSchedule {
enabled: boolean
/** 0-23, UTC hour when the daily auto-sync should run. */
/**
* 0-23, UTC hour when the daily auto-sync should run. Legacy field: kept
* for records written before hour_local existed, and mirrored on writes so
* old readers keep an approximate value.
*/
hour_utc: number
/**
* 0-23, Europe/Stockholm wall-clock hour. Preferred over hour_utc: it stays
* put across DST transitions. Absent on records from before this field.
*/
hour_local?: number
/** ISO timestamp of the last auto-sync attempt (success or failure). */
last_auto_sync_at: string | null
/** Outcome of the last auto-sync attempt. */
last_auto_sync_status: 'success' | 'error' | null
/** Short error message if the last auto-sync failed. */
last_auto_sync_error: string | null
/**
* Number of auto-sync attempts in a row that failed. Reset to 0 on
* success; drives the failure-alert email threshold.
*/
consecutive_failures?: number
/** ISO timestamp of the last failure-alert email (throttle anchor). */
last_alert_at?: string | null
}
/**
+79 -1
View File
@@ -67,6 +67,8 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
@@ -9024,7 +9026,7 @@ export const tools: McpTool[] = [
// leaves kvittenser null rather than hard-failing the status check;
// auth errors throw and map to SKATTEVERKET_NOT_CONNECTED.
let kvittenser: unknown = null
const res = await agiGetKvittenser(supabase, userId, arbetsgivare, period)
const res = await agiGetKvittenser({ mode: 'user', supabase, userId }, arbetsgivare, period)
await writeSkatteverketAudit(ctx, {
endpoint: 'kvittenser', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
@@ -9416,6 +9418,82 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_import_rot_rut_beslut',
title: 'Import Rot/Rut Decision File',
description:
'Import Skatteverkets beslutsfil (decision JSON from the rot/rut e-tjänst) and record godkänt belopp on the matching begäran. Exact matching only; per-beslut outcomes in results. Book the payout afterwards via the settle endpoint hint in next.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
file_content: {
type: 'string',
description: 'The beslutsfil content verbatim (JSON text as downloaded from skatteverket.se)',
},
},
required: ['file_content'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
imported: { type: 'number' },
already_imported: { type: 'number' },
errors: { type: 'number' },
results: {
type: 'array',
items: { type: 'object' },
description: 'Per-beslut outcome: status imported/already_imported/error, request_id, decided_total, rejected flag, next-step hint',
},
},
required: ['imported', 'already_imported', 'errors', 'results'],
},
annotations: {
readOnlyHint: false, // records beslut on rot_rut_payout_requests
destructiveHint: false,
idempotentHint: true, // re-importing the same file reports already_imported
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const raw = args.file_content as string
if (typeof raw !== 'string' || raw.trim() === '') {
throw new Error('file_content is required (the beslutsfil JSON text)')
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('file_content är inte giltig JSON. Klistra in beslutsfilen oförändrad.')
}
const validated = RotRutBeslutFileSchema.safeParse(parsed)
if (!validated.success) {
throw new Error(
`Beslutsfilen har fel format: ${validated.error.issues
.slice(0, 3)
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ')}`,
)
}
const result = await importRotRutBeslutFile(supabase, companyId, validated.data)
if (!result.ok) {
throw new Error(
result.code === 'ROT_RUT_BESLUT_WRONG_COMPANY'
? 'Beslutsfilens utförare matchar inte företagets organisationsnummer.'
: 'Beslutsfilen kunde inte importeras.',
)
}
return {
imported: result.imported,
already_imported: result.already_imported,
errors: result.errors,
results: result.results,
}
},
},
{
name: 'gnubok_audit_package',
title: 'Generate Audit Package',
@@ -0,0 +1,277 @@
import { describe, it, expect } from 'vitest'
import { settleAgiTaxPayments } from '../lib/agi-tax-settlement'
const COMPANY = 'company-1'
/**
* Minimal recording mock for the two query shapes the settlement uses:
* a filtered select on agi_declarations and a guarded update. The generic
* queued mock in tests/helpers.ts doesn't record call arguments, and the
* update payload/filters are exactly what these tests need to assert.
*/
function createSettlementMock(opts: {
declarations?: unknown[]
selectError?: { message: string }
updateError?: { message: string }
} = {}) {
const updates: Array<{
payload: Record<string, unknown>
filters: Record<string, unknown>
}> = []
const selects: Array<Record<string, unknown>> = []
const makeChain = (
kind: 'select' | 'update',
payload?: Record<string, unknown>,
) => {
const filters: Record<string, unknown> = {}
const chain = {
eq(col: string, val: unknown) {
filters[col] = val
return chain
},
in(col: string, val: unknown) {
filters[col] = val
return chain
},
is(col: string, val: unknown) {
filters[col] = val
return chain
},
then(resolve: (v: unknown) => void) {
if (kind === 'select') {
selects.push(filters)
resolve({
data: opts.selectError ? null : opts.declarations ?? [],
error: opts.selectError ?? null,
})
} else {
updates.push({ payload: payload ?? {}, filters })
resolve({ error: opts.updateError ?? null })
}
},
}
return chain
}
const supabase = {
from(_table: string) {
return {
select: () => makeChain('select'),
update: (payload: Record<string, unknown>) =>
makeChain('update', payload),
}
},
}
return { supabase, updates, selects }
}
function declaration(overrides: Record<string, unknown> = {}) {
return {
id: 'agi-may',
period_year: 2026,
period_month: 5,
total_tax: 3391,
total_avgifter: 6284,
tax_paid_at: null,
...overrides,
}
}
function agiDebitRow(overrides: Record<string, unknown> = {}) {
return {
transaktionsdatum: '2026-06-12',
transaktionstext: 'Arbetsgivardeklaration 202605',
belopp_skatteverket: -9675,
...overrides,
}
}
describe('settleAgiTaxPayments', () => {
it('settles the declaration when the AGI debit matches period and exact amount', async () => {
const { supabase, updates } = createSettlementMock({
declarations: [declaration()],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow()],
1500,
)
expect(settled).toBe(1)
expect(updates).toHaveLength(1)
expect(updates[0].payload).toEqual({ tax_paid_at: '2026-06-12T00:00:00Z' })
// Guarded update: right row, right company, still unpaid.
expect(updates[0].filters).toMatchObject({
id: 'agi-may',
company_id: COMPANY,
tax_paid_at: null,
})
})
it('settles nothing when the skattekonto is in deficit', async () => {
const { supabase, updates, selects } = createSettlementMock({
declarations: [declaration()],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow()],
-0.01,
)
expect(settled).toBe(0)
expect(updates).toHaveLength(0)
expect(selects).toHaveLength(0)
})
it('skips amount mismatches, including ore-level drift', async () => {
const { supabase, updates } = createSettlementMock({
declarations: [declaration()],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[
agiDebitRow({ belopp_skatteverket: -9675.01 }),
agiDebitRow({ belopp_skatteverket: -9674 }),
],
0,
)
expect(settled).toBe(0)
expect(updates).toHaveLength(0)
})
it('compares amounts to the ore, tolerating float representation', async () => {
const { supabase, updates } = createSettlementMock({
declarations: [
declaration({ total_tax: 3390.55, total_avgifter: 6284.45 }),
],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow({ belopp_skatteverket: -9675 })],
0,
)
expect(settled).toBe(1)
expect(updates).toHaveLength(1)
})
it('ignores positive rows and rows without an AGI period token', async () => {
const { supabase, updates, selects } = createSettlementMock({
declarations: [declaration()],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[
// Inbetalning: positive, and no period token anyway.
agiDebitRow({
transaktionstext: 'Inbetalning bokförd 260610',
belopp_skatteverket: 9675,
}),
// Debit but not AGI.
agiDebitRow({
transaktionstext: 'Moms 202605',
belopp_skatteverket: -9675,
}),
],
1000,
)
expect(settled).toBe(0)
expect(updates).toHaveLength(0)
// No candidates at all: the declaration lookup is skipped entirely.
expect(selects).toHaveLength(0)
})
it('settles each declaration at most once per batch', async () => {
const { supabase, updates } = createSettlementMock({
declarations: [declaration()],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow(), agiDebitRow()],
0,
)
expect(settled).toBe(1)
expect(updates).toHaveLength(1)
})
it('handles multiple periods in one batch', async () => {
const { supabase, updates } = createSettlementMock({
declarations: [
declaration(),
declaration({
id: 'agi-june',
period_month: 6,
total_tax: 3400,
total_avgifter: 6300,
}),
],
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[
agiDebitRow(),
agiDebitRow({
transaktionsdatum: '2026-07-12',
transaktionstext: 'Arbetsgivardeklaration 202606',
belopp_skatteverket: -9700,
}),
],
250,
)
expect(settled).toBe(2)
expect(updates).toHaveLength(2)
expect(updates.map(u => u.filters.id).sort()).toEqual(['agi-june', 'agi-may'].sort())
})
it('returns 0 and does not throw when the lookup fails', async () => {
const { supabase, updates } = createSettlementMock({
selectError: { message: 'boom' },
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow()],
0,
)
expect(settled).toBe(0)
expect(updates).toHaveLength(0)
})
it('returns 0 and does not throw when the update fails', async () => {
const { supabase } = createSettlementMock({
declarations: [declaration()],
updateError: { message: 'boom' },
})
const settled = await settleAgiTaxPayments(
supabase as never,
COMPANY,
[agiDebitRow()],
0,
)
expect(settled).toBe(0)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Mock the token-store to bypass DB and supply a fresh access token.
const deleteTokensMock = vi.fn()
@@ -25,7 +25,8 @@ vi.mock('../lib/oauth', () => ({
exchangeCodeForTokens: vi.fn(),
}))
import { skvRequest, SkatteverketAuthError } from '../lib/api-client'
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError } from '../lib/api-client'
import { __resetSystemTokenCacheForTests } from '../lib/system-auth/token-provider'
const fakeSupabase = {} as unknown as Parameters<typeof skvRequest>[0]
@@ -157,6 +158,78 @@ describe('SkatteverketAuthError', () => {
})
})
describe('skvRequestWithAuth: system mode', () => {
beforeEach(() => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub'
__resetSystemTokenCacheForTests()
deleteTokensMock.mockClear()
})
afterEach(() => {
delete process.env.SKATTEVERKET_SYSTEM_AUTH_MODE
delete process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM
})
it('sends the system token and returns success responses', async () => {
mockFetchStatus(200, '{"ok":true}')
const res = await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect(res.status).toBe(200)
const call = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
expect((call[1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer stub-system-token',
})
})
it('401 in system mode -> SYSTEM_AUTH_FAILED and NEVER touches the user token table', async () => {
mockFetchStatus(401, '{"error":"Token has been revoked."}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
}
// The revoked-body branch deletes the user row in user mode; system
// mode must never reach it.
expect(deleteTokensMock).not.toHaveBeenCalled()
})
it('403 in system mode -> OMBUD_GRANT_MISSING (company-level)', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('OMBUD_GRANT_MISSING')
}
expect(deleteTokensMock).not.toHaveBeenCalled()
})
it('403 invalid_scope in system mode -> SYSTEM_AUTH_FAILED (config, not grant)', async () => {
mockFetchStatus(403, '{"error":"invalid_scope"}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
}
})
it('unconfigured system auth -> SYSTEM_AUTH_FAILED before any fetch', async () => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'off'
global.fetch = vi.fn() as unknown as typeof fetch
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
}
expect(global.fetch).not.toHaveBeenCalled()
})
})
describe('refresh-token 404 classification', () => {
// SKV's per-flow refresh tokens live 65 minutes, so daily crons always find
// a dead token and get 404 id_not_found. That must surface as the
@@ -19,6 +19,7 @@ const GATED: Array<{ method: string; path: string }> = [
{ method: 'GET', path: '/authorize' },
{ method: 'POST', path: '/declaration/validate' },
{ method: 'POST', path: '/declaration/draft' },
{ method: 'POST', path: '/declaration/submit' },
{ method: 'PUT', path: '/declaration/lock' },
{ method: 'POST', path: '/agi/submit' },
{ method: 'POST', path: '/agi/spara' },
@@ -0,0 +1,117 @@
/**
* Grant-probe classification: 200 / felkod 3 / OMBUD_GRANT_MISSING / 404 /
* transient failures, and the transient-error-never-downgrades rule (which
* lives in connection-store's recordProbeResult and is asserted through the
* recorded input here + directly below).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockSkvRequestWithAuth = vi.fn()
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
skvRequestWithAuth: (...a: unknown[]) => mockSkvRequestWithAuth(...a),
}
})
const mockRecordProbeResult = vi.fn()
vi.mock('../lib/connection-store', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
recordProbeResult: (...a: unknown[]) => mockRecordProbeResult(...a),
}
})
import { probeCompanyGrants } from '../lib/grant-probe'
import { SkatteverketAuthError } from '../lib/api-client'
beforeEach(() => {
vi.clearAllMocks()
mockRecordProbeResult.mockResolvedValue({ id: 'conn-1', status: 'verified' })
vi.spyOn(console, 'info').mockImplementation(() => {})
})
describe('probeCompanyGrants', () => {
it('both probes 200 -> both granted', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce({ ok: true, status: 200 }) // saldo
.mockResolvedValueOnce({ ok: true, status: 200 }) // utkast
const result = await probeCompanyGrants('company-1', '165560000000')
expect(result.lasombud.status).toBe('granted')
expect(result.momsOmbud.status).toBe('granted')
// Both calls ran on SYSTEM credentials.
expect(mockSkvRequestWithAuth.mock.calls[0][0]).toEqual({ mode: 'system' })
expect(mockSkvRequestWithAuth.mock.calls[1][0]).toEqual({ mode: 'system' })
})
it('records the actual 2xx status as detail, not a hardcoded 200', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce({ ok: true, status: 204 }) // saldo
.mockResolvedValueOnce({ ok: true, status: 200 }) // utkast
const result = await probeCompanyGrants('company-1', '165560000000')
expect(result.lasombud).toEqual({ status: 'granted', detail: '204' })
expect(result.momsOmbud).toEqual({ status: 'granted', detail: '200' })
})
it('felkod 3 (no skattekonto) still proves the lasombud authorization', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ felkod: 3 }) })
.mockResolvedValueOnce({ ok: false, status: 404 })
const result = await probeCompanyGrants('company-1', '165560000000')
expect(result.lasombud.status).toBe('granted')
// 404 on /utkast = no draft, but the gateway authorized us.
expect(result.momsOmbud.status).toBe('granted')
})
it('OMBUD_GRANT_MISSING classifies as denied', async () => {
mockSkvRequestWithAuth
.mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING'))
.mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING'))
const result = await probeCompanyGrants('company-1', '165560000000')
expect(result.lasombud.status).toBe('denied')
expect(result.momsOmbud.status).toBe('denied')
})
it('transient failures classify as error, never denied', async () => {
mockSkvRequestWithAuth
.mockRejectedValueOnce(new SkatteverketAuthError('overload', 'RATE_LIMITED'))
.mockRejectedValueOnce(new Error('fetch failed'))
const result = await probeCompanyGrants('company-1', '165560000000')
expect(result.lasombud.status).toBe('error')
expect(result.momsOmbud.status).toBe('error')
const recorded = mockRecordProbeResult.mock.calls[0][0]
expect(recorded.error).toBeTruthy()
})
it('persists the probe outcome via recordProbeResult', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 })
await probeCompanyGrants('company-1', '165560000000', 'user-1')
expect(mockRecordProbeResult).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
orgNumber: '165560000000',
createdBy: 'user-1',
lasombud: expect.objectContaining({ status: 'granted' }),
momsOmbud: expect.objectContaining({ status: 'granted' }),
error: null,
})
)
})
})
@@ -0,0 +1,208 @@
/**
* Kvittens confirmation email: the atomic claim-then-send dedup.
*
* The notification_log row is inserted FIRST as a claim (guarded by the
* partial unique index from migration 20260712113000); the email is only
* sent when this invocation won the insert. A 23505 unique violation means
* another overlapping cron run already claimed the kvittens: no send.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const mockIsConfigured = vi.fn()
const mockSendEmail = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
import { sendKvittensNotification } from '../lib/kvittens-notification'
interface RecordedOp {
table: string
op: 'select' | 'insert' | 'delete'
payload?: Record<string, unknown>
filters: Record<string, unknown>
}
/**
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
* assertions need the recorded operation ORDER and payloads: claim insert
* before send, delete filters on release.
*/
function makeSupabase(opts: {
alreadyRow?: { id: string } | null
member?: Record<string, unknown> | null
insertError?: { code?: string; message: string } | null
} = {}) {
const ops: RecordedOp[] = []
const from = (table: string) => {
const call: RecordedOp = { table, op: 'select', filters: {} }
ops.push(call)
const builder: Record<string, unknown> = {}
Object.assign(builder, {
select: () => builder,
insert: (payload: Record<string, unknown>) => {
call.op = 'insert'
call.payload = payload
return Promise.resolve({ data: null, error: opts.insertError ?? null })
},
delete: () => {
call.op = 'delete'
return builder
},
eq: (key: string, value: unknown) => {
call.filters[key] = value
return builder
},
maybeSingle: async () => {
if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null }
if (table === 'company_members') {
const member =
opts.member === undefined
? { user_id: 'user-1', profiles: { email: 'user@example.com' } }
: opts.member
return { data: member, error: null }
}
return { data: null, error: null }
},
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, ops }
}
const baseInput = {
companyId: 'company-1',
userId: 'user-1',
kind: 'agi' as const,
period: '202606',
kvittensnummer: 'KV-123',
referenceId: '3f9d3c9a-1c2b-4d5e-8f6a-7b8c9d0e1f2a',
}
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('sendKvittensNotification', () => {
it('claims the notification_log row BEFORE sending the email', async () => {
const { supabase, ops } = makeSupabase()
let claimsAtSendTime = -1
mockSendEmail.mockImplementation(async () => {
claimsAtSendTime = ops.filter((o) => o.op === 'insert').length
return { success: true }
})
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: true })
expect(claimsAtSendTime).toBe(1)
const insert = ops.find((o) => o.op === 'insert')
expect(insert?.table).toBe('notification_log')
expect(insert?.payload).toMatchObject({
user_id: 'user-1',
company_id: 'company-1',
notification_type: 'skv_kvittens',
reference_id: baseInput.referenceId,
delivery_status: 'sent',
})
})
it('a 23505 unique violation on the claim means already claimed: no email', async () => {
const { supabase } = makeSupabase({
insertError: { code: '23505', message: 'duplicate key value violates unique constraint' },
})
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('a non-23505 claim failure fails closed: no email, reason claim_failed', async () => {
const { supabase } = makeSupabase({
insertError: { code: '57014', message: 'canceling statement' },
})
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'claim_failed' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('the pre-check fast path short-circuits on an existing row', async () => {
const { supabase, ops } = makeSupabase({ alreadyRow: { id: 'log-1' } })
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
expect(ops.some((o) => o.op === 'insert')).toBe(false)
})
it('releases the claim when the send fails, so a later run can retry', async () => {
const { supabase, ops } = makeSupabase()
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'send_failed' })
const release = ops.find((o) => o.op === 'delete')
expect(release?.table).toBe('notification_log')
expect(release?.filters).toMatchObject({
user_id: 'user-1',
notification_type: 'skv_kvittens',
reference_id: baseInput.referenceId,
})
})
it('releases the claim when the send throws', async () => {
const { supabase, ops } = makeSupabase()
mockSendEmail.mockRejectedValue(new Error('network'))
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'error' })
expect(ops.some((o) => o.op === 'delete' && o.table === 'notification_log')).toBe(true)
})
it('maps a non-uuid reference id (VAT composite key) to a deterministic uuid', async () => {
// notification_log.reference_id is a uuid column; the VAT cron passes a
// composite string key. It must map to the SAME uuid on every run for
// the claim to dedup.
const input = { ...baseInput, kind: 'vat' as const, referenceId: 'vat_company-1_202606' }
const run1 = makeSupabase()
await sendKvittensNotification(run1.supabase, input)
const run2 = makeSupabase()
await sendKvittensNotification(run2.supabase, input)
const ref1 = run1.ops.find((o) => o.op === 'insert')?.payload?.reference_id as string
const ref2 = run2.ops.find((o) => o.op === 'insert')?.payload?.reference_id as string
expect(ref1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
expect(ref2).toBe(ref1)
})
it('does nothing when email is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, ops } = makeSupabase()
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
expect(ops).toHaveLength(0)
})
it('does not claim or send when the token owner is no longer a member', async () => {
const { supabase, ops } = makeSupabase({ member: null })
const result = await sendKvittensNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
expect(mockSendEmail).not.toHaveBeenCalled()
expect(ops.some((o) => o.op === 'insert')).toBe(false)
})
})
@@ -0,0 +1,147 @@
/**
* resolveReadAuth preference matrix: system credentials when the flag is on
* and the grant is verified; the company's user token otherwise; explicit
* no_token / needs_reconsent outcomes for the crons' quiet buckets.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGetConnection = vi.fn()
vi.mock('../lib/connection-store', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, getConnection: (...a: unknown[]) => mockGetConnection(...a) }
})
const mockMode = vi.fn()
const mockConfigured = vi.fn()
vi.mock('../lib/system-auth/config', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getSystemAuthMode: () => mockMode(),
isSystemAuthConfigured: () => mockConfigured(),
}
})
import { resolveReadAuth, hasVerifiedGrant } from '../lib/resolve-auth'
import type { SupabaseClient } from '@supabase/supabase-js'
function makeSupabase(tokenRow: { user_id: string; status: string } | null) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq']) {
chain[m] = vi.fn(() => chain)
}
chain.maybeSingle = vi.fn().mockResolvedValue({ data: tokenRow, error: null })
return { from: vi.fn(() => chain) } as unknown as SupabaseClient
}
const GRANTED_CONNECTION = {
status: 'verified',
lasombud_status: 'granted',
moms_ombud_status: 'granted',
}
beforeEach(() => {
vi.clearAllMocks()
mockMode.mockReturnValue('off')
mockConfigured.mockReturnValue(false)
})
describe('resolveReadAuth', () => {
it('mode off -> user token by company lookup (pre-hybrid behavior)', async () => {
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user', tokenUserId: 'user-1' })
expect(mockGetConnection).not.toHaveBeenCalled()
})
it('mode on + verified grant -> system auth', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue(GRANTED_CONNECTION)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'system', tokenUserId: 'user-1' })
if (result.ok) expect(result.auth).toEqual({ mode: 'system' })
})
it('mode on but grant denied -> falls back to user token', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue({
...GRANTED_CONNECTION,
lasombud_status: 'denied',
status: 'partial',
})
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
})
it('mode on but unconfigured -> never consults the connection table', async () => {
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(false)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
expect(mockGetConnection).not.toHaveBeenCalled()
})
it('shadow mode never selects system auth', async () => {
mockMode.mockReturnValue('shadow')
mockConfigured.mockReturnValue(true)
mockGetConnection.mockResolvedValue(GRANTED_CONNECTION)
const supabase = makeSupabase({ user_id: 'user-1', status: 'active' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toMatchObject({ ok: true, source: 'user' })
})
it('explicit userId short-circuits the company lookup', async () => {
const supabase = makeSupabase(null)
const result = await resolveReadAuth(supabase, 'company-1', {
requires: 'moms_ombud',
userId: 'user-9',
})
expect(result).toMatchObject({ ok: true, source: 'user', tokenUserId: 'user-9' })
})
it('no token row -> no_token', async () => {
const supabase = makeSupabase(null)
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toEqual({ ok: false, reason: 'no_token' })
})
it('needs_reconsent row -> needs_reconsent', async () => {
const supabase = makeSupabase({ user_id: 'user-1', status: 'needs_reconsent' })
const result = await resolveReadAuth(supabase, 'company-1', { requires: 'lasombud' })
expect(result).toEqual({ ok: false, reason: 'needs_reconsent' })
})
})
describe('hasVerifiedGrant', () => {
it('requires both the grant and a verified/partial aggregate status', async () => {
mockGetConnection.mockResolvedValue({
status: 'revoked',
lasombud_status: 'granted',
moms_ombud_status: 'unknown',
})
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(false)
mockGetConnection.mockResolvedValue({
status: 'partial',
lasombud_status: 'granted',
moms_ombud_status: 'denied',
})
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(true)
expect(await hasVerifiedGrant('company-1', 'moms_ombud')).toBe(false)
})
it('returns false when no connection row exists', async () => {
mockGetConnection.mockResolvedValue(null)
expect(await hasVerifiedGrant('company-1', 'lasombud')).toBe(false)
})
})
@@ -0,0 +1,110 @@
/**
* mTLS token transport: the node:https request/response wiring. Notably the
* response-stream 'error' path: a mid-body failure happens after headers, so
* it surfaces on res, not req; without a res error listener the fetchToken
* promise would hang and the unhandled 'error' event would crash Node.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { EventEmitter } from 'node:events'
const mockRequest = vi.fn()
vi.mock('node:https', () => ({
default: { request: (...a: unknown[]) => mockRequest(...a) },
}))
import { mtlsTransport } from '../lib/system-auth/transport'
type FakeRes = EventEmitter & { statusCode: number }
function makeFakeReq(onEnd: () => void) {
const req = new EventEmitter() as EventEmitter & {
write: (chunk: unknown) => void
end: () => void
destroy: (err?: Error) => void
}
req.write = vi.fn()
req.end = () => queueMicrotask(onEnd)
req.destroy = (err?: Error) => {
if (err) req.emit('error', err)
}
return req
}
/** Wire mockRequest so that ending the request produces the given response events. */
function respondWith(statusCode: number, emit: (res: FakeRes) => void) {
mockRequest.mockImplementation((_options: unknown, onResponse: (res: FakeRes) => void) => {
const res = Object.assign(new EventEmitter(), { statusCode }) as FakeRes
return makeFakeReq(() => {
onResponse(res)
emit(res)
})
})
}
const ENV_KEYS = [
'SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL',
'SKATTEVERKET_SYSTEM_CERT_PEM_B64',
'SKATTEVERKET_SYSTEM_KEY_PEM_B64',
]
let savedEnv: Record<string, string | undefined>
beforeEach(() => {
vi.clearAllMocks()
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
process.env.SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL = 'https://oauth2.test.skatteverket.se/token'
process.env.SKATTEVERKET_SYSTEM_CERT_PEM_B64 = Buffer.from(
'-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----'
).toString('base64')
process.env.SKATTEVERKET_SYSTEM_KEY_PEM_B64 = Buffer.from(
'-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----'
).toString('base64')
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
})
describe('mtlsTransport.fetchToken', () => {
it('resolves a token from a clean 200 response', async () => {
respondWith(200, (res) => {
res.emit('data', Buffer.from('{"access_token":"tok-1",'))
res.emit('data', Buffer.from('"expires_in":600}'))
res.emit('end')
})
const result = await mtlsTransport.fetchToken(['skattekonto'])
expect(result.accessToken).toBe('tok-1')
expect(result.expiresAt).toBeGreaterThan(Date.now())
})
it('rejects when the response stream errors mid-body instead of hanging', async () => {
respondWith(200, (res) => {
res.emit('data', Buffer.from('{"access_token":"tok'))
res.emit('error', new Error('socket hang up'))
})
await expect(mtlsTransport.fetchToken(['skattekonto'])).rejects.toThrow('socket hang up')
})
it('rejects on a non-2xx token endpoint answer', async () => {
respondWith(401, (res) => {
res.emit('data', Buffer.from('{"error":"invalid_client"}'))
res.emit('end')
})
await expect(mtlsTransport.fetchToken([])).rejects.toThrow('401')
})
it('rejects on request-level errors (pre-response)', async () => {
mockRequest.mockImplementation(() => {
const req = makeFakeReq(() => {})
req.end = () => queueMicrotask(() => req.emit('error', new Error('ECONNREFUSED')))
return req
})
await expect(mtlsTransport.fetchToken([])).rejects.toThrow('ECONNREFUSED')
})
})
@@ -0,0 +1,155 @@
/**
* System (CCG) auth: config parsing and the token provider's cache
* semantics. The stub transport stands in for the real mechanism, which is
* pending Skatteverket's org-flow documentation.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockFetchToken = vi.fn()
vi.mock('../lib/system-auth/transport', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getSystemAuthTransport: () => ({ fetchToken: (...a: unknown[]) => mockFetchToken(...a) }),
}
})
import {
getSystemAuthMode,
getSystemAuthMechanism,
getSystemScopes,
isSystemAuthConfigured,
getSystemCertInfo,
} from '../lib/system-auth/config'
import {
getSystemAccessToken,
invalidateSystemToken,
SystemAuthUnavailableError,
__resetSystemTokenCacheForTests,
} from '../lib/system-auth/token-provider'
const ENV_KEYS = [
'SKATTEVERKET_SYSTEM_AUTH_MODE',
'SKATTEVERKET_SYSTEM_AUTH_MECHANISM',
'SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL',
'SKATTEVERKET_SYSTEM_CERT_PEM_B64',
'SKATTEVERKET_SYSTEM_KEY_PEM_B64',
'SKATTEVERKET_SYSTEM_SCOPES',
]
let savedEnv: Record<string, string | undefined>
beforeEach(() => {
vi.clearAllMocks()
__resetSystemTokenCacheForTests()
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
for (const k of ENV_KEYS) delete process.env[k]
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
})
describe('system-auth config', () => {
it('defaults to mode off and mechanism mtls', () => {
expect(getSystemAuthMode()).toBe('off')
expect(getSystemAuthMechanism()).toBe('mtls')
})
it('parses valid modes and rejects garbage', () => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'shadow'
expect(getSystemAuthMode()).toBe('shadow')
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'ON'
expect(getSystemAuthMode()).toBe('on')
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'banana'
expect(getSystemAuthMode()).toBe('off')
})
it('is unconfigured in mode off and configured with stub + on', () => {
expect(isSystemAuthConfigured()).toBe(false)
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub'
expect(isSystemAuthConfigured()).toBe(true)
})
it('mtls requires token url + cert + key', () => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'mtls'
expect(isSystemAuthConfigured()).toBe(false)
process.env.SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL = 'https://oauth2.test.skatteverket.se/token'
process.env.SKATTEVERKET_SYSTEM_CERT_PEM_B64 = Buffer.from('-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----').toString('base64')
process.env.SKATTEVERKET_SYSTEM_KEY_PEM_B64 = Buffer.from('-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----').toString('base64')
expect(isSystemAuthConfigured()).toBe(true)
})
it('parses scopes from env with a sensible default', () => {
expect(getSystemScopes()).toEqual(['skattekonto', 'agd:lasa', 'momsdeklaration'])
process.env.SKATTEVERKET_SYSTEM_SCOPES = 'a b'
expect(getSystemScopes()).toEqual(['a', 'b'])
})
it('returns null cert info when the PEM cannot be parsed', () => {
process.env.SKATTEVERKET_SYSTEM_CERT_PEM_B64 = Buffer.from('-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----').toString('base64')
expect(getSystemCertInfo()).toBeNull()
})
})
describe('system token provider', () => {
beforeEach(() => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on'
process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub'
})
it('throws SystemAuthUnavailableError when unconfigured', async () => {
process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'off'
await expect(getSystemAccessToken()).rejects.toBeInstanceOf(SystemAuthUnavailableError)
expect(mockFetchToken).not.toHaveBeenCalled()
})
it('mints once and serves from cache until invalidated', async () => {
mockFetchToken.mockResolvedValue({ accessToken: 'tok-1', expiresAt: Date.now() + 3600_000 })
expect(await getSystemAccessToken()).toBe('tok-1')
expect(await getSystemAccessToken()).toBe('tok-1')
expect(mockFetchToken).toHaveBeenCalledTimes(1)
invalidateSystemToken()
mockFetchToken.mockResolvedValue({ accessToken: 'tok-2', expiresAt: Date.now() + 3600_000 })
expect(await getSystemAccessToken()).toBe('tok-2')
expect(mockFetchToken).toHaveBeenCalledTimes(2)
})
it('re-mints when the cached token is within the refresh margin', async () => {
// Expires in 60s: inside the 5-minute refresh-ahead margin.
mockFetchToken.mockResolvedValueOnce({ accessToken: 'short', expiresAt: Date.now() + 60_000 })
expect(await getSystemAccessToken()).toBe('short')
mockFetchToken.mockResolvedValueOnce({ accessToken: 'fresh', expiresAt: Date.now() + 3600_000 })
expect(await getSystemAccessToken()).toBe('fresh')
expect(mockFetchToken).toHaveBeenCalledTimes(2)
})
it('coalesces concurrent mints into one transport call', async () => {
let release: (v: { accessToken: string; expiresAt: number }) => void = () => {}
mockFetchToken.mockImplementationOnce(
() => new Promise((resolve) => { release = resolve })
)
const first = getSystemAccessToken()
const second = getSystemAccessToken()
release({ accessToken: 'tok-x', expiresAt: Date.now() + 3600_000 })
expect(await first).toBe('tok-x')
expect(await second).toBe('tok-x')
expect(mockFetchToken).toHaveBeenCalledTimes(1)
})
it('wraps transport failures in SystemAuthUnavailableError and does not cache them', async () => {
mockFetchToken.mockRejectedValueOnce(new Error('handshake failed'))
await expect(getSystemAccessToken()).rejects.toBeInstanceOf(SystemAuthUnavailableError)
mockFetchToken.mockResolvedValueOnce({ accessToken: 'recovered', expiresAt: Date.now() + 3600_000 })
expect(await getSystemAccessToken()).toBe('recovered')
})
})
@@ -0,0 +1,166 @@
/**
* Tests for the shared VAT submit chain (kontrollera -> utkast -> lås).
* Covers the stage discriminator semantics that the one-click route and the
* pending-operations commit service both rely on: validation errors abort
* before any SKV write, draft failures are retry-safe, lock failures report
* that the draft survived in Eget utrymme.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockSkvRequest = vi.fn()
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
const mockBuildMomsuppgift = vi.fn()
vi.mock('../lib/declaration-prep', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, buildMomsuppgift: (...a: unknown[]) => mockBuildMomsuppgift(...a) }
})
vi.mock('../lib/audit', () => ({ writeSkatteverketAudit: vi.fn() }))
import { submitVatDeclarationChain } from '../lib/vat-submit'
import type { ExtensionContext } from '@/lib/extensions/types'
const PARAMS = { periodType: 'monthly' as const, year: 2026, period: 6 }
function makeCtx() {
return {
supabase: {},
userId: 'user-1',
companyId: 'company-1',
settings: { set: vi.fn().mockResolvedValue(undefined) },
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as ExtensionContext
}
beforeEach(() => {
vi.clearAllMocks()
mockBuildMomsuppgift.mockResolvedValue({
redovisare: '165560000000',
redovisningsperiod: '202606',
momsuppgift: { summaMoms: 150 },
})
})
describe('submitVatDeclarationChain', () => {
it('validation ERRORs abort before any write at SKV', async () => {
mockSkvRequest.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
kontrollResultat: {
status: 'ERROR',
resultat: [{ kod: '49', status: 'ERROR', beskrivning: 'Summan stämmer inte' }],
},
}),
})
const ctx = makeCtx()
const result = await submitVatDeclarationChain(ctx, PARAMS, { validate: true })
expect(result).toMatchObject({
ok: false,
stage: 'validation',
httpStatus: 422,
draftSaved: false,
})
// Only the kontrollera call: no utkast, no lås, no persisted state.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toBe('/kontrollera/165560000000/202606')
expect((ctx.settings.set as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
})
it('validation warnings do not block: full chain runs to signing link', async () => {
mockSkvRequest
.mockResolvedValueOnce({
ok: true, status: 200,
json: async () => ({
kontrollResultat: {
status: 'WARNING',
resultat: [{ kod: '10', status: 'WARNING', beskrivning: 'Ovanligt belopp' }],
},
}),
}) // kontrollera
.mockResolvedValueOnce({
ok: true, status: 200,
json: async () => ({ kontrollResultat: { status: 'OK' } }),
}) // utkast
.mockResolvedValueOnce({
ok: true, status: 200,
json: async () => ({ signeringsLank: 'https://skv.test/sign/abc' }),
}) // lås
const ctx = makeCtx()
const result = await submitVatDeclarationChain(ctx, PARAMS, { validate: true })
expect(result).toMatchObject({ ok: true, signingUrl: 'https://skv.test/sign/abc' })
expect(mockSkvRequest).toHaveBeenCalledTimes(3)
// Locked state persisted with the picker params for the kvittens cron.
const setMock = ctx.settings.set as ReturnType<typeof vi.fn>
const lastState = JSON.parse(setMock.mock.calls.at(-1)![1] as string)
expect(lastState).toMatchObject({
status: 'draft_locked',
periodType: 'monthly',
year: 2026,
period: 6,
signeringsLank: 'https://skv.test/sign/abc',
})
})
it('skips kontrollera when validate is not requested', async () => {
mockSkvRequest
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }) // utkast
.mockResolvedValueOnce({
ok: true, status: 200,
json: async () => ({ signeringsLank: 'https://skv.test/sign/xyz' }),
}) // lås
const result = await submitVatDeclarationChain(makeCtx(), PARAMS)
expect(result).toMatchObject({ ok: true, signingUrl: 'https://skv.test/sign/xyz' })
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
expect(mockSkvRequest.mock.calls[0][3]).toBe('/utkast/165560000000/202606')
})
it('utkast failure -> stage draft, nothing saved, retry-safe', async () => {
mockSkvRequest.mockResolvedValueOnce({
ok: false, status: 400, text: async () => 'bad rutor',
})
const ctx = makeCtx()
const result = await submitVatDeclarationChain(ctx, PARAMS)
expect(result).toMatchObject({
ok: false, stage: 'draft', httpStatus: 400, draftSaved: false,
})
expect(mockSkvRequest).toHaveBeenCalledTimes(1) // never reached /las
expect((ctx.settings.set as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
})
it('lås failure -> stage lock with draftSaved: true', async () => {
mockSkvRequest
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }) // utkast
.mockResolvedValueOnce({ ok: false, status: 409, text: async () => 'redan låst' }) // lås
const result = await submitVatDeclarationChain(makeCtx(), PARAMS)
expect(result).toMatchObject({
ok: false, stage: 'lock', httpStatus: 409, draftSaved: true,
})
})
it('lås without signeringsLank -> stage lock 502 with draftSaved: true', async () => {
mockSkvRequest
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }) // utkast
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }) // lås, no link
const result = await submitVatDeclarationChain(makeCtx(), PARAMS)
expect(result).toMatchObject({
ok: false, stage: 'lock', httpStatus: 502, draftSaved: true,
})
})
})
+318 -63
View File
@@ -12,7 +12,7 @@ import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { writeSkatteverketAudit } from './lib/audit'
import { skvAuthCodeToStructured } from './lib/error-map'
import {
@@ -21,6 +21,13 @@ import {
type VatDeclarationPrep,
type AgiUnderlagPrep,
} from './lib/declaration-prep'
import { submitVatDeclarationChain } from './lib/vat-submit'
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
import { getSystemAuthMode, isSystemAuthConfigured, getOmbudOrgNumber, getSystemCertInfo } from './lib/system-auth/config'
import { getConnection, markConnectionRevoked } from './lib/connection-store'
import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth'
import { probeCompanyGrants } from './lib/grant-probe'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
import {
@@ -46,7 +53,7 @@ import {
SkattekontoMatchError,
} from './lib/skattekonto-match'
import { splitTransactions } from './lib/skattekonto-buckets'
import type { SkattekontoBalanceSnapshot, SkatteverketUtkastResponse } from './types'
import type { SkattekontoBalanceSnapshot } from './types'
import type { VatPeriodType } from '@/types'
/**
@@ -105,6 +112,26 @@ import type { VatPeriodType } from '@/types'
* 10. Run a single AGI end-to-end against test on a real client before
* switching that client over.
*
* ─── System auth (ombud + org certificate) cutover checklist ──────────────
* The hybrid model's background-read credentials. All code ships behind
* SKATTEVERKET_SYSTEM_AUTH_MODE=off; flipping to shadow/on requires:
*
* 1. Skatteverket's CCG/org-flow docs (token endpoint URL, mechanism
* mTLS vs private_key_jwt, scope names, whether APIGW headers persist,
* whether resource calls need the client cert).
* 2. Organisationscertifikat from Expisoft (test + prod) for Accounted's
* org number, base64-wrapped into SKATTEVERKET_SYSTEM_CERT_PEM_B64 /
* _KEY_PEM_B64.
* 3. Bilateral avtal per API for the org flow (skattekonto read, AGI read,
* momsdeklaration ombud) + APIGW subscriptions for the system client.
* 4. Set SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _SCOPES, _CLIENT_ID,
* _AUTH_MECHANISM per the docs; SKATTEVERKET_OMBUD_ORG_NUMBER =
* Accounted's org number (shown to users in the grant instructions).
* 5. Validate grant-probe.ts classification against real sandbox 403
* bodies (shadow mode in the test environment first).
* 6. Godkännandetest per API, then SKATTEVERKET_SYSTEM_AUTH_MODE=on in
* prod. User tokens remain the fallback indefinitely.
*
* The /status endpoint reports which environment is active so the UI can
* surface a Testmiljö / Produktion badge.
*/
@@ -452,6 +479,138 @@ export const skatteverketExtension: Extension = {
},
},
// ══════════════════════════════════════════════════════════════
// System connection (ombud + organization certificate)
//
// The hybrid auth model's per-company side: the user grants Accounted's
// org number a behorighet at Skatteverket's Ombud och behorigheter
// e-service (one-time BankID signature), we verify it with a probe on
// SYSTEM credentials, and background reads stop depending on the
// 65-minute personal token. All of it is inert until
// SKATTEVERKET_SYSTEM_AUTH_MODE is switched on.
// ══════════════════════════════════════════════════════════════
// ── System connection: status + instructions ────────────────────
{
method: 'GET',
path: '/system-connection',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const mode = getSystemAuthMode()
const available = mode !== 'off' && isSystemAuthConfigured()
if (!available) {
return NextResponse.json({ available: false, mode })
}
const connection = await getConnection(ctx.companyId, currentSkvEnvironment())
return NextResponse.json({
available: true,
mode,
environment: currentSkvEnvironment(),
// What the user grants the behorigheter to, plus where.
ombud_org_number: getOmbudOrgNumber(),
grant_url: 'https://skatteverket.se/ombud',
behorigheter: [
{ key: 'lasombud', label: 'Juridiskt läsombud' },
{ key: 'moms_ombud', label: 'Momsdeklaration, ombud' },
],
cert: getSystemCertInfo(),
connection,
})
},
},
// ── System connection: verify (probe the grants) ────────────────
{
method: 'POST',
path: '/system-connection/verify',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
const roleBlocked = await requireAgiWriteRole(ctx)
if (roleBlocked) return roleBlocked
if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) {
return NextResponse.json(
{ error: 'Systemanslutningen är inte aktiverad i denna miljö.' },
{ status: 503 }
)
}
// Manual probes are rate limited: one per minute per company.
const existing = await getConnection(ctx.companyId, currentSkvEnvironment())
if (existing?.last_probe_at && Date.now() - new Date(existing.last_probe_at).getTime() < 60_000) {
return NextResponse.json(
{ error: 'Vänta en minut mellan verifieringar.', connection: existing },
{ status: 429 }
)
}
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
return NextResponse.json(
{ error: 'Organisationsnummer saknas. Ange det under Inställningar först.' },
{ status: 400 }
)
}
const orgNumber = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag'
)
try {
const result = await probeCompanyGrants(ctx.companyId, orgNumber, ctx.userId)
await writeSkatteverketAudit(ctx, {
endpoint: 'system-connection/verify',
agRegistreradId: orgNumber,
outcome: 'ok',
})
return NextResponse.json({
data: {
connection: result.connection,
// Spelled-out per-behorighet outcome so the UI can say
// "läsombud OK, momsbehörighet saknas fortfarande".
lasombud: result.lasombud,
moms_ombud: result.momsOmbud,
},
})
} catch (err) {
return handleSkvError(err)
}
},
},
// ── System connection: revoke locally ───────────────────────────
{
method: 'DELETE',
path: '/system-connection',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const roleBlocked = await requireAgiWriteRole(ctx)
if (roleBlocked) return roleBlocked
// Marks the local row revoked (kept for history). Withdrawing the
// actual behorighet happens at skatteverket.se; this only stops us
// from using system credentials for the company.
await markConnectionRevoked(ctx.companyId, currentSkvEnvironment())
return NextResponse.json({ success: true })
},
},
// ── Validate declaration (dry run) ──────────────────────────────
// Sends momsuppgift to Skatteverket's /kontrollera endpoint.
// Returns ERROR/WARNING/OK without saving anything.
@@ -730,6 +889,67 @@ export const skatteverketExtension: Extension = {
},
},
// ── One-click submit (kontrollera -> utkast -> lås) ─────────────
// The whole "skicka för signering" chain behind one button. Validation
// errors abort before anything is written to Eget utrymme; a lock
// failure leaves the saved draft in place and says so (draft_saved),
// so the UI can offer a lock-only retry instead of a full re-submit.
{
method: 'POST',
path: '/declaration/submit',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const blocked = await requireSkvCapability(ctx)
if (blocked) return blocked
try {
const body = (await request.json()) as {
periodType?: VatPeriodType
year?: number
period?: number
}
const { periodType, year, period } = body
if (!periodType || !year || !period) {
return NextResponse.json(
{ error: 'Saknar obligatoriska fält: periodType, year, period' },
{ status: 400 }
)
}
const result = await submitVatDeclarationChain(
ctx,
{ periodType, year, period },
{ validate: true }
)
if (!result.ok) {
return NextResponse.json(
{
error: result.error,
stage: result.stage,
draft_saved: result.draftSaved,
kontrollResultat: result.kontrollresultat,
},
{ status: result.httpStatus }
)
}
return NextResponse.json({
data: {
signeringsLank: result.signingUrl,
redovisare: result.redovisare,
redovisningsperiod: result.redovisningsperiod,
kontrollResultat: result.kontrollresultat,
},
})
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch submitted declaration ─────────────────────────────────
{
method: 'GET',
@@ -742,9 +962,20 @@ export const skatteverketExtension: Extension = {
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
// resolveReadAuth: post-signing checks should outlive the user's
// 65-minute session when the company has a moms_ombud grant.
const resolved = await resolveReadAuth(ctx.supabase, ctx.companyId, {
requires: 'moms_ombud',
userId: ctx.userId,
})
if (!resolved.ok) {
return NextResponse.json(
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
{ status: 401 }
)
}
const response = await skvRequestWithAuth(
resolved.auth,
'GET',
`/inlamnat/${redovisare}/${redovisningsperiod}`
)
@@ -762,6 +993,14 @@ export const skatteverketExtension: Extension = {
}
const data = await response.json()
// A non-null inlamnat means the declaration is filed: complete the
// period's moms deadline. Best-effort, gated on the caller passing
// the picker params (older clients omit them).
if (data) {
await completeVatDeadlineFromRequest(request, ctx, 'submitted')
}
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
@@ -781,9 +1020,18 @@ export const skatteverketExtension: Extension = {
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
const resolved = await resolveReadAuth(ctx.supabase, ctx.companyId, {
requires: 'moms_ombud',
userId: ctx.userId,
})
if (!resolved.ok) {
return NextResponse.json(
{ error: 'Inte ansluten till Skatteverket.', code: 'NOT_CONNECTED' },
{ status: 401 }
)
}
const response = await skvRequestWithAuth(
resolved.auth,
'GET',
`/beslutat/${redovisare}/${redovisningsperiod}`
)
@@ -801,6 +1049,13 @@ export const skatteverketExtension: Extension = {
}
const data = await response.json()
// A beslut means Skatteverket has processed the filing: confirm
// the period's moms deadline (terminal state).
if (data) {
await completeVatDeadlineFromRequest(request, ctx, 'confirmed')
}
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
@@ -1246,7 +1501,11 @@ export const skatteverketExtension: Extension = {
)
}
const result = await agiGetKvittenser(ctx.supabase, ctx.userId, arbetsgivare, period)
const result = await agiGetKvittenser(
{ mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
arbetsgivare,
period
)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
@@ -1969,6 +2228,46 @@ function parseQueryParams(
return { redovisare, redovisningsperiod }
}
/**
* Build the deadline generator's tax_period string (`YYYY-MM` monthly,
* `YYYY-QN` quarterly) from the picker params. Annual VAT has no system
* deadline type, so yearly returns null and the deadline hook is a no-op.
*/
function vatTaxPeriod(periodType: VatPeriodType, year: number, period: number): string | null {
if (periodType === 'monthly') return `${year}-${String(period).padStart(2, '0')}`
if (periodType === 'quarterly') return `${year}-Q${period}`
return null
}
/**
* Complete the moms deadline for the period identified by the request's
* optional periodType/year/period query params. Both moms deadline types are
* passed: company settings decide which one exists, the other is a no-op.
* Best-effort by design (completeTaxDeadline never throws).
*/
async function completeVatDeadlineFromRequest(
request: Request,
ctx: ExtensionContext,
newStatus: 'submitted' | 'confirmed'
): Promise<void> {
const url = new URL(request.url)
const periodType = url.searchParams.get('periodType') as VatPeriodType | null
const year = Number(url.searchParams.get('year'))
const period = Number(url.searchParams.get('period'))
if (!periodType || !Number.isFinite(year) || !Number.isFinite(period) || !year || !period) {
return
}
const taxPeriod = vatTaxPeriod(periodType, year, period)
if (!taxPeriod) return
await completeTaxDeadline(
ctx.supabase,
ctx.companyId,
['moms_monthly', 'moms_quarterly'],
taxPeriod,
newStatus
)
}
/**
* Load the AGI XML for a salary run from agi_declarations.xml_content
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml).
@@ -2092,65 +2391,21 @@ async function commitSubmitVatDeclaration(
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
// 1. POST /utkast: save the draft to Eget utrymme. Overwrites any prior
// draft for the period, so retry after a mid-chain failure is safe.
const utkast = await skvRequest(
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
outcome: utkast.ok ? 'ok' : 'skv_error', responseStatus: utkast.status,
})
if (!utkast.ok) {
const text = await utkast.text().catch(() => '')
// The staged figures were already reviewed at approval time, so the
// chain starts at the utkast write (no kontrollera pre-step here).
const result = await submitVatDeclarationChain(ctx, { periodType, year, period })
if (!result.ok) {
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: utkast.status,
recoverable: false, error: `Skatteverket svarade med ${utkast.status}: ${text}`,
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: result.httpStatus,
recoverable: false, error: result.error,
}
}
const utkastData = (await utkast.json()) as SkatteverketUtkastResponse
// 2. PUT /las: lock for signing; returns the BankID signeringslänk.
const las = await skvRequest(
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
outcome: las.ok ? 'ok' : 'skv_error', responseStatus: las.status,
})
if (!las.ok) {
const text = await las.text().catch(() => '')
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: las.status,
recoverable: false, error: `Skatteverket svarade med ${las.status}: ${text}`,
}
}
const lasData = (await las.json()) as SkatteverketUtkastResponse
if (!lasData.signeringsLank) {
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 502, recoverable: false,
error: 'Skatteverket låste deklarationen men returnerade ingen signeringslänk.',
}
}
// Persist locked state so the UI/poller can resume (mirrors /declaration/lock).
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_locked', redovisare, redovisningsperiod,
signeringsLank: lasData.signeringsLank, updatedAt: new Date().toISOString(),
}),
)
return {
ok: true,
signing_url: lasData.signeringsLank,
redovisningsperiod,
redovisare,
kontrollresultat: utkastData.kontrollResultat ?? null,
signing_url: result.signingUrl,
redovisningsperiod: result.redovisningsperiod,
redovisare: result.redovisare,
kontrollresultat: result.kontrollresultat,
}
} catch (err) {
return mapServiceError(ctx, 'declaration/submit', err)
@@ -1,5 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import { skvRequest, skvRequestWithAuth, type SkvAuth } from './api-client'
import type {
SkatteverketAGIErrorBody,
SkatteverketAGIGranskningsunderlagResponse,
@@ -282,16 +282,18 @@ export async function agiSkapaGranskningsunderlag(
*
* Returns an empty kvittenser array until the user has signed in Mina Sidor.
* Once signed, each receipt carries uuidKvittens + signeradAv + signeradTid.
*
* Takes SkvAuth (not supabase+userId): kvittens polling is a background
* read, so the crons can run it on system credentials when the company has
* granted the lasombud behorighet.
*/
export async function agiGetKvittenser(
supabase: SupabaseClient,
userId: string,
auth: SkvAuth,
arbetsgivare: string,
period: string,
): Promise<Result<SkatteverketAGIKvittenserResponse>> {
const response = await skvRequest(
supabase,
userId,
const response = await skvRequestWithAuth(
auth,
'GET',
`${periodPath(arbetsgivare, period)}/kvittenser`,
undefined,
@@ -0,0 +1,131 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { parseAgiPeriod } from './skattekonto-match'
const log = createLogger('agi-tax-settlement')
/**
* Auto-settle AGI tax payments from booked skattekonto rows.
*
* When Skatteverket books the "Arbetsgivardeklaration YYYYMM" debit on the
* skattekonto, the declared amount has been drawn from the account. If the
* account is not in deficit at that point, the period's tax obligation is
* settled: flip agi_declarations.tax_paid_at so the salary UI stops showing
* the period as unpaid.
*
* The rule is deliberately strict (deterministic, no inference):
* - saldo must be >= 0: a deficit means something is still unpaid, so
* nothing gets marked paid;
* - the row's amount must equal total_tax + total_avgifter to the ore:
* corrections and partial draws fall back to the manual mark-paid button.
*/
export interface SettleableSkattekontoRow {
transaktionsdatum: string
transaktionstext: string
belopp_skatteverket: number
}
interface AgiDeclarationRow {
id: string
period_year: number
period_month: number
total_tax: number
total_avgifter: number
tax_paid_at: string | null
}
function periodKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, '0')}`
}
/**
* Flip tax_paid_at for AGI declarations whose skattekonto debit row is booked.
* Returns the number of declarations settled. Never throws: settlement is a
* best-effort side effect of the sync and must not fail it.
*/
export async function settleAgiTaxPayments(
supabase: SupabaseClient,
companyId: string,
bookedRows: SettleableSkattekontoRow[],
saldoSkatteverket: number,
): Promise<number> {
try {
if (saldoSkatteverket < 0) return 0
// Debit rows (money drawn from the account) carrying an AGI period token.
const candidates = bookedRows
.map(row => {
if (row.belopp_skatteverket >= 0) return null
const period = parseAgiPeriod(row.transaktionstext)
if (!period) return null
return { row, period }
})
.filter((c): c is NonNullable<typeof c> => c !== null)
if (candidates.length === 0) return 0
const years = Array.from(new Set(candidates.map(c => c.period.year)))
const months = Array.from(new Set(candidates.map(c => c.period.month)))
const { data, error } = await supabase
.from('agi_declarations')
.select('id, period_year, period_month, total_tax, total_avgifter, tax_paid_at')
.eq('company_id', companyId)
.in('period_year', years)
.in('period_month', months)
.is('tax_paid_at', null)
if (error) {
log.warn('agi settlement lookup failed', {
companyId,
message: error.message,
})
return 0
}
const declarationsByPeriod = new Map<string, AgiDeclarationRow>()
for (const decl of (data ?? []) as AgiDeclarationRow[]) {
declarationsByPeriod.set(periodKey(decl.period_year, decl.period_month), decl)
}
let settled = 0
const settledIds = new Set<string>()
for (const { row, period } of candidates) {
const decl = declarationsByPeriod.get(periodKey(period.year, period.month))
if (!decl || settledIds.has(decl.id)) continue
const drawn = Math.round(Math.abs(row.belopp_skatteverket) * 100)
const declared = Math.round((decl.total_tax + decl.total_avgifter) * 100)
if (drawn !== declared) continue
const { error: updateError } = await supabase
.from('agi_declarations')
.update({ tax_paid_at: `${row.transaktionsdatum}T00:00:00Z` })
.eq('id', decl.id)
.eq('company_id', companyId)
.is('tax_paid_at', null) // guard against concurrent settles
if (updateError) {
log.warn('agi settlement update failed', {
companyId,
declarationId: decl.id,
message: updateError.message,
})
continue
}
settledIds.add(decl.id)
settled++
}
return settled
} catch (err) {
log.warn('agi settlement failed', {
companyId,
message: err instanceof Error ? err.message : String(err),
})
return 0
}
}
@@ -3,8 +3,22 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { refreshAccessToken } from './oauth'
import { getTokens, storeTokens, deleteTokens } from './token-store'
import { getSystemAccessToken, invalidateSystemToken } from './system-auth/token-provider'
import type { SkatteverketTokens } from '../types'
/**
* Credential selector for SKV API calls.
*
* 'user' : the personal BankID OAuth token (per-user, 65-minute refresh
* chain). The only mode that existed before the hybrid model.
* 'system' : Accounted's own Client Credentials token (org certificate),
* authorized per company via an ombud grant at Skatteverket.
* Used by background reads; carries no user session at all.
*/
export type SkvAuth =
| { mode: 'user'; supabase: SupabaseClient; userId: string }
| { mode: 'system' }
const log = createLogger('skatteverket-api-client')
// Cap diagnostic-body logging at 200 chars and redact any Bearer token
@@ -191,12 +205,9 @@ async function refreshTokenForUser(
}
/**
* Make an authenticated request to the Skatteverket API.
*
* Automatically handles:
* - Token refresh if expired
* - Required headers (Client_Id, Client_Secret, correlation ID)
* - Rate limiting
* Make an authenticated request to the Skatteverket API with the user's
* personal BankID token. Thin wrapper kept for the ~40 existing call sites;
* new auth-aware code calls skvRequestWithAuth directly.
*/
export async function skvRequest(
supabase: SupabaseClient,
@@ -205,6 +216,31 @@ export async function skvRequest(
path: string,
body?: unknown,
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
return skvRequestWithAuth({ mode: 'user', supabase, userId }, method, path, body, options)
}
/**
* Make an authenticated request to the Skatteverket API.
*
* Automatically handles:
* - Credential resolution per auth mode (user token refresh, or the cached
* system CCG token)
* - Required headers (Client_Id, Client_Secret, correlation ID)
* - Rate limiting
*
* Error semantics differ by mode: user-mode 401/403s map to the reconnect
* codes (SESSION_EXPIRED, TOKEN_REVOKED, ...); system-mode failures never
* touch skatteverket_tokens and map to SYSTEM_AUTH_FAILED (run-level
* credential problem) or OMBUD_GRANT_MISSING (this company has not granted,
* or has revoked, the behorighet).
*/
export async function skvRequestWithAuth(
auth: SkvAuth,
method: string,
path: string,
body?: unknown,
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
if (isDisabled()) {
throw new SkatteverketAuthError(
@@ -212,7 +248,20 @@ export async function skvRequest(
'ACCESS_DENIED'
)
}
const accessToken = await getValidToken(supabase, userId)
let accessToken: string
if (auth.mode === 'user') {
accessToken = await getValidToken(auth.supabase, auth.userId)
} else {
try {
accessToken = await getSystemAccessToken()
} catch (err) {
throw new SkatteverketAuthError(
err instanceof Error ? err.message : 'Systemtoken kunde inte hämtas.',
'SYSTEM_AUTH_FAILED'
)
}
}
await enforceRateLimit()
@@ -279,10 +328,23 @@ export async function skvRequest(
log.warn('401 from Skatteverket API', {
url,
statusCode: 401,
authMode: auth.mode,
body: safeBodyForLog(text),
headers: skvHeaders,
})
if (auth.mode === 'system') {
// A rejected system token is a run-level credential problem (cert,
// token endpoint, APIGW subscription): drop the cache so the next
// call mints fresh, and never touch the user token table from here.
invalidateSystemToken()
throw new SkatteverketAuthError(
'Skatteverket avvisade systemautentiseringen. Kontrollera certifikatet ' +
'och APIGW-prenumerationerna för systemklienten.',
'SYSTEM_AUTH_FAILED'
)
}
const lower = text.toLowerCase()
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
@@ -313,9 +375,9 @@ export async function skvRequest(
// primary auth error to the user.
if (lower.includes('revoked') || lower.includes('token has been revoked')) {
try {
await deleteTokens(supabase, userId)
await deleteTokens(auth.supabase, auth.userId)
} catch (cleanupErr) {
log.error('failed to clear revoked token row', cleanupErr as Error, { userId })
log.error('failed to clear revoked token row', cleanupErr as Error, { userId: auth.userId })
}
throw new SkatteverketAuthError(
'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' +
@@ -390,8 +452,28 @@ export async function skvRequest(
log.warn('403 from Skatteverket API', {
url,
statusCode: 403,
authMode: auth.mode,
body: safeBodyForLog(text),
})
if (auth.mode === 'system') {
if (text.includes('invalid_scope') || text.includes('required scope')) {
throw new SkatteverketAuthError(
'Systemtokenens scope räcker inte för denna tjänst. Kontrollera ' +
'SKATTEVERKET_SYSTEM_SCOPES mot tjänstens krav.',
'SYSTEM_AUTH_FAILED'
)
}
// With valid system credentials, a 403 means this company has not
// granted (or has revoked) the behorighet for Accounted's org number.
// Company-level: the caller downgrades the connection row, other
// companies in the same run are unaffected.
throw new SkatteverketAuthError(
'Företaget har inte gett Accounted behörighet hos Skatteverket, ' +
'eller så har behörigheten återkallats i Ombud och behörigheter.',
'OMBUD_GRANT_MISSING'
)
}
// Missing scope on the access token: fires when an existing connection
// pre-dates an extension that needed a new scope (the AGI/`agd` rollout
// is the canonical example). The user has to disconnect + reconnect to
@@ -458,6 +540,12 @@ export async function skvRequest(
* RATE_LIMITED : 429 from SKV API gateway
* TOKEN_CORRUPTED : stored tokens cannot be decrypted (key rotated
* or row tampered with); user must reconnect
* SYSTEM_AUTH_FAILED : system (CCG) credential problem: token could not
* be minted, was rejected, or lacks scope. Run-level:
* affects every company, fix is configuration-side.
* OMBUD_GRANT_MISSING: 403 on a system-mode call: this company has not
* granted (or has revoked) Accounted's behorighet at
* Skatteverket. Company-level.
*/
export class SkatteverketAuthError extends Error {
constructor(
@@ -472,6 +560,8 @@ export class SkatteverketAuthError extends Error {
| 'ACCESS_DENIED'
| 'RATE_LIMITED'
| 'TOKEN_CORRUPTED'
| 'SYSTEM_AUTH_FAILED'
| 'OMBUD_GRANT_MISSING'
) {
super(message)
this.name = 'SkatteverketAuthError'
@@ -0,0 +1,235 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('skatteverket-connection-store')
/**
* Accessor for skatteverket_company_connections: the per-company system-auth
* (ombud grant) state.
*
* All writes route through a service-role client (mirrors token-store.ts):
* probes run with system credentials server-side and the calling routes
* enforce user identity and role before reaching this module. User sessions
* can only SELECT (RLS).
*/
let _serviceClient: SupabaseClient | null = null
function getServiceClient(): SupabaseClient {
if (_serviceClient) return _serviceClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) {
throw new Error(
'skatteverket connection-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY'
)
}
_serviceClient = createClient(url, key, { auth: { persistSession: false } })
return _serviceClient
}
export type SkvEnvironment = 'test' | 'production'
export type SkvBehorighet = 'lasombud' | 'moms_ombud'
export type GrantStatus = 'unknown' | 'granted' | 'denied' | 'error'
export type ConnectionStatus = 'pending' | 'partial' | 'verified' | 'revoked' | 'error'
export interface SkvCompanyConnection {
id: string
company_id: string
environment: SkvEnvironment
org_number: string
status: ConnectionStatus
lasombud_status: GrantStatus
lasombud_checked_at: string | null
moms_ombud_status: GrantStatus
moms_ombud_checked_at: string | null
verified_at: string | null
last_probe_at: string | null
last_probe_detail: Record<string, unknown> | null
last_error: string | null
}
const CONNECTION_COLUMNS =
'id, company_id, environment, org_number, status, lasombud_status, lasombud_checked_at, ' +
'moms_ombud_status, moms_ombud_checked_at, verified_at, last_probe_at, last_probe_detail, last_error'
export async function getConnection(
companyId: string,
environment: SkvEnvironment
): Promise<SkvCompanyConnection | null> {
const { data, error } = await getServiceClient()
.from('skatteverket_company_connections')
.select(CONNECTION_COLUMNS)
.eq('company_id', companyId)
.eq('environment', environment)
.maybeSingle()
if (error) {
log.warn('getConnection failed', { companyId, environment, error: error.message })
return null
}
return (data as SkvCompanyConnection | null) ?? null
}
/** Aggregate status from the per-behorighet grant states. */
function aggregateStatus(
lasombud: GrantStatus,
momsOmbud: GrantStatus
): ConnectionStatus {
const states = [lasombud, momsOmbud]
const grantedCount = states.filter((s) => s === 'granted').length
if (grantedCount === states.length) return 'verified'
if (grantedCount > 0) return 'partial'
if (states.every((s) => s === 'denied')) return 'pending'
if (states.some((s) => s === 'error')) return 'error'
return 'pending'
}
export interface ProbeResultInput {
companyId: string
environment: SkvEnvironment
orgNumber: string
createdBy?: string
lasombud?: { status: GrantStatus; detail?: unknown }
momsOmbud?: { status: GrantStatus; detail?: unknown }
error?: string | null
}
/**
* Persist a probe outcome. Transient errors never downgrade a previously
* granted behorighet: only an explicit 'denied' classification does.
*/
export async function recordProbeResult(
input: ProbeResultInput
): Promise<SkvCompanyConnection | null> {
const supabase = getServiceClient()
const existing = await getConnection(input.companyId, input.environment)
const now = new Date().toISOString()
const nextGrant = (
previous: GrantStatus,
probe: { status: GrantStatus } | undefined
): GrantStatus => {
if (!probe) return previous
if (probe.status === 'error' && previous === 'granted') return 'granted'
return probe.status
}
const lasombudStatus = nextGrant(existing?.lasombud_status ?? 'unknown', input.lasombud)
const momsOmbudStatus = nextGrant(existing?.moms_ombud_status ?? 'unknown', input.momsOmbud)
const status = aggregateStatus(lasombudStatus, momsOmbudStatus)
const row: Record<string, unknown> = {
company_id: input.companyId,
environment: input.environment,
org_number: input.orgNumber,
status,
lasombud_status: lasombudStatus,
moms_ombud_status: momsOmbudStatus,
last_probe_at: now,
last_probe_detail: {
lasombud: input.lasombud ?? null,
moms_ombud: input.momsOmbud ?? null,
},
last_error: input.error ?? null,
}
if (input.lasombud) row.lasombud_checked_at = now
if (input.momsOmbud) row.moms_ombud_checked_at = now
if (input.createdBy && !existing) row.created_by = input.createdBy
if (status === 'verified' && !existing?.verified_at) row.verified_at = now
const { data, error } = await supabase
.from('skatteverket_company_connections')
.upsert(row, { onConflict: 'company_id,environment' })
.select(CONNECTION_COLUMNS)
.single()
if (error) {
log.error('recordProbeResult failed', error, {
companyId: input.companyId,
environment: input.environment,
})
return null
}
return data as unknown as SkvCompanyConnection
}
/**
* Downgrade a behorighet after a company-level OMBUD_GRANT_MISSING observed
* during background work (companies can withdraw the grant at any time).
*/
export async function markGrantRevoked(
companyId: string,
environment: SkvEnvironment,
behorighet: SkvBehorighet,
errorCode?: string
): Promise<void> {
const existing = await getConnection(companyId, environment)
if (!existing) return
const now = new Date().toISOString()
const lasombudStatus = behorighet === 'lasombud' ? 'denied' : existing.lasombud_status
const momsOmbudStatus = behorighet === 'moms_ombud' ? 'denied' : existing.moms_ombud_status
const anyGranted = lasombudStatus === 'granted' || momsOmbudStatus === 'granted'
const { error } = await getServiceClient()
.from('skatteverket_company_connections')
.update({
lasombud_status: lasombudStatus,
moms_ombud_status: momsOmbudStatus,
...(behorighet === 'lasombud'
? { lasombud_checked_at: now }
: { moms_ombud_checked_at: now }),
status: anyGranted ? 'partial' : 'revoked',
last_error: errorCode ?? 'OMBUD_GRANT_MISSING',
})
.eq('id', existing.id)
if (error) {
log.warn('markGrantRevoked failed', { companyId, behorighet, error: error.message })
}
}
/** Set the whole connection revoked (explicit user disconnect). */
export async function markConnectionRevoked(
companyId: string,
environment: SkvEnvironment
): Promise<void> {
const { error } = await getServiceClient()
.from('skatteverket_company_connections')
.update({
status: 'revoked',
lasombud_status: 'unknown',
moms_ombud_status: 'unknown',
})
.eq('company_id', companyId)
.eq('environment', environment)
if (error) {
log.warn('markConnectionRevoked failed', { companyId, error: error.message })
}
}
/**
* Companies whose given behorighet is granted, for cron enumeration.
*/
export async function listVerifiedCompanies(
environment: SkvEnvironment,
behorighet: SkvBehorighet,
limit = 200
): Promise<Array<{ company_id: string; org_number: string; created_by: string | null }>> {
const column = behorighet === 'lasombud' ? 'lasombud_status' : 'moms_ombud_status'
const { data, error } = await getServiceClient()
.from('skatteverket_company_connections')
.select('company_id, org_number, created_by')
.eq('environment', environment)
.in('status', ['verified', 'partial'])
.eq(column, 'granted')
.limit(limit)
if (error) {
log.warn('listVerifiedCompanies failed', { environment, behorighet, error: error.message })
return []
}
return (data ?? []) as Array<{ company_id: string; org_number: string; created_by: string | null }>
}
/** Test hook: reset the memoized service client. */
export function __resetConnectionStoreForTests(): void {
_serviceClient = null
}
@@ -34,8 +34,14 @@ export function skvAuthCodeToStructured(
return { code: 'SKATTEVERKET_NOT_CONNECTED', httpStatus: 401 }
case 'BEHORIGHET_SAKNAS':
case 'ACCESS_DENIED':
// Missing ombud grant is fixed at SKV's Ombud och behorigheter, same
// remediation family as BEHORIGHET_SAKNAS.
case 'OMBUD_GRANT_MISSING':
return { code: 'SKATTEVERKET_ACCESS_DENIED', httpStatus: 403 }
case 'RATE_LIMITED':
return { code: 'SKATTEVERKET_RATE_LIMITED', httpStatus: 429 }
case 'SYSTEM_AUTH_FAILED':
// Configuration-side failure: nothing the end user can remediate.
return { code: 'SKATTEVERKET_INTERNAL_ERROR', httpStatus: 502 }
}
}
@@ -0,0 +1,140 @@
import { skvRequestWithAuth, SkatteverketAuthError } from './api-client'
import { getSkattekontoBaseUrl } from './skattekonto-client'
import { recordProbeResult, type GrantStatus, type SkvCompanyConnection } from './connection-store'
import { currentSkvEnvironment } from './resolve-auth'
import { createLogger } from '@/lib/logger'
const log = createLogger('skatteverket-grant-probe')
/**
* Behorighet verification probes.
*
* After the user grants Accounted's org number a behorighet in Skatteverket's
* Ombud och behorigheter e-service, nothing tells us: there is no callback.
* The probe makes one cheap read per behorighet with SYSTEM credentials and
* classifies the outcome:
*
* lasombud : GET skattekonto saldo for the company's org number.
* 200 -> granted; felkod 3 (no skattekonto registered) also
* proves authorization passed -> granted-with-note;
* OMBUD_GRANT_MISSING (403) -> denied; anything transient
* (5xx, timeout, rate limit) -> error, which never downgrades
* a previously granted state (connection-store rule).
* moms_ombud : GET moms /utkast for the current period. 200 or 404 (no
* draft exists, but the gateway authorized us) -> granted;
* OMBUD_GRANT_MISSING -> denied.
*
* The classification heuristics live here, in one file, because they are
* assumptions until validated against real sandbox 403 bodies (Phase 2 of
* the rollout): expected churn stays contained.
*/
export interface ProbeClassification {
status: GrantStatus
detail: string
}
function classifyError(err: unknown): ProbeClassification {
if (err instanceof SkatteverketAuthError) {
if (err.code === 'OMBUD_GRANT_MISSING') {
return { status: 'denied', detail: err.code }
}
// SYSTEM_AUTH_FAILED, RATE_LIMITED, ACCESS_DENIED (kill switch or APIGW)
// are all our-side or transient: not evidence about the grant.
return { status: 'error', detail: err.code }
}
return { status: 'error', detail: err instanceof Error ? err.message : String(err) }
}
async function probeLasombud(orgNumber: string): Promise<ProbeClassification> {
try {
const response = await skvRequestWithAuth(
{ mode: 'system' },
'GET',
`/skattekonton/${orgNumber}/saldo`,
undefined,
{ baseUrl: getSkattekontoBaseUrl() }
)
if (response.ok) return { status: 'granted', detail: String(response.status) }
// felkod 3 = no skattekonto registered: the authorization layer passed,
// the account state is a separate matter.
try {
const body = (await response.json()) as { felkod?: number }
if (body?.felkod === 3) {
return { status: 'granted', detail: 'felkod 3 (inget skattekonto registrerat)' }
}
return { status: 'error', detail: `HTTP ${response.status}, felkod ${body?.felkod ?? 'okänd'}` }
} catch {
return { status: 'error', detail: `HTTP ${response.status}` }
}
} catch (err) {
return classifyError(err)
}
}
/** Current YYYYMM-style moms period for the draft probe. */
function currentMomsPeriod(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`
}
async function probeMomsOmbud(orgNumber: string): Promise<ProbeClassification> {
try {
const response = await skvRequestWithAuth(
{ mode: 'system' },
'GET',
`/utkast/${orgNumber}/${currentMomsPeriod()}`
)
// 404 just means no draft for the period: the gateway authorized us.
if (response.ok || response.status === 404) {
return { status: 'granted', detail: String(response.status) }
}
return { status: 'error', detail: `HTTP ${response.status}` }
} catch (err) {
return classifyError(err)
}
}
export interface GrantProbeResult {
connection: SkvCompanyConnection | null
lasombud: ProbeClassification
momsOmbud: ProbeClassification
}
/**
* Run both behorighet probes for a company and persist the outcome.
* The caller has already verified role + capability and resolved the
* company's normalized 12-digit org number.
*/
export async function probeCompanyGrants(
companyId: string,
orgNumber: string,
createdBy?: string
): Promise<GrantProbeResult> {
const [lasombud, momsOmbud] = [await probeLasombud(orgNumber), await probeMomsOmbud(orgNumber)]
log.info('grant probe completed', {
companyId,
lasombud: lasombud.status,
momsOmbud: momsOmbud.status,
})
const connection = await recordProbeResult({
companyId,
environment: currentSkvEnvironment(),
orgNumber,
createdBy,
lasombud: { status: lasombud.status, detail: lasombud.detail },
momsOmbud: { status: momsOmbud.status, detail: momsOmbud.detail },
error:
lasombud.status === 'error' || momsOmbud.status === 'error'
? [lasombud, momsOmbud]
.filter((p) => p.status === 'error')
.map((p) => p.detail)
.join('; ')
: null,
})
return { connection, lasombud, momsOmbud }
}
@@ -0,0 +1,206 @@
/**
* Kvittens confirmation notification (email).
*
* The signing happens on Skatteverket's site, and the kvittens is often
* observed by a background cron long after the user closed the tab. Without
* an outbound notification the filing confirmation is silent: the user only
* learns the declaration went through if they come back and look. Both
* kvittens crons (AGI and VAT) call this after flipping the local state.
*
* Email is the delivery channel: it is the one notification surface that is
* always available (push-notifications is a separate, optional extension and
* cross-extension imports are not allowed). Deduplication goes through
* notification_log under type 'skv_kvittens' so a re-observed kvittens never
* notifies twice: the log row is inserted FIRST as a claim, guarded by a
* partial unique index on (user_id, reference_id) where notification_type =
* 'skv_kvittens' (migration 20260712113000), and the email is only sent when
* this invocation won the insert. A unique violation (23505) means another
* overlapping cron run already claimed the kvittens.
*
* Best-effort by design: a notification failure must never fail the
* reconciliation that observed the kvittens. The body carries the period and
* kvittens number but no amounts (same data-minimization stance as the
* skattekonto drift email).
*/
import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
const log = createLogger('skv-kvittens-notification')
export interface KvittensNotificationInput {
companyId: string
/** The token-owning user: recipient candidate for the email. */
userId: string
kind: 'vat' | 'agi'
/** Human-readable period, e.g. "2026-06" or "202606". */
period: string
kvittensnummer: string
/** Dedup key for the notification log (e.g. declaration id or period key). */
referenceId: string
}
export async function sendKvittensNotification(
supabase: SupabaseClient,
input: KvittensNotificationInput
): Promise<{ sent: boolean; reason?: string }> {
const referenceUuid = toReferenceUuid(input.referenceId)
try {
const email = getEmailService()
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
// Dedup fast path: cheap read that skips the work below on re-observed
// kvittenser. NOT the enforcement: the claim insert further down is.
const { data: already } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', input.userId)
.eq('notification_type', 'skv_kvittens')
.eq('reference_id', referenceUuid)
.maybeSingle()
if (already) return { sent: false, reason: 'duplicate' }
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
if (!recipient) {
log.info('no authorised recipient for kvittens email', { companyId: input.companyId })
return { sent: false, reason: 'no_recipient' }
}
// Claim before sending. The partial unique index on notification_log
// (user_id, reference_id) where notification_type = 'skv_kvittens' makes
// this atomic: of two overlapping cron invocations exactly one wins the
// insert; the loser gets a unique violation and skips the send.
const { error: claimError } = await supabase.from('notification_log').insert({
user_id: input.userId,
company_id: input.companyId,
notification_type: 'skv_kvittens',
reference_id: referenceUuid,
days_before: 0,
delivery_status: 'sent',
})
if (claimError) {
if (claimError.code === '23505') return { sent: false, reason: 'duplicate' }
// Without a claim we cannot guarantee single delivery: skip the send
// (fail closed on the never-twice guarantee) and let a later
// observation retry.
log.warn('kvittens claim insert failed', {
companyId: input.companyId,
referenceId: input.referenceId,
error: claimError.message,
})
return { sent: false, reason: 'claim_failed' }
}
const label = input.kind === 'vat' ? 'Momsdeklarationen' : 'Arbetsgivardeklarationen'
const labelLower = input.kind === 'vat' ? 'momsdeklarationen' : 'arbetsgivardeklarationen'
const subject = `${label} för ${input.period} är inlämnad`
const text = [
`Din ${labelLower} för ${input.period} har signerats och lämnats in hos Skatteverket.`,
'',
`Kvittensnummer: ${input.kvittensnummer}`,
'',
'Ingen åtgärd behövs. Kvittensen finns sparad i Accounted.',
].join('\n')
const html = [
`<p>Din ${labelLower} för ${escapeHtml(input.period)} har signerats och lämnats in hos Skatteverket.</p>`,
`<p>Kvittensnummer: <strong>${escapeHtml(input.kvittensnummer)}</strong></p>`,
'<p>Ingen åtgärd behövs. Kvittensen finns sparad i Accounted.</p>',
].join('')
let result: Awaited<ReturnType<typeof email.sendEmail>>
try {
result = await email.sendEmail({ to: recipient, subject, text, html })
} catch (sendErr) {
// Release the claim so a later observation can retry the send.
await releaseClaim(supabase, input.userId, referenceUuid)
throw sendErr
}
if (!result.success) {
log.warn('kvittens email send failed', { companyId: input.companyId, error: result.error })
await releaseClaim(supabase, input.userId, referenceUuid)
return { sent: false, reason: 'send_failed' }
}
return { sent: true }
} catch (err) {
log.warn('kvittens notification failed', {
companyId: input.companyId,
referenceId: input.referenceId,
error: err instanceof Error ? err.message : String(err),
})
return { sent: false, reason: 'error' }
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* notification_log.reference_id is a uuid column. The AGI cron passes the
* declaration row id (already a uuid); the VAT cron has no declaration row
* and passes a composite string key, which Postgres would reject (22P02)
* before dedup could even happen. Map non-uuid keys to a deterministic
* uuid-shaped SHA-256 digest so the same kvittens always resolves to the
* same claim row.
*/
function toReferenceUuid(referenceId: string): string {
if (UUID_PATTERN.test(referenceId)) return referenceId
const hex = createHash('sha256').update(referenceId).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/** Remove a claim whose email never went out, so a later run can retry. */
async function releaseClaim(
supabase: SupabaseClient,
userId: string,
referenceUuid: string
): Promise<void> {
try {
await supabase
.from('notification_log')
.delete()
.eq('user_id', userId)
.eq('notification_type', 'skv_kvittens')
.eq('reference_id', referenceUuid)
} catch (err) {
// A stuck claim only suppresses a retry of this one email: log and move on.
log.warn('failed to release kvittens claim', {
userId,
referenceUuid,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* The recipient must still be an active member of the company (mirrors the
* drift-email rule): a token owner who has since been removed from the
* company must not receive filing confirmations for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -0,0 +1,109 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getSkatteverketEnvironment, type SkvAuth } from './api-client'
import { getSystemAuthMode, isSystemAuthConfigured } from './system-auth/config'
import {
getConnection,
type SkvBehorighet,
type SkvEnvironment,
} from './connection-store'
/**
* Auth resolution for background READ paths (skattekonto sync, kvittens
* polling, moms inlamnat/beslutat checks).
*
* Preference order:
* 1. System credentials, when SKATTEVERKET_SYSTEM_AUTH_MODE=on, the system
* flow is configured, and the company's connection row shows the
* required behorighet as granted for the current environment.
* 2. The company's user token (the pre-hybrid behavior), looked up by
* company_id like the kvittens cron always did.
*
* Write paths (moms utkast/las, AGI submit/spara/granskningsunderlag) stay
* hard-wired to user mode: the personal flow needs no ombud grant and the
* BankID signing step is personal by nature.
*
* Retiring user-token reads later (the full ombud switch) is a policy change
* inside this function only.
*/
export function currentSkvEnvironment(): SkvEnvironment {
return getSkatteverketEnvironment() === 'prod' ? 'production' : 'test'
}
export type ResolvedReadAuth =
| {
ok: true
auth: SkvAuth
source: 'system' | 'user'
/** The token-owning user (notification recipient); null in pure system mode. */
tokenUserId: string | null
}
| { ok: false; reason: 'no_token' | 'needs_reconsent' }
/** True when the company's connection row has the behorighet granted. */
export async function hasVerifiedGrant(
companyId: string,
behorighet: SkvBehorighet
): Promise<boolean> {
const connection = await getConnection(companyId, currentSkvEnvironment())
if (!connection) return false
const grant =
behorighet === 'lasombud' ? connection.lasombud_status : connection.moms_ombud_status
return grant === 'granted' && ['verified', 'partial'].includes(connection.status)
}
export async function resolveReadAuth(
supabase: SupabaseClient,
companyId: string,
opts: { requires: SkvBehorighet; userId?: string }
): Promise<ResolvedReadAuth> {
if (getSystemAuthMode() === 'on' && isSystemAuthConfigured()) {
if (await hasVerifiedGrant(companyId, opts.requires)) {
return {
ok: true,
auth: { mode: 'system' },
source: 'system',
tokenUserId: opts.userId ?? (await lookupTokenUser(supabase, companyId))?.userId ?? null,
}
}
}
if (opts.userId) {
return {
ok: true,
auth: { mode: 'user', supabase, userId: opts.userId },
source: 'user',
tokenUserId: opts.userId,
}
}
const token = await lookupTokenUser(supabase, companyId)
if (!token) return { ok: false, reason: 'no_token' }
if (token.needsReconsent) return { ok: false, reason: 'needs_reconsent' }
return {
ok: true,
auth: { mode: 'user', supabase, userId: token.userId },
source: 'user',
tokenUserId: token.userId,
}
}
async function lookupTokenUser(
supabase: SupabaseClient,
companyId: string
): Promise<{ userId: string; needsReconsent: boolean } | null> {
// The token table is user-scoped (one BankID identity per user) but
// carries company_id: match on company so a multi-company operator's
// token is only used for the company that owns the work.
const { data } = await supabase
.from('skatteverket_tokens')
.select('user_id, status')
.eq('company_id', companyId)
.maybeSingle()
if (!data?.user_id) return null
return {
userId: data.user_id as string,
needsReconsent: data.status === 'needs_reconsent',
}
}
@@ -1,5 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import { skvRequestWithAuth, type SkvAuth } from './api-client'
import type {
SkatteverketSaldoResponse,
SkatteverketTransaktionerResponse,
@@ -20,7 +19,7 @@ import type {
const DEFAULT_SKATTEKONTO_BASE_URL =
'https://api.test.skatteverket.se/beskattning/skattekonto/v2'
function getBaseUrl(): string {
export function getSkattekontoBaseUrl(): string {
return (
process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL ||
DEFAULT_SKATTEKONTO_BASE_URL
@@ -100,19 +99,17 @@ export class SkatteverketSkattekontoError extends Error {
* @param datum Optional ISO date (YYYY-MM-DD); fetch balance as of date
*/
export async function getSaldo(
supabase: SupabaseClient,
userId: string,
auth: SkvAuth,
omfragad: string,
datum?: string,
): Promise<SkatteverketSaldoResponse> {
const qs = datum ? `?datum=${encodeURIComponent(datum)}` : ''
const response = await skvRequest(
supabase,
userId,
const response = await skvRequestWithAuth(
auth,
'GET',
`/skattekonton/${omfragad}/saldo${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
{ baseUrl: getSkattekontoBaseUrl() },
)
if (!response.ok) {
@@ -130,19 +127,17 @@ export async function getSaldo(
* 555 days back; max lookback is 915 days.
*/
export async function getTransaktioner(
supabase: SupabaseClient,
userId: string,
auth: SkvAuth,
omfragad: string,
datumFrom?: string,
): Promise<SkatteverketTransaktionerResponse> {
const qs = datumFrom ? `?datumFrom=${encodeURIComponent(datumFrom)}` : ''
const response = await skvRequest(
supabase,
userId,
const response = await skvRequestWithAuth(
auth,
'GET',
`/skattekonton/${omfragad}/transaktioner${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
{ baseUrl: getSkattekontoBaseUrl() },
)
if (!response.ok) {
@@ -4,8 +4,9 @@ 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 { settleAgiTaxPayments } from './agi-tax-settlement'
import { getSaldo, getTransaktioner } from './skattekonto-client'
import { SkatteverketAuthError } from './api-client'
import { SkatteverketAuthError, type SkvAuth } from './api-client'
import type {
SkatteverketBookedTransaction,
SkatteverketUpcomingTransaction,
@@ -125,11 +126,17 @@ function upcomingToRow(
* 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.
* 4. Auto-settle AGI tax payments from booked AGI debit rows (see
* agi-tax-settlement.ts).
* 5. Cache the saldo response in extension_data with a fetched-at timestamp.
* 6. Emit skattekonto.synced and (when applicable) other events.
*/
export async function syncSkattekonto(
ctx: ExtensionContext,
// Defaults to the ctx user's personal token: the interactive manual-sync
// route keeps its exact pre-hybrid behavior. The cron passes system auth
// for companies with a verified lasombud grant.
auth: SkvAuth = { mode: 'user', supabase: ctx.supabase, userId: ctx.userId },
): Promise<SkattekontoSyncResult> {
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
@@ -137,8 +144,8 @@ export async function syncSkattekonto(
let transaktioner: Awaited<ReturnType<typeof getTransaktioner>>
try {
;[saldo, transaktioner] = await Promise.all([
getSaldo(ctx.supabase, ctx.userId, omfragad),
getTransaktioner(ctx.supabase, ctx.userId, omfragad),
getSaldo(auth, omfragad),
getTransaktioner(auth, omfragad),
])
} catch (err) {
if (err instanceof SkatteverketAuthError) {
@@ -209,6 +216,16 @@ export async function syncSkattekonto(
}
}
// Auto-settle AGI tax payments: when the "Arbetsgivardeklaration YYYYMM"
// debit is booked and the account is not in deficit, the period is paid.
// Best-effort inside (never throws).
await settleAgiTaxPayments(
ctx.supabase,
ctx.companyId,
bookedRows,
saldo.saldoSkatteverket,
)
// Cache balance snapshot.
const snapshot: SkattekontoBalanceSnapshot = {
saldo,
@@ -0,0 +1,133 @@
import crypto from 'crypto'
/**
* Configuration for the system (organization certificate) auth flow.
*
* Unlike the personal BankID flow, the system flow authenticates Accounted
* itself with an organisationscertifikat via the OAuth2 Client Credentials
* Grant: one token for the whole tenant, no per-user session, no 65-minute
* refresh treadmill. Companies authorize it once by granting Accounted's org
* number a behorighet ("Juridiskt lasombud", "Momsdeklaration, ombud") in
* Skatteverket's Ombud och behorigheter e-service.
*
* The exact token-endpoint mechanics (mTLS vs private_key_jwt, scope names)
* are pending Skatteverket's CCG documentation: everything here is behind
* SKATTEVERKET_SYSTEM_AUTH_MODE (default 'off') and the transport seam in
* transport.ts, so the mechanism can be finalized without touching callers.
*
* Modes:
* off : system auth never used (default; behavior identical to before)
* shadow : crons log whether a verified grant exists but keep using user
* tokens (rollout confidence signal, zero behavior change)
* on : background reads prefer system credentials for companies with a
* verified grant; user tokens remain the fallback
*/
export type SystemAuthMode = 'off' | 'shadow' | 'on'
export type SystemAuthMechanism = 'mtls' | 'private_key_jwt' | 'stub'
export function getSystemAuthMode(): SystemAuthMode {
const raw = (process.env.SKATTEVERKET_SYSTEM_AUTH_MODE ?? 'off').toLowerCase()
return raw === 'on' || raw === 'shadow' ? raw : 'off'
}
export function getSystemAuthMechanism(): SystemAuthMechanism {
const raw = (process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM ?? '').toLowerCase()
if (raw === 'mtls' || raw === 'private_key_jwt' || raw === 'stub') return raw
return 'mtls'
}
export function getSystemTokenUrl(): string | null {
return process.env.SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL ?? null
}
export function getSystemClientId(): string | null {
return process.env.SKATTEVERKET_SYSTEM_CLIENT_ID ?? null
}
/**
* Default scopes for the system token. The real scope names for the org
* flow are pending SKV docs; override via env when they land.
*/
export function getSystemScopes(): string[] {
const raw = process.env.SKATTEVERKET_SYSTEM_SCOPES ?? 'skattekonto agd:lasa momsdeklaration'
return raw.split(/\s+/).filter(Boolean)
}
/** Accounted's own org number: what the end company grants behorighet to. */
export function getOmbudOrgNumber(): string | null {
return process.env.SKATTEVERKET_OMBUD_ORG_NUMBER ?? null
}
/**
* PEM material arrives base64-wrapped: env var UIs (Vercel included) mangle
* raw newlines, so the PEM is base64-encoded once more for transport.
*/
function decodePemEnv(name: string): string | null {
const raw = process.env[name]
if (!raw) return null
try {
const decoded = Buffer.from(raw, 'base64').toString('utf8')
return decoded.includes('-----BEGIN') ? decoded : null
} catch {
return null
}
}
export function getSystemCertPem(): string | null {
return decodePemEnv('SKATTEVERKET_SYSTEM_CERT_PEM_B64')
}
export function getSystemKeyPem(): string | null {
return decodePemEnv('SKATTEVERKET_SYSTEM_KEY_PEM_B64')
}
export function getSystemKeyPassphrase(): string | null {
return process.env.SKATTEVERKET_SYSTEM_KEY_PASSPHRASE ?? null
}
export function getSystemCaPem(): string | null {
return decodePemEnv('SKATTEVERKET_SYSTEM_CA_PEM_B64')
}
/**
* True when enough configuration exists for the system flow to mint tokens.
* The stub mechanism needs no certificate (tests and local development).
*/
export function isSystemAuthConfigured(): boolean {
if (getSystemAuthMode() === 'off') return false
if (getSystemAuthMechanism() === 'stub') return true
return Boolean(getSystemTokenUrl() && getSystemCertPem() && getSystemKeyPem())
}
export interface SystemCertInfo {
subject: string
notAfter: string
daysUntilExpiry: number
expiresSoon: boolean
}
const CERT_EXPIRY_WARNING_DAYS = 30
/**
* Parse the configured certificate for expiry surfacing in the status
* endpoint. Returns null when no certificate is configured or it cannot be
* parsed (a parse failure will surface at token minting anyway).
*/
export function getSystemCertInfo(): SystemCertInfo | null {
const pem = getSystemCertPem()
if (!pem) return null
try {
const cert = new crypto.X509Certificate(pem)
const notAfterMs = new Date(cert.validTo).getTime()
const daysUntilExpiry = Math.floor((notAfterMs - Date.now()) / (24 * 60 * 60 * 1000))
return {
subject: cert.subject,
notAfter: new Date(notAfterMs).toISOString(),
daysUntilExpiry,
expiresSoon: daysUntilExpiry <= CERT_EXPIRY_WARNING_DAYS,
}
} catch {
return null
}
}
@@ -0,0 +1,76 @@
import { createLogger } from '@/lib/logger'
import { getSystemAuthTransport } from './transport'
import { getSystemScopes, isSystemAuthConfigured } from './config'
const log = createLogger('skatteverket-system-auth')
/**
* System token cache: ONE token per environment for the whole tenant, never
* per company (the org certificate identifies Accounted; the company is
* addressed per request via its org number in the path). CCG has no refresh
* token: an expired or invalidated token is simply minted again.
*
* Errors here are plain SystemAuthUnavailableError, not SkatteverketAuthError:
* api-client.ts wraps them (avoids a circular import) and maps them to the
* SYSTEM_AUTH_FAILED code.
*/
const REFRESH_MARGIN_MS = 5 * 60 * 1000
export class SystemAuthUnavailableError extends Error {
constructor(message: string) {
super(message)
this.name = 'SystemAuthUnavailableError'
}
}
let cached: { token: string; expiresAt: number } | null = null
let inFlight: Promise<string> | null = null
export async function getSystemAccessToken(): Promise<string> {
if (!isSystemAuthConfigured()) {
throw new SystemAuthUnavailableError(
'Systemautentiseringen mot Skatteverket är inte konfigurerad i denna miljö.'
)
}
if (cached && cached.expiresAt > Date.now() + REFRESH_MARGIN_MS) {
return cached.token
}
// Coalesce concurrent mints: crons fan out per company and must not
// hammer the token endpoint with parallel identical requests.
if (inFlight) return inFlight
inFlight = (async () => {
try {
const result = await getSystemAuthTransport().fetchToken(getSystemScopes())
cached = { token: result.accessToken, expiresAt: result.expiresAt }
return result.accessToken
} catch (err) {
log.warn('system token minting failed', {
error: err instanceof Error ? err.message : String(err),
})
throw err instanceof SystemAuthUnavailableError
? err
: new SystemAuthUnavailableError(
err instanceof Error ? err.message : 'Systemtoken kunde inte hämtas.'
)
} finally {
inFlight = null
}
})()
return inFlight
}
/** Drop the cached token (called after a 401 on a system-mode request). */
export function invalidateSystemToken(): void {
cached = null
}
/** Test hook: reset module state between tests. */
export function __resetSystemTokenCacheForTests(): void {
cached = null
inFlight = null
}
@@ -0,0 +1,193 @@
import crypto from 'crypto'
import https from 'node:https'
import {
getSystemAuthMechanism,
getSystemCaPem,
getSystemCertPem,
getSystemClientId,
getSystemKeyPassphrase,
getSystemKeyPem,
getSystemTokenUrl,
} from './config'
/**
* Token transport seam for the system (Client Credentials) flow.
*
* The exact mechanism Skatteverket uses for the org flow is pending their
* CCG documentation: it is either mTLS at the token endpoint (client
* certificate on the TLS handshake) or RFC 7523 private_key_jwt (a signed
* client_assertion over plain TLS). Both are implemented behind one
* interface so switching is an env var, not a refactor. The stub transport
* exists for tests and local development without certificate material.
*
* The repo deliberately avoids undici (see lib/webhooks/pinned-fetch.ts);
* the mTLS variant uses node:https.request, which accepts cert/key options
* and works on both the Vercel Node runtime and Docker self-host.
*/
export interface SystemTokenResult {
accessToken: string
/** Unix ms timestamp when the token expires. */
expiresAt: number
}
export interface SystemAuthTransport {
fetchToken(scopes: string[]): Promise<SystemTokenResult>
}
class SystemTransportError extends Error {
constructor(message: string) {
super(message)
this.name = 'SystemTransportError'
}
}
interface TokenEndpointResponse {
access_token?: string
expires_in?: number
}
function parseTokenResponse(status: number, body: string): SystemTokenResult {
if (status < 200 || status >= 300) {
throw new SystemTransportError(`Skatteverket token endpoint svarade med ${status}: ${body.slice(0, 200)}`)
}
let parsed: TokenEndpointResponse
try {
parsed = JSON.parse(body) as TokenEndpointResponse
} catch {
throw new SystemTransportError('Skatteverket token endpoint returnerade ogiltig JSON.')
}
if (!parsed.access_token) {
throw new SystemTransportError('Skatteverket token endpoint returnerade ingen access_token.')
}
const expiresInSec = typeof parsed.expires_in === 'number' ? parsed.expires_in : 3600
return { accessToken: parsed.access_token, expiresAt: Date.now() + expiresInSec * 1000 }
}
/** Client Credentials over mTLS: the org certificate rides the TLS handshake. */
export const mtlsTransport: SystemAuthTransport = {
async fetchToken(scopes: string[]): Promise<SystemTokenResult> {
const tokenUrl = getSystemTokenUrl()
const cert = getSystemCertPem()
const key = getSystemKeyPem()
if (!tokenUrl || !cert || !key) {
throw new SystemTransportError(
'System auth (mtls) saknar konfiguration: SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _CERT_PEM_B64 och _KEY_PEM_B64 krävs.'
)
}
const params = new URLSearchParams({ grant_type: 'client_credentials' })
if (scopes.length > 0) params.set('scope', scopes.join(' '))
const clientId = getSystemClientId()
if (clientId) params.set('client_id', clientId)
const bodyString = params.toString()
const url = new URL(tokenUrl)
const { status, body } = await new Promise<{ status: number; body: string }>(
(resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port || 443,
path: `${url.pathname}${url.search}`,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(bodyString),
},
cert,
key,
passphrase: getSystemKeyPassphrase() ?? undefined,
ca: getSystemCaPem() ?? undefined,
timeout: 15_000,
},
(res) => {
const chunks: Buffer[] = []
// Mid-body failures (after headers) emit 'error' on the response
// stream, not the request: without this listener the promise
// stays pending and the unhandled 'error' event crashes Node.
res.on('error', reject)
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () =>
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
)
}
)
req.on('timeout', () => req.destroy(new SystemTransportError('Token endpoint timeout.')))
req.on('error', reject)
req.write(bodyString)
req.end()
}
)
return parseTokenResponse(status, body)
},
}
/** Client Credentials with an RFC 7523 signed client_assertion (plain TLS). */
export const privateKeyJwtTransport: SystemAuthTransport = {
async fetchToken(scopes: string[]): Promise<SystemTokenResult> {
const tokenUrl = getSystemTokenUrl()
const key = getSystemKeyPem()
const clientId = getSystemClientId()
if (!tokenUrl || !key || !clientId) {
throw new SystemTransportError(
'System auth (private_key_jwt) saknar konfiguration: SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _KEY_PEM_B64 och _CLIENT_ID krävs.'
)
}
const nowSec = Math.floor(Date.now() / 1000)
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(
JSON.stringify({
iss: clientId,
sub: clientId,
aud: tokenUrl,
jti: crypto.randomUUID(),
iat: nowSec,
exp: nowSec + 300,
})
).toString('base64url')
const signer = crypto.createSign('RSA-SHA256')
signer.update(`${header}.${payload}`)
const signature = signer
.sign({ key, passphrase: getSystemKeyPassphrase() ?? undefined })
.toString('base64url')
const assertion = `${header}.${payload}.${signature}`
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: assertion,
})
if (scopes.length > 0) params.set('scope', scopes.join(' '))
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
signal: AbortSignal.timeout(15_000),
})
const body = await response.text()
return parseTokenResponse(response.status, body)
},
}
/** Deterministic fake for tests and local development. */
export const stubTransport: SystemAuthTransport = {
async fetchToken(): Promise<SystemTokenResult> {
return { accessToken: 'stub-system-token', expiresAt: Date.now() + 3600 * 1000 }
},
}
export function getSystemAuthTransport(): SystemAuthTransport {
switch (getSystemAuthMechanism()) {
case 'stub':
return stubTransport
case 'private_key_jwt':
return privateKeyJwtTransport
case 'mtls':
default:
return mtlsTransport
}
}
@@ -0,0 +1,169 @@
/**
* Shared VAT "skicka för signering" chain: (kontrollera) -> POST /utkast ->
* PUT /las -> signeringslänk.
*
* Two callers file through this module so they can never drift apart:
* - the one-click extension route `POST /declaration/submit` (index.ts),
* which runs the kontrollera pre-step so a declaration with validation
* errors is stopped before anything is written to Eget utrymme
* - the pending-operations commit service `commitSubmitVatDeclaration`
* (index.ts), where the staged figures were already reviewed and the
* chain starts at the utkast write
*
* The momsuppgift is recomputed here via buildMomsuppgift (recompute-at-
* commit): the figures filed always equal what the preview showed for the
* same ledger state.
*
* SkatteverketAuthError propagates to the caller: the route maps it through
* handleSkvError, the commit service through mapServiceError.
*/
import type { ExtensionContext } from '@/lib/extensions/types'
import type { VatPeriodType } from '@/types'
import { skvRequest } from './api-client'
import { writeSkatteverketAudit } from './audit'
import { buildMomsuppgift } from './declaration-prep'
import type { SkatteverketKontroll, SkatteverketKontrollResultat, SkatteverketUtkastResponse } from '../types'
export interface VatSubmitChainParams {
periodType: VatPeriodType
year: number
period: number
}
export type VatSubmitChainResult =
| {
ok: true
signingUrl: string
redovisare: string
redovisningsperiod: string
kontrollresultat: SkatteverketKontrollResultat | null
}
| {
ok: false
/**
* Where the chain stopped. `validation` failures happen before any
* write at Skatteverket; `draft` failures leave nothing worth keeping
* (utkast overwrites, so retrying the whole chain is safe); `lock`
* failures leave a saved draft in Eget utrymme (`draftSaved: true`),
* so the retry only needs the lock step.
*/
stage: 'validation' | 'draft' | 'lock'
httpStatus: number
error: string
kontrollresultat: SkatteverketKontrollResultat | null
draftSaved: boolean
}
export async function submitVatDeclarationChain(
ctx: ExtensionContext,
params: VatSubmitChainParams,
options: { validate?: boolean } = {}
): Promise<VatSubmitChainResult> {
const { supabase, userId } = ctx
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, ctx.companyId, params)
// 0. Optional kontrollera pre-step: SKV validates the arithmetic without
// saving anything. ERROR-level findings abort the chain here, before
// any state exists at Skatteverket.
if (options.validate) {
const kontrollera = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/validate', agRegistreradId: redovisare, redovisningsperiod,
outcome: kontrollera.ok ? 'ok' : 'skv_error', responseStatus: kontrollera.status,
})
if (!kontrollera.ok) {
const text = await kontrollera.text().catch(() => '')
return {
ok: false, stage: 'validation', httpStatus: kontrollera.status,
error: `Skatteverket svarade med ${kontrollera.status}: ${text}`,
kontrollresultat: null, draftSaved: false,
}
}
const kontrolleraData = (await kontrollera.json()) as SkatteverketUtkastResponse
const findings: SkatteverketKontroll[] = kontrolleraData.kontrollResultat?.resultat ?? []
const errorCount = findings.filter((k) => k.status === 'ERROR').length
if (errorCount > 0) {
return {
ok: false, stage: 'validation', httpStatus: 422,
error: `Skatteverket hittade ${errorCount} valideringsfel i deklarationen.`,
kontrollresultat: kontrolleraData.kontrollResultat ?? null, draftSaved: false,
}
}
}
// 1. POST /utkast: save the draft to Eget utrymme. Overwrites any prior
// draft for the period, so retry after a mid-chain failure is safe.
const utkast = await skvRequest(
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
outcome: utkast.ok ? 'ok' : 'skv_error', responseStatus: utkast.status,
})
if (!utkast.ok) {
const text = await utkast.text().catch(() => '')
return {
ok: false, stage: 'draft', httpStatus: utkast.status,
error: `Skatteverket svarade med ${utkast.status}: ${text}`,
kontrollresultat: null, draftSaved: false,
}
}
const utkastData = (await utkast.json()) as SkatteverketUtkastResponse
// periodType/year/period ride along so the VAT kvittens cron can complete
// the period's moms deadline without reverse-parsing redovisningsperiod.
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_saved', redovisare, redovisningsperiod,
periodType: params.periodType, year: params.year, period: params.period,
kontrollresultat: utkastData.kontrollResultat, updatedAt: new Date().toISOString(),
}),
)
// 2. PUT /las: lock for signing; returns the BankID signeringslänk.
const las = await skvRequest(
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
outcome: las.ok ? 'ok' : 'skv_error', responseStatus: las.status,
})
if (!las.ok) {
const text = await las.text().catch(() => '')
return {
ok: false, stage: 'lock', httpStatus: las.status,
error: `Skatteverket svarade med ${las.status}: ${text}`,
kontrollresultat: utkastData.kontrollResultat ?? null, draftSaved: true,
}
}
const lasData = (await las.json()) as SkatteverketUtkastResponse
if (!lasData.signeringsLank) {
return {
ok: false, stage: 'lock', httpStatus: 502,
error: 'Skatteverket låste deklarationen men returnerade ingen signeringslänk.',
kontrollresultat: utkastData.kontrollResultat ?? null, draftSaved: true,
}
}
// Persist locked state so the UI/poller can resume (mirrors /declaration/lock).
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_locked', redovisare, redovisningsperiod,
periodType: params.periodType, year: params.year, period: params.period,
signeringsLank: lasData.signeringsLank, updatedAt: new Date().toISOString(),
}),
)
return {
ok: true,
signingUrl: lasData.signeringsLank,
redovisare,
redovisningsperiod,
kontrollresultat: utkastData.kontrollResultat ?? null,
}
}
+11 -1
View File
@@ -17,7 +17,17 @@
"SKATTEVERKET_AGD_INLAMNING_API_BASE_URL",
"SKATTEVERKET_AGD_PERIOD_API_BASE_URL",
"SKATTEVERKET_SKATTEKONTO_API_BASE_URL",
"SKATTEVERKET_DISABLED"
"SKATTEVERKET_DISABLED",
"SKATTEVERKET_SYSTEM_AUTH_MODE",
"SKATTEVERKET_SYSTEM_AUTH_MECHANISM",
"SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL",
"SKATTEVERKET_SYSTEM_CLIENT_ID",
"SKATTEVERKET_SYSTEM_CERT_PEM_B64",
"SKATTEVERKET_SYSTEM_KEY_PEM_B64",
"SKATTEVERKET_SYSTEM_KEY_PASSPHRASE",
"SKATTEVERKET_SYSTEM_CA_PEM_B64",
"SKATTEVERKET_SYSTEM_SCOPES",
"SKATTEVERKET_OMBUD_ORG_NUMBER"
],
"npmDependencies": [],
"definition": {
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Force the capability gate to run (no dev bypass) but stub requireCapability
// so entitlement is controlled per test. Mirrors the enable-banking suite.
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
})
// Never let a unit test reach the Stripe API: deauthorize is mocked, the pure
// env-derived helpers (buildAuthorizeUrl, isStripeConnectConfigured) stay real.
vi.mock('../lib/connect', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/connect')>()
return { ...actual, deauthorizeAccount: vi.fn().mockResolvedValue(undefined) }
})
import { stripeExtension } from '../index'
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { deauthorizeAccount } from '../lib/connect'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
function findRoute(method: string, path: string) {
const route = stripeExtension.apiRoutes?.find((r) => r.method === method && r.path === path)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(method: string, body?: unknown): Request {
return new Request('https://test.local/api/extensions/ext/stripe/x', {
method,
headers: { 'Content-Type': 'application/json' },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
}
function makeContext(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'stripe',
requestId: 'req_test',
supabase,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
const USER = { id: 'user-1', is_anonymous: false }
describe('stripe extension routes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireCapability).mockResolvedValue(null)
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', 'ca_test_123')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('POST /connect', () => {
it('returns 401 without a user', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(401)
})
it('blocks anonymous (sandbox) users before any external call', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', is_anonymous: true } },
error: null,
})
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.sandbox_blocked).toBe(true)
})
it('blocks sandbox companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: true } }) // company_settings
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.sandbox_blocked).toBe(true)
})
it('returns 403 capability_blocked when not entitled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
vi.mocked(requireCapability).mockResolvedValue(
capabilityBlockedResponse(CAPABILITY.stripe_payments),
)
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.capability_blocked).toBe(true)
expect(body.capability).toBe(CAPABILITY.stripe_payments)
})
it('returns 503 when Connect is not configured', async () => {
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', '')
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(503)
})
it('returns 409 when an active connection already exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
enqueue({
data: [{ id: 'conn-1', status: 'active', created_at: new Date().toISOString() }],
})
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(409)
})
it('returns 409 when a fresh pending connection exists (double-click guard)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
enqueue({
data: [{ id: 'conn-1', status: 'pending', created_at: new Date().toISOString() }],
})
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(409)
})
it('stages a pending connection and returns the Stripe authorize URL', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
enqueue({ data: [] }) // no existing connections
enqueue({ data: { id: 'conn-new' } }) // insert
const res = await findRoute('POST', '/connect').handler(
makeRequest('POST', {}),
makeContext(supabase),
)
expect(res.status).toBe(200)
const body = (await res.json()) as { url: string }
const url = new URL(body.url)
expect(url.origin + url.pathname).toBe('https://connect.stripe.com/oauth/authorize')
expect(url.searchParams.get('client_id')).toBe('ca_test_123')
expect(url.searchParams.get('scope')).toBe('read_write')
expect(url.searchParams.get('state')).toBeTruthy()
expect(url.searchParams.get('redirect_uri')).toBe(
'http://localhost:3000/api/extensions/stripe/callback',
)
})
})
describe('DELETE /disconnect', () => {
it('returns 404 when no connection exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: [] })
const res = await findRoute('DELETE', '/disconnect').handler(
makeRequest('DELETE', {}),
makeContext(supabase),
)
expect(res.status).toBe(404)
})
it('deauthorizes an active connection and marks it revoked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: [{ id: 'conn-1', status: 'active', stripe_account_id: 'acct_1' }],
})
enqueue({ data: null, error: null }) // update -> revoked
const ctx = makeContext(supabase)
const res = await findRoute('DELETE', '/disconnect').handler(
makeRequest('DELETE', { connection_id: 'conn-1' }),
ctx,
)
expect(res.status).toBe(200)
expect(vi.mocked(deauthorizeAccount)).toHaveBeenCalledWith('acct_1')
expect(ctx.emit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'stripe.disconnected' }),
)
})
it('still revokes locally when Stripe deauthorize fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
vi.mocked(deauthorizeAccount).mockRejectedValueOnce(new Error('already revoked'))
enqueue({
data: [{ id: 'conn-1', status: 'active', stripe_account_id: 'acct_1' }],
})
enqueue({ data: null, error: null })
const res = await findRoute('DELETE', '/disconnect').handler(
makeRequest('DELETE', {}),
makeContext(supabase),
)
expect(res.status).toBe(200)
})
})
describe('GET /status', () => {
it('returns 401 without a user', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null })
const res = await findRoute('GET', '/status').handler(
makeRequest('GET'),
makeContext(supabase),
)
expect(res.status).toBe(401)
})
it('prefers the active connection and reports configured', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: [
{ id: 'conn-2', status: 'error', stripe_account_id: null },
{ id: 'conn-1', status: 'active', stripe_account_id: 'acct_1', livemode: true },
],
})
const res = await findRoute('GET', '/status').handler(
makeRequest('GET'),
makeContext(supabase),
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
configured: boolean
connection: { id: string } | null
}
expect(body.configured).toBe(true)
expect(body.connection?.id).toBe('conn-1')
})
})
})
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const pricesCreate = vi.fn()
const paymentLinksCreate = vi.fn()
const paymentLinksUpdate = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({
prices: { create: pricesCreate },
paymentLinks: { create: paymentLinksCreate, update: paymentLinksUpdate },
}),
}))
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
vi.mock('@/lib/sandbox/guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/sandbox/guard')>()
return { ...actual, isSandboxCompany: vi.fn().mockResolvedValue(false) }
})
import { hasCapability } from '@/lib/entitlements/has-capability'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import {
createInvoicePaymentLink,
deactivatePaymentLink,
handleInvoicePaid,
handleCreditNoteCreated,
} from '../lib/payment-links'
const ACTIVE_CONNECTION = {
id: 'conn-1',
stripe_account_id: 'acct_1',
livemode: false, // matches sk_test_ platform key stubbed below
}
function makeEligibleInvoice() {
return makeInvoice({
id: 'inv-1',
invoice_number: 'F-2026-0001',
currency: 'SEK',
total: 1250,
remaining_amount: 1250,
payment_link_url: null,
})
}
describe('stripe payment links', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(hasCapability).mockResolvedValue(true)
vi.mocked(isSandboxCompany).mockResolvedValue(false)
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', 'ca_test_123')
pricesCreate.mockResolvedValue({ id: 'price_1' })
paymentLinksCreate.mockResolvedValue({
id: 'plink_1',
url: 'https://buy.stripe.com/test_abc',
})
paymentLinksUpdate.mockResolvedValue({ id: 'plink_1', active: false })
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('createInvoicePaymentLink', () => {
it('returns null when Connect is not configured', async () => {
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', '')
const { supabase } = createQueuedMockSupabase()
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
)
expect(result).toBeNull()
expect(pricesCreate).not.toHaveBeenCalled()
})
it('returns null for sandbox companies (no external calls)', async () => {
vi.mocked(isSandboxCompany).mockResolvedValue(true)
const { supabase } = createQueuedMockSupabase()
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
)
expect(result).toBeNull()
expect(pricesCreate).not.toHaveBeenCalled()
})
it('returns null without the stripe_payments capability', async () => {
vi.mocked(hasCapability).mockResolvedValue(false)
const { supabase } = createQueuedMockSupabase()
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
)
expect(result).toBeNull()
})
it('returns null without an active connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // stripe_connections maybeSingle
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
)
expect(result).toBeNull()
expect(pricesCreate).not.toHaveBeenCalled()
})
it('returns null on live/test mode drift between key and connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ACTIVE_CONNECTION, livemode: true } }) // key is sk_test_
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
)
expect(result).toBeNull()
expect(pricesCreate).not.toHaveBeenCalled()
})
it('returns null when nothing is payable', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
const invoice = makeEligibleInvoice()
invoice.remaining_amount = 0
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', invoice,
)
expect(result).toBeNull()
})
it('creates a single-use link for the payable amount in öre on the connected account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
const invoice = makeEligibleInvoice()
// ROT/RUT-style payable: remaining is customer share, not the total
invoice.total = 2000
invoice.remaining_amount = 1234.56
const result = await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', invoice,
)
expect(result).toEqual({ url: 'https://buy.stripe.com/test_abc', externalId: 'plink_1' })
expect(pricesCreate).toHaveBeenCalledWith(
{
currency: 'sek',
unit_amount: 123456,
product_data: { name: 'Faktura F-2026-0001' },
},
{ stripeAccount: 'acct_1', idempotencyKey: 'acc_inv_price_inv-1_123456' },
)
expect(paymentLinksCreate).toHaveBeenCalledWith(
{
line_items: [{ price: 'price_1', quantity: 1 }],
restrictions: { completed_sessions: { limit: 1 } },
metadata: {
invoice_id: 'inv-1',
company_id: 'company-1',
invoice_number: 'F-2026-0001',
},
},
{ stripeAccount: 'acct_1', idempotencyKey: 'acc_inv_plink_inv-1_123456' },
)
})
it('derives fresh idempotency keys when the payable amount changes', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
enqueue({ data: ACTIVE_CONNECTION })
const invoice = makeEligibleInvoice()
invoice.remaining_amount = 1250
await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', invoice,
)
// Draft amount changed before a resend: reusing the old key with new
// parameters would be rejected by Stripe (idempotency_error).
invoice.remaining_amount = 999.5
await createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', invoice,
)
expect(pricesCreate.mock.calls[0][1].idempotencyKey).toBe('acc_inv_price_inv-1_125000')
expect(pricesCreate.mock.calls[1][1].idempotencyKey).toBe('acc_inv_price_inv-1_99950')
expect(paymentLinksCreate.mock.calls[0][1].idempotencyKey).toBe('acc_inv_plink_inv-1_125000')
expect(paymentLinksCreate.mock.calls[1][1].idempotencyKey).toBe('acc_inv_plink_inv-1_99950')
})
it('propagates Stripe API failures to the caller', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
pricesCreate.mockRejectedValue(new Error('rate limited'))
await expect(
createInvoicePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'user-1', makeEligibleInvoice(),
),
).rejects.toThrow('rate limited')
})
})
describe('deactivation', () => {
it('deactivates via the connected account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
const ok = await deactivatePaymentLink(
supabase as unknown as SupabaseClient, 'company-1', 'plink_1',
)
expect(ok).toBe(true)
expect(paymentLinksUpdate).toHaveBeenCalledWith(
'plink_1',
{ active: false },
{ stripeAccount: 'acct_1' },
)
})
it('invoice.paid handler is a no-op without a stripe link id', async () => {
const { supabase } = createQueuedMockSupabase()
await handleInvoicePaid(
{ invoice: makeInvoice({ stripe_payment_link_id: null }), companyId: 'company-1' },
{ supabase } as never,
)
expect(paymentLinksUpdate).not.toHaveBeenCalled()
})
it('invoice.paid handler deactivates the link and swallows failures', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: ACTIVE_CONNECTION })
paymentLinksUpdate.mockRejectedValue(new Error('gone'))
await expect(
handleInvoicePaid(
{
invoice: makeInvoice({ stripe_payment_link_id: 'plink_1' }),
companyId: 'company-1',
},
{ supabase } as never,
),
).resolves.toBeUndefined()
expect(paymentLinksUpdate).toHaveBeenCalled()
})
it('credit_note.created handler deactivates the ORIGINAL invoice link', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'inv-orig', stripe_payment_link_id: 'plink_9' } }) // original invoice
enqueue({ data: ACTIVE_CONNECTION })
await handleCreditNoteCreated(
{
creditNote: { credited_invoice_id: 'inv-orig' } as never,
companyId: 'company-1',
},
{ supabase } as never,
)
expect(paymentLinksUpdate).toHaveBeenCalledWith(
'plink_9',
{ active: false },
{ stripeAccount: 'acct_1' },
)
})
})
})
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
import type Stripe from 'stripe'
import type { CreateJournalEntryInput } from '@/types'
const balanceTransactionsList = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({ balanceTransactions: { list: balanceTransactionsList } }),
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
findFiscalPeriod: vi.fn(),
}))
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { processPayoutPaidEvent } from '../lib/payouts'
import type { StripeConnection } from '../types'
const CONNECTION: StripeConnection = {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
stripe_account_id: 'acct_1',
livemode: false,
status: 'active',
oauth_state: null,
display_name: null,
last_event_created_at: null,
last_event_id: null,
error_message: null,
connected_at: '2026-07-01T00:00:00.000Z',
disconnected_at: null,
created_at: '2026-07-01T00:00:00.000Z',
updated_at: '2026-07-01T00:00:00.000Z',
}
// 2026-07-10 arrival: 1 000,00 gross, 25,50 fees, 974,50 net.
function makePayoutEvent(overrides: Record<string, unknown> = {}) {
return {
id: 'evt_po_1',
type: 'payout.paid',
created: 1_767_000_000,
data: {
object: {
id: 'po_1',
amount: 97450,
currency: 'sek',
arrival_date: 1_767_000_000,
livemode: false,
...overrides,
},
},
} as unknown as Stripe.Event
}
function stubBalanceTxns(txns: Array<Record<string, unknown>>) {
balanceTransactionsList.mockReturnValue({
autoPagingToArray: vi.fn().mockResolvedValue(txns),
})
}
const CLEAN_TXNS = [
{ type: 'charge', amount: 60000, fee: 1530, currency: 'sek' },
{ type: 'charge', amount: 40000, fee: 1020, currency: 'sek' },
{ type: 'payout', amount: -97450, fee: 0, currency: 'sek' },
]
describe('processPayoutPaidEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
vi.mocked(findFiscalPeriod).mockResolvedValue('period-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-po-1' } as never)
stubBalanceTxns(CLEAN_TXNS)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('books a clean SEK payout with reverse-charge fee lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] }) // claim
enqueue({ data: { vat_registered: true } }) // company_settings
enqueue({ data: null }) // finalize row
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome).toEqual({ status: 'booked', reason: null })
expect(vi.mocked(createJournalEntry)).toHaveBeenCalledTimes(1)
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as CreateJournalEntryInput
expect(input.source_type).toBe('stripe_payout')
const byAccount = new Map(
input.lines.map((l) => [l.account_number, l]),
)
// Net to bank
expect(byAccount.get('1930')).toMatchObject({ debit_amount: 974.5 })
// Fees as cost
expect(byAccount.get('6570')).toMatchObject({ debit_amount: 25.5 })
// Ruta 21 basis pair
expect(byAccount.get('4535')).toMatchObject({ debit_amount: 25.5 })
expect(byAccount.get('4598')).toMatchObject({ credit_amount: 25.5 })
// Fiktiv moms 25% of fees
expect(byAccount.get('2645')).toMatchObject({ debit_amount: 6.38 })
expect(byAccount.get('2614')).toMatchObject({ credit_amount: 6.38 })
// Clearing of the gross
expect(byAccount.get('1686')).toMatchObject({ credit_amount: 1000 })
// The entry balances to the öre.
const debits = input.lines.reduce((s, l) => s + (l.debit_amount || 0), 0)
const credits = input.lines.reduce((s, l) => s + (l.credit_amount || 0), 0)
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
})
it('skips a payout already claimed by an earlier run', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // claim conflict
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('already_processed')
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('sends payouts containing refunds to needs_review', async () => {
stubBalanceTxns([
...CLEAN_TXNS,
{ type: 'refund', amount: -10000, fee: 0, currency: 'sek' },
])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: { vat_registered: true } })
enqueue({ data: null })
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('needs_review')
expect(outcome.reason).toBe('non_deterministic_txn_refund')
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('sends non-SEK payouts to needs_review', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: null }) // finalize
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent({ currency: 'eur' }),
)
expect(outcome.status).toBe('needs_review')
expect(outcome.reason).toBe('non_sek_payout')
})
it('sends arithmetic drift to needs_review', async () => {
stubBalanceTxns([
{ type: 'charge', amount: 60000, fee: 1530, currency: 'sek' },
{ type: 'payout', amount: -97450, fee: 0, currency: 'sek' },
]) // gross 600 - fees 15.30 = 584.70 != net 974.50
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: { vat_registered: true } })
enqueue({ data: null })
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('needs_review')
expect(outcome.reason).toBe('arithmetic_mismatch')
})
it('sends non-VAT-registered companies to needs_review (RC on fees)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: { vat_registered: false } })
enqueue({ data: null })
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('needs_review')
expect(outcome.reason).toBe('not_vat_registered')
})
it('records a locked-period booking failure as needs_review', async () => {
vi.mocked(createJournalEntry).mockRejectedValue(
new Error('Cannot write to locked/closed fiscal period'),
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: { vat_registered: true } })
enqueue({ data: null })
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('needs_review')
expect(outcome.reason).toContain('booking_failed')
})
it('books a zero-fee payout without any reverse-charge lines', async () => {
stubBalanceTxns([
{ type: 'charge', amount: 97450, fee: 0, currency: 'sek' },
{ type: 'payout', amount: -97450, fee: 0, currency: 'sek' },
])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'po-row-1' }] })
enqueue({ data: { vat_registered: true } })
enqueue({ data: null })
const outcome = await processPayoutPaidEvent(
supabase as unknown as SupabaseClient,
CONNECTION,
makePayoutEvent(),
)
expect(outcome.status).toBe('booked')
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as CreateJournalEntryInput
const accounts = input.lines.map((l) => l.account_number)
expect(accounts).toEqual(['1930', '1686'])
})
})
@@ -0,0 +1,329 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const eventsList = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({ events: { list: eventsList } }),
}))
vi.mock('@/lib/invoices/settle-invoice-payment', () => ({
settleInvoicePayment: vi.fn(),
}))
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
import { eventBus } from '@/lib/events/bus'
import { syncStripeConnection } from '../lib/sync'
import type { StripeConnection } from '../types'
const CONNECTION: StripeConnection = {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
stripe_account_id: 'acct_1',
livemode: false,
status: 'active',
oauth_state: null,
display_name: 'Test AB',
last_event_created_at: null,
last_event_id: null,
error_message: null,
connected_at: '2026-07-01T00:00:00.000Z',
disconnected_at: null,
created_at: '2026-07-01T00:00:00.000Z',
updated_at: '2026-07-01T00:00:00.000Z',
}
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: 'cs_1',
payment_link: 'plink_1',
payment_intent: 'pi_1',
amount_total: 125000, // 1250.00 SEK in öre
currency: 'sek',
payment_status: 'paid',
livemode: false,
metadata: { invoice_id: 'inv-1', company_id: 'company-1' },
...overrides,
}
}
function makeEvent(session: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return {
id: 'evt_1',
type: 'checkout.session.completed',
created: 1_767_000_000,
data: { object: session },
...overrides,
}
}
function stubEvents(events: unknown[]) {
eventsList.mockReturnValue({
autoPagingToArray: vi.fn().mockResolvedValue(events),
})
}
function payableInvoice(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({
id: 'inv-1',
status: 'sent',
currency: 'SEK',
total: 1250,
}),
remaining_amount: 1250,
paid_amount: 0,
stripe_payment_link_id: 'plink_1',
customer: { name: 'Kund AB' },
items: [],
...overrides,
}
}
describe('syncStripeConnection', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123')
vi.stubEnv('STRIPE_CONNECT_CLIENT_ID', 'ca_test_123')
vi.mocked(settleInvoicePayment).mockResolvedValue({
ok: true,
newStatus: 'paid',
newPaidAmount: 1250,
newRemaining: 0,
journalEntryId: 'je-9',
paidAt: '2026-07-12T00:00:00.000Z',
})
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('settles a deterministic match against 1686 and advances the cursor', async () => {
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] }) // claim insert
enqueue({ data: payableInvoice() }) // invoice by payment link
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } })
enqueue({ data: null }) // event row finalize
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary).toMatchObject({ fetched: 1, settled: 1, needsReview: 0, ignored: 0 })
expect(vi.mocked(settleInvoicePayment)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
paymentAmountInInvoiceCurrency: 1250,
settlementAccountNumber: '1686',
accountingMethod: 'accrual',
entityType: 'aktiebolag',
paymentDate: new Date(1_767_000_000 * 1000).toISOString().split('T')[0],
}),
)
})
it('records amount drift as needs_review and never settles', async () => {
stubEvents([makeEvent(makeSession({ amount_total: 100000 }))]) // 1000 vs 1250 remaining
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] })
enqueue({ data: payableInvoice() })
enqueue({ data: null }) // event row finalize
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary).toMatchObject({ settled: 0, needsReview: 1 })
expect(vi.mocked(settleInvoicePayment)).not.toHaveBeenCalled()
})
it('records an already-paid invoice as needs_review (double payment)', async () => {
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] })
enqueue({ data: payableInvoice({ status: 'paid' }) })
enqueue({ data: null })
enqueue({ data: null })
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary.needsReview).toBe(1)
expect(vi.mocked(settleInvoicePayment)).not.toHaveBeenCalled()
})
it('records non-SEK invoices as needs_review (v1 automation scope)', async () => {
stubEvents([makeEvent(makeSession({ currency: 'eur', amount_total: 50000 }))])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] })
enqueue({
data: payableInvoice({ currency: 'EUR', total: 500, remaining_amount: 500 }),
})
enqueue({ data: null })
enqueue({ data: null })
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary.needsReview).toBe(1)
expect(vi.mocked(settleInvoicePayment)).not.toHaveBeenCalled()
})
it('ignores test-mode events on a live-mode connection', async () => {
stubEvents([makeEvent(makeSession({ livemode: false }))])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] })
enqueue({ data: null }) // event row finalize
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(
supabase as unknown as SupabaseClient,
{ ...CONNECTION, livemode: true },
)
expect(summary).toMatchObject({ settled: 0, needsReview: 0, ignored: 1 })
})
it('skips events already claimed by an earlier run', async () => {
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // claim conflict
enqueue({ data: [] }) // reclaim: not stale
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary).toMatchObject({ alreadyProcessed: 1, settled: 0 })
expect(vi.mocked(settleInvoicePayment)).not.toHaveBeenCalled()
})
it('marks the connection revoked when Stripe reports severed access', async () => {
eventsList.mockReturnValue({
autoPagingToArray: vi
.fn()
.mockRejectedValue(
Object.assign(new Error('The account acct_1 is not connected to your platform'), {
type: 'StripePermissionError',
}),
),
})
const disconnected = vi.fn()
eventBus.on('stripe.disconnected', disconnected)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null }) // connection status update
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary.revoked).toBe(true)
expect(summary.settled).toBe(0)
// Auto-detected revocation must reach the audit trail, mirroring the
// user-initiated disconnect emission.
expect(disconnected).toHaveBeenCalledWith({
connectionId: 'conn-1',
stripeAccountId: 'acct_1',
reason: 'revoked_upstream',
userId: 'user-1',
companyId: 'company-1',
})
})
it('falls back to accrual/enskild_firma when the company has no settings row', async () => {
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] }) // claim insert
enqueue({ data: payableInvoice() }) // invoice by payment link
enqueue({ data: null }) // company_settings: no row (maybeSingle -> null, no error)
enqueue({ data: null }) // event row finalize
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary.settled).toBe(1)
expect(vi.mocked(settleInvoicePayment)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
accountingMethod: 'accrual',
entityType: 'enskild_firma',
}),
)
})
it('stops mid-batch at the deadline and persists the cursor only over processed events', async () => {
let now = 1_000
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
try {
// Settling the first event burns the remaining time budget.
vi.mocked(settleInvoicePayment).mockImplementation(async () => {
now = 10_000
return {
ok: true,
newStatus: 'paid',
newPaidAmount: 1250,
newRemaining: 0,
journalEntryId: 'je-9',
paidAt: '2026-07-12T00:00:00.000Z',
}
})
stubEvents([
makeEvent(makeSession()),
makeEvent(makeSession({ id: 'cs_2' }), { id: 'evt_2', created: 1_767_000_100 }),
])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] }) // claim insert (evt_1)
enqueue({ data: payableInvoice() }) // invoice by payment link
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } })
enqueue({ data: null }) // event row finalize (evt_1)
enqueue({ data: null }) // cursor update
const summary = await syncStripeConnection(
supabase as unknown as SupabaseClient,
CONNECTION,
undefined,
5_000, // deadline passes between the first and second event
)
expect(summary).toMatchObject({
fetched: 2,
settled: 1,
needsReview: 0,
deadlineReached: true,
})
expect(vi.mocked(settleInvoicePayment)).toHaveBeenCalledTimes(1)
// evt_2 was never claimed and the cursor advanced only over evt_1, so
// the next run refetches evt_2 (cursor overlap) and processes it then.
expect(vi.mocked(supabase.from).mock.calls.map((c) => c[0])).toEqual([
'stripe_payment_events', // claim evt_1
'invoices',
'company_settings',
'stripe_payment_events', // finalize evt_1
'stripe_connections', // cursor persisted up to evt_1
])
} finally {
nowSpy.mockRestore()
}
})
it('records a settle failure (e.g. locked period) as needs_review', async () => {
vi.mocked(settleInvoicePayment).mockResolvedValue({
ok: false,
code: 'BOOKKEEPING_ERROR',
error: new Error('Cannot write to locked/closed fiscal period'),
})
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] })
enqueue({ data: payableInvoice() })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } })
enqueue({ data: null })
enqueue({ data: null })
const summary = await syncStripeConnection(supabase as unknown as SupabaseClient, CONNECTION)
expect(summary.needsReview).toBe(1)
})
})
@@ -0,0 +1,346 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CreditCard, Link2, RefreshCw, Unlink } from 'lucide-react'
import type { StripeReviewEvent, StripeStatusResponse } from '../types'
type ConnectionInfo = NonNullable<StripeStatusResponse['connection']>
const KNOWN_REVIEW_REASONS = new Set([
'invoice_not_found',
'invoice_already_paid',
'amount_mismatch',
'currency_mismatch',
'non_sek_invoice',
])
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
active: 'success',
pending: 'secondary',
revoked: 'warning',
error: 'destructive',
}
export default function StripeSettingsPanel() {
const t = useTranslations('settings_payments')
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const { formatDateLong } = useFormat()
const [loading, setLoading] = useState(true)
const [configured, setConfigured] = useState(false)
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
const [connecting, setConnecting] = useState(false)
const [disconnecting, setDisconnecting] = useState(false)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false)
const [needsReviewCount, setNeedsReviewCount] = useState(0)
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
const loadStatus = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/stripe/status')
if (!res.ok) return
const data = (await res.json()) as StripeStatusResponse
setConfigured(data.configured)
setConnection(data.connection)
setNeedsReviewCount(data.needs_review_count ?? 0)
setNeedsReview(data.needs_review ?? [])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadStatus()
}, [loadStatus])
// Consume the one-shot OAuth bounce-back params (?stripe_connected=true /
// ?stripe_error=...) off the render path, mirroring the banking section.
useEffect(() => {
const connected = searchParams.get('stripe_connected')
const error = searchParams.get('stripe_error')
if (!connected && !error) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
if (connected) {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
} else if (error) {
// useSearchParams().get() already returns the decoded value; do not decode again.
let message = error
if (message === 'account_already_connected') message = t('error_account_already_connected')
else if (message === 'access_denied') message = t('error_access_denied')
else if (['invalid_state', 'missing_parameters', 'connection_failed', 'activation_failed'].includes(message)) {
message = t('error_generic')
}
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
}
router.replace('/settings/payments')
})
return () => { cancelled = true }
}, [searchParams, router, toast, t])
async function handleConnect() {
setConnecting(true)
try {
const res = await fetch('/api/extensions/ext/stripe/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = (await res.json()) as { url?: string; error?: string }
if (!res.ok || !data.url) {
toast({
title: t('connect_failed_title'),
description: data.error || t('error_generic'),
variant: 'destructive',
})
return
}
window.location.href = data.url
} catch {
toast({ title: t('connect_failed_title'), description: t('error_generic'), variant: 'destructive' })
} finally {
setConnecting(false)
}
}
async function handleSyncNow() {
setSyncing(true)
try {
const res = await fetch('/api/extensions/ext/stripe/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = (await res.json().catch(() => ({}))) as {
settled?: number
needsReview?: number
error?: string
}
if (!res.ok) {
toast({
title: t('sync_failed_title'),
description: data.error || t('error_generic'),
variant: 'destructive',
})
return
}
toast({
title: t('sync_done_title'),
description: t('sync_done_description', {
settled: data.settled ?? 0,
review: data.needsReview ?? 0,
}),
})
await loadStatus()
} finally {
setSyncing(false)
}
}
async function handleDisconnect() {
if (!connection) return
setDisconnecting(true)
try {
const res = await fetch('/api/extensions/ext/stripe/disconnect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connection.id }),
})
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string }
toast({
title: t('disconnect_failed_title'),
description: data.error || t('error_generic'),
variant: 'destructive',
})
return
}
toast({ title: t('disconnected_toast_title') })
setConfirmDisconnect(false)
await loadStatus()
} finally {
setDisconnecting(false)
}
}
if (loading) {
return (
<Card>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-10 w-40" />
</CardContent>
</Card>
)
}
if (!configured) {
// Hosted: the Connect platform isn't live yet, so the whole integration
// presents as "coming soon" (every server path is already a no-op without
// STRIPE_CONNECT_CLIENT_ID). Self-hosted admins get the honest
// configuration message instead: for them it's a setup task, not a launch.
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
if (isSelfHosted) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="p-0">
<EmptyState
icon={CreditCard}
title={t('coming_soon_title')}
description={t('coming_soon_description')}
/>
</CardContent>
</Card>
)
}
const isActive = connection?.status === 'active'
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-0">
<p className="text-sm text-muted-foreground">{t('description')}</p>
{connection ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{connection.display_name || connection.stripe_account_id || t('unnamed_account')}
</span>
<Badge variant={STATUS_VARIANT[connection.status]}>
{t(`status_${connection.status}`)}
</Badge>
{!connection.livemode && isActive && (
<Badge variant="warning">{t('test_mode')}</Badge>
)}
</div>
{isActive && connection.connected_at && (
<p className="mt-1 text-sm text-muted-foreground">
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
</p>
)}
{connection.error_message && connection.status !== 'active' && (
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
)}
</div>
</div>
{isActive ? (
confirmDisconnect ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDisconnect}
disabled={disconnecting}
>
{t('disconnect_confirm')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnect(false)}
disabled={disconnecting}
>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
<RefreshCw className="mr-2 h-4 w-4" />
{syncing ? t('syncing') : t('sync_now')}
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
<Unlink className="mr-2 h-4 w-4" />
{t('disconnect')}
</Button>
</div>
)
) : (
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{connecting ? t('connecting') : t('connect')}
</Button>
)}
</div>
) : (
<div>
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{connecting ? t('connecting') : t('connect')}
</Button>
<p className="mt-3 text-sm text-muted-foreground">{t('connect_hint')}</p>
</div>
)}
{isActive && needsReviewCount > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('needs_review_title')}
</h2>
<Badge variant="warning">{needsReviewCount}</Badge>
</div>
<p className="text-sm text-muted-foreground">{t('needs_review_hint')}</p>
<ul className="divide-y divide-border rounded-lg border border-border">
{needsReview.map((event) => (
<li key={event.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="text-sm">
{event.reason && KNOWN_REVIEW_REASONS.has(event.reason)
? t(`reason_${event.reason}`)
: event.reason || t('reason_unknown')}
</p>
{event.event_created_at && (
<p className="text-xs text-muted-foreground tabular-nums">
{formatDate(event.event_created_at)}
</p>
)}
</div>
{event.amount != null && (
<span className="shrink-0 text-sm tabular-nums">
{formatCurrency(event.amount, event.currency ?? 'SEK')}
</span>
)}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)
}
+406
View File
@@ -0,0 +1,406 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
import {
buildAuthorizeUrl,
deauthorizeAccount,
isStripeConnectConfigured,
isLiveMode,
} from './lib/connect'
import {
createInvoicePaymentLink,
handleCreditNoteCreated,
handleInvoicePaid,
} from './lib/payment-links'
import { syncStripeConnection } from './lib/sync'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import type { StripeConnection, StripeStatusResponse } from './types'
// Per-user limits: connect/disconnect are outward-facing OAuth operations,
// sync hits the Stripe API.
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
// A pending row younger than this blocks a second connect attempt so a
// double-click cannot start two OAuth round-trips (only one state would
// survive, stranding the other at the callback).
const PENDING_FRESH_MS = 60_000
const NOT_CONFIGURED_MESSAGE =
'Stripe-integrationen är inte konfigurerad på den här installationen.'
/**
* Stripe Connect extension
*
* Connects a company's Stripe account via Connect OAuth (Standard accounts).
* Auto-creates a Stripe Payment Link when an invoice is sent, marks invoices
* paid from Stripe checkout events, and books payouts (gross/fees/net) against
* the 1686 clearing account.
*
* Required environment variables:
* - STRIPE_SECRET_KEY (the platform account key, shared with billing)
* - STRIPE_CONNECT_CLIENT_ID (ca_... from the platform's Connect settings)
*/
export const stripeExtension: Extension = {
id: 'stripe',
name: 'Stripe-betalningar',
version: '1.0.0',
settingsPanel: {
label: 'Betalningar (Stripe)',
path: '/settings/payments',
},
// Core-callable services, resolved via the extension registry (core never
// imports extension code). The send routes use this to auto-fill
// invoices.payment_link_url before the email/PDF render.
services: {
createInvoicePaymentLink,
},
eventHandlers: [
// A settled or credited invoice must stop accepting money through its link.
{ eventType: 'invoice.paid', handler: handleInvoicePaid },
{ eventType: 'credit_note.created', handler: handleCreditNoteCreated },
],
apiRoutes: [
{
method: 'GET',
path: '/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
// Prefer the active connection; otherwise surface the most recent row
// so the panel can show pending/error/revoked states.
const { data: rows } = await supabase
.from('stripe_connections')
.select(
'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at',
)
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
.limit(10)
const connection =
rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
// Events + payouts the deterministic matcher refused to auto-apply.
// Members can read both ledgers under RLS; the panel lists them for
// manual handling.
const { data: reviewRows, count: reviewCount } = await supabase
.from('stripe_payment_events')
.select('id, reason, amount, currency, invoice_id, event_created_at', {
count: 'exact',
})
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const { data: payoutRows, count: payoutCount } = await supabase
.from('stripe_payouts')
.select('id, reason, amount, currency, event_created_at', { count: 'exact' })
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const payload: StripeStatusResponse = {
configured: isStripeConnectConfigured(),
connection,
needs_review_count: (reviewCount ?? 0) + (payoutCount ?? 0),
needs_review: [
...(reviewRows ?? []),
...(payoutRows ?? []).map((p) => ({ ...p, invoice_id: null })),
],
}
return NextResponse.json(payload)
},
},
{
method: 'POST',
path: '/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const capabilityBlocked = await requireCapability(
supabase,
companyId,
CAPABILITY.stripe_payments,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'stripe:sync',
identifier: user.id,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
// Membership-scoped lookup via the user client; the sync itself runs
// on the service client because the event ledger is service-write-only.
const { data: connection } = await supabase
.from('stripe_connections')
.select('*')
.eq('company_id', companyId)
.eq('status', 'active')
.maybeSingle()
if (!connection) {
return NextResponse.json({ error: 'Inget anslutet Stripe-konto.' }, { status: 404 })
}
try {
const serviceClient = createServiceClientNoCookies()
const summary = await syncStripeConnection(
serviceClient,
connection as StripeConnection,
)
return NextResponse.json({ success: true, ...summary })
} catch (error) {
log.error('[stripe] Manual sync failed', {
message: error instanceof Error ? error.message : String(error),
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
{ status: 502 },
)
}
},
},
{
method: 'POST',
path: '/connect',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
// Anonymous/sandbox users must never reach Stripe (same doctrine as
// billing checkout: the sandbox never talks to external services).
// The anon check is identity truth and runs first (no DB round trip).
if (user.is_anonymous) return sandboxBlockedResponse()
const sandboxBlocked = await guardSandbox(supabase, companyId)
if (sandboxBlocked) return sandboxBlocked
const capabilityBlocked = await requireCapability(
supabase,
companyId,
CAPABILITY.stripe_payments,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'stripe:connect',
identifier: user.id,
...RATE_LIMIT_CONNECT,
})
if (!rl.ok) return rl.response!
if (!isStripeConnectConfigured()) {
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
}
const { data: existing } = await supabase
.from('stripe_connections')
.select('id, status, created_at')
.eq('company_id', companyId)
.in('status', ['active', 'pending'])
.order('created_at', { ascending: false })
if (existing?.some((c) => c.status === 'active')) {
return NextResponse.json(
{ error: 'Företaget har redan ett anslutet Stripe-konto. Koppla från det först.' },
{ status: 409 },
)
}
const pending = existing?.filter((c) => c.status === 'pending') ?? []
const freshPending = pending.find(
(c) => Date.now() - new Date(c.created_at).getTime() < PENDING_FRESH_MS,
)
if (freshPending) {
return NextResponse.json(
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
{ status: 409 },
)
}
if (pending.length > 0) {
// Supersede stale pending attempts so their oauth_state can never
// complete a callback after this new round-trip starts.
await supabase
.from('stripe_connections')
.update({
status: 'error',
error_message: 'Superseded by new connection attempt',
oauth_state: null,
})
.eq('company_id', companyId)
.eq('status', 'pending')
}
// Persist the CSRF state BEFORE handing the user to Stripe: the
// callback locates the row by oauth_state alone, so the row must
// exist before Stripe can ever redirect back with that state.
const oauthState = crypto.randomUUID()
const { data: created, error: insertError } = await supabase
.from('stripe_connections')
.insert({
company_id: companyId,
user_id: user.id,
status: 'pending',
oauth_state: oauthState,
livemode: isLiveMode(),
})
.select('id')
.single()
if (insertError || !created) {
log.error('[stripe] Failed to stage pending connection', {
message: insertError?.message,
code: insertError?.code,
companyId,
})
return NextResponse.json(
{ error: 'Kunde inte starta anslutningen. Försök igen.' },
{ status: 500 },
)
}
log.info('[stripe] Starting Connect OAuth', {
connection_id: created.id,
company_id: companyId,
livemode: isLiveMode(),
})
return NextResponse.json({ url: buildAuthorizeUrl(oauthState) })
},
},
{
method: 'DELETE',
path: '/disconnect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'stripe:disconnect',
identifier: user.id,
...RATE_LIMIT_DISCONNECT,
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
const base = supabase
.from('stripe_connections')
.select('id, status, stripe_account_id')
.eq('company_id', companyId)
const query = body.connection_id
? base.eq('id', body.connection_id).limit(1)
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
const { data: rows, error: findError } = await query
const connection = rows?.[0] as
| Pick<StripeConnection, 'id' | 'status' | 'stripe_account_id'>
| undefined
if (findError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
// Best-effort revoke at Stripe: an already-severed connection throws,
// which is fine (the goal state is reached either way). Logged at WARN
// so a systematic revoke failure stays visible to monitoring.
if (connection.status === 'active' && connection.stripe_account_id) {
try {
await deauthorizeAccount(connection.stripe_account_id)
} catch (revokeError) {
log.warn('[stripe] Deauthorize skipped (likely already revoked)', {
message: revokeError instanceof Error ? revokeError.message : String(revokeError),
connection_id: connection.id,
})
}
}
const { error: updateError } = await supabase
.from('stripe_connections')
.update({
status: 'revoked',
oauth_state: null,
disconnected_at: new Date().toISOString(),
})
.eq('id', connection.id)
.eq('company_id', companyId)
if (updateError) {
log.error('[stripe] Failed to mark connection revoked', {
message: updateError.message,
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Kunde inte koppla från. Försök igen.' },
{ status: 500 },
)
}
if (ctx?.emit) {
try {
await ctx.emit({
type: 'stripe.disconnected',
payload: {
connectionId: connection.id,
stripeAccountId: connection.stripe_account_id,
reason: 'user',
userId: user.id,
companyId,
},
})
} catch {
// Audit event failure must not block the disconnect itself.
}
}
return NextResponse.json({ success: true })
},
},
],
}
export default stripeExtension
+107
View File
@@ -0,0 +1,107 @@
import type Stripe from 'stripe'
import { getStripe } from '@/lib/stripe/client'
/**
* Stripe Connect (OAuth for Standard accounts) helpers.
*
* Accounted's own Stripe account is the Connect PLATFORM. A company's Stripe
* account attaches via the OAuth flow; from then on every API call runs with
* the platform secret key plus the `Stripe-Account` header. We never hold a
* per-company key or token: the stored acct_... id is useless without the
* platform key, and either side can revoke the connection at any time.
*/
const OAUTH_AUTHORIZE_URL = 'https://connect.stripe.com/oauth/authorize'
export function getConnectClientId(): string | undefined {
return process.env.STRIPE_CONNECT_CLIENT_ID
}
/** Whether the Connect integration is configured on this deployment. */
export function isStripeConnectConfigured(): boolean {
return Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_CONNECT_CLIENT_ID)
}
/** Whether the platform key runs in live mode (payments affect real money). */
export function isLiveMode(): boolean {
return Boolean(process.env.STRIPE_SECRET_KEY?.startsWith('sk_live_'))
}
/** Per-request options routing an API call to the connected account. */
export function connectedAccountOptions(stripeAccountId: string): Stripe.RequestOptions {
return { stripeAccount: stripeAccountId }
}
/** Build the connect.stripe.com authorize URL for the OAuth round-trip. */
export function buildAuthorizeUrl(state: string): string {
const clientId = getConnectClientId()
if (!clientId) throw new Error('STRIPE_CONNECT_CLIENT_ID is not configured')
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/stripe/callback`
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
scope: 'read_write',
state,
redirect_uri: redirectUri,
})
return `${OAUTH_AUTHORIZE_URL}?${params.toString()}`
}
export interface ConnectedAccount {
stripeAccountId: string
livemode: boolean
}
/** Exchange the OAuth authorization code for the connected account id. */
export async function exchangeCodeForAccount(code: string): Promise<ConnectedAccount> {
const token = await getStripe().oauth.token({ grant_type: 'authorization_code', code })
if (!token.stripe_user_id) {
throw new Error('Stripe OAuth token response missing stripe_user_id')
}
return {
stripeAccountId: token.stripe_user_id,
livemode: token.livemode ?? isLiveMode(),
}
}
/**
* Revoke the platform's access to a connected account. Idempotent from our
* side: an already-deauthorized account throws, which callers treat as done.
*/
export async function deauthorizeAccount(stripeAccountId: string): Promise<void> {
const clientId = getConnectClientId()
if (!clientId) throw new Error('STRIPE_CONNECT_CLIENT_ID is not configured')
await getStripe().oauth.deauthorize({ client_id: clientId, stripe_user_id: stripeAccountId })
}
/** Best-effort display name for the settings panel; never throws. */
export async function fetchAccountDisplayName(stripeAccountId: string): Promise<string | null> {
try {
const account = await getStripe().accounts.retrieve(stripeAccountId)
return (
account.business_profile?.name ||
account.settings?.dashboard?.display_name ||
account.email ||
null
)
} catch {
return null
}
}
/**
* Whether an API error means the connection itself is dead (the account
* deauthorized the platform, or the account no longer exists), as opposed to
* a transient failure. Used to flip a connection to status 'revoked' so the
* UI offers a reconnect instead of retrying forever.
*/
export function isRevokedConnectionError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const err = error as { type?: string; code?: string; message?: string }
if (err.type === 'StripePermissionError') return true
if (err.code === 'account_invalid') return true
// The OAuth layer reports a severed connection with this phrasing.
return /not connected to your platform|application access may have been revoked/i.test(
err.message ?? '',
)
}
@@ -0,0 +1,198 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { Invoice, CreditNote } from '@/types'
import {
connectedAccountOptions,
isLiveMode,
isStripeConnectConfigured,
} from './connect'
import type { StripeConnection } from '../types'
const log = createLogger('stripe/payment-links')
type ActiveConnection = Pick<StripeConnection, 'id' | 'stripe_account_id' | 'livemode'>
async function getActiveConnection(
supabase: SupabaseClient,
companyId: string,
): Promise<ActiveConnection | null> {
const { data } = await supabase
.from('stripe_connections')
.select('id, stripe_account_id, livemode')
.eq('company_id', companyId)
.eq('status', 'active')
.maybeSingle()
if (!data?.stripe_account_id) return null
return data as ActiveConnection
}
/**
* Registry service (`services.createInvoicePaymentLink`): create a single-use
* Stripe Payment Link for an invoice on the company's connected account.
*
* Returns null when this company/invoice is not eligible (no connection, no
* capability, sandbox, live/test-mode drift, nothing to pay): the caller then
* simply sends the invoice without a link. Throws on actual Stripe API
* failures so the caller can surface a warning.
*
* The link amount is the customer's payable share (`remaining_amount`, which
* is total minus ROT/RUT deduction at send time) in the invoice currency.
* Stripe idempotency keys are derived from the invoice id plus the payable
* amount in minor units: a retried send of the same amount reuses the same
* Price and Payment Link instead of minting duplicates, while a resend after
* the amount changed mints fresh objects instead of tripping Stripe's
* idempotency conflict (reused key with different parameters is rejected).
*/
export async function createInvoicePaymentLink(
supabase: SupabaseClient,
companyId: string,
_userId: string,
invoice: Invoice,
): Promise<{ url: string; externalId: string } | null> {
if (!isStripeConnectConfigured()) return null
// The sandbox never talks to external services.
if (await isSandboxCompany(supabase, companyId)) return null
if (!(await hasCapability(supabase, companyId, CAPABILITY.stripe_payments))) return null
const connection = await getActiveConnection(supabase, companyId)
if (!connection) return null
// A connection made in one mode must not create links in the other: a
// platform key swap (test <-> live) would otherwise silently mint links
// against the wrong account universe.
if (connection.livemode !== isLiveMode()) {
log.warn('live/test mode drift between connection and platform key; skipping link', {
companyId,
connectionLivemode: connection.livemode,
})
return null
}
const payable =
invoice.remaining_amount ?? (invoice.total - (invoice.deduction_total ?? 0))
if (!(payable > 0)) return null
const stripe = getStripe()
const opts = connectedAccountOptions(connection.stripe_account_id!)
const productName = invoice.invoice_number
? `Faktura ${invoice.invoice_number}`
: 'Faktura'
const unitAmount = Math.round(payable * 100)
const price = await stripe.prices.create(
{
currency: invoice.currency.toLowerCase(),
unit_amount: unitAmount,
product_data: { name: productName },
},
{ ...opts, idempotencyKey: `acc_inv_price_${invoice.id}_${unitAmount}` },
)
const link = await stripe.paymentLinks.create(
{
line_items: [{ price: price.id, quantity: 1 }],
// One invoice, one payment: a completed session deactivates the link,
// so a customer can never pay the same invoice twice through it.
restrictions: { completed_sessions: { limit: 1 } },
metadata: {
invoice_id: invoice.id,
company_id: companyId,
invoice_number: invoice.invoice_number ?? '',
},
},
{ ...opts, idempotencyKey: `acc_inv_plink_${invoice.id}_${unitAmount}` },
)
log.info('created payment link for invoice', {
companyId,
invoiceId: invoice.id,
paymentLinkId: link.id,
})
return { url: link.url, externalId: link.id }
}
/** Deactivate a payment link on the company's connected account. */
export async function deactivatePaymentLink(
supabase: SupabaseClient,
companyId: string,
paymentLinkId: string,
): Promise<boolean> {
if (!isStripeConnectConfigured()) return false
const connection = await getActiveConnection(supabase, companyId)
if (!connection) return false
await getStripe().paymentLinks.update(
paymentLinkId,
{ active: false },
connectedAccountOptions(connection.stripe_account_id!),
)
return true
}
/**
* invoice.paid handler: once an invoice is settled (through Stripe or any
* other channel), its payment link must stop accepting money. Belt to the
* single-use restriction's suspenders. Best-effort: a failure is logged,
* never surfaced to the flow that emitted the event.
*/
export async function handleInvoicePaid(
payload: { invoice: Invoice; companyId: string },
ctx?: ExtensionContext,
): Promise<void> {
const linkId = payload.invoice?.stripe_payment_link_id
if (!linkId) return
const supabase = ctx?.supabase ?? createServiceClientNoCookies()
try {
await deactivatePaymentLink(supabase, payload.companyId, linkId)
log.info('deactivated payment link after invoice paid', {
invoiceId: payload.invoice.id,
paymentLinkId: linkId,
})
} catch (err) {
log.warn('failed to deactivate payment link after invoice paid', {
invoiceId: payload.invoice?.id,
paymentLinkId: linkId,
reason: err instanceof Error ? err.message : String(err),
})
}
}
/**
* credit_note.created handler: crediting an invoice voids the claim, so the
* ORIGINAL invoice's payment link must be deactivated (the credit note itself
* never carries one; derived documents don't copy the column).
*/
export async function handleCreditNoteCreated(
payload: { creditNote: CreditNote; companyId: string },
ctx?: ExtensionContext,
): Promise<void> {
const originalId = payload.creditNote?.credited_invoice_id
if (!originalId) return
const supabase = ctx?.supabase ?? createServiceClientNoCookies()
try {
const { data: original } = await supabase
.from('invoices')
.select('id, stripe_payment_link_id')
.eq('id', originalId)
.eq('company_id', payload.companyId)
.maybeSingle()
const linkId = original?.stripe_payment_link_id
if (!linkId) return
await deactivatePaymentLink(supabase, payload.companyId, linkId)
log.info('deactivated payment link after credit note', {
invoiceId: originalId,
paymentLinkId: linkId,
})
} catch (err) {
log.warn('failed to deactivate payment link after credit note', {
invoiceId: originalId,
reason: err instanceof Error ? err.message : String(err),
})
}
}
+250
View File
@@ -0,0 +1,250 @@
import type Stripe from 'stripe'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
generateReverseChargeBasisLines,
generateReverseChargeLines,
} from '@/lib/bookkeeping/vat-entries'
import { createLogger, type Logger } from '@/lib/logger'
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
import { connectedAccountOptions } from './connect'
import type { StripeConnection } from '../types'
const defaultLog = createLogger('stripe/payouts')
/**
* Book a Stripe payout as one journal entry:
*
* Debit 1930 Företagskonto [net]
* Debit 6570 Bankkostnader [fees]
* Debit 4535 + Credit 4598 [fees] ruta 21 basis pair
* Debit 2645 / Credit 2614 [25% of fees] fiktiv moms
* Credit 1686 Fordringar för kontokort [gross]
*
* Stripe Payments Europe Ltd (Ireland) invoices the fees, so they are an EU
* services purchase under omvänd skattskyldighet: the same vat-entries
* generators as supplier invoices produce the ruta 21 basis pair and the
* 2645/2614 fiktiv-moms pair, keeping the momsdeklaration correct by
* construction (rutor 21, 30, 48).
*
* Auto-booked ONLY when fully deterministic: every balance transaction in the
* payout is a charge/payment (plus the payout row itself), the currency is
* SEK, the company is VAT registered, and gross - fees equals the payout net
* exactly. Refunds, disputes, adjustments, FX or arithmetic drift become
* needs_review rows: a human decides, the sync never guesses.
*
* The 1930 debit then appears in bank reconciliation
* (get_unlinked_1930_lines) for linking against the incoming bank feed
* transaction, which closes the loop without double-booking the deposit.
*/
/** Balance transaction types that make up a plain card-payment payout. */
const DETERMINISTIC_TXN_TYPES = new Set(['charge', 'payment'])
interface PayoutLike {
id: string
amount?: number | null
currency?: string | null
arrival_date?: number | null
livemode?: boolean
}
export interface PayoutOutcome {
status: 'booked' | 'needs_review' | 'ignored' | 'already_processed'
reason: string | null
}
export async function processPayoutPaidEvent(
supabase: SupabaseClient,
connection: StripeConnection,
event: Stripe.Event,
log: Logger = defaultLog,
): Promise<PayoutOutcome> {
const payout = event.data.object as PayoutLike
if (!payout?.id) return { status: 'ignored', reason: 'malformed_event' }
// Claim (idempotency): one row per (connection, payout). A duplicate event
// or overlapping poll window is a no-op.
const { data: inserted } = await supabase
.from('stripe_payouts')
.upsert(
{
company_id: connection.company_id,
connection_id: connection.id,
payout_id: payout.id,
stripe_event_id: event.id,
amount: typeof payout.amount === 'number' ? payout.amount / 100 : null,
currency: payout.currency?.toUpperCase() ?? null,
arrival_date: payout.arrival_date
? new Date(payout.arrival_date * 1000).toISOString().split('T')[0]
: null,
status: 'processing',
event_created_at: new Date(event.created * 1000).toISOString(),
},
{ onConflict: 'connection_id,payout_id', ignoreDuplicates: true },
)
.select('id')
if (!inserted || inserted.length === 0) {
return { status: 'already_processed', reason: null }
}
const claimId = (inserted[0] as { id: string }).id
const outcome = await evaluateAndBook(supabase, connection, payout, event, log)
await supabase
.from('stripe_payouts')
.update({
status: outcome.status === 'booked' ? 'booked' : outcome.status,
reason: outcome.reason,
...(outcome.journalEntryId ? { journal_entry_id: outcome.journalEntryId } : {}),
...(outcome.gross != null ? { gross: outcome.gross } : {}),
...(outcome.fees != null ? { fees: outcome.fees } : {}),
})
.eq('id', claimId)
return { status: outcome.status, reason: outcome.reason }
}
interface EvaluationOutcome {
status: 'booked' | 'needs_review' | 'ignored'
reason: string | null
journalEntryId?: string
gross?: number
fees?: number
}
async function evaluateAndBook(
supabase: SupabaseClient,
connection: StripeConnection,
payout: PayoutLike,
event: Stripe.Event,
log: Logger,
): Promise<EvaluationOutcome> {
if (payout.livemode !== undefined && payout.livemode !== connection.livemode) {
return { status: 'ignored', reason: 'livemode_mismatch' }
}
if (payout.currency?.toLowerCase() !== 'sek') {
return { status: 'needs_review', reason: 'non_sek_payout' }
}
const { data: settings } = await supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', connection.company_id)
.maybeSingle()
if (settings?.vat_registered === false) {
// Reverse charge on the fees interacts with the company's VAT status;
// a human decides how a non-VAT-registered company books this.
return { status: 'needs_review', reason: 'not_vat_registered' }
}
const stripe = getStripe()
const txns = await stripe.balanceTransactions
.list({ payout: payout.id, limit: 100 }, connectedAccountOptions(connection.stripe_account_id!))
.autoPagingToArray({ limit: 1000 })
let grossOre = 0
let feesOre = 0
for (const txn of txns) {
if (txn.type === 'payout') continue // the payout row itself (negative net)
if (!DETERMINISTIC_TXN_TYPES.has(txn.type)) {
return { status: 'needs_review', reason: `non_deterministic_txn_${txn.type}` }
}
if (txn.currency?.toLowerCase() !== 'sek') {
return { status: 'needs_review', reason: 'non_sek_balance_txn' }
}
grossOre += txn.amount
feesOre += txn.fee
}
const gross = grossOre / 100
const fees = feesOre / 100
const net = typeof payout.amount === 'number' ? payout.amount / 100 : null
if (net == null || Math.round((gross - fees) * 100) !== Math.round(net * 100)) {
return {
status: 'needs_review',
reason: 'arithmetic_mismatch',
gross,
fees,
}
}
if (!(gross > 0)) {
return { status: 'needs_review', reason: 'empty_payout', gross, fees }
}
const arrivalDate = payout.arrival_date
? new Date(payout.arrival_date * 1000).toISOString().split('T')[0]
: new Date(event.created * 1000).toISOString().split('T')[0]
const fiscalPeriodId = await findFiscalPeriod(supabase, connection.company_id, arrivalDate)
if (!fiscalPeriodId) {
return { status: 'needs_review', reason: 'no_open_fiscal_period', gross, fees }
}
const round = (n: number) => Math.round(n * 100) / 100
const lines: CreateJournalEntryLineInput[] = [
{
account_number: '1930',
debit_amount: round(net),
credit_amount: 0,
line_description: `Stripe-utbetalning ${payout.id}`,
},
]
if (fees > 0) {
lines.push({
account_number: '6570',
debit_amount: round(fees),
credit_amount: 0,
line_description: 'Stripe-avgifter (omvänd skattskyldighet, EU)',
})
// Ruta 21 basis pair (4535 / 4598) + fiktiv moms (2645 / 2614): same
// generators as the supplier reverse-charge flow, so the VAT report
// picks the fees up identically.
lines.push(...generateReverseChargeBasisLines(round(fees), 0.25, 'eu_business'))
lines.push(...generateReverseChargeLines(round(fees), 0.25, false))
}
lines.push({
account_number: '1686',
debit_amount: 0,
credit_amount: round(gross),
line_description: 'Avräkning Stripe-betalningar',
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: arrivalDate,
description: `Stripe-utbetalning ${payout.id}`,
source_type: 'stripe_payout',
lines,
}
try {
const entry = await createJournalEntry(
supabase,
connection.company_id,
connection.user_id,
input,
)
if (!entry) {
return { status: 'needs_review', reason: 'booking_returned_null', gross, fees }
}
log.info('booked stripe payout', {
connectionId: connection.id,
payoutId: payout.id,
journalEntryId: entry.id,
net,
fees,
})
return { status: 'booked', reason: null, journalEntryId: entry.id, gross, fees }
} catch (err) {
return {
status: 'needs_review',
reason: `booking_failed: ${err instanceof Error ? err.message : String(err)}`,
gross,
fees,
}
}
}
+432
View File
@@ -0,0 +1,432 @@
import type Stripe from 'stripe'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
import { eventBus } from '@/lib/events/bus'
import {
settleInvoicePayment,
type InvoiceWithCustomerName,
} from '@/lib/invoices/settle-invoice-payment'
import { createLogger, type Logger } from '@/lib/logger'
import type { EntityType } from '@/types'
import { connectedAccountOptions, isRevokedConnectionError } from './connect'
import { processPayoutPaidEvent } from './payouts'
import type { StripeConnection } from '../types'
const defaultLog = createLogger('stripe/sync')
/**
* Stripe payment sync: polls the connected account's event stream and applies
* checkout.session.completed events (payments through our auto-created invoice
* payment links) to bookkeeping.
*
* Matching is DETERMINISTIC ONLY (project doctrine: act on exact keys, never
* confidence). A session settles its invoice when ALL hold:
* - the session's payment link (or metadata.invoice_id fallback) resolves to
* an invoice in the connection's company
* - invoice status is payable (sent / overdue / partially_paid)
* - session amount equals the invoice's remaining amount exactly (öre)
* - currencies match, invoice currency is SEK (v1 automation scope)
* - session livemode matches the connection
* Anything else is recorded as needs_review with a reason and surfaced in the
* settings panel: never guessed at, never dropped silently.
*
* Settlement books Debit 1686 (Fordringar för kontokort) / Credit 1510 via the
* shared settleInvoicePayment service: the money sits in the Stripe balance
* until the payout, which clears 1686 against 1930 (payout booking).
*
* Idempotency: each event is claimed into stripe_payment_events under a
* (connection, event id) unique constraint before processing; the polling
* cursor always overlaps, so re-seen events are no-ops. Stale 'processing'
* claims (a crash mid-run) are reclaimed after 1h.
*/
const EVENT_TYPES = ['checkout.session.completed', 'payout.paid'] as const
/** Re-poll overlap; unique constraints make the duplicates no-ops. */
const CURSOR_OVERLAP_SECONDS = 600
/** A 'processing' claim older than this is considered crashed and reclaimed. */
const STALE_CLAIM_MS = 60 * 60 * 1000
const PAYABLE_STATUSES = ['sent', 'overdue', 'partially_paid']
interface CheckoutSessionLike {
id: string
payment_link?: string | { id: string } | null
payment_intent?: string | { id: string } | null
amount_total?: number | null
currency?: string | null
payment_status?: string | null
livemode?: boolean
metadata?: Record<string, string> | null
}
export interface StripeSyncSummary {
fetched: number
settled: number
payoutsBooked: number
needsReview: number
ignored: number
alreadyProcessed: number
/** Set when the connection turned out to be revoked upstream. */
revoked?: boolean
/** Set when the caller's time budget ran out before all events were processed. */
deadlineReached?: boolean
}
type TerminalStatus = 'matched_booked' | 'needs_review' | 'ignored'
interface ProcessOutcome {
status: TerminalStatus
reason: string | null
invoiceId: string | null
journalEntryId: string | null
}
export async function syncStripeConnection(
supabase: SupabaseClient,
connection: StripeConnection,
log: Logger = defaultLog,
/**
* Absolute deadline (epoch ms) from the caller's time budget (the cron
* route). When it passes mid-batch the event loop stops BEFORE the next
* unprocessed event; the cursor then advances only over what was actually
* processed, so the next run resumes exactly where this one stopped.
* Omitted (manual sync, tests): no budget, the full batch is processed.
*/
deadlineMs?: number,
): Promise<StripeSyncSummary> {
const summary: StripeSyncSummary = {
fetched: 0,
settled: 0,
payoutsBooked: 0,
needsReview: 0,
ignored: 0,
alreadyProcessed: 0,
}
if (!connection.stripe_account_id) return summary
const stripe = getStripe()
const opts = connectedAccountOptions(connection.stripe_account_id)
// Poll window: from the cursor (with overlap) or, on the first run, from
// when the connection was established: no payment links of ours exist
// before that.
const cursorMs = connection.last_event_created_at
? new Date(connection.last_event_created_at).getTime()
: connection.connected_at
? new Date(connection.connected_at).getTime()
: Date.now() - 24 * 60 * 60 * 1000
const gte = Math.max(0, Math.floor(cursorMs / 1000) - CURSOR_OVERLAP_SECONDS)
let events: Stripe.Event[]
try {
events = await stripe.events
.list({ types: [...EVENT_TYPES], created: { gte }, limit: 100 }, opts)
.autoPagingToArray({ limit: 1000 })
} catch (err) {
if (isRevokedConnectionError(err)) {
log.warn('connection revoked upstream; marking revoked', {
connectionId: connection.id,
})
await supabase
.from('stripe_connections')
.update({
status: 'revoked',
disconnected_at: new Date().toISOString(),
error_message: 'Åtkomsten återkallades hos Stripe.',
})
.eq('id', connection.id)
// An upstream revocation is the same outward-facing consent transition
// as a user-initiated disconnect: land it in the audit trail too.
try {
await eventBus.emit({
type: 'stripe.disconnected',
payload: {
connectionId: connection.id,
stripeAccountId: connection.stripe_account_id,
reason: 'revoked_upstream',
userId: connection.user_id,
companyId: connection.company_id,
},
})
} catch {
// Audit event failure must not block marking the connection revoked.
}
summary.revoked = true
return summary
}
throw err
}
summary.fetched = events.length
// Stripe returns newest first; apply oldest first so partial payments and
// cursor advancement stay chronological.
events.sort((a, b) => a.created - b.created)
let maxCreated = 0
let lastProcessedEventId: string | null = null
let processedCount = 0
for (const event of events) {
// Enforce the time budget per event, not just per connection: a large
// batch must not blow the cron's maxDuration. The fetch above is a single
// bounded call; the expensive part is the per-event DB + bookkeeping work
// below. Breaking here, before claiming or counting the event, keeps the
// cursor behind the unprocessed tail so the next run picks it up.
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
summary.deadlineReached = true
log.info('time budget exhausted mid-connection; stopping event batch', {
connectionId: connection.id,
processed: processedCount,
remaining: events.length - processedCount,
})
break
}
maxCreated = Math.max(maxCreated, event.created)
lastProcessedEventId = event.id
processedCount++
// Payouts run through their own idempotent ledger (stripe_payouts).
if (event.type === 'payout.paid') {
const payoutOutcome = await processPayoutPaidEvent(supabase, connection, event, log)
if (payoutOutcome.status === 'booked') summary.payoutsBooked++
else if (payoutOutcome.status === 'needs_review') summary.needsReview++
else if (payoutOutcome.status === 'ignored') summary.ignored++
else summary.alreadyProcessed++
if (payoutOutcome.status === 'needs_review') {
log.info('stripe payout not auto-booked', {
connectionId: connection.id,
eventId: event.id,
reason: payoutOutcome.reason,
})
}
continue
}
const claim = await claimEvent(supabase, connection, event)
if (!claim) {
summary.alreadyProcessed++
continue
}
let outcome: ProcessOutcome
try {
outcome = await processCheckoutSessionEvent(supabase, connection, event, log)
} catch (err) {
// Unexpected processing failure: record for review rather than leaving
// a dangling 'processing' claim until the stale-reclaim window.
outcome = {
status: 'needs_review',
reason: `processing_failed: ${err instanceof Error ? err.message : String(err)}`,
invoiceId: null,
journalEntryId: null,
}
}
await supabase
.from('stripe_payment_events')
.update({
status: outcome.status,
reason: outcome.reason,
invoice_id: outcome.invoiceId,
journal_entry_id: outcome.journalEntryId,
})
.eq('id', claim.id)
if (outcome.status === 'matched_booked') summary.settled++
else if (outcome.status === 'needs_review') summary.needsReview++
else summary.ignored++
if (outcome.status !== 'matched_booked') {
log.info('stripe event not auto-applied', {
connectionId: connection.id,
eventId: event.id,
status: outcome.status,
reason: outcome.reason,
})
}
}
if (maxCreated > 0) {
await supabase
.from('stripe_connections')
.update({
last_event_created_at: new Date(maxCreated * 1000).toISOString(),
last_event_id: lastProcessedEventId,
})
.eq('id', connection.id)
}
return summary
}
/**
* Claim the event for processing. Returns the claim row id, or null when the
* event was already handled (or is being handled) by an earlier run.
*/
async function claimEvent(
supabase: SupabaseClient,
connection: StripeConnection,
event: Stripe.Event,
): Promise<{ id: string } | null> {
const session = event.data.object as CheckoutSessionLike
const { data: inserted } = await supabase
.from('stripe_payment_events')
.upsert(
{
company_id: connection.company_id,
connection_id: connection.id,
stripe_event_id: event.id,
checkout_session_id: session.id ?? null,
payment_intent_id: idOf(session.payment_intent),
payment_link_id: idOf(session.payment_link),
amount: typeof session.amount_total === 'number' ? session.amount_total / 100 : null,
currency: session.currency?.toUpperCase() ?? null,
status: 'processing',
event_created_at: new Date(event.created * 1000).toISOString(),
},
{ onConflict: 'connection_id,stripe_event_id', ignoreDuplicates: true },
)
.select('id')
if (inserted && inserted.length > 0) return inserted[0] as { id: string }
// Conflict: reclaim only if the prior claim crashed (stale 'processing').
const staleBefore = new Date(Date.now() - STALE_CLAIM_MS).toISOString()
const { data: reclaimed } = await supabase
.from('stripe_payment_events')
.update({ status: 'processing' })
.eq('connection_id', connection.id)
.eq('stripe_event_id', event.id)
.eq('status', 'processing')
.lt('updated_at', staleBefore)
.select('id')
return reclaimed && reclaimed.length > 0 ? (reclaimed[0] as { id: string }) : null
}
function idOf(value: string | { id: string } | null | undefined): string | null {
if (!value) return null
return typeof value === 'string' ? value : value.id
}
async function processCheckoutSessionEvent(
supabase: SupabaseClient,
connection: StripeConnection,
event: Stripe.Event,
log: Logger,
): Promise<ProcessOutcome> {
const session = event.data.object as CheckoutSessionLike
const none = { invoiceId: null, journalEntryId: null }
if (session.livemode !== connection.livemode) {
return { status: 'ignored', reason: 'livemode_mismatch', ...none }
}
// Async payment methods emit checkout.session.completed with
// payment_status 'unpaid'; money that has not arrived is not booked.
if (session.payment_status && session.payment_status !== 'paid') {
return { status: 'ignored', reason: `payment_status_${session.payment_status}`, ...none }
}
// Resolve the invoice: primary key is our stored payment link id; fallback
// is the invoice id we stamped into the link metadata. Both are exact keys,
// both scoped to the connection's company.
const paymentLinkId = idOf(session.payment_link)
// Only the customer's name is joined; InvoiceWithCustomerName models that
// partial relation honestly (shared with the settlement boundary).
let invoice: InvoiceWithCustomerName | null = null
if (paymentLinkId) {
const { data } = await supabase
.from('invoices')
.select('*, customer:customers(name), items:invoice_items(*)')
.eq('company_id', connection.company_id)
.eq('stripe_payment_link_id', paymentLinkId)
.maybeSingle()
invoice = data as InvoiceWithCustomerName | null
}
if (!invoice && session.metadata?.invoice_id) {
const { data } = await supabase
.from('invoices')
.select('*, customer:customers(name), items:invoice_items(*)')
.eq('company_id', connection.company_id)
.eq('id', session.metadata.invoice_id)
.maybeSingle()
invoice = data as InvoiceWithCustomerName | null
}
if (!invoice) {
return { status: 'needs_review', reason: 'invoice_not_found', ...none }
}
const outcomeBase = { invoiceId: invoice.id, journalEntryId: null }
if (invoice.status === 'paid') {
return { status: 'needs_review', reason: 'invoice_already_paid', ...outcomeBase }
}
if (!PAYABLE_STATUSES.includes(invoice.status)) {
return {
status: 'needs_review',
reason: `invoice_not_payable_${invoice.status}`,
...outcomeBase,
}
}
const sessionCurrency = session.currency?.toUpperCase()
if (!sessionCurrency || sessionCurrency !== invoice.currency) {
return { status: 'needs_review', reason: 'currency_mismatch', ...outcomeBase }
}
// v1 automation scope: SEK only. A non-SEK settlement needs FX handling on
// both the clearing account and the payout leg; reviewed manually instead.
if (invoice.currency !== 'SEK') {
return { status: 'needs_review', reason: 'non_sek_invoice', ...outcomeBase }
}
const amount = typeof session.amount_total === 'number' ? session.amount_total / 100 : null
const remaining = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
if (amount == null || Math.round(amount * 100) !== Math.round(remaining * 100)) {
return { status: 'needs_review', reason: 'amount_mismatch', ...outcomeBase }
}
// maybeSingle: a company without a settings row is a legitimate no-result
// case that falls back to the defaults below, not a swallowed error.
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', connection.company_id)
.maybeSingle()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const paymentDate = new Date(event.created * 1000).toISOString().split('T')[0]
const result = await settleInvoicePayment(supabase, connection.company_id, connection.user_id, {
invoice,
paymentAmountInInvoiceCurrency: amount,
paymentDate,
accountingMethod,
entityType,
// Money is in the Stripe balance, not the bank: settle against 1686
// (Fordringar för kontokort); the payout later clears 1686 into 1930.
settlementAccountNumber: '1686',
})
if (!result.ok) {
const reason =
result.code === 'BOOKKEEPING_ERROR'
? `bookkeeping_error: ${result.error instanceof Error ? result.error.message : String(result.error)}`
: result.code.toLowerCase()
return { status: 'needs_review', reason, ...outcomeBase }
}
log.info('stripe payment settled invoice', {
connectionId: connection.id,
invoiceId: invoice.id,
journalEntryId: result.journalEntryId,
amount,
})
return {
status: 'matched_booked',
reason: null,
invoiceId: invoice.id,
journalEntryId: result.journalEntryId,
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"id": "stripe",
"sector": "general",
"exportName": "stripeExtension",
"entryPoint": "@/extensions/general/stripe",
"workspace": null,
"requiredEnvVars": ["STRIPE_SECRET_KEY", "STRIPE_CONNECT_CLIENT_ID"],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "Stripe-betalningar",
"category": "operations",
"icon": "CreditCard",
"dataPattern": "manual",
"hasOwnData": true,
"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.",
"subscriptionNotice": "Denna integration kräver ett eget Stripe-konto. Stripes transaktionsavgifter tillkommer enligt ditt avtal med Stripe."
}
}
+46
View File
@@ -0,0 +1,46 @@
/** Row shape of public.stripe_connections. */
export interface StripeConnection {
id: string
company_id: string
user_id: string
stripe_account_id: string | null
livemode: boolean
status: 'pending' | 'active' | 'revoked' | 'error'
oauth_state: string | null
display_name: string | null
last_event_created_at: string | null
last_event_id: string | null
error_message: string | null
connected_at: string | null
disconnected_at: string | null
created_at: string
updated_at: string
}
/** Row shape of public.stripe_payment_events (needs_review projection). */
export interface StripeReviewEvent {
id: string
reason: string | null
amount: number | null
currency: string | null
invoice_id: string | null
event_created_at: string | null
}
/** Status payload returned by GET /api/extensions/ext/stripe/status. */
export interface StripeStatusResponse {
configured: boolean
connection: Pick<
StripeConnection,
| 'id'
| 'status'
| 'stripe_account_id'
| 'livemode'
| 'display_name'
| 'error_message'
| 'connected_at'
| 'last_event_created_at'
> | null
needs_review_count?: number
needs_review?: StripeReviewEvent[]
}