Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect Verified against the Swedish Common Interpretation of ISO 20022 (Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4: Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22), and XSD-validated against the official pain.001.001.03 schema: - drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl gets the domestic NURG default) - drop RmtInf (not allowed for SALA salary payments; the beneficiary statement text comes from the Dataclearing LON code) - address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA, account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN - share the clearing/account split (Swedbank 5-digit shift, Nordea personkonto prefix dedup) between the LB and pain.001 generators via splitDomesticBankAccount, fixing pain.001 duplicating the personkonto clearing - clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx counter surviving truncation; carry the org number on Dbtr - return 400 from the pain001 route on an invalid clearing instead of emitting a broken file Also includes two unrelated decision-log lines from the parallel revisor-review session (DECISIONS.md is a shared append-only log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(nav): surface the year-end chain in the sidebar Add Periodiseringar, Arsredovisning (aktiebolag only) and Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt & bokslut group, in workflow order. Entity gating via a new entityOnly flag on NavItem; isActive carve-outs extended so exactly one row lights up for the new routes. Driven by an external revisor review that concluded these features did not exist because none of them were reachable from the nav. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): Stripe Connect integration behind config gate Connect OAuth per company (only the acct_ id is stored), automatic single-use Payment Links on invoice send, deterministic payment settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686), payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614), and a 15-minute sync cron. Non-deterministic events land as needs_review, never guessed at. Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the send hook and cron no-op, and the settings page shows 'Kommer snart' (hosted) until the Connect platform is verified. Self-hosted keeps the honest not-configured message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete generate-declaration.ts has updated non-existent columns (type/period/ status) since inception, so the arbetsgivardeklaration deadline was never auto-completed. Replace with a shared helper targeting the real schema (tax_deadline_type/tax_period/is_completed), also used by the kvittens crons and moms handlers in the follow-up commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record godkant belopp on the matching begaran: matched by stored skv_referensnummer first, then exact name among active undecided requests; arenden by fakturanummer then personnummer, exactly-one or the beslut errors (all-or-nothing). Never auto-settles: recording the beslut and booking the payout are separate acts. Exposed as an API route and the gnubok_import_rot_rut_beslut MCP tool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications Hybrid auth program: system CCG (org certificate) for background reads while personal BankID stays for interactive submissions, since SKV per-flow refresh tokens live 65 min and crons structurally cannot run on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE (default off) with a stub transport until the Expisoft cert and CCG avtal land; auth resolution is centralized in resolve-auth.ts. Also in this change: - One-click VAT submit chaining kontrollera -> utkast -> las server-side with a stage discriminator; step-by-step buttons demoted to the overflow menu. - Kvittens crons (AGI + new VAT schedule) with email-only notifications, deduped in notification_log under the new skv_kvittens type. - Ombud grant probe + verification UI in the connect panel, and a dashboard promo card for unconnected companies. - skatteverket_company_connections table with pg-real coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card The "Skatt att betala" card only cleared via the manual mark-paid button on the run detail page; the promised automatic flip from the Skattekonto sync was never implemented, so paid periods stayed red. - settleAgiTaxPayments: during every skattekonto sync, a booked "Arbetsgivardeklaration YYYYMM" debit row settles the matching agi_declarations.tax_paid_at, but only when the amount equals the declared total to the ore and the account is not in deficit (deterministic; drift or deficit falls back to manual). - Salary overview card: reconnect hint when the SKV token needs re-consent (link to /settings/tax, silent when the extension is off), plus an inline "Markera som betald" button reusing the existing endpoint and salary_payments strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add cloud backup scheduling and alerting features - Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due. - Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures. - Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours. - Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats. - Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files. - Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content. - Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup. * fix(stripe): correct invoice clearing reference and improve type safety in sync logic * fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType settleInvoicePayment takes accountingMethod as a raw settings string, but resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union. Normalize at the call site (anything but 'cash' books as accrual), matching the existing useCashEntry semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review findings and nitpicks on PR #1004 Review findings: - backup settings redirect: always force view=export over incoming params - AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the signed-state persist error, guard recovery calls in catch blocks so one company cannot abort the rest; surface grant_revoked in the run summary - kvittens notifications: atomic claim-first dedup with a partial unique index; map non-uuid reference keys to deterministic uuids - grant probe: record the actual 2xx status; mTLS transport: handle response-stream errors - stripe: amount-aware idempotency keys for payment links; emit stripe.disconnected on upstream revocations - ROT/RUT beslut import: mutate in-memory request state after apply, move item + header writes into an atomic apply_rot_rut_beslut RPC, add rot_rut_payout to JournalEntrySourceTypeSchema - migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on journal_entries, notification_log and rot_rut_payout_requests - cloud backup: hour_utc-only schedule updates clear stale hour_local Nitpicks: - stripe sync: enforce the cron time budget inside per-connection event processing with idempotent cursor progress; maybeSingle for settings; honest partial-customer DTO shared with the settlement boundary - shared applyPaymentLinkToInvoice helper for both invoice send routes, v1 docblock documents step 6b and PAYMENT_LINK_FAILED - settings panel: drop redundant decodeURIComponent - cloud backup: document worst-case archive memory headroom Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ee3c33c7a4
commit
98d0c7f2d0
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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('<script>')
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user