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
@@ -72,3 +72,26 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-11] Hoisted local VAT checks + RC-gap worklist out of SkatteverketPanel into ungated VatChecksCard: the panel's paywall/not-connected early-returns hid compliance errors from exactly the users who file manually.
|
||||
[2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff.
|
||||
[2026-07-12] Compliance-review triage on the payment-link PR: finding 1 (email pay button on kreditfaktura) verified FALSE: invoice-templates.ts derives isCreditNote from credited_invoice_id and hidePayment already gates both HTML and text builders; no change. Finding 2 was the real deferred v1 gap but misfiled against invoice-columns.ts (which already carries deduction_total): the actual hole was the v1 send route's hand-rolled fetch projection, now replaced with the shared INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so PDF/email inputs cannot drift from the GET shape again (closes the [2026-07-10] deferred ROT/RUT send fix; also gives v1 sends the pay button + deduction box). Finding 3 accepted as a robustness fix only: the non-ok path already reflected true server state, but a thrown fetch left the Godkann spinner stuck; approve handler now try/catch/finally with a server refetch on failure.
|
||||
[2026-07-12] pain.001 salary dialect hardened to the Swedish Common Interpretation (Bankforeningen "Common Payment Types in Sweden" Appendix 1, Example 4 Salaries; cross-checked vs Nordea Corporate Access pain.001 examples v2.6, 2026-06-22): dropped SvcLvl SEPA (SEPA credit transfer is EUR-only; omitting SvcLvl gets the domestic NURG default), dropped RmtInf (Nordea: remittance info not allowed for SALA; statement text comes from the Dataclearing LON code), creditor addressed as CdtrAgt ClrSysMmbId SESBA + CdtrAcct Othr SchmeNm BBAN with the account WITHOUT clearing, Dbtr now carries OrgId, all ids clamped to Max35Text. The clearing/account split (Swedbank 5-digit shift, Nordea personkonto prefix dedup) is extracted to splitDomesticBankAccount in lib/salary/payment/bank-account.ts and shared by BOTH the bg-lb and pain001 generators so the two formats can never route a payment differently again (pain001 previously concatenated raw digits and duplicated the personkonto clearing). Swedbank MmbId = first 4 clearing digits with the 5th shifted into the account, mirroring the production-proven LB encoding and the appendix's 4-digit MmbId salary example; run a generated file through Swedbank Validex (and the other banks' test uploads) before the 1 Aug Bg Lon campaign.
|
||||
[2026-07-12] ESG/CO2 reporting parked (no build): external revisor review flagged its absence; not a purchase criterion for the target segment (tech-native sjalvbokforare). Revisit on real customer demand; likely shape then is a spend-based CO2 estimate on supplier invoices as an extension, not core.
|
||||
[2026-07-12] "Projektredovisning" split into two scopes after the revisor review: full project accounting (WIP, successiv vinstavrakning, budget follow-up) parked indefinitely; light time-to-invoice (time entries to invoice rows; schema support already exists via project_time_entries and dimension FKs) stays an OPEN 2026 positioning decision, deliberately not committed yet.
|
||||
[2026-07-12] Stripe integration ships as Connect OAuth from day one (Emil's explicit call over the API-key-first recommendation): we store only the connected acct_ id, never a key or token, so no encryption story is needed; revocation works from either side. Payment Link only, never a Stripe Invoice object: a second legal invoice with its own number series would violate the single-faktura principle, and Stripe Invoicing costs extra per invoice. The paste-link MVP column (payment_link_url) is auto-filled exactly as its 2026-07-09 decision anticipated.
|
||||
[2026-07-12] Core reaches the Stripe extension through the existing Extension.services registry field (lib/extensions/payment-links.ts bridge) instead of an event: the link must exist BEFORE the email/PDF render, so an after-the-fact handler cannot work, and a direct import from the send route would break the zero-extensions core build. Link creation failure degrades to a PARTIAL warning: invoice dispatch is the legal act and never blocks on a PSP.
|
||||
[2026-07-12] Stripe payments settle against 1686 (Fordringar for kontokort och kuponger), NOT the traditional 1580: the BAS board moved card/coupon acquirer receivables from 1580 to 1686 (a receivable on the acquirer belongs under Ovriga kortfristiga fordringar, not Kundfordringar), which is exactly why the repo's full BAS 2026 import lists 1580 among the removed non-standard accounts. A first attempt re-added 1580 to bas-data; reverted after checking bas.se, since 1686 already exists in the catalog. Payouts book Dr 1930 net / Dr 6570 fees / 4535+4598 basis pair / 2645+2614 fiktiv moms / Cr 1686 gross, reusing the supplier reverse-charge generators since Stripe Payments Europe is Irish (EU services RC, rutor 21/30/48). The 1930 line surfaces in get_unlinked_1930_lines for bank-rec linking, so the deposit is never double-booked.
|
||||
[2026-07-12] Stripe sync auto-posts WITHOUT pending-operations staging when the match is fully deterministic (exact payment-link id or invoice id + exact remaining amount + currency + livemode; payouts additionally require only charge/payment balance txns, SEK, VAT-registered, gross-fees=net): consistent with the determinism doctrine and the recurring/reminder/accrual cron precedent. Everything else lands as needs_review rows (stripe_payment_events / stripe_payouts) shown in the settings panel: never guessed, never dropped. Non-SEK and refund-bearing payouts are deliberately out of v1 automation scope.
|
||||
[2026-07-12] mark-paid orchestration extracted verbatim into lib/invoices/settle-invoice-payment.ts so the Stripe cron and the manual route share one booking/CAS/orphan-cancel/event path; the route's existing test suite (19 tests) is the regression net. The duplicate-payment guard stays route-side: for the cron the Stripe event IS the authoritative payment. stripe_payments joined PAID_CAPABILITIES with a migration backfilling grants by mirroring bank_sync (existing payers would otherwise stay dark until their next billing webhook).
|
||||
[2026-07-12] Skatteverket hybrid auth: system CCG (org certificate) for background reads, personal BankID flow kept for interactive submissions: SKV per-flow refresh tokens live 65 min so crons structurally cannot run on them; full ombud switch deferred until CCG docs/avtal land (all code behind SKATTEVERKET_SYSTEM_AUTH_MODE=off, stub transport, retiring user-token reads later is a policy change in resolve-auth.ts only).
|
||||
[2026-07-12] Kvittens notifications are email-only from the skatteverket extension (notification_log dedup under new skv_kvittens type), not push: push-notifications is a disabled extension and cross-extension imports are not allowed; wiring an event handler there was speculative. Revisit if push-notifications gets enabled.
|
||||
[2026-07-12] Fixed silently-dead AGI deadline auto-complete (generate-declaration.ts updated non-existent columns type/period/status=completed since inception): replaced with shared lib/deadlines/complete-tax-deadline.ts (tax_deadline_type/tax_period/is_completed), also now called from the kvittens crons and the moms inlamnat/beslutat handlers.
|
||||
[2026-07-12] One-click VAT submit chains kontrollera->utkast->las server-side in vat-submit.ts with a stage discriminator (validation aborts pre-write, lock failure reports draft_saved); the pending-operations commit path reuses the same chain WITHOUT the kontrollera pre-step since staged figures were already reviewed. Step-by-step buttons demoted to the overflow menu, not removed.
|
||||
[2026-07-12] ROT/RUT beslutsfil import matches begaran by stored skv_referensnummer first, then exact name among active undecided requests; arenden match by fakturanummer then personnummer, exactly-one or the beslut errors (all-or-nothing, determinism principle). Never auto-settles: recording the beslut and booking the payout are separate acts.
|
||||
[2026-07-12] AGI tax_paid_at auto-settles from skattekonto sync only when the booked AGI debit row matches the declared total to the ore AND saldo >= 0: deficit or amount drift means something is still unpaid, so those fall back to the manual mark-paid button (determinism over inference). Salary card reconnect hint fires only on needs_reconsent, never on routine 65-min token expiry (that would nag every user).
|
||||
[2026-07-12] Cloud-backup auto-sync defaults ON after Google Drive connect (opt-out), with the first backup kicked off in the background via next/server after(): a backup that defaults to off protects nobody; reconnects keep the user's existing schedule.
|
||||
[2026-07-12] Backup failure alerts email only the schedule-owning user (must still be an active company member), throttled to one per company per 7 days; needs_reauth alerts once per incident: silent backup failure is the worst outcome, weekly nagging the second worst.
|
||||
[2026-07-12] Cloud-backup cron due-logic changed from exact hour match to "daily slot passed and no attempt since it": a time-budget overrun previously skipped the leftover companies for the entire day. Schedule hour now stored as Europe/Stockholm wall-clock (hour_local, DST-stable), hour_utc kept as legacy fallback.
|
||||
[2026-07-12] Backup dump classification is enforced by tests/pg/full-archive-coverage.pg.test.ts (every company_id table must be dumped, covered elsewhere, or excluded with a reason). The dump list had rotted: salary/assets/dimensions/articles/rot-rut/voucher_gap_explanations were never added, invoice_items/supplier_invoice_items/receipt_line_items were queried by a company_id column they do not have, and transactions was ordered by nonexistent booking_date: all three produced silent error stubs in every existing backup.
|
||||
[2026-07-12] Drive backup layout is one "Arkiv <ar>.zip" per rakenskapsAr + Grunddata.zip + LASMIG.txt, updated in place with per-file fingerprints, instead of a new timestamped full ZIP per sync: bounds Drive usage and nightly upload size; Drive keeps ~30 days of prior versions of updated files. Old timestamped files are left untouched.
|
||||
[2026-07-12] Per-archive-file size limit is 300 MB (not the plan's ~750 MB) despite resumable/chunked uploads: JSZip builds each archive fully in memory on a serverless function; per-year splitting makes the limit per rakenskapsAr, which is the real unlock.
|
||||
[2026-07-12] Archive reports get CSV twins (semicolon-separated, decimal comma, UTF-8 BOM for Swedish Excel) instead of PDF: zero new dependencies; the JSON stays canonical and a CSV formatting error can never take down the archive (per-file try/catch).
|
||||
[2026-07-12] Kvittens email dedup: notification_log row is now inserted FIRST as an atomic claim (partial unique index 20260712113000 on user_id+reference_id where notification_type = 'skv_kvittens'; 23505 = already claimed, claim released on send failure), and non-uuid reference ids (the VAT cron's composite key) are mapped to a deterministic SHA-256-derived uuid inside kvittens-notification.ts: reference_id is a uuid column, so the old string key silently failed both the dedup select and the insert (22P02); normalizing in-module beats widening the shared column to text or changing the cron's key formula.
|
||||
[2026-07-12] applyPaymentLinkToInvoice (shared send-route payment-link helper) lives in lib/extensions/payment-links.ts, not extensions/general/stripe/lib/payment-links.ts as the review suggested: both send routes reach payment links through the core registry bridge, and a core route importing the Stripe extension directly would break the zero-extensions core build; per-route logging differences are preserved via logPrefix/logContext options.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { ArrowRight, CalendarClock, HandCoins, Loader2, Plus, UserX, Users } from 'lucide-react'
|
||||
import { ArrowRight, CalendarClock, CheckCircle2, HandCoins, Loader2, Plus, UserX, Users } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -51,13 +51,18 @@ export default function SalaryPage() {
|
||||
const [payDay, setPayDay] = useState(25)
|
||||
const [agiDeadline, setAgiDeadline] = useState<{ due_date: string; title: string } | null>(null)
|
||||
const [taxPayment, setTaxPayment] = useState<TaxPaymentState | null>(null)
|
||||
// The Skatteverket connection previously worked but now needs re-consent:
|
||||
// the skattekonto sync (which auto-settles the tax card) is paused.
|
||||
const [skvNeedsReconsent, setSkvNeedsReconsent] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [markingPaid, setMarkingPaid] = useState(false)
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('salary')
|
||||
const tp = useTranslations('salary_payments')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [runsRes, empRes, settingsRes] = await Promise.all([
|
||||
@@ -92,6 +97,19 @@ export default function SalaryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Connection health for the tax card hint. Only needs_reconsent counts:
|
||||
// the routine short-lived token expiry is normal and must not nag. Any
|
||||
// failure (extension disabled → 503, network) silently means no hint.
|
||||
try {
|
||||
const statusRes = await fetch('/api/extensions/ext/skatteverket/status')
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json()
|
||||
setSkvNeedsReconsent(status?.needsReconsent === true)
|
||||
}
|
||||
} catch {
|
||||
// Extension unavailable: no hint.
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
@@ -148,6 +166,34 @@ export default function SalaryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Inline mark-paid on the tax card: same endpoint as TaxPaymentPanel on the
|
||||
// run detail page, for users who paid Skatteverket outside the app.
|
||||
async function markTaxPaid(period: string) {
|
||||
setMarkingPaid(true)
|
||||
try {
|
||||
const res = await fetch(`/api/skatteverket/tax-payments/${period}/mark-paid`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const result = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: tp('tax_mark_paid_failed_title'),
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: tp('tax_marked_paid') })
|
||||
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
|
||||
if (txRes.ok) {
|
||||
const tx = await txRes.json()
|
||||
setTaxPayment(tx.data)
|
||||
}
|
||||
} finally {
|
||||
setMarkingPaid(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Real header renders immediately; only the data surfaces are skeletons.
|
||||
return (
|
||||
@@ -388,6 +434,30 @@ export default function SalaryPage() {
|
||||
? t('card_tax_paid', { date: formatDate(taxPayment.tax_paid_at) })
|
||||
: t('card_tax_unpaid', { period: periodOf(latestBooked) })}
|
||||
</p>
|
||||
{!taxPayment?.tax_paid_at && skvNeedsReconsent && (
|
||||
<Link
|
||||
href="/settings/tax"
|
||||
className="mt-1 block text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t('card_tax_reconnect')}
|
||||
</Link>
|
||||
)}
|
||||
{!taxPayment?.tax_paid_at && canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => markTaxPaid(periodOf(latestBooked))}
|
||||
disabled={markingPaid}
|
||||
>
|
||||
{markingPaid ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{tp('tax_mark_paid_button')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('card_tax_none')}</p>
|
||||
|
||||
@@ -4,6 +4,21 @@ import { redirect } from 'next/navigation'
|
||||
// /import (Importera/Exportera). Den här sidan finns kvar endast som en
|
||||
// permanent omdirigering så att gamla bokmärken och cloud-backup-extensionens
|
||||
// `settingsPanel.path` fortfarande tar användaren till rätt plats.
|
||||
export default function BackupSettingsPage() {
|
||||
redirect('/import?view=export#cloud-backup')
|
||||
//
|
||||
// Query-parametrar följer med: Googles OAuth-callback landar här med
|
||||
// `?cloud_backup=connected_first` (m.fl.) och kortet på /import läser dem
|
||||
// för att visa rätt toast och börja polla efter första synken.
|
||||
export default async function BackupSettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const qs = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') qs.set(key, value)
|
||||
}
|
||||
// Set last so an incoming ?view=... can never override the intended view.
|
||||
qs.set('view', 'export')
|
||||
redirect(`/import?${qs.toString()}#cloud-backup`)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsShell } from '@/components/settings/SettingsShell'
|
||||
const TAB_TO_ROUTE: Record<string, string> = {
|
||||
company: '/settings/company',
|
||||
invoicing: '/settings/invoicing',
|
||||
payments: '/settings/payments',
|
||||
bookkeeping: '/settings/bookkeeping',
|
||||
tax: '/settings/tax',
|
||||
team: '/settings/team',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PaymentsSettingsContent } from '@/components/settings/sections/PaymentsSettingsContent'
|
||||
|
||||
export default function PaymentsSettingsPage() {
|
||||
return <PaymentsSettingsContent />
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
@@ -12,6 +12,15 @@ vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({
|
||||
saveExtensionData: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/cloud-backup/lib/backup-alert', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/extensions/general/cloud-backup/lib/backup-alert')>()
|
||||
return {
|
||||
...actual,
|
||||
sendBackupFailureAlert: vi.fn().mockResolvedValue({ sent: true }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
@@ -22,13 +31,18 @@ import {
|
||||
performSync,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { sendBackupFailureAlert } from '@/extensions/general/cloud-backup/lib/backup-alert'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockPerformSync = vi.mocked(performSync)
|
||||
const mockSaveExtensionData = vi.mocked(saveExtensionData)
|
||||
const mockSendBackupFailureAlert = vi.mocked(sendBackupFailureAlert)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
|
||||
// All tests run at a frozen 2026-07-12 12:30 UTC.
|
||||
const NOW = new Date('2026-07-12T12:30:00.000Z')
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/cloud-backup/auto-sync/cron', {
|
||||
headers: { authorization: 'Bearer test-secret' },
|
||||
@@ -75,13 +89,49 @@ function makeSupabaseStub(
|
||||
return { from } as any
|
||||
}
|
||||
|
||||
function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: 12,
|
||||
last_auto_sync_at: null,
|
||||
last_auto_sync_status: null,
|
||||
last_auto_sync_error: null,
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function okSyncResult() {
|
||||
return {
|
||||
ok: true as const,
|
||||
lastSync: {
|
||||
at: '2026-07-12T12:30:00Z',
|
||||
folder_id: 'folder-1',
|
||||
files: [],
|
||||
total_size_bytes: 1000,
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/drive/folders/folder-1',
|
||||
uploadedCount: 1,
|
||||
skippedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('cloud-backup auto-sync cron', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ now: NOW })
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.test'
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockSendBackupFailureAlert.mockResolvedValue({ sent: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns 401 when cron auth fails', async () => {
|
||||
@@ -95,13 +145,43 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
})
|
||||
|
||||
it('skips schedules that are disabled', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ enabled: false })])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips schedules whose slot is later today', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow({ hour_utc: 13 })]))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches up companies whose earlier slot was missed', async () => {
|
||||
// 03:00 slot, never synced: still due at 12:30 (e.g. after a time-budget overrun).
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow({ hour_utc: 3 })]))
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.successes).toBe(1)
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips schedules that already ran since today\'s slot', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: { enabled: false, hour_utc: new Date().getUTCHours() },
|
||||
},
|
||||
scheduleRow({ hour_utc: 3, last_auto_sync_at: '2026-07-12T03:05:00.000Z' }),
|
||||
])
|
||||
)
|
||||
|
||||
@@ -112,73 +192,25 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips schedules whose hour does not match the current UTC hour', async () => {
|
||||
const offHour = (new Date().getUTCHours() + 5) % 24
|
||||
it('runs again when the last attempt was yesterday', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: { enabled: true, hour_utc: offHour },
|
||||
},
|
||||
scheduleRow({ hour_utc: 12, last_auto_sync_at: '2026-07-11T12:05:00.000Z' }),
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.successes).toBe(1)
|
||||
})
|
||||
|
||||
it('skips schedules whose last_auto_sync_at is less than 20h ago', async () => {
|
||||
const recent = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
it('runs sync with document fallback and persists success state', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: recent,
|
||||
},
|
||||
},
|
||||
])
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs sync and persists success for qualifying schedules', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
lastSync: {
|
||||
at: '2026-04-20T03:00:00Z',
|
||||
file_id: 'f-1',
|
||||
file_name: 'arkiv.zip',
|
||||
file_size_bytes: 1000,
|
||||
folder_id: 'folder-1',
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/file/d/f-1/view',
|
||||
})
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
@@ -188,92 +220,162 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
companyId: 'c-1',
|
||||
userId: 'u-1',
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
})
|
||||
)
|
||||
expect(body.successes).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
|
||||
// Persisted the success state on the schedule
|
||||
const [, , , key, value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect(key).toBe('google_drive_schedule')
|
||||
expect((value as any).last_auto_sync_status).toBe('success')
|
||||
expect((value as any).last_auto_sync_error).toBeNull()
|
||||
// Success resets the failure counter.
|
||||
expect((value as any).consecutive_failures).toBe(0)
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records error status when performSync returns ok=false', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
it('increments the failure counter without alerting below the threshold', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow()]))
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'archive_too_large',
|
||||
message: 'Archive exceeds size limit',
|
||||
size_bytes: 100 * 1024 * 1024,
|
||||
size_limit_bytes: 80 * 1024 * 1024,
|
||||
reason: 'upload_failed',
|
||||
message: 'Drive upload failed: 500',
|
||||
})
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.successes).toBe(0)
|
||||
expect(body.errors).toBe(1)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_auto_sync_status).toBe('error')
|
||||
expect((value as any).last_auto_sync_error).toBe('Archive exceeds size limit')
|
||||
expect((value as any).consecutive_failures).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches thrown errors and records them against the schedule', async () => {
|
||||
it('alerts when the consecutive failure threshold is reached', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'upload_failed',
|
||||
message: 'Drive upload failed: 500',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: 'c-1',
|
||||
kind: 'repeated_failures',
|
||||
consecutiveFailures: 3,
|
||||
errorMessage: 'Drive upload failed: 500',
|
||||
})
|
||||
)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).consecutive_failures).toBe(3)
|
||||
expect((value as any).last_alert_at).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('throttles repeat alerts via last_alert_at', async () => {
|
||||
const recentAlert = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString()
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
scheduleRow({ consecutive_failures: 5, last_alert_at: recentAlert }),
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'upload_failed',
|
||||
message: 'still failing',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_alert_at).toBe(recentAlert)
|
||||
})
|
||||
|
||||
it('alerts immediately when the token dies during the sync', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow()]))
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'needs_reauth',
|
||||
message: 'Google Drive authorization expired; reconnect required',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth' })
|
||||
)
|
||||
})
|
||||
|
||||
it('catches thrown errors, counts them and records them against the schedule', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
mockPerformSync.mockRejectedValueOnce(new Error('Drive quota exceeded'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.errors).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'repeated_failures', consecutiveFailures: 3 })
|
||||
)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_auto_sync_status).toBe('error')
|
||||
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
|
||||
expect((value as any).consecutive_failures).toBe(3)
|
||||
})
|
||||
|
||||
it('skips connections flagged needs_reauth without syncing or touching the schedule', async () => {
|
||||
it('skips pre-flagged needs_reauth connections and alerts once per incident', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
// The incident had not been alerted yet: one alert, persisted on the schedule.
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth' })
|
||||
)
|
||||
expect(mockSaveExtensionData).toHaveBeenCalledTimes(1)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_alert_at).toEqual(expect.any(String))
|
||||
// last_auto_sync_* stays untouched: it keeps showing the original failure.
|
||||
expect((value as any).last_auto_sync_at).toBeNull()
|
||||
})
|
||||
|
||||
it('does not re-alert an already alerted needs_reauth incident', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[scheduleRow({ last_alert_at: '2026-07-10T04:00:00.000Z' })],
|
||||
{
|
||||
connectionRows: [
|
||||
{ company_id: 'c-1', value: { status: 'needs_reauth' } },
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -282,58 +384,30 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.successes).toBe(0)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only skips the flagged company when others are due', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: 'c-live',
|
||||
user_id: 'u-2',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
{ ...scheduleRow(), company_id: 'c-dead' },
|
||||
{ ...scheduleRow(), company_id: 'c-live', user_id: 'u-2' },
|
||||
],
|
||||
{
|
||||
connectionRows: [
|
||||
{ company_id: 'c-dead', value: { status: 'needs_reauth' } },
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
{ company_id: 'c-live', value: { status: 'active' } },
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
lastSync: {
|
||||
at: '2026-07-10T03:00:00Z',
|
||||
file_id: 'f-1',
|
||||
file_name: 'arkiv.zip',
|
||||
file_size_bytes: 1000,
|
||||
folder_id: 'folder-1',
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/file/d/f-1/view',
|
||||
})
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
@@ -348,20 +422,9 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
|
||||
it('fails open and attempts the sync when the connection lookup errors', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
{ connectionError: { message: 'connection query failed' } }
|
||||
)
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionError: { message: 'connection query failed' },
|
||||
})
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
SCHEDULE_KEY,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { isScheduleDue } from '@/extensions/general/cloud-backup/lib/schedule'
|
||||
import {
|
||||
sendBackupFailureAlert,
|
||||
shouldSendBackupAlert,
|
||||
type BackupAlertKind,
|
||||
} from '@/extensions/general/cloud-backup/lib/backup-alert'
|
||||
import type {
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveSchedule,
|
||||
@@ -16,10 +22,15 @@ import type {
|
||||
/**
|
||||
* GET /api/extensions/cloud-backup/auto-sync/cron
|
||||
*
|
||||
* Runs hourly. Finds all companies with `google_drive_schedule.enabled = true`
|
||||
* whose `hour_utc` matches the current UTC hour, and whose `last_auto_sync_at`
|
||||
* is either unset or more than 20 hours old. Triggers a full Drive backup for
|
||||
* each qualifying company via the shared `performSync()` helper.
|
||||
* Runs hourly. Finds all companies whose auto-sync is due (daily slot has
|
||||
* passed and no attempt has run since it: see `isScheduleDue`) and triggers a
|
||||
* full Drive backup for each via the shared `performSync()` helper. Companies
|
||||
* left over when a run hits its time budget stay due and are picked up by the
|
||||
* next hourly run instead of losing the day.
|
||||
*
|
||||
* Failures increment `consecutive_failures` on the schedule; alert emails go
|
||||
* out on dead tokens (once per incident) and repeated failures (threshold in
|
||||
* `backup-alert.ts`), throttled per company.
|
||||
*
|
||||
* Uses the service role client: no user session, no RLS. Each row in
|
||||
* `extension_data` carries its own `user_id` (the user who configured the
|
||||
@@ -37,7 +48,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
const currentHourUtc = new Date().getUTCHours()
|
||||
const now = new Date()
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
@@ -58,16 +69,9 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
||||
}
|
||||
|
||||
const candidates = rows.filter((r) => {
|
||||
const schedule = r.value as GoogleDriveSchedule | null
|
||||
if (!schedule || !schedule.enabled) return false
|
||||
if (schedule.hour_utc !== currentHourUtc) return false
|
||||
if (schedule.last_auto_sync_at) {
|
||||
const ageMs = Date.now() - new Date(schedule.last_auto_sync_at).getTime()
|
||||
if (ageMs < 20 * 60 * 60 * 1000) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const candidates = rows.filter((r) =>
|
||||
isScheduleDue(r.value as GoogleDriveSchedule | null, now)
|
||||
)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -98,13 +102,11 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const needsReauthCompanyIds = new Set(
|
||||
(connectionRows ?? [])
|
||||
.filter(
|
||||
(r) => (r.value as GoogleDriveConnection | null)?.status === 'needs_reauth'
|
||||
)
|
||||
.map((r) => r.company_id as string)
|
||||
)
|
||||
const connectionByCompany = new Map<string, GoogleDriveConnection>()
|
||||
for (const r of connectionRows ?? []) {
|
||||
const value = r.value as GoogleDriveConnection | null
|
||||
if (value) connectionByCompany.set(r.company_id as string, value)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
||||
@@ -115,6 +117,40 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
error?: string
|
||||
}[] = []
|
||||
|
||||
/**
|
||||
* Send a failure alert if warranted and return the new last_alert_at.
|
||||
* Best-effort: alert failures are logged inside sendBackupFailureAlert.
|
||||
*/
|
||||
const maybeAlert = async (params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
kind: BackupAlertKind
|
||||
consecutiveFailures: number
|
||||
errorMessage: string | null
|
||||
lastAlertAt: string | null | undefined
|
||||
}): Promise<string | null> => {
|
||||
const prior = params.lastAlertAt ?? null
|
||||
if (
|
||||
!shouldSendBackupAlert({
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
lastAlertAt: prior,
|
||||
now: new Date(),
|
||||
})
|
||||
) {
|
||||
return prior
|
||||
}
|
||||
const sent = await sendBackupFailureAlert(supabase, {
|
||||
companyId: params.companyId,
|
||||
userId: params.userId,
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
errorMessage: params.errorMessage,
|
||||
origin,
|
||||
})
|
||||
return sent.sent ? new Date().toISOString() : prior
|
||||
}
|
||||
|
||||
for (const row of candidates) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
ctx.log.info('time budget reached', {
|
||||
@@ -128,9 +164,35 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const userId = row.user_id as string
|
||||
const schedule = row.value as GoogleDriveSchedule
|
||||
|
||||
if (needsReauthCompanyIds.has(companyId)) {
|
||||
const connection = connectionByCompany.get(companyId)
|
||||
if (connection?.status === 'needs_reauth') {
|
||||
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
||||
// failure from the night the dead token was detected.
|
||||
// failure from the night the dead token was detected. But make sure the
|
||||
// incident has been alerted once: a token can go dead via a manual sync
|
||||
// (which never emails) and would otherwise stay silent forever.
|
||||
const alertedSinceIncident =
|
||||
schedule.last_alert_at &&
|
||||
connection.needs_reauth_at &&
|
||||
new Date(schedule.last_alert_at).getTime() >=
|
||||
new Date(connection.needs_reauth_at).getTime()
|
||||
if (!alertedSinceIncident) {
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: 'needs_reauth',
|
||||
consecutiveFailures: schedule.consecutive_failures ?? 0,
|
||||
errorMessage: null,
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
if (lastAlertAt !== (schedule.last_alert_at ?? null)) {
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, {
|
||||
...schedule,
|
||||
last_alert_at: lastAlertAt,
|
||||
}).catch((persistErr) => {
|
||||
ctx.log.error('failed to persist alert state', persistErr as Error, { companyId })
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
|
||||
continue
|
||||
}
|
||||
@@ -142,13 +204,31 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
userId,
|
||||
origin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
})
|
||||
|
||||
const consecutiveFailures = syncResult.ok
|
||||
? 0
|
||||
: (schedule.consecutive_failures ?? 0) + 1
|
||||
let lastAlertAt = schedule.last_alert_at ?? null
|
||||
if (!syncResult.ok) {
|
||||
lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: syncResult.message,
|
||||
lastAlertAt,
|
||||
})
|
||||
}
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
||||
last_auto_sync_error: syncResult.ok ? null : syncResult.message,
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated)
|
||||
|
||||
@@ -163,11 +243,23 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
companyId,
|
||||
})
|
||||
|
||||
const consecutiveFailures = (schedule.consecutive_failures ?? 0) + 1
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: message.slice(0, 200),
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: 'error',
|
||||
last_auto_sync_error: message.slice(0, 200),
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch(
|
||||
(persistErr) => {
|
||||
|
||||
@@ -30,7 +30,11 @@ vi.mock('@/extensions/general/skatteverket/lib/api-client', () => {
|
||||
this.name = 'SkatteverketAuthError'
|
||||
}
|
||||
}
|
||||
return { SkatteverketAuthError }
|
||||
return {
|
||||
SkatteverketAuthError,
|
||||
// resolve-auth's currentSkvEnvironment (grant-revoked path) reads this.
|
||||
getSkatteverketEnvironment: vi.fn().mockReturnValue('test'),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/token-store', () => ({
|
||||
@@ -49,17 +53,40 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
// The route's real connection-store hits the DB via its own service client;
|
||||
// mocking keeps the grant-revoked path deterministic. resolve-auth also
|
||||
// imports getConnection from here (only used when system auth mode is on,
|
||||
// which is off in tests), so export it too.
|
||||
vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({
|
||||
getConnection: vi.fn().mockResolvedValue(null),
|
||||
markGrantRevoked: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/deadlines/complete-tax-deadline', () => ({
|
||||
completeTaxDeadline: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/kvittens-notification', () => ({
|
||||
sendKvittensNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockAgiGetKvittenser = vi.mocked(agiGetKvittenser)
|
||||
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
||||
const mockMarkGrantRevoked = vi.mocked(markGrantRevoked)
|
||||
const mockCompleteTaxDeadline = vi.mocked(completeTaxDeadline)
|
||||
const mockSendKvittensNotification = vi.mocked(sendKvittensNotification)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/skatteverket/agi/kvittenser/cron', {
|
||||
@@ -87,7 +114,7 @@ function makeSupabaseStub(tables: Record<string, { data: unknown; error?: unknow
|
||||
const result = tables[table] ?? { data: null, error: null }
|
||||
const resolved = { data: result.data, error: result.error ?? null }
|
||||
const chain: any = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit', 'update', 'delete']) {
|
||||
for (const method of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete', 'insert']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
|
||||
@@ -160,10 +187,105 @@ describe('AGI kvittenser cron', () => {
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.grantRevoked).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports signed and sends the notification when completeTaxDeadline throws', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
kvittenser: [
|
||||
{
|
||||
uuidKvittens: 'uuid-1',
|
||||
signeradAv: '191212121212',
|
||||
signeradTid: '2026-06-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)
|
||||
mockCompleteTaxDeadline.mockRejectedValueOnce(new Error('deadline table unavailable'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
// The filing succeeded before the best-effort step failed: the row must
|
||||
// still land as signed, never as error (the next run only revisits
|
||||
// pending_signature rows, so an error here would be lost permanently).
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
|
||||
// The notification still goes out even though deadline completion threw.
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledWith(expect.anything(), {
|
||||
companyId: 'comp-1',
|
||||
userId: 'user-1',
|
||||
kind: 'agi',
|
||||
period: expect.any(String),
|
||||
kvittensnummer: 'uuid-1',
|
||||
referenceId: 'decl-1',
|
||||
})
|
||||
|
||||
// The failure is a warning, not an error.
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('completeTaxDeadline failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports signed when sendKvittensNotification throws', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
kvittenser: [
|
||||
{
|
||||
uuidKvittens: 'uuid-1',
|
||||
signeradAv: '191212121212',
|
||||
signeradTid: '2026-06-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)
|
||||
mockSendKvittensNotification.mockRejectedValueOnce(new Error('smtp down'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('sendKvittensNotification failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('counts grant_revoked in the run summary', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('Ombud grant missing.', 'OMBUD_GRANT_MISSING'),
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(1)
|
||||
expect(body.grantRevoked).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.apigwConfig).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ status: 'grant_revoked', error: 'OMBUD_GRANT_MISSING' })
|
||||
expect(mockMarkGrantRevoked).toHaveBeenCalledWith('comp-1', expect.any(String), 'lasombud', 'OMBUD_GRANT_MISSING')
|
||||
|
||||
const summaryLine = logSpy.mock.calls.map(c => String(c[0])).find(m => m.includes('Processed'))
|
||||
expect(summaryLine).toContain('1 grants revoked')
|
||||
})
|
||||
|
||||
it('logs a warn (not error) and records apigw_config on ACCESS_DENIED', async () => {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
@@ -78,7 +82,7 @@ export async function GET(request: Request) {
|
||||
declarationId: string
|
||||
companyId: string
|
||||
period: string
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'apigw_config' | 'error'
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
@@ -103,26 +107,21 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// The token table is user-scoped (one BankID identity per user) but
|
||||
// also carries company_id. Match on company_id so a multi-company
|
||||
// operator's token is reused only for the company that owns the AGI.
|
||||
const { data: token } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!token?.user_id) {
|
||||
results.push({ declarationId, companyId, period, status: 'no_token' })
|
||||
continue
|
||||
}
|
||||
|
||||
// A connection flagged needs_reconsent cannot heal on its own (SKV's
|
||||
// per-flow refresh tokens live 65 minutes) — skip quietly instead of
|
||||
// failing the same pending declaration every run until the user
|
||||
// re-consents.
|
||||
if (token.status === 'needs_reconsent') {
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
// Auth resolution prefers system credentials (verified lasombud grant)
|
||||
// and falls back to the company's user token: kvittens polling is the
|
||||
// canonical case for the hybrid model, since the user signed at SKV
|
||||
// and their 65-minute session is usually long dead by the time the
|
||||
// kvittens exists.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'lasombud' })
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
// A connection flagged needs_reconsent cannot heal on its own
|
||||
// (SKV's per-flow refresh tokens live 65 minutes): skip quietly
|
||||
// instead of failing the same declaration every run.
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
} else {
|
||||
results.push({ declarationId, companyId, period, status: 'no_token' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -142,7 +141,7 @@ export async function GET(request: Request) {
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag',
|
||||
)
|
||||
|
||||
const kvittRes = await agiGetKvittenser(supabase, token.user_id as string, arbetsgivare, period)
|
||||
const kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
|
||||
if (!kvittRes.ok) {
|
||||
results.push({
|
||||
declarationId, companyId, period,
|
||||
@@ -186,7 +185,7 @@ export async function GET(request: Request) {
|
||||
status: 'submitted',
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
submitted_at: submittedAt,
|
||||
submitted_by: token.user_id,
|
||||
submitted_by: resolved.tokenUserId,
|
||||
response_data: {
|
||||
signeradAv: kvittens.signeradAv ?? null,
|
||||
signeradTid: kvittens.signeradTid ?? null,
|
||||
@@ -216,10 +215,62 @@ export async function GET(request: Request) {
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', `agi_submission_${period}`)
|
||||
|
||||
// The declaration is already flipped to submitted above, and the next
|
||||
// run only revisits pending_signature rows: from here on everything is
|
||||
// best-effort. Each step gets its own try/catch so a failure is logged
|
||||
// as a warning without masking the successful filing or skipping the
|
||||
// remaining confirmation steps.
|
||||
|
||||
// The kvittens is the canonical filing receipt: confirm the period's
|
||||
// arbetsgivardeklaration deadline (terminal state).
|
||||
try {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['arbetsgivardeklaration'],
|
||||
`${decl.period_year}-${String(decl.period_month).padStart(2, '0')}`,
|
||||
'confirmed'
|
||||
)
|
||||
} catch (deadlineErr) {
|
||||
console.warn('[agi-kvittenser-cron] completeTaxDeadline failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: deadlineErr instanceof Error ? deadlineErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
|
||||
// Tell the user: signing happened at Skatteverket, often long after
|
||||
// they closed our tab, so this is the only confirmation they get.
|
||||
if (resolved.tokenUserId) {
|
||||
try {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'agi',
|
||||
period,
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
referenceId: declarationId,
|
||||
})
|
||||
} catch (notifyErr) {
|
||||
console.warn('[agi-kvittenser-cron] sendKvittensNotification failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: notifyErr instanceof Error ? notifyErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ declarationId, companyId, period, status: 'signed' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// System-mode read rejected: the company withdrew the behorighet.
|
||||
// Downgrade the connection row so the next run falls back to the
|
||||
// user token (if any). Never touches skatteverket_tokens.
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
|
||||
results.push({ declarationId, companyId, period, status: 'grant_revoked', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
@@ -272,11 +323,12 @@ export async function GET(request: Request) {
|
||||
const signed = results.filter(r => r.status === 'signed').length
|
||||
const stillPending = results.filter(r => r.status === 'still_pending').length
|
||||
const expired = results.filter(r => r.status === 'expired_token').length
|
||||
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
|
||||
const apigwConfig = results.filter(r => r.status === 'apigw_config').length
|
||||
const errors = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -284,6 +336,7 @@ export async function GET(request: Request) {
|
||||
signed,
|
||||
stillPending,
|
||||
expired,
|
||||
grantRevoked,
|
||||
apigwConfig,
|
||||
errors,
|
||||
results,
|
||||
|
||||
@@ -7,9 +7,12 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
|
||||
import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketAuthError, type SkvAuth } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
|
||||
import { listVerifiedCompanies, markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { currentSkvEnvironment, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -19,16 +22,29 @@ export const maxDuration = 60
|
||||
* GET /api/extensions/skatteverket/skattekonto/sync/cron
|
||||
*
|
||||
* Daily skattekonto sync (cron 0 4 * * *: 04:00 UTC, 06:00 Swedish time).
|
||||
* Pulls saldo + transactions for every company that has a connected
|
||||
* Skatteverket token, and persists the results to skattekonto_transactions.
|
||||
* Pulls saldo + transactions and persists them to skattekonto_transactions.
|
||||
*
|
||||
* Work list is a union keyed by company:
|
||||
* 1. System-mode entries: companies with a verified lasombud grant, synced
|
||||
* on Accounted's own CCG credentials (no user token involved, so the
|
||||
* 65-minute personal-token lifetime stops mattering here). Active only
|
||||
* when SKATTEVERKET_SYSTEM_AUTH_MODE=on.
|
||||
* 2. User-mode entries: companies with an active personal token, exactly
|
||||
* the pre-hybrid behavior. Fallback during the transition and for
|
||||
* companies that never grant the behorighet.
|
||||
*
|
||||
* Shadow mode logs per user-mode company whether a verified grant exists,
|
||||
* without any behavior change: the rollout confidence signal.
|
||||
*
|
||||
* Skips a company if it was synced within the last hour (cooldown),
|
||||
* to keep manual + cron triggers from racing each other.
|
||||
*
|
||||
* Time budget: 50s (Vercel default 60s function timeout, 10s margin).
|
||||
*
|
||||
* Per-company errors are logged but do not abort the run: one expired
|
||||
* token shouldn't block 49 other working syncs.
|
||||
* Per-company errors are logged but do not abort the run. A system-token
|
||||
* minting failure (SYSTEM_AUTH_FAILED) short-circuits the remaining
|
||||
* system-mode entries (one config problem, not fifty) but user-mode entries
|
||||
* still run.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
@@ -48,12 +64,10 @@ export async function GET(request: Request) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Find all companies with a connected, believed-working token. The token
|
||||
// row is keyed by user_id but carries company_id (multi-tenant refactor).
|
||||
// Rows flagged needs_reconsent are excluded: SKV's per-flow refresh tokens
|
||||
// live 65 minutes, so a connection that failed with a terminal auth error
|
||||
// can never heal on its own — retrying it every night only produced a
|
||||
// per-company error log until the user re-consents (which resets status).
|
||||
// User-token entries. The token row is keyed by user_id but carries
|
||||
// company_id (multi-tenant refactor). Rows flagged needs_reconsent are
|
||||
// excluded: SKV's per-flow refresh tokens live 65 minutes, so a connection
|
||||
// that failed with a terminal auth error can never heal on its own.
|
||||
const { data: tokens, error: tokensError } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, company_id, expires_at, refresh_count')
|
||||
@@ -69,36 +83,72 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return NextResponse.json({ message: 'No connected tokens', processed: 0 })
|
||||
const systemAuthActive = getSystemAuthMode() === 'on' && isSystemAuthConfigured()
|
||||
const systemCompanies = systemAuthActive
|
||||
? await listVerifiedCompanies(currentSkvEnvironment(), 'lasombud')
|
||||
: []
|
||||
|
||||
type WorkItem = { companyId: string; userId: string; source: 'system' | 'user' }
|
||||
const tokenByCompany = new Map<string, string>()
|
||||
for (const token of tokens ?? []) {
|
||||
if (token.company_id) tokenByCompany.set(token.company_id as string, token.user_id as string)
|
||||
}
|
||||
|
||||
const work: WorkItem[] = []
|
||||
const systemCompanyIds = new Set<string>()
|
||||
for (const company of systemCompanies) {
|
||||
// ctx still needs a user identity (events, drift emails). Prefer the
|
||||
// token owner, fall back to whoever verified the grant.
|
||||
const userId = tokenByCompany.get(company.company_id) ?? company.created_by
|
||||
if (!userId) continue
|
||||
systemCompanyIds.add(company.company_id)
|
||||
work.push({ companyId: company.company_id, userId, source: 'system' })
|
||||
}
|
||||
for (const token of tokens ?? []) {
|
||||
const companyId = token.company_id as string | null
|
||||
if (!companyId) {
|
||||
console.warn('[skattekonto-sync-cron] token without company_id skipped', {
|
||||
userId: token.user_id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (systemCompanyIds.has(companyId)) continue
|
||||
work.push({ companyId, userId: token.user_id as string, source: 'user' })
|
||||
}
|
||||
|
||||
if (work.length === 0) {
|
||||
return NextResponse.json({ message: 'No connected companies', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 50_000
|
||||
const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
|
||||
const shadowMode = getSystemAuthMode() === 'shadow'
|
||||
|
||||
type Result = {
|
||||
userId: string
|
||||
companyId: string
|
||||
status: 'synced' | 'skipped_cooldown' | 'expired' | 'error'
|
||||
source: 'system' | 'user'
|
||||
status: 'synced' | 'skipped_cooldown' | 'expired' | 'grant_revoked' | 'system_auth_failed' | 'error'
|
||||
booked?: number
|
||||
upcoming?: number
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
// One config problem, not one per company: after the first minting
|
||||
// failure, skip the remaining system-mode entries this run.
|
||||
let systemAuthFailed = false
|
||||
|
||||
for (const token of tokens) {
|
||||
for (const item of work) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} tokens`)
|
||||
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} companies`)
|
||||
break
|
||||
}
|
||||
|
||||
const userId = token.user_id as string
|
||||
const companyId = token.company_id as string | null
|
||||
const { companyId, userId, source } = item
|
||||
|
||||
if (!companyId) {
|
||||
// Pre-multi-tenant tokens may lack company_id. Skip: cannot scope.
|
||||
results.push({ userId, companyId: '(missing)', status: 'error', error: 'No company_id on token' })
|
||||
if (source === 'system' && systemAuthFailed) {
|
||||
results.push({ userId, companyId, source, status: 'system_auth_failed', error: 'skipped after first failure' })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -121,13 +171,21 @@ export async function GET(request: Request) {
|
||||
if (lastSyncedAt) {
|
||||
const elapsed = Date.now() - new Date(lastSyncedAt).getTime()
|
||||
if (elapsed < SYNC_COOLDOWN_MS) {
|
||||
results.push({ userId, companyId, status: 'skipped_cooldown' })
|
||||
results.push({ userId, companyId, source, status: 'skipped_cooldown' })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (shadowMode) {
|
||||
// Rollout signal only: would this company have run on system auth?
|
||||
const grantReady = await hasVerifiedGrant(companyId, 'lasombud')
|
||||
console.info('[skattekonto-sync-cron] shadow: grant state', { companyId, grantReady })
|
||||
}
|
||||
|
||||
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
||||
const syncResult = await syncSkattekonto(ctx)
|
||||
const auth: SkvAuth =
|
||||
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId }
|
||||
const syncResult = await syncSkattekonto(ctx, auth)
|
||||
|
||||
// Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits
|
||||
// `skattekonto.drift_detected` when |drift| > tolerance and not throttled.
|
||||
@@ -145,6 +203,7 @@ export async function GET(request: Request) {
|
||||
results.push({
|
||||
userId,
|
||||
companyId,
|
||||
source,
|
||||
status: 'synced',
|
||||
booked: syncResult.booked,
|
||||
upcoming: syncResult.upcoming,
|
||||
@@ -152,22 +211,45 @@ export async function GET(request: Request) {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
// Terminal auth states are a known outcome: surface them distinctly
|
||||
// so ops can dashboard "X companies need to reconnect", persist the
|
||||
// health flag so this cron stops retrying the row, and let the UI
|
||||
// prompt for re-consent proactively.
|
||||
if (source === 'system' && err instanceof SkatteverketAuthError) {
|
||||
// System-mode failures never touch skatteverket_tokens.
|
||||
if (err.code === 'SYSTEM_AUTH_FAILED') {
|
||||
if (!systemAuthFailed) {
|
||||
systemAuthFailed = true
|
||||
console.warn(
|
||||
'[skattekonto-sync-cron] system token minting/authentication failed; skipping remaining system-mode entries this run',
|
||||
{ companyId, message },
|
||||
)
|
||||
}
|
||||
results.push({ userId, companyId, source, status: 'system_auth_failed', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// The company withdrew the behorighet: downgrade the connection
|
||||
// row so the next run falls back to the user token (if any). The
|
||||
// sync cron doubles as the ongoing grant liveness probe.
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
|
||||
results.push({ userId, companyId, source, status: 'grant_revoked', error: err.code })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal user-token auth states are a known outcome: surface them
|
||||
// distinctly, persist the health flag so this cron stops retrying the
|
||||
// row, and let the UI prompt for re-consent proactively.
|
||||
if (
|
||||
source === 'user' &&
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
results.push({ userId, companyId, source, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
// TOKEN_REVOKED auto-deletes the row inside skvRequest — treat it as
|
||||
// the same quiet "reconnect needed" outcome, not a runtime error.
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
if (source === 'user' && err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
results.push({ userId, companyId, source, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -175,20 +257,23 @@ export async function GET(request: Request) {
|
||||
console.error('[skattekonto-sync-cron] Sync failed', {
|
||||
userId,
|
||||
companyId,
|
||||
source,
|
||||
message,
|
||||
felkod,
|
||||
})
|
||||
results.push({ userId, companyId, status: 'error', error: message })
|
||||
results.push({ userId, companyId, source, status: 'error', error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const synced = results.filter(r => r.status === 'synced').length
|
||||
const skipped = results.filter(r => r.status === 'skipped_cooldown').length
|
||||
const expired = results.filter(r => r.status === 'expired').length
|
||||
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
|
||||
const systemAuthFailures = results.filter(r => r.status === 'system_auth_failed').length
|
||||
const errors = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${errors} errors`,
|
||||
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${grantRevoked} grant revoked, ${systemAuthFailures} system-auth failures, ${errors} errors`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -196,6 +281,8 @@ export async function GET(request: Request) {
|
||||
synced,
|
||||
skipped,
|
||||
expired,
|
||||
grantRevoked,
|
||||
systemAuthFailures,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/api-client', () => {
|
||||
class SkatteverketAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SkatteverketAuthError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
SkatteverketAuthError,
|
||||
skvRequest: vi.fn(),
|
||||
skvRequestWithAuth: vi.fn(),
|
||||
getSkatteverketEnvironment: vi.fn(() => 'test'),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({
|
||||
getConnection: vi.fn().mockResolvedValue(null),
|
||||
markGrantRevoked: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/token-store', () => ({
|
||||
RECONSENT_ERROR_CODES: [
|
||||
'SESSION_EXPIRED',
|
||||
'REFRESH_EXHAUSTED',
|
||||
'MISSING_SCOPE',
|
||||
'TOKEN_CORRUPTED',
|
||||
] as const,
|
||||
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/kvittens-notification', () => ({
|
||||
sendKvittensNotification: vi.fn().mockResolvedValue({ sent: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/deadlines/complete-tax-deadline', () => ({
|
||||
completeTaxDeadline: vi.fn().mockResolvedValue({ completed: 1 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { skvRequestWithAuth, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { markNeedsReconsent } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockSkvRequest = vi.mocked(skvRequestWithAuth)
|
||||
const mockSendKvittensNotification = vi.mocked(sendKvittensNotification)
|
||||
const mockCompleteTaxDeadline = vi.mocked(completeTaxDeadline)
|
||||
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
||||
const mockMarkGrantRevoked = vi.mocked(markGrantRevoked)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/skatteverket/vat/kvittenser/cron', {
|
||||
headers: { authorization: 'Bearer test-secret' },
|
||||
})
|
||||
}
|
||||
|
||||
const LOCKED_STATE = {
|
||||
status: 'draft_locked',
|
||||
redovisare: '165560000000',
|
||||
redovisningsperiod: '202606',
|
||||
periodType: 'monthly',
|
||||
year: 2026,
|
||||
period: 6,
|
||||
signeringsLank: 'https://skv.test/sign/abc',
|
||||
}
|
||||
|
||||
function makeSupabaseStub(
|
||||
tables: Record<string, { data: unknown; error?: unknown; updateError?: unknown }>,
|
||||
) {
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
const result = tables[table] ?? { data: null, error: null }
|
||||
const resolved = { data: result.data, error: result.error ?? null }
|
||||
const chain: any = {}
|
||||
let isUpdate = false
|
||||
for (const method of ['select', 'eq', 'in', 'like', 'order', 'limit', 'delete', 'insert']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.update = vi.fn(() => {
|
||||
isUpdate = true
|
||||
return chain
|
||||
})
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
|
||||
chain.single = vi.fn().mockResolvedValue(resolved)
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve(isUpdate && result.updateError ? { data: null, error: result.updateError } : resolved)
|
||||
return chain
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
function stubHappyTables(state: Record<string, unknown> = LOCKED_STATE) {
|
||||
return makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(state) }],
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('VAT kvittenser cron', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.SKATTEVERKET_ENABLED = 'true'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockMarkNeedsReconsent.mockResolvedValue(undefined)
|
||||
mockMarkGrantRevoked.mockResolvedValue(undefined)
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore()
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 401 when cron auth fails', async () => {
|
||||
mockVerifyCronSecret.mockReturnValueOnce(
|
||||
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) as any,
|
||||
)
|
||||
const res = await GET(makeRequest())
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the extension flag is off', async () => {
|
||||
process.env.SKATTEVERKET_ENABLED = 'false'
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks the filing signed, completes the moms deadline, and notifies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123', tidpunkt: '2026-07-01T10:00:00Z' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ companyId: 'comp-1', period: '202606', status: 'signed' })
|
||||
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'comp-1',
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
'2026-06',
|
||||
'confirmed',
|
||||
)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: 'comp-1',
|
||||
userId: 'user-1',
|
||||
kind: 'vat',
|
||||
kvittensnummer: 'KV-123',
|
||||
}),
|
||||
)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('quarterly picker params produce a YYYY-QN tax period', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
stubHappyTables({ ...LOCKED_STATE, periodType: 'quarterly', period: 2 }),
|
||||
)
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-456' }),
|
||||
} as any)
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledWith(
|
||||
expect.anything(), 'comp-1', ['moms_monthly', 'moms_quarterly'], '2026-Q2', 'confirmed',
|
||||
)
|
||||
})
|
||||
|
||||
it('legacy state without picker params still flips status but skips the deadline', async () => {
|
||||
const { periodType: _pt, year: _y, period: _p, ...legacyState } = LOCKED_STATE
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables(legacyState))
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-789' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records still_pending on 404 without touching state', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockResolvedValueOnce({ ok: false, status: 404 } as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.stillPending).toBe(1)
|
||||
expect(body.signed).toBe(0)
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips rows that are not draft_locked', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables({ ...LOCKED_STATE, status: 'draft_saved' }))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockSkvRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags reconsent codes as expired_token and marks the connection', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('Sessionen har gått ut.', 'SESSION_EXPIRED'),
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.expired).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
|
||||
})
|
||||
|
||||
it('records error for generic failures without aborting the run', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a failing signed-state update yields an error row and skips deadline + notification', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) }],
|
||||
updateError: { message: 'connection reset', code: '08006' },
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
}),
|
||||
)
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(0)
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
period: '202606',
|
||||
status: 'error',
|
||||
error: 'Failed to persist signed state: connection reset',
|
||||
})
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
function stubTwoCompanyTables() {
|
||||
return makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [
|
||||
{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) },
|
||||
{ company_id: 'comp-2', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) },
|
||||
],
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
})
|
||||
}
|
||||
|
||||
it('a throwing markGrantRevoked does not abort the remaining companies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubTwoCompanyTables())
|
||||
mockSkvRequest
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('Ombud saknas.', 'OMBUD_GRANT_MISSING'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123' }),
|
||||
} as any)
|
||||
mockMarkGrantRevoked.mockRejectedValueOnce(new Error('db outage'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
status: 'error',
|
||||
error: 'OMBUD_GRANT_MISSING',
|
||||
})
|
||||
expect(body.results[1]).toMatchObject({ companyId: 'comp-2', status: 'signed' })
|
||||
expect(mockMarkGrantRevoked).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a throwing markNeedsReconsent does not abort the remaining companies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubTwoCompanyTables())
|
||||
mockSkvRequest
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('Sessionen har gått ut.', 'SESSION_EXPIRED'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-456' }),
|
||||
} as any)
|
||||
mockMarkNeedsReconsent.mockRejectedValueOnce(new Error('network blip'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
status: 'expired_token',
|
||||
error: 'SESSION_EXPIRED',
|
||||
})
|
||||
expect(body.results[1]).toMatchObject({ companyId: 'comp-2', status: 'signed' })
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { skvRequestWithAuth, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { SkatteverketInlamnatResponse } from '@/extensions/general/skatteverket/types'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* GET /api/extensions/skatteverket/vat/kvittenser/cron
|
||||
*
|
||||
* VAT filing reconciliation, mirroring the AGI kvittens cron. A locked VAT
|
||||
* draft (`submission_{period}` extension_data row with status
|
||||
* 'draft_locked') means the user was handed a BankID signing link at
|
||||
* Skatteverket. If they sign and never come back to the panel, nothing on
|
||||
* our side records that the declaration was filed: the submission state
|
||||
* stays "awaiting signature", the moms deadline stays open, and the user
|
||||
* gets no confirmation. This cron polls /inlamnat for those periods and on
|
||||
* a hit flips the stored state to 'signed', completes the period's moms
|
||||
* deadline, and emails the filing confirmation.
|
||||
*
|
||||
* Per-row errors are logged and skipped: one expired token must not block
|
||||
* other companies' reconciliation.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) return authError
|
||||
|
||||
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
|
||||
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// AGI submission state uses the distinct `agi_submission_` prefix, so the
|
||||
// `submission_` filter below cannot match AGI rows.
|
||||
const { data: rows, error: rowsError } = await supabase
|
||||
.from('extension_data')
|
||||
.select('company_id, key, value')
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.like('key', 'submission\\_%')
|
||||
.order('updated_at', { ascending: true })
|
||||
.limit(200)
|
||||
|
||||
if (rowsError) {
|
||||
console.error('[vat-kvittenser-cron] Failed to fetch submission states', {
|
||||
message: rowsError.message,
|
||||
code: rowsError.code,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to fetch submission states' }, { status: 500 })
|
||||
}
|
||||
|
||||
interface SubmissionState {
|
||||
status?: string
|
||||
redovisare?: string
|
||||
redovisningsperiod?: string
|
||||
periodType?: VatPeriodType
|
||||
year?: number
|
||||
period?: number
|
||||
signeringsLank?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const locked = (rows ?? []).flatMap((row) => {
|
||||
try {
|
||||
const state: SubmissionState =
|
||||
typeof row.value === 'string' ? JSON.parse(row.value) : (row.value as SubmissionState)
|
||||
if (state?.status !== 'draft_locked' || !state.redovisare || !state.redovisningsperiod) {
|
||||
return []
|
||||
}
|
||||
return [{ companyId: row.company_id as string, key: row.key as string, state }]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
if (locked.length === 0) {
|
||||
return NextResponse.json({ message: 'No locked drafts', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 50_000
|
||||
|
||||
type Result = {
|
||||
companyId: string
|
||||
period: string
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'expired_token' | 'error'
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
|
||||
for (const item of locked) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log(`[vat-kvittenser-cron] Time budget reached after ${results.length} rows`)
|
||||
break
|
||||
}
|
||||
|
||||
const { companyId, key, state } = item
|
||||
const period = state.redovisningsperiod as string
|
||||
|
||||
if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Prefers system credentials (verified moms_ombud grant), falls back
|
||||
// to the company's user token: post-signing checks are exactly where
|
||||
// the 65-minute personal session is usually already dead.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'moms_ombud' })
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
results.push({ companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
} else {
|
||||
results.push({ companyId, period, status: 'no_token' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const response = await skvRequestWithAuth(
|
||||
resolved.auth,
|
||||
'GET',
|
||||
`/inlamnat/${state.redovisare}/${period}`
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
results.push({ companyId, period, status: 'still_pending' })
|
||||
continue
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
results.push({ companyId, period, status: 'error', error: `${response.status}: ${text}` })
|
||||
continue
|
||||
}
|
||||
|
||||
const inlamnat = (await response.json()) as SkatteverketInlamnatResponse
|
||||
|
||||
// Flip the stored state so the panel shows "inlämnad" instead of a
|
||||
// stale "awaiting signature" view. Keep the row (unlike AGI, the VAT
|
||||
// panel reads it for kvittens display on revisit).
|
||||
const { error: updateError } = await supabase
|
||||
.from('extension_data')
|
||||
.update({
|
||||
value: JSON.stringify({
|
||||
...state,
|
||||
status: 'signed',
|
||||
kvittensnummer: inlamnat.kvittensnummer ?? null,
|
||||
tidpunkt: inlamnat.tidpunkt ?? null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', key)
|
||||
|
||||
if (updateError) {
|
||||
// The row is still draft_locked, so the next run retries it. Skip
|
||||
// the deadline completion and the notification: doing them now
|
||||
// would double-fire when the retry succeeds.
|
||||
console.error('[vat-kvittenser-cron] Failed to persist signed state', {
|
||||
companyId,
|
||||
period,
|
||||
message: updateError.message,
|
||||
code: updateError.code,
|
||||
})
|
||||
results.push({
|
||||
companyId,
|
||||
period,
|
||||
status: 'error',
|
||||
error: `Failed to persist signed state: ${updateError.message}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Complete the period's moms deadline. Only possible when the state
|
||||
// carries the picker params (written by the one-click chain; states
|
||||
// persisted by the older step-by-step routes lack them).
|
||||
if (state.periodType && state.year && state.period) {
|
||||
const taxPeriod =
|
||||
state.periodType === 'monthly'
|
||||
? `${state.year}-${String(state.period).padStart(2, '0')}`
|
||||
: state.periodType === 'quarterly'
|
||||
? `${state.year}-Q${state.period}`
|
||||
: null
|
||||
if (taxPeriod) {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
taxPeriod,
|
||||
'confirmed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.tokenUserId) {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'vat',
|
||||
period,
|
||||
kvittensnummer: inlamnat.kvittensnummer ?? period,
|
||||
referenceId: `vat_${companyId}_${period}`,
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ companyId, period, status: 'signed' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// System-mode read rejected: downgrade the connection row so the
|
||||
// next run falls back to the user token (if any). Best-effort: a
|
||||
// failure here must not abort the remaining companies' rows.
|
||||
try {
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'moms_ombud', err.code)
|
||||
} catch (revokeErr) {
|
||||
console.warn('[vat-kvittenser-cron] Failed to mark grant revoked', {
|
||||
companyId,
|
||||
period,
|
||||
message: revokeErr instanceof Error ? revokeErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
results.push({ companyId, period, status: 'error', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
// Persist the health flag so the cron stops retrying this
|
||||
// connection. Best-effort: a failure here must not abort the
|
||||
// remaining companies' rows.
|
||||
try {
|
||||
const { data: tokenRow } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
}
|
||||
} catch (reconsentErr) {
|
||||
console.warn('[vat-kvittenser-cron] Failed to persist reconsent flag', {
|
||||
companyId,
|
||||
period,
|
||||
message: reconsentErr instanceof Error ? reconsentErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
results.push({ companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err instanceof SkatteverketAuthError) {
|
||||
results.push({ companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('[vat-kvittenser-cron] Reconciliation failed', { companyId, period, message })
|
||||
results.push({ companyId, period, status: 'error', error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const signed = results.filter((r) => r.status === 'signed').length
|
||||
const stillPending = results.filter((r) => r.status === 'still_pending').length
|
||||
const expired = results.filter((r) => r.status === 'expired_token').length
|
||||
const errors = results.filter((r) => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[vat-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${errors} errors`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: results.length,
|
||||
signed,
|
||||
stillPending,
|
||||
expired,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { hashAuthCode } from '@/lib/auth/oauth-codes'
|
||||
import {
|
||||
exchangeCodeForAccount,
|
||||
fetchAccountDisplayName,
|
||||
} from '@/extensions/general/stripe/lib/connect'
|
||||
|
||||
// This route emits stripe.connected (audit trail). ensureInitialized() must
|
||||
// run at module load so the event_log handler has subscribed before the first
|
||||
// emit on a cold instance.
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/stripe/callback
|
||||
*
|
||||
* OAuth callback for Stripe Connect authorization. Must be a real Next.js
|
||||
* route (not an extension dispatcher handler) because Stripe redirects the
|
||||
* user's browser to this URL directly.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const settingsUrl = `${baseUrl}/settings/payments`
|
||||
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
// access_denied is the user cancelling at Stripe: an expected outcome.
|
||||
const logDenied = error === 'access_denied' ? console.warn : console.error
|
||||
logDenied('[stripe] Connect authorization denied', {
|
||||
error,
|
||||
error_description: errorDescription,
|
||||
has_state: !!state,
|
||||
})
|
||||
|
||||
if (state) {
|
||||
try {
|
||||
const supabase = await createServiceClient()
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
} catch (cleanupError) {
|
||||
console.error('[stripe] Failed to clean up pending connection:', cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent(errorMessage)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(`${settingsUrl}?stripe_error=missing_parameters`)
|
||||
}
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
try {
|
||||
// Locate the connection awaiting this callback by oauth_state (CSRF-safe:
|
||||
// the token is a single-use random UUID written before the redirect and
|
||||
// cleared below).
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select('id, user_id, company_id')
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
.single()
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('[stripe] No pending connection for oauth_state', {
|
||||
findError: findError
|
||||
? { message: findError.message, code: findError.code }
|
||||
: null,
|
||||
hasCode: !!code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replay protection (OAuth 2.1 §4.1.2): a code may be exchanged once.
|
||||
// The PRIMARY KEY on oauth_used_codes rejects a second insert.
|
||||
const { error: replayError } = await supabase
|
||||
.from('oauth_used_codes')
|
||||
.insert({ code_hash: hashAuthCode(code) })
|
||||
if (replayError) {
|
||||
console.error('[stripe] Authorization code already used', {
|
||||
connectionId: pendingConnection.id,
|
||||
code: replayError.code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const { stripeAccountId, livemode } = await exchangeCodeForAccount(code)
|
||||
const displayName = await fetchAccountDisplayName(stripeAccountId)
|
||||
|
||||
const { data: updatedConnection, error: updateError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
stripe_account_id: stripeAccountId,
|
||||
livemode,
|
||||
display_name: displayName,
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
oauth_state: null, // Clear to prevent replay
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
.select('id, company_id, user_id, stripe_account_id, livemode')
|
||||
.single()
|
||||
|
||||
if (updateError || !updatedConnection) {
|
||||
// 23505 = one of the partial unique indexes: this Stripe account is
|
||||
// already actively connected (to this or another company), or the
|
||||
// company connected in a parallel tab. Both are user-facing conflicts.
|
||||
const isConflict = updateError?.code === '23505'
|
||||
console.error('[stripe] Failed to activate connection', {
|
||||
connectionId: pendingConnection.id,
|
||||
error: updateError
|
||||
? { message: updateError.message, code: updateError.code }
|
||||
: null,
|
||||
})
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: isConflict
|
||||
? 'Stripe-kontot är redan anslutet till ett företag.'
|
||||
: 'Anslutningen kunde inte slutföras.',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent(
|
||||
isConflict ? 'account_already_connected' : 'activation_failed',
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'stripe.connected',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
stripeAccountId: updatedConnection.stripe_account_id!,
|
||||
livemode: updatedConnection.livemode,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// Non-fatal: the DB state (source of truth) is already committed.
|
||||
console.error('[stripe] Failed to emit stripe.connected event', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${settingsUrl}?stripe_connected=true`)
|
||||
} catch (error) {
|
||||
console.error('[stripe] Callback error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
hasCode: !!code,
|
||||
})
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: 'Anslutningen kunde inte slutföras.',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
} catch (cleanupError) {
|
||||
console.error('[stripe] Callback cleanup failed:', cleanupError)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('connection_failed')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { syncStripeConnection } from '@/extensions/general/stripe/lib/sync'
|
||||
import type { StripeConnection } from '@/extensions/general/stripe/types'
|
||||
|
||||
// settleInvoicePayment emits invoice.paid; the Stripe extension's own
|
||||
// link-deactivation handler (and webhook fan-out) must be wired before the
|
||||
// first emit on a cold instance.
|
||||
ensureInitialized()
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/extensions/stripe/sync/cron
|
||||
* Polls each active Stripe connection's event stream every 15 minutes and
|
||||
* applies checkout payments to invoices (deterministic match, 1686 clearing).
|
||||
*
|
||||
* Processes up to 50 connections per run, oldest-synced first. Idempotent:
|
||||
* event claims are unique per (connection, event), so overlapping windows and
|
||||
* re-runs are no-ops.
|
||||
*/
|
||||
export const GET = withCronContext('cron.stripe_sync', async (_request, ctx) => {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'Missing Supabase configuration' },
|
||||
})
|
||||
}
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_CONNECT_CLIENT_ID) {
|
||||
return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.order('last_event_created_at', { ascending: true, nullsFirst: true })
|
||||
.limit(50)
|
||||
|
||||
if (connError) {
|
||||
ctx.log.error('failed to fetch stripe connections', connError, {
|
||||
message: connError.message,
|
||||
code: connError.code,
|
||||
})
|
||||
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
|
||||
// Absolute deadline shared with syncStripeConnection so a single
|
||||
// connection's event batch cannot blow the budget on its own: the sync
|
||||
// stops between events and persists its cursor up to what it processed.
|
||||
const deadlineMs = startTime + TIME_BUDGET_MS
|
||||
|
||||
const results: Array<{
|
||||
connectionId: string
|
||||
settled: number
|
||||
needsReview: number
|
||||
ignored: number
|
||||
status: 'synced' | 'revoked' | 'error'
|
||||
}> = []
|
||||
|
||||
for (const connection of connections as StripeConnection[]) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
break
|
||||
}
|
||||
|
||||
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.stripe_payments))) {
|
||||
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncStripeConnection(supabase, connection, ctx.log, deadlineMs)
|
||||
if (summary.deadlineReached) {
|
||||
ctx.log.info('connection stopped early on time budget; remaining events resume next run', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
}
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
settled: summary.settled,
|
||||
needsReview: summary.needsReview,
|
||||
ignored: summary.ignored,
|
||||
status: summary.revoked ? 'revoked' : 'synced',
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.log.error('stripe sync failed for connection', error as Error, {
|
||||
connectionId: connection.id,
|
||||
companyId: connection.company_id,
|
||||
})
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ error_message: 'Synkroniseringen misslyckades. Försöker igen automatiskt.' })
|
||||
.eq('id', connection.id)
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
settled: 0,
|
||||
needsReview: 0,
|
||||
ignored: 0,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totals = results.reduce(
|
||||
(acc, r) => ({
|
||||
settled: acc.settled + r.settled,
|
||||
needsReview: acc.needsReview + r.needsReview,
|
||||
}),
|
||||
{ settled: 0, needsReview: 0 },
|
||||
)
|
||||
ctx.log.info('stripe sync summary', {
|
||||
processed: results.length,
|
||||
totalSettled: totals.settled,
|
||||
totalNeedsReview: totals.needsReview,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ processed: results.length, ...totals, results })
|
||||
})
|
||||
@@ -172,7 +172,9 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
undefined, // paymentAmount: full settle
|
||||
undefined // settlementAccountNumber: default 1930
|
||||
)
|
||||
})
|
||||
|
||||
@@ -239,7 +241,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
'enskild_firma',
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
undefined // settlementAccountNumber: default 1930
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,6 +17,10 @@ ensureInitialized()
|
||||
*
|
||||
* Faktureringsmetoden (accrual): Debit 1930, Credit 1510 (clearing entry)
|
||||
* Kontantmetoden (cash): Debit 1930, Credit 30xx, Credit 26xx
|
||||
*
|
||||
* The booking + status transition live in settleInvoicePayment (shared with
|
||||
* the Stripe payment sync); this route owns request parsing, the payable
|
||||
* guard, and the duplicate-payment advisory.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.mark_paid',
|
||||
@@ -142,23 +137,10 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
// the current accounting_method setting. If the invoice was booked at
|
||||
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510:
|
||||
// otherwise the receivable orphans and 30xx + VAT double-count. Only
|
||||
// when there is no prior JE (pure kontantmetoden) do we recognise
|
||||
// revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Ledger math + overpayment guard via the shared planInvoicePayment helper:
|
||||
// the same single source of truth the match-invoice flow uses, so the three
|
||||
// mark-paid surfaces (this route, the v1 API, and the agent commit path)
|
||||
// cannot drift again. Runs BEFORE any journal entry is created so a doomed
|
||||
// overpayment never burns a voucher number. paymentAmount and the
|
||||
// duplicate-payment guard above operate in the booking currency (SEK for
|
||||
// custom lines); convert to invoice currency for the ledger comparison so a
|
||||
// foreign-currency invoice isn't falsely rejected as overpaid.
|
||||
// paymentAmount and the duplicate-payment guard above operate in the
|
||||
// booking currency (SEK for custom lines); convert to invoice currency for
|
||||
// the ledger comparison so a foreign-currency invoice isn't falsely
|
||||
// rejected as overpaid.
|
||||
const fxRate =
|
||||
invoice.currency && invoice.currency !== 'SEK' && invoice.exchange_rate
|
||||
? invoice.exchange_rate
|
||||
@@ -166,168 +148,49 @@ export const POST = withRouteContext(
|
||||
const paymentAmountInInvoiceCurrency = customLines
|
||||
? roundOre(paymentAmount / fxRate)
|
||||
: paymentAmount
|
||||
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
|
||||
if (!payment.ok) {
|
||||
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', opLog, {
|
||||
requestId,
|
||||
details: payment.details,
|
||||
})
|
||||
}
|
||||
const { newPaidAmount, newRemaining, newStatus } = payment.plan
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
const result = await settleInvoicePayment(supabase, companyId!, user.id, {
|
||||
invoice: invoice as Invoice & { customer?: { name?: string | null } | null },
|
||||
paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
accountingMethod,
|
||||
entityType,
|
||||
exchangeRateDifference,
|
||||
customLines,
|
||||
})
|
||||
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
|
||||
requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
|
||||
requestId,
|
||||
details: { paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = resolveInvoicePaymentSourceType({
|
||||
invoiceAlreadyBooked,
|
||||
accountingMethod,
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'BOOKKEEPING_ERROR':
|
||||
return errorResponse(result.error, opLog, { requestId })
|
||||
case 'UPDATE_FAILED':
|
||||
opLog.error('failed to update invoice status', result.error as Error)
|
||||
return errorResponse(result.error, opLog, { requestId })
|
||||
case 'INVOICE_PAID_BOOK_FAILED':
|
||||
opLog.error('failed to create payment journal entry', undefined, {
|
||||
details: result.details,
|
||||
})
|
||||
return errorResponseFromCode(result.code, opLog, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
})
|
||||
case 'INVOICE_PAID_RACE':
|
||||
return errorResponseFromCode(result.code, opLog, { requestId })
|
||||
default:
|
||||
return errorResponseFromCode(result.code, opLog, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
})
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
}
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
entityType, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
exchangeRateDifference, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, opLog, { requestId })
|
||||
}
|
||||
opLog.error('failed to create payment journal entry', err as Error)
|
||||
return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
|
||||
// Fail closed: a real invoice must produce a payment voucher. If a helper
|
||||
// returned null without throwing (e.g. a closed/locked fiscal period),
|
||||
// refuse to mark the invoice paid: flipping status with no journal entry
|
||||
// orphans the receivable and diverges the GL from the sub-ledger.
|
||||
if (!journalEntryId) {
|
||||
opLog.error('mark-paid produced no journal entry; refusing to mark paid', undefined, {
|
||||
invoiceId: id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'no_journal_entry_created' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CAS guard: only update if status is still in a payable state.
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
...(newStatus === 'paid' ? { paid_at: now } : {}),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
opLog.error('failed to update invoice status', updateError)
|
||||
// The payment voucher already posted but the invoice row did not flip to
|
||||
// paid; cancel the orphan so the GL doesn't diverge from the sub-ledger.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning',
|
||||
)
|
||||
}
|
||||
return errorResponse(updateError, opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
// Status changed between read and write (concurrent settle): cancel the
|
||||
// orphaned payment voucher and document the voucher gap before reporting.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
}
|
||||
return errorResponseFromCode('INVOICE_PAID_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Notify subscribers: invoice.paid fans out to registered webhooks
|
||||
// (lib/webhooks/handler.ts). Best-effort: the payment is already committed,
|
||||
// so an emit failure must not fail the request. Mirrors the v1 route.
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.paid',
|
||||
payload: {
|
||||
invoice: {
|
||||
...(invoice as Invoice),
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: newStatus === 'paid' ? now : (invoice as Invoice).paid_at,
|
||||
} as Invoice,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
paymentAmount: paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
opLog.error('invoice.paid emit failed', err as Error, { invoiceId: id })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: newStatus,
|
||||
paid_at: newStatus === 'paid' ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
status: result.newStatus,
|
||||
paid_at: result.paidAt,
|
||||
paid_amount: result.newPaidAmount,
|
||||
remaining_amount: result.newRemaining,
|
||||
journal_entry_id: result.journalEntryId,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
@@ -131,6 +132,18 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Auto-create an online payment link (extension-provided, e.g. Stripe) now
|
||||
// that the number exists, so the email button and PDF QR carry it. A
|
||||
// failure never blocks the send: the faktura is legally valid without a
|
||||
// link, so it degrades to a PARTIAL warning instead.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
|
||||
// Final render with the assigned number: this is the buffer attached to
|
||||
// the email and later archived as underlag. Override status to 'sent' on
|
||||
// the in-memory copy: the DB flip happens after email delivery (line
|
||||
@@ -206,6 +219,10 @@ export const POST = withRouteContext(
|
||||
// which sub-step broke.
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
{
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST as importPOST } from '../import/route'
|
||||
|
||||
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
|
||||
// Skatteverket official example personnummer (synthetic).
|
||||
const PNR = '193610058590'
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function makeBeslutFile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
version: '1',
|
||||
utforare: '168780003656',
|
||||
beslut: [
|
||||
{
|
||||
namn: 'ROT 2026-07-02',
|
||||
referensnummer: '20260000185-01',
|
||||
arenden: [{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 2000 }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
describe('POST /api/rot-rut/beslut/import', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on a malformed beslutsfil', async () => {
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', {
|
||||
method: 'POST',
|
||||
body: { version: '1', utforare: 'not-digits', beslut: [] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 ROT_RUT_BESLUT_WRONG_COMPANY when utforare mismatches', async () => {
|
||||
enqueue({ data: { org_number: '556123-4567' } }) // company_settings
|
||||
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ROT_RUT_BESLUT_WRONG_COMPANY')
|
||||
})
|
||||
|
||||
it('imports a matching beslut and returns per-beslut outcomes', async () => {
|
||||
enqueue({ data: { org_number: '878000-3656' } }) // company_settings
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: REQUEST_ID,
|
||||
name: 'ROT 2026-07-02',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
decided_at: null,
|
||||
skv_referensnummer: null,
|
||||
},
|
||||
],
|
||||
}) // requests
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
requested_amount: 3000,
|
||||
invoice: {
|
||||
invoice_number: '96458',
|
||||
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
|
||||
},
|
||||
},
|
||||
],
|
||||
}) // items
|
||||
enqueue({ data: null }) // item update
|
||||
enqueue({ data: null }) // request update
|
||||
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { imported: number; errors: number; results: Array<Record<string, unknown>> }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.imported).toBe(1)
|
||||
expect(body.data.errors).toBe(0)
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
request_id: REQUEST_ID,
|
||||
decided_total: 2000,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
|
||||
|
||||
/**
|
||||
* POST /api/rot-rut/beslut/import
|
||||
*
|
||||
* Import Skatteverkets beslutsfil (the decision JSON downloaded from the
|
||||
* rot/rut e-tjänst) and record godkänt belopp on the matching payout
|
||||
* requests: per-item decided_amount, request decided_total/decided_at, the
|
||||
* SKV referensnummer, and the rejected status for 0-kr avslag.
|
||||
*
|
||||
* The body is the beslutsfil content verbatim. Matching is exact-only and
|
||||
* each beslut applies all-or-nothing; unmatched beslut are reported per
|
||||
* entry in the response instead of failing the whole import. Booking the
|
||||
* actual utbetalning stays with POST /payout-requests/{id}/settle.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'rot_rut.beslut.import',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, RotRutBeslutFileSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await importRotRutBeslutFile(supabase, companyId!, validation.data)
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.code, log, { requestId })
|
||||
}
|
||||
|
||||
log.info('rot/rut beslutsfil imported', {
|
||||
imported: result.imported,
|
||||
alreadyImported: result.already_imported,
|
||||
errors: result.errors,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -133,11 +133,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}`
|
||||
|
||||
const xml = generatePain001(companyData, employees, {
|
||||
messageId,
|
||||
paymentDate: run.payment_date,
|
||||
periodLabel,
|
||||
})
|
||||
let xml: string
|
||||
try {
|
||||
xml = generatePain001(companyData, employees, {
|
||||
messageId,
|
||||
paymentDate: run.payment_date,
|
||||
periodLabel,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Kunde inte generera betalfil'
|
||||
return NextResponse.json({ error: msg }, { status: 400 })
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
|
||||
@@ -7,9 +7,10 @@ ensureInitialized()
|
||||
/**
|
||||
* Mark the AGI period's tax payment (skatt + avgifter) as paid.
|
||||
*
|
||||
* This is a manual confirmation by the user: bank reconciliation against
|
||||
* Skattekontot transactions can also flip this flag automatically (handled
|
||||
* elsewhere via the Skattekonto sync).
|
||||
* This is a manual confirmation by the user. The Skattekonto sync also flips
|
||||
* the flag automatically when the period's AGI debit row is booked with the
|
||||
* exact declared amount and the account is not in deficit (see
|
||||
* extensions/general/skatteverket/lib/agi-tax-settlement.ts).
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ period: string }> }>(
|
||||
'tax_payment.mark_paid',
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
* 500 INVOICE_SEND_PDF_RENDER_FAILED, no number burned.
|
||||
* 6. ensureInvoiceNumber allocates the F-series number atomically.
|
||||
* Fail → 500 INVOICE_SEND_NUMBER_ASSIGN_FAILED.
|
||||
* 6b. Auto-create an online payment link (extension-provided, e.g. Stripe)
|
||||
* and persist it on the invoice row. Best-effort: a provider or persist
|
||||
* failure never blocks the send; it surfaces as a PAYMENT_LINK_FAILED
|
||||
* warning on the response once the email is delivered.
|
||||
* 7. Final PDF render with the real number.
|
||||
* 8. Email send via Resend (the email extension). Fail → 502
|
||||
* INVOICE_SEND_PROVIDER_FAILED. The number IS consumed at this point;
|
||||
@@ -43,7 +47,8 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
@@ -357,6 +362,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
const finalInvoiceNumber =
|
||||
(numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number
|
||||
|
||||
// Step 6b: auto-create an online payment link (extension-provided, e.g.
|
||||
// Stripe) now that the number exists, so the email button and PDF QR
|
||||
// carry it. Failure degrades to a PAYMENT_LINK_FAILED warning: the
|
||||
// faktura is legally valid without a link. The helper mirrors the link
|
||||
// onto the in-memory row only after a successful persist so a link on
|
||||
// the PDF can always be matched back to the row.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
typed as Invoice,
|
||||
ctx.log,
|
||||
{
|
||||
invoiceNumber: finalInvoiceNumber,
|
||||
logPrefix: 'invoices.send: ',
|
||||
logContext: { invoiceId },
|
||||
},
|
||||
)
|
||||
|
||||
// Also override `status` to 'sent' on the in-memory copy. The actual DB
|
||||
// flip happens at step 9a (after email delivery), but if we render with
|
||||
// the stale 'draft' status the customer receives a PDF stamped
|
||||
@@ -371,6 +396,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
try {
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(settings)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
|
||||
pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
@@ -380,6 +406,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
originalInvoiceNumber,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
paymentLinkQrDataUrl,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -435,6 +462,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Email has been delivered. Subsequent failures surface as warnings.
|
||||
const warnings: { code: string; message: string }[] = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
warnings.push({ code: 'PAYMENT_LINK_FAILED', message: paymentLinkFailure })
|
||||
}
|
||||
|
||||
// Step 9a: status flip to 'sent'. The `.eq('status', 'draft')` is an
|
||||
// optimistic-lock guard against a concurrent state change between fetch
|
||||
// and write. PostgREST returns `{ error: null }` for 0-row updates, so
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
// Local mirror of the cloud-backup status shape: core must not import from
|
||||
// @/extensions/, so the fields we read are declared here.
|
||||
interface BackupStatus {
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning shown on the dashboard ONLY when the Google Drive backup is failing
|
||||
* (dead token or errored auto-sync). A backup that silently stops is worse
|
||||
* than none; this makes the failure visible where the user actually is.
|
||||
* Renders nothing when the extension is off, disconnected, or healthy.
|
||||
*/
|
||||
export default function BackupHealthBanner() {
|
||||
const t = useTranslations('extensions')
|
||||
const [status, setStatus] = useState<BackupStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return
|
||||
let cancelled = false
|
||||
fetch('/api/extensions/ext/cloud-backup/status')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => {
|
||||
if (!cancelled && body?.data) setStatus(body.data as BackupStatus)
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail silent: the dashboard must not degrade over a status probe.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!status?.connected) return null
|
||||
const failing =
|
||||
status.needs_reauth || status.schedule?.last_auto_sync_status === 'error'
|
||||
if (!failing) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{status.needs_reauth
|
||||
? t('ext_cloud_backup_banner_reauth')
|
||||
: t('ext_cloud_backup_banner_failing')}
|
||||
</p>
|
||||
<Link
|
||||
href="/import#cloud-backup"
|
||||
className="mt-1 inline-block text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{t('ext_cloud_backup_banner_action')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
||||
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
||||
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
||||
import {
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
@@ -121,6 +123,7 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
|
||||
return (
|
||||
<div className="stagger-enter space-y-8">
|
||||
<BackupHealthBanner />
|
||||
{/* Build-assistant hero: shown only until the company has a verified
|
||||
agent_profile, so existing/migrated users get a clear prompt instead
|
||||
of a full-screen onboarding takeover. Once the assistant is built the
|
||||
@@ -240,6 +243,16 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connect-Skatteverket nudge for existing companies. Gated on
|
||||
agentBuilt so it never stacks under the build-assistant hero:
|
||||
one CTA surface at a time. */}
|
||||
{agentBuilt && (
|
||||
<SkatteverketPromoCard
|
||||
companyId={companyId}
|
||||
connected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Att göra: the unified worklist. One section, every actionable item,
|
||||
same counts as the sidebar badges (lib/worklist). */}
|
||||
<AttGoraSection
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
Percent,
|
||||
Landmark,
|
||||
CalendarClock,
|
||||
CalendarRange,
|
||||
FileCheck,
|
||||
FileSpreadsheet,
|
||||
ScrollText,
|
||||
} from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
@@ -115,7 +118,10 @@ type NavLabelKey =
|
||||
| 'vat_declaration'
|
||||
| 'skattekonto'
|
||||
| 'deadlines'
|
||||
| 'periodiseringar'
|
||||
| 'year_end'
|
||||
| 'annual_report'
|
||||
| 'income_declaration'
|
||||
| 'help'
|
||||
| 'settings'
|
||||
|
||||
@@ -153,6 +159,9 @@ interface NavItem {
|
||||
// capability. Cosmetic only, the page and API gates are the real
|
||||
// enforcement; this just keeps the sidebar honest for non-payers.
|
||||
requiredCapability?: CapabilityKey
|
||||
// Statutory surfaces that only exist for one company form (INK2 vs
|
||||
// NE-bilaga, årsredovisning): hidden for the other entity type.
|
||||
entityOnly?: EntityType
|
||||
hidden?: boolean
|
||||
comingSoon?: boolean
|
||||
devBadge?: boolean
|
||||
@@ -166,7 +175,6 @@ const navItems: NavItem[] = [
|
||||
// Arbeta: everything the user produces. The bookkeeping funnel leads
|
||||
// (Bokföring · Underlag · Transaktioner · Granskning: kept as separate
|
||||
// rows until the unified workspace lands), then the transactional flows.
|
||||
// Löner: "Beta" badge while we validate the end-to-end salary + AGI flow.
|
||||
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
|
||||
// enskild firma that hires staff gets payroll. Owner self-payroll stays
|
||||
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
|
||||
@@ -176,7 +184,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
|
||||
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
|
||||
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true, betaBadge: true },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
||||
// Analys: read the numbers. KPI stays a separate row until the fused
|
||||
// Rapporter surface (nav_ia_redesign §F) is built.
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
|
||||
@@ -187,18 +195,26 @@ const navItems: NavItem[] = [
|
||||
{ href: '/customers', labelKey: 'customers', icon: Users, group: 'data' },
|
||||
{ href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' },
|
||||
{ href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' },
|
||||
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true, betaBadge: true },
|
||||
{ href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true },
|
||||
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'data' },
|
||||
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' },
|
||||
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true },
|
||||
{ href: '/import', labelKey: 'import', icon: Upload, group: 'data' },
|
||||
// Skatt & bokslut: everything submitted to the state. Rescues the
|
||||
// previously nav-orphaned /skattekonto and /deadlines, and promotes the
|
||||
// VAT declaration out of the report catalog.
|
||||
// VAT declaration out of the report catalog. The year-end chain
|
||||
// (periodiseringar → bokslut → årsredovisning → inkomstdeklaration) is
|
||||
// listed in workflow order; the last two are entity-gated because the
|
||||
// surfaces only exist for one company form (ÅR + INK2 for aktiebolag,
|
||||
// NE-bilaga for enskild firma).
|
||||
{ href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' },
|
||||
{ href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' },
|
||||
{ href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' },
|
||||
{ href: '/bookkeeping/periodiseringar', labelKey: 'periodiseringar', icon: CalendarRange, group: 'skatt' },
|
||||
{ href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' },
|
||||
{ href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', entityOnly: 'aktiebolag' },
|
||||
{ href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'aktiebolag' },
|
||||
{ href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', entityOnly: 'enskild_firma' },
|
||||
]
|
||||
|
||||
// Map known extension hrefs to nav translation keys so sidebar labels translate.
|
||||
@@ -306,14 +322,29 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
if (href === '/salary') {
|
||||
return pathname === '/salary' || pathname.startsWith('/salary/runs')
|
||||
}
|
||||
// Bokslut (/bookkeeping/year-end) and Moms (/reports/vat-declaration)
|
||||
// have their own rows under Skatt & bokslut: carve them out of their
|
||||
// parent routes so exactly one row lights up.
|
||||
// Routes with their own rows under Skatt & bokslut (Bokslut, Moms,
|
||||
// Periodiseringar, Årsredovisning, Inkomstdeklaration) are carved out
|
||||
// of their parent routes so exactly one row lights up.
|
||||
if (href === '/bookkeeping') {
|
||||
return pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end')
|
||||
return (
|
||||
pathname.startsWith('/bookkeeping') &&
|
||||
!pathname.startsWith('/bookkeeping/year-end') &&
|
||||
!pathname.startsWith('/bookkeeping/periodiseringar')
|
||||
)
|
||||
}
|
||||
if (href === '/bookkeeping/year-end') {
|
||||
return (
|
||||
pathname.startsWith('/bookkeeping/year-end') &&
|
||||
!pathname.startsWith('/bookkeeping/year-end/arsredovisning')
|
||||
)
|
||||
}
|
||||
if (href === '/reports') {
|
||||
return pathname.startsWith('/reports') && !pathname.startsWith('/reports/vat-declaration')
|
||||
return (
|
||||
pathname.startsWith('/reports') &&
|
||||
!pathname.startsWith('/reports/vat-declaration') &&
|
||||
!pathname.startsWith('/reports/ink2-declaration') &&
|
||||
!pathname.startsWith('/reports/ne-declaration')
|
||||
)
|
||||
}
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
@@ -427,6 +458,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// the active company holds the capability. The page + API gates enforce
|
||||
// the paywall; this keeps the sidebar from advertising a dead workspace.
|
||||
if (item.requiredCapability && !capabilities.includes(item.requiredCapability)) return false
|
||||
// Entity-gated statutory surfaces: INK2/ÅR for aktiebolag, NE for
|
||||
// enskild firma; the page for the other form doesn't exist.
|
||||
if (item.entityOnly && item.entityOnly !== entityType) return false
|
||||
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
|
||||
// floating AgentTrigger and avoids a nav entry that only bounces to the
|
||||
// home checklist (chat/layout redirects unverified users to /).
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ArrowRight, FileCheck, X } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
const dismissKey = (companyId: string) => `erp_skv_promo_dismissed:${companyId}`
|
||||
// storage events only fire in OTHER tabs; this custom event covers the
|
||||
// same-tab dismissal so useSyncExternalStore re-reads localStorage.
|
||||
const DISMISS_EVENT = 'erp-skv-promo-dismissed'
|
||||
|
||||
function subscribeToDismissal(onStoreChange: () => void) {
|
||||
window.addEventListener('storage', onStoreChange)
|
||||
window.addEventListener(DISMISS_EVENT, onStoreChange)
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStoreChange)
|
||||
window.removeEventListener(DISMISS_EVENT, onStoreChange)
|
||||
}
|
||||
}
|
||||
|
||||
interface SkatteverketPromoCardProps {
|
||||
companyId: string
|
||||
/** True when the active user already has a Skatteverket token. */
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible dashboard nudge for companies that have never connected
|
||||
* Skatteverket. New companies meet the connect step in NewUserChecklist;
|
||||
* this card is the equivalent surface for existing companies, which
|
||||
* otherwise only discover the integration deep inside the VAT/AGI flows.
|
||||
* Capability-less users never see it: the paywall upsell lives where the
|
||||
* intent is (SkatteverketPanel, AGIPanel), not on the dashboard.
|
||||
*/
|
||||
export function SkatteverketPromoCard({ companyId, connected }: SkatteverketPromoCardProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const extensionEnabled = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
const hasCapability = useCapability(CAPABILITY.skatteverket)
|
||||
|
||||
// Server snapshot says dismissed: the card appears only after hydration,
|
||||
// when localStorage is readable, so server and client never disagree.
|
||||
const dismissed = useSyncExternalStore(
|
||||
subscribeToDismissal,
|
||||
() => localStorage.getItem(dismissKey(companyId)) === 'true',
|
||||
() => true
|
||||
)
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
localStorage.setItem(dismissKey(companyId), 'true')
|
||||
window.dispatchEvent(new Event(DISMISS_EVENT))
|
||||
}, [companyId])
|
||||
|
||||
if (!extensionEnabled || !hasCapability || connected || dismissed) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center gap-4">
|
||||
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-secondary">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-display text-xl leading-tight">{t('skv_promo_title')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('skv_promo_description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild className="flex-shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page; the authorize endpoint 302s to Skatteverket, which the client router cannot follow */}
|
||||
<a href="/api/extensions/ext/skatteverket/authorize?return_to=/">
|
||||
{t('skv_promo_cta')}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-label={t('skv_promo_dismiss')}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -11,13 +11,13 @@ export default function CloudBackupWorkspace(_props: WorkspaceComponentProps) {
|
||||
<Cloud className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground">Molnsynkronisering</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">
|
||||
Koppla ditt Google Drive-konto under Säkerhetsbackup för att synka arkiv till din
|
||||
egen molnlagring.
|
||||
Koppla ditt Google Drive-konto under Importera/Exportera för att synka arkiv till
|
||||
din egen molnlagring.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/settings/backup">
|
||||
<Link href="/import#cloud-backup">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Gå till säkerhetsbackup
|
||||
Gå till säkerhetskopiering
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDi
|
||||
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
|
||||
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import {
|
||||
ROT_WORK_TYPES,
|
||||
@@ -120,6 +121,22 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§).
|
||||
// Self-billing is never available when editing an existing draft.
|
||||
const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice')
|
||||
// Active Stripe connection: drives the "auto payment link" toggle in the
|
||||
// payment link section. Absent extension or no connection → toggle hidden.
|
||||
const [stripeConnected, setStripeConnected] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!ENABLED_EXTENSION_IDS.has('stripe')) return
|
||||
let cancelled = false
|
||||
fetch('/api/extensions/ext/stripe/status')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data?.connection?.status === 'active') setStripeConnected(true)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const schema = useMemo(() => {
|
||||
const itemSchema = z.object({
|
||||
@@ -213,6 +230,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
},
|
||||
{ message: t('validation_payment_link_https') },
|
||||
),
|
||||
// Opt-out for the automatic Stripe payment link on send (only rendered
|
||||
// when the company has an active Stripe connection).
|
||||
payment_link_auto: z.boolean().optional(),
|
||||
// Self-billing received (mottagen självfaktura). Present in the form for
|
||||
// both modes; required only in self_billed mode: enforced in onSubmit.
|
||||
external_invoice_number: z.string().optional(),
|
||||
@@ -315,6 +335,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
our_reference: initial.our_reference ?? '',
|
||||
notes: initial.notes ?? '',
|
||||
payment_link_url: initial.payment_link_url ?? '',
|
||||
payment_link_auto: initial.payment_link_auto ?? true,
|
||||
external_invoice_number: '',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
@@ -348,6 +369,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice' as InvoiceDocumentType,
|
||||
payment_link_url: '',
|
||||
payment_link_auto: true,
|
||||
external_invoice_number: '',
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
@@ -2126,7 +2148,26 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
{errors.payment_link_url ? (
|
||||
<p className="text-sm text-destructive">{errors.payment_link_url.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('payment_link_hint')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}
|
||||
</p>
|
||||
)}
|
||||
{stripeConnected && !watch('payment_link_url')?.trim() && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Switch
|
||||
id="payment_link_auto"
|
||||
checked={watch('payment_link_auto') ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
setValue('payment_link_auto', v, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="payment_link_auto"
|
||||
className="text-sm font-normal text-muted-foreground"
|
||||
>
|
||||
{t('payment_link_auto_label')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Info,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
@@ -37,6 +36,7 @@ import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/f
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
|
||||
interface SkatteverketStatus {
|
||||
connected: boolean
|
||||
@@ -113,6 +113,9 @@ function isOrgNumberMissing(err: unknown): boolean {
|
||||
* so a slow SKV round-trip is never silent.
|
||||
*/
|
||||
const ACTION_IN_FLIGHT_LABELS: Record<string, string> = {
|
||||
validate: 'Validerar deklarationen...',
|
||||
draft: 'Sparar utkast...',
|
||||
lock: 'Låser utkastet...',
|
||||
fetchDraft: 'Hämtar utkast...',
|
||||
check: 'Kontrollerar inlämning...',
|
||||
fetchDecided: 'Hämtar beslut...',
|
||||
@@ -389,6 +392,84 @@ function SkatteverketPanelInner({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click filing: the server chains kontrollera -> utkast -> lås and
|
||||
* returns the signing link. Stage-aware failures come back with a `stage`
|
||||
* discriminator: `validation` stopped before anything was written at SKV,
|
||||
* `lock` with `draft_saved` means the draft survives in Eget utrymme and
|
||||
* only the lock step needs a retry (available under Fler åtgärder).
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (localBlocked) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text:
|
||||
'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' +
|
||||
'du skickar till Skatteverket.',
|
||||
})
|
||||
return
|
||||
}
|
||||
setActionLoading('submit')
|
||||
setNotice(null)
|
||||
setKontroller([])
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/declaration/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ periodType, year, period }),
|
||||
})
|
||||
const result = await res.json()
|
||||
|
||||
if (res.ok && result.data?.signeringsLank) {
|
||||
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
|
||||
setKontroller(controls)
|
||||
setSigneringslank(result.data.signeringsLank)
|
||||
setNotice({
|
||||
kind: 'success',
|
||||
text:
|
||||
'Deklarationen är kontrollerad, sparad och låst. Öppna signeringslänken ' +
|
||||
'för att signera med BankID.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stage) {
|
||||
const controls: KontrollResult[] = result.kontrollResultat?.resultat || []
|
||||
if (controls.length > 0) setKontroller(controls)
|
||||
if (result.stage === 'validation') {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text: result.error || 'Skatteverket hittade valideringsfel i deklarationen.',
|
||||
})
|
||||
} else if (result.stage === 'lock' && result.draft_saved) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text:
|
||||
'Utkastet är sparat hos Skatteverket men kunde inte låsas för signering. ' +
|
||||
'Försök igen med "Lås och signera" under Fler åtgärder.',
|
||||
})
|
||||
} else {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text: result.error || 'Kunde inte skicka deklarationen till Skatteverket',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!applyApiError(result)) {
|
||||
setNotice({
|
||||
kind: 'error',
|
||||
text: 'Kunde inte skicka deklarationen till Skatteverket',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setNotice({ kind: 'error', text: 'Kunde inte skicka deklarationen till Skatteverket' })
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlock = async () => {
|
||||
setActionLoading('unlock')
|
||||
setNotice(null)
|
||||
@@ -426,10 +507,12 @@ function SkatteverketPanelInner({
|
||||
setActionLoading('check')
|
||||
setNotice(null)
|
||||
try {
|
||||
// periodType/year/period let the server complete the period's moms
|
||||
// deadline when the filing is confirmed.
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent(
|
||||
await getRedovisare()
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}`
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${year}&period=${period}`
|
||||
)
|
||||
const result = await res.json()
|
||||
if (applyApiError(result)) {
|
||||
@@ -449,7 +532,7 @@ function SkatteverketPanelInner({
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}, [applyApiError, getRedovisare, getRedovisningsperiod])
|
||||
}, [applyApiError, getRedovisare, getRedovisningsperiod, periodType, year, period])
|
||||
|
||||
// While a signing link is outstanding, re-check submission status when the
|
||||
// user returns to this tab: signing happens on Skatteverket's site, so the
|
||||
@@ -534,7 +617,7 @@ function SkatteverketPanelInner({
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent(
|
||||
await getRedovisare()
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}`
|
||||
)}&redovisningsperiod=${getRedovisningsperiod()}&periodType=${periodType}&year=${year}&period=${period}`
|
||||
)
|
||||
const result = await res.json()
|
||||
if (applyApiError(result)) {
|
||||
@@ -691,6 +774,45 @@ function SkatteverketPanelInner({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
{/* The demoted step-by-step actions: the visible surface is
|
||||
the one-click "Skicka till Skatteverket" button; these
|
||||
remain for partial retries (e.g. lock-only after a lock
|
||||
failure) and for users who want to inspect each step. */}
|
||||
<DropdownMenuLabel>Steg för steg</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
onSelect={() => handleValidate()}
|
||||
>
|
||||
<div>
|
||||
<p>Validera</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kontrollera deklarationen hos Skatteverket utan att spara
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
onSelect={() => handleSaveDraft()}
|
||||
>
|
||||
<div>
|
||||
<p>Spara utkast</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Spara deklarationen som utkast i Eget utrymme
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!hasData || actionLoading !== null}
|
||||
onSelect={() => handleLock()}
|
||||
>
|
||||
<div>
|
||||
<p>Lås och signera</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lås det sparade utkastet och hämta signeringslänken
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Status hos Skatteverket</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
disabled={actionLoading !== null}
|
||||
@@ -889,61 +1011,46 @@ function SkatteverketPanelInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forward lifecycle: the only always-visible action row. */}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{/* Forward lifecycle: one primary action. The individual steps live
|
||||
in the overflow menu under "Steg för steg". */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleValidate}
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{actionLoading === 'validate' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileCheck className="h-4 w-4" />
|
||||
)}
|
||||
Validera
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!hasData || localBlocked || actionLoading !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{actionLoading === 'draft' ? (
|
||||
{actionLoading === 'submit' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Spara utkast
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleLock}
|
||||
disabled={!hasData || hasErrors || actionLoading !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{actionLoading === 'lock' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
Lås och signera
|
||||
Skicka till Skatteverket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deklarationen kontrolleras, sparas som utkast i{' '}
|
||||
<InfoTooltip
|
||||
variant="help"
|
||||
content="Ditt företags privata yta hos Skatteverket. Utkast som sparas där räknas inte som inlämnade förrän de har signerats med BankID."
|
||||
>
|
||||
<span>Eget utrymme</span>
|
||||
</InfoTooltip>{' '}
|
||||
och låses för signering med BankID hos Skatteverket. Inget lämnas in
|
||||
förrän du har signerat.
|
||||
</p>
|
||||
|
||||
{/* Visible disabled-state explanations: title attributes never show
|
||||
on disabled buttons. */}
|
||||
{localBlocked && (
|
||||
<p className="text-sm text-destructive">
|
||||
Åtgärda felen under Kontroll av underlaget högst upp på sidan innan du
|
||||
validerar eller skickar in.
|
||||
skickar in.
|
||||
</p>
|
||||
)}
|
||||
{hasErrors && !localBlocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Valideringsfelen ovan måste åtgärdas innan utkastet kan låsas.
|
||||
Valideringsfelen ovan måste åtgärdas innan deklarationen kan lämnas in.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
@@ -785,7 +787,11 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
BankID signing is even possible. */}
|
||||
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">{t('draft_locked_title')}</p>
|
||||
<p className="text-sm font-medium">
|
||||
<InfoTooltip variant="help" content={t('granskningsunderlag_gloss')}>
|
||||
{t('draft_locked_title')}
|
||||
</InfoTooltip>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t('draft_locked_description')}
|
||||
</p>
|
||||
@@ -920,11 +926,6 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
variant="outline"
|
||||
onClick={handleSubmit}
|
||||
disabled={actionLoading === 'submit' || !hasSkatteverket}
|
||||
title={
|
||||
!hasSkatteverket
|
||||
? t('submit_upgrade_title')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{actionLoading === 'submit' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
@@ -977,13 +978,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)}
|
||||
|
||||
{!readOnly && !isSigned && !hasSkatteverket && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('upgrade_hint_before')}{' '}
|
||||
<a href="/settings/billing" className="font-medium underline hover:no-underline">
|
||||
{t('upgrade_hint_link')}
|
||||
</a>{' '}
|
||||
{t('upgrade_hint_after')}
|
||||
</p>
|
||||
<UpgradeNote>{t('upgrade_note')}</UpgradeNote>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -28,6 +28,15 @@ type Status =
|
||||
}
|
||||
|
||||
export function SkatteverketConnectPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SkatteverketPersonalConnectionCard />
|
||||
<SkatteverketSystemConnectionCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkatteverketPersonalConnectionCard() {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
const { toast } = useToast()
|
||||
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
|
||||
@@ -250,6 +259,151 @@ export function SkatteverketConnectPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
type GrantStatus = 'unknown' | 'granted' | 'denied' | 'error'
|
||||
|
||||
interface SystemConnectionState {
|
||||
available: boolean
|
||||
mode?: string
|
||||
environment?: string
|
||||
ombud_org_number?: string | null
|
||||
grant_url?: string
|
||||
cert?: { notAfter: string; daysUntilExpiry: number; expiresSoon: boolean } | null
|
||||
connection?: {
|
||||
status: string
|
||||
lasombud_status: GrantStatus
|
||||
moms_ombud_status: GrantStatus
|
||||
verified_at: string | null
|
||||
last_probe_at: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The system (ombud + organization certificate) connection: the one-time
|
||||
* grant that lets background syncs run without a personal BankID session.
|
||||
* Renders nothing until SKATTEVERKET_SYSTEM_AUTH_MODE is switched on
|
||||
* server-side, so the whole section is invisible during Phase 1.
|
||||
*/
|
||||
function SkatteverketSystemConnectionCard() {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
const { toast } = useToast()
|
||||
const [state, setState] = useState<SystemConnectionState | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/system-connection')
|
||||
if (!res.ok) {
|
||||
setState({ available: false })
|
||||
return
|
||||
}
|
||||
setState((await res.json()) as SystemConnectionState)
|
||||
} catch {
|
||||
setState({ available: false })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadState()
|
||||
}, [])
|
||||
|
||||
async function verify() {
|
||||
setVerifying(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/skatteverket/system-connection/verify', {
|
||||
method: 'POST',
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (res.status === 429) {
|
||||
toast({ title: t('system_verify_rate_limited') })
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('system_verify_failed'),
|
||||
description: typeof body?.error === 'string' ? body.error : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
await loadState()
|
||||
} catch {
|
||||
toast({ title: t('system_verify_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!state?.available) return null
|
||||
|
||||
const grantBadge = (status: GrantStatus | undefined) => {
|
||||
switch (status) {
|
||||
case 'granted':
|
||||
return (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t('system_status_granted')}
|
||||
</Badge>
|
||||
)
|
||||
case 'denied':
|
||||
return <Badge variant="destructive">{t('system_status_denied')}</Badge>
|
||||
case 'error':
|
||||
return <Badge variant="warning">{t('system_status_error')}</Badge>
|
||||
default:
|
||||
return <Badge variant="outline">{t('system_status_unknown')}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('system_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('system_intro')}</p>
|
||||
|
||||
{state.ombud_org_number && (
|
||||
<div className="rounded-md border border-border bg-secondary/40 p-3 text-sm">
|
||||
<p className="text-muted-foreground">{t('system_org_label')}</p>
|
||||
<p className="font-mono font-medium tabular-nums">{state.ombud_org_number}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
||||
<span>{t('system_behorighet_lasombud')}</span>
|
||||
{grantBadge(state.connection?.lasombud_status)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border p-3">
|
||||
<span>{t('system_behorighet_moms')}</span>
|
||||
{grantBadge(state.connection?.moms_ombud_status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.cert?.expiresSoon && (
|
||||
<div className="flex gap-2 rounded-md border border-border bg-secondary/40 p-3 text-sm text-foreground">
|
||||
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{state.grant_url && (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={state.grant_url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('system_open_ombud')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={verify} disabled={verifying}>
|
||||
{verifying ? t('system_verifying') : t('system_verify')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
|
||||
const t = useTranslations('settings_skatteverket_connect')
|
||||
if (disabled) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const StripePanel = getSettingsPanel('stripe')
|
||||
|
||||
export function PaymentsSettingsContent() {
|
||||
const t = useTranslations('settings_payments')
|
||||
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{hasStripeExtension && StripePanel ? (
|
||||
<StripePanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={CreditCard}
|
||||
title={t('not_enabled_title')}
|
||||
description={t('not_enabled_description')}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t('go_to_extensions')}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { TaxSettingsContent } from './TaxSettingsContent'
|
||||
import { SalarySettingsContent } from './SalarySettingsContent'
|
||||
import { InvoicingSettingsContent } from './InvoicingSettingsContent'
|
||||
import { TemplatesSettingsContent } from './TemplatesSettingsContent'
|
||||
import { PaymentsSettingsContent } from './PaymentsSettingsContent'
|
||||
import { BankingSettingsContent } from './BankingSettingsContent'
|
||||
import { AssistantSettingsContent } from './AssistantSettingsContent'
|
||||
import { ApiSettingsContent } from './ApiSettingsContent'
|
||||
@@ -26,6 +27,7 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
|
||||
salary: SalarySettingsContent,
|
||||
invoicing: InvoicingSettingsContent,
|
||||
templates: TemplatesSettingsContent,
|
||||
payments: PaymentsSettingsContent,
|
||||
banking: BankingSettingsContent,
|
||||
assistant: AssistantSettingsContent,
|
||||
api: ApiSettingsContent,
|
||||
|
||||
@@ -41,6 +41,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
|
||||
const hasCompany = !!company
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
|
||||
|
||||
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
|
||||
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under
|
||||
@@ -56,6 +57,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
|
||||
// with staff. #782
|
||||
{ id: 'salary', href: '/settings/salary', label: t('salary'), group: 'accounting', show: hasCompany && (company?.entity_type === 'aktiebolag' || !!company?.pays_salaries) },
|
||||
{ id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany },
|
||||
{ id: 'payments', href: '/settings/payments', label: t('payments'), group: 'sales', show: hasCompany && !isSandbox && hasStripeExtension },
|
||||
{ id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany },
|
||||
{ id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ id: 'assistant', href: '/settings/assistant', label: t('assistant'), group: 'tools', show: hasCompany && identity.isVerified },
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction"]}
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe"]}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -207,7 +207,9 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
@@ -384,6 +386,9 @@ export const CreateInvoiceSchema = z.object({
|
||||
])
|
||||
.transform((v) => v || undefined)
|
||||
.optional(),
|
||||
// Per-invoice opt-out for the automatic Stripe payment link on send.
|
||||
// Omitted → true (create) / kept as sent by the form (edit).
|
||||
payment_link_auto: z.boolean().optional(),
|
||||
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
|
||||
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
|
||||
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
|
||||
@@ -484,6 +489,34 @@ export const RotRutSettleSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// The beslutsfil JSON downloaded from Skatteverkets rot/rut e-tjänst
|
||||
// (dev_docs/skatteverket/husavdrag/exempel_beslut.json + ht.raml).
|
||||
export const RotRutBeslutFileSchema = z.object({
|
||||
version: z.string(),
|
||||
// Utförarens orgnr, 12 digits with 16-prefix in SKV's file.
|
||||
utforare: z.string().regex(/^\d{10,12}$/),
|
||||
beslut: z
|
||||
.array(
|
||||
z.object({
|
||||
// NamnPaBegaran as submitted (1-16 chars); the primary match key
|
||||
// against rot_rut_payout_requests.name.
|
||||
namn: z.string().min(1),
|
||||
referensnummer: z.string().regex(/^\d{11}(-\d+)?$/),
|
||||
arenden: z
|
||||
.array(
|
||||
z.object({
|
||||
personnummer: z.string().regex(/^\d{12}$/),
|
||||
fakturanummer: z.string().max(20).optional(),
|
||||
// Whole kronor; 0 = avslag for the ärende.
|
||||
godkantBelopp: z.number().int().min(0),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Articles (artikelregister)
|
||||
// ============================================================
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
export const INVOICE_FULL_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
|
||||
export const INVOICE_ITEM_FULL_COLUMNS =
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
|
||||
@@ -445,6 +445,11 @@ export async function createInvoiceJournalEntry(
|
||||
*
|
||||
* Debit 1930 Företagskonto [total]
|
||||
* Credit 1510 Kundfordringar [total]
|
||||
*
|
||||
* `settlementAccountNumber` overrides the debit side for payments that land
|
||||
* somewhere other than the bank account: e.g. '1686' (Fordringar för
|
||||
* kontokort) when a Stripe payment settles into the PSP balance and only
|
||||
* reaches 1930 with the later payout.
|
||||
*/
|
||||
export async function createInvoicePaymentJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
@@ -454,7 +459,8 @@ export async function createInvoicePaymentJournalEntry(
|
||||
paymentDate: string,
|
||||
exchangeRateDifference?: number,
|
||||
customerName?: string,
|
||||
paymentAmount?: number
|
||||
paymentAmount?: number,
|
||||
settlementAccountNumber: string = '1930'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -487,9 +493,9 @@ export async function createInvoicePaymentJournalEntry(
|
||||
// For receivables: positive diff = gain (received more), negative = loss (received less)
|
||||
const actualSekReceived = bookedSekAmount + exchangeRateDifference
|
||||
|
||||
// Debit: Bank at actual SEK received
|
||||
// Debit: settlement account (bank by default) at actual SEK received
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: Math.round(actualSekReceived * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
@@ -525,7 +531,7 @@ export async function createInvoicePaymentJournalEntry(
|
||||
// Standard SEK payment or no exchange rate difference
|
||||
lines.push(
|
||||
{
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: Math.round(bookedSekAmount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
@@ -682,7 +688,8 @@ export async function createInvoiceCashEntry(
|
||||
invoice: Invoice,
|
||||
paymentDate: string,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
customerName?: string
|
||||
customerName?: string,
|
||||
settlementAccountNumber: string = '1930'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -748,7 +755,7 @@ export async function createInvoiceCashEntry(
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const bankAmount = Math.round((cashDebit - rotRut.totalSek) * 100) / 100
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
account_number: settlementAccountNumber,
|
||||
debit_amount: bankAmount,
|
||||
credit_amount: 0,
|
||||
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { completeTaxDeadline } from '../complete-tax-deadline'
|
||||
|
||||
/**
|
||||
* Chain mock that records every builder call and resolves to the given
|
||||
* result when awaited (the helper ends the chain with .select('id')).
|
||||
*/
|
||||
function makeSupabase(result: { data?: unknown; error?: unknown }) {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['update', 'eq', 'in', 'select']) {
|
||||
chain[method] = vi.fn((...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
|
||||
const from = vi.fn(() => chain)
|
||||
return { supabase: { from } as unknown as SupabaseClient, from, calls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('completeTaxDeadline', () => {
|
||||
it('completes matching open deadlines and returns the count', async () => {
|
||||
const { supabase, from, calls } = makeSupabase({ data: [{ id: 'd1' }, { id: 'd2' }] })
|
||||
|
||||
const result = await completeTaxDeadline(
|
||||
supabase, 'company-1', ['moms_monthly', 'moms_quarterly'], '2026-06', 'submitted',
|
||||
)
|
||||
|
||||
expect(result).toEqual({ completed: 2 })
|
||||
expect(from).toHaveBeenCalledWith('deadlines')
|
||||
|
||||
const updateCall = calls.find((c) => c.method === 'update')
|
||||
expect(updateCall?.args[0]).toMatchObject({
|
||||
is_completed: true,
|
||||
status: 'submitted',
|
||||
})
|
||||
expect((updateCall?.args[0] as Record<string, unknown>).completed_at).toBeTruthy()
|
||||
|
||||
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqArgs).toContainEqual(['tax_period', '2026-06'])
|
||||
expect(eqArgs).toContainEqual(['is_completed', false])
|
||||
|
||||
const inCall = calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['tax_deadline_type', ['moms_monthly', 'moms_quarterly']])
|
||||
})
|
||||
|
||||
it('quarterly period string matches the generator format', async () => {
|
||||
const { supabase, calls } = makeSupabase({ data: [{ id: 'd1' }] })
|
||||
|
||||
await completeTaxDeadline(supabase, 'company-1', ['moms_quarterly'], '2026-Q2', 'confirmed')
|
||||
|
||||
const eqArgs = calls.filter((c) => c.method === 'eq').map((c) => c.args)
|
||||
expect(eqArgs).toContainEqual(['tax_period', '2026-Q2'])
|
||||
const updateCall = calls.find((c) => c.method === 'update')
|
||||
expect(updateCall?.args[0]).toMatchObject({ status: 'confirmed' })
|
||||
})
|
||||
|
||||
it('is a no-op returning 0 when nothing matches', async () => {
|
||||
const { supabase } = makeSupabase({ data: [] })
|
||||
const result = await completeTaxDeadline(
|
||||
supabase, 'company-1', ['arbetsgivardeklaration'], '2026-01', 'submitted',
|
||||
)
|
||||
expect(result).toEqual({ completed: 0 })
|
||||
})
|
||||
|
||||
it('swallows DB errors: logs and returns 0, never throws', async () => {
|
||||
const { supabase } = makeSupabase({ error: { message: 'permission denied' } })
|
||||
await expect(
|
||||
completeTaxDeadline(supabase, 'company-1', ['moms_monthly'], '2026-06', 'submitted'),
|
||||
).resolves.toEqual({ completed: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('complete-tax-deadline')
|
||||
|
||||
/**
|
||||
* Mark a system-generated tax deadline as completed from an external signal
|
||||
* (a declaration was generated, filed, or decided).
|
||||
*
|
||||
* Deadline completion is a courtesy on top of the filing flow, never part of
|
||||
* it: this helper logs failures and returns instead of throwing, so a broken
|
||||
* deadline row can never block a submission to Skatteverket.
|
||||
*
|
||||
* `taxPeriod` must use the deadline generator's format
|
||||
* (lib/tax/deadline-generator.ts): `YYYY-MM` for monthly, `YYYY-QN` for
|
||||
* quarterly, `YYYY` for annual. Build it from the caller's own period params;
|
||||
* do not reverse-parse Skatteverket's redovisningsperiod strings.
|
||||
*
|
||||
* Pass every deadline type that can represent the filing (e.g. both
|
||||
* `moms_monthly` and `moms_quarterly` for a VAT filing): the company's
|
||||
* settings decide which one exists, and the `IN` filter makes the wrong one
|
||||
* a no-op.
|
||||
*/
|
||||
export async function completeTaxDeadline(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
taxDeadlineTypes: string[],
|
||||
taxPeriod: string,
|
||||
newStatus: 'submitted' | 'confirmed'
|
||||
): Promise<{ completed: number }> {
|
||||
const now = new Date().toISOString()
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_at: now,
|
||||
status: newStatus,
|
||||
status_changed_at: now,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.in('tax_deadline_type', taxDeadlineTypes)
|
||||
.eq('tax_period', taxPeriod)
|
||||
.eq('is_completed', false)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
log.warn('Failed to auto-complete tax deadline', {
|
||||
companyId,
|
||||
taxDeadlineTypes,
|
||||
taxPeriod,
|
||||
error: error.message,
|
||||
})
|
||||
return { completed: 0 }
|
||||
}
|
||||
|
||||
return { completed: data?.length ?? 0 }
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export const CAPABILITY = {
|
||||
migration: 'migration',
|
||||
/** Bolagsverket iXBRL årsredovisning filing. Reserved (extension not yet enabled). */
|
||||
bolagsverket: 'bolagsverket',
|
||||
/** Stripe Connect: auto payment links on invoices + payment/payout sync. */
|
||||
stripe_payments: 'stripe_payments',
|
||||
} as const
|
||||
|
||||
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
||||
@@ -52,6 +54,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
CAPABILITY.bank_sync,
|
||||
CAPABILITY.skatteverket,
|
||||
CAPABILITY.email_send,
|
||||
CAPABILITY.stripe_payments,
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -712,6 +712,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Filen kunde inte skapas.',
|
||||
message_en: 'The payout file could not be created.',
|
||||
},
|
||||
ROT_RUT_BESLUT_WRONG_COMPANY: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Beslutsfilens utförare matchar inte företagets organisationsnummer. Kontrollera att filen laddades ner för rätt företag.',
|
||||
message_en:
|
||||
"The decision file's utförare does not match the company's organisation number. Check that the file was downloaded for the right company.",
|
||||
},
|
||||
INVOICE_CREATE_INSERT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte sparas.',
|
||||
|
||||
@@ -73,6 +73,11 @@ export type CoreEvent =
|
||||
userId: string
|
||||
companyId: string
|
||||
} }
|
||||
// Stripe Connect lifecycle: connect/disconnect are outward-facing consent
|
||||
// transitions (a third party gains/loses access to payment data), so they
|
||||
// land in event_log for the audit trail, mirroring bank_connection.*.
|
||||
| { type: 'stripe.connected'; payload: { connectionId: string; stripeAccountId: string; livemode: boolean; userId: string; companyId: string } }
|
||||
| { type: 'stripe.disconnected'; payload: { connectionId: string; stripeAccountId: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import {
|
||||
maybeCreatePaymentLinkForInvoice,
|
||||
applyPaymentLinkToInvoice,
|
||||
} from '@/lib/extensions/payment-links'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
const supabase = {} as SupabaseClient
|
||||
const log = { warn: vi.fn() }
|
||||
|
||||
function eligibleInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
||||
return makeInvoice({
|
||||
payment_link_url: null,
|
||||
document_type: 'invoice',
|
||||
credited_invoice_id: null,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function registerProvider(create: (...args: unknown[]) => Promise<unknown>) {
|
||||
extensionRegistry.register({
|
||||
id: 'fake-psp',
|
||||
name: 'Fake PSP',
|
||||
version: '1.0.0',
|
||||
services: { createInvoicePaymentLink: create as (...args: unknown[]) => Promise<unknown> },
|
||||
})
|
||||
}
|
||||
|
||||
describe('maybeCreatePaymentLinkForInvoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
extensionRegistry.unregister('fake-psp')
|
||||
})
|
||||
|
||||
it('returns null when no provider extension is registered', async () => {
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a manually pasted link exists', { payment_link_url: 'https://buy.stripe.com/x' }],
|
||||
['the per-invoice toggle is off', { payment_link_auto: false }],
|
||||
['the document is a proforma', { document_type: 'proforma' as const }],
|
||||
['the document is a credit note', { credited_invoice_id: 'orig-1' }],
|
||||
])('skips the provider when %s', async (_label, overrides) => {
|
||||
const create = vi.fn()
|
||||
registerProvider(create)
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(overrides as Partial<Invoice>), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the provider link on success', async () => {
|
||||
const create = vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
registerProvider(create)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', invoice)
|
||||
})
|
||||
|
||||
it('treats a provider null (not eligible) as no link', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue(null))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('never throws: a provider failure becomes { ok: false, reason }', async () => {
|
||||
registerProvider(vi.fn().mockRejectedValue(new Error('stripe down')))
|
||||
const result = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase, 'company-1', 'user-1', eligibleInvoice(), log,
|
||||
)
|
||||
expect(result).toEqual({ ok: false, reason: 'stripe down' })
|
||||
expect(log.warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPaymentLinkToInvoice', () => {
|
||||
function mockPersist(error: { message: string } | null) {
|
||||
const eqCompany = vi.fn().mockResolvedValue({ error })
|
||||
const eqId = vi.fn().mockReturnValue({ eq: eqCompany })
|
||||
const update = vi.fn().mockReturnValue({ eq: eqId })
|
||||
const from = vi.fn().mockReturnValue({ update })
|
||||
return { client: { from } as unknown as SupabaseClient, from, update, eqId, eqCompany }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
extensionRegistry.unregister('fake-psp')
|
||||
})
|
||||
|
||||
it('is a no-op when no provider is registered', async () => {
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: null })
|
||||
expect(persist.from).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the link, then mirrors it onto the invoice object', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
}))
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: null })
|
||||
expect(persist.update).toHaveBeenCalledWith({
|
||||
payment_link_url: 'https://buy.stripe.com/test_abc',
|
||||
stripe_payment_link_id: 'plink_1',
|
||||
})
|
||||
expect(persist.eqId).toHaveBeenCalledWith('id', invoice.id)
|
||||
expect(persist.eqCompany).toHaveBeenCalledWith('company_id', 'company-1')
|
||||
expect(invoice.payment_link_url).toBe('https://buy.stripe.com/test_abc')
|
||||
expect(invoice.stripe_payment_link_id).toBe('plink_1')
|
||||
})
|
||||
|
||||
it('persist failure returns { failure } and does NOT mutate the invoice', async () => {
|
||||
registerProvider(vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
}))
|
||||
const persist = mockPersist({ message: 'row is locked' })
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
{ logPrefix: 'invoices.send: ', logContext: { invoiceId: invoice.id } },
|
||||
)
|
||||
expect(result).toEqual({ failure: 'row is locked' })
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
expect(invoice.stripe_payment_link_id).toBeUndefined()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'invoices.send: payment link created but not persisted; sending without it',
|
||||
{ invoiceId: invoice.id, err: { message: 'row is locked' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('provider failure returns { failure: reason } without touching the DB', async () => {
|
||||
registerProvider(vi.fn().mockRejectedValue(new Error('stripe down')))
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice()
|
||||
const result = await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
)
|
||||
expect(result).toEqual({ failure: 'stripe down' })
|
||||
expect(persist.from).not.toHaveBeenCalled()
|
||||
expect(invoice.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the invoiceNumber override to the provider without mutating the invoice', async () => {
|
||||
const create = vi.fn().mockResolvedValue({
|
||||
url: 'https://buy.stripe.com/test_abc',
|
||||
externalId: 'plink_1',
|
||||
})
|
||||
registerProvider(create)
|
||||
const persist = mockPersist(null)
|
||||
const invoice = eligibleInvoice({ invoice_number: null })
|
||||
await applyPaymentLinkToInvoice(
|
||||
persist.client, 'company-1', 'user-1', invoice, log,
|
||||
{ invoiceNumber: 'F-2026-0042' },
|
||||
)
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
persist.client,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ invoice_number: 'F-2026-0042' }),
|
||||
)
|
||||
expect(invoice.invoice_number).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 13 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(13)
|
||||
it('should have 14 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(14)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(13)
|
||||
expect(extensions.length).toBe(14)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
+1
@@ -10,4 +10,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'skatteverket',
|
||||
'invoice-inbox',
|
||||
'document-extraction',
|
||||
'stripe',
|
||||
])
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
||||
import { skatteverketExtension } from '@/extensions/general/skatteverket'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { documentExtractionExtension } from '@/extensions/general/document-extraction'
|
||||
import { stripeExtension } from '@/extensions/general/stripe'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
@@ -20,4 +21,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
skatteverketExtension,
|
||||
invoiceInboxExtension,
|
||||
documentExtractionExtension,
|
||||
stripeExtension,
|
||||
]
|
||||
|
||||
+12
@@ -119,5 +119,17 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"invoice_inbox_items"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "stripe",
|
||||
"name": "Stripe-betalningar",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "CreditCard",
|
||||
"dataPattern": "manual",
|
||||
"description": "Betalningslänkar på fakturor och automatisk avprickning via Stripe",
|
||||
"longDescription": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura. Betalningar prickas av mot rätt faktura och Stripe-utbetalningar bokförs med avgifter och moms.",
|
||||
"hasOwnData": true,
|
||||
"subscriptionNotice": "Denna integration kräver ett eget Stripe-konto. Stripes transaktionsavgifter tillkommer enligt ditt avtal med Stripe."
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* Core-side bridge to an extension-provided invoice payment link service.
|
||||
*
|
||||
* The send routes call this to auto-fill `invoices.payment_link_url` before
|
||||
* the email/PDF render. Core must never import extension code, so the
|
||||
* provider is resolved through the registry: an extension that exposes
|
||||
* `services.createInvoicePaymentLink` (today: the Stripe extension) becomes
|
||||
* the provider. With zero extensions enabled this is a no-op, keeping the
|
||||
* extension-free core build and its tests untouched.
|
||||
*
|
||||
* The provider itself decides eligibility that only it can know (active
|
||||
* connection, capability, sandbox, live/test mode) and returns null when it
|
||||
* declines. This bridge handles the invoice-shaped eligibility that is true
|
||||
* for ANY provider, and never throws: a payment link is a convenience, an
|
||||
* invoice send must not fail because a PSP is down.
|
||||
*/
|
||||
|
||||
export const CREATE_INVOICE_PAYMENT_LINK_SERVICE = 'createInvoicePaymentLink'
|
||||
|
||||
export interface ProvidedPaymentLink {
|
||||
url: string
|
||||
externalId: string
|
||||
}
|
||||
|
||||
export type PaymentLinkOutcome =
|
||||
| { ok: true; url: string; externalId: string }
|
||||
| { ok: false; reason: string }
|
||||
| null
|
||||
|
||||
interface MinimalLog {
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
export interface ApplyPaymentLinkOptions {
|
||||
/**
|
||||
* Invoice number to use for link creation when the caller holds a fresher
|
||||
* value than the invoice object (the v1 route re-reads the number after
|
||||
* atomic allocation). The invoice object itself is not touched.
|
||||
*/
|
||||
invoiceNumber?: string | null
|
||||
/** Route-specific prefix for the persist-failure warning message. */
|
||||
logPrefix?: string
|
||||
/** Extra structured context attached to the persist-failure warning. */
|
||||
logContext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function maybeCreatePaymentLinkForInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
log?: MinimalLog,
|
||||
): Promise<PaymentLinkOutcome> {
|
||||
// Provider-agnostic eligibility. A manually pasted link always wins; only
|
||||
// real, non-credit invoices carry a payment request; the per-invoice
|
||||
// toggle opts out entirely.
|
||||
if (invoice.payment_link_url) return null
|
||||
if (invoice.payment_link_auto === false) return null
|
||||
if (invoice.document_type && invoice.document_type !== 'invoice') return null
|
||||
if (invoice.credited_invoice_id) return null
|
||||
|
||||
const provider = extensionRegistry
|
||||
.getAll()
|
||||
.find((ext) => typeof ext.services?.[CREATE_INVOICE_PAYMENT_LINK_SERVICE] === 'function')
|
||||
if (!provider) return null
|
||||
|
||||
try {
|
||||
const result = (await provider.services) as ProvidedPaymentLink | null
|
||||
if (result && typeof result.url === 'string' && typeof result.externalId === 'string') {
|
||||
return { ok: true, url: result.url, externalId: result.externalId }
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
log?.warn('payment link provider failed; sending without a link', {
|
||||
provider: provider.id,
|
||||
invoiceId: invoice.id,
|
||||
reason,
|
||||
})
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full send-route flow: create a payment link (if a provider extension is
|
||||
* enabled), persist it on the invoices row, and only then mirror it onto the
|
||||
* in-memory invoice object so the email button and PDF QR carry it.
|
||||
*
|
||||
* Shared by the dashboard and v1 send routes. Never throws and never blocks
|
||||
* the send: any failure (provider error or persist error) is returned as
|
||||
* `{ failure }` for the caller to surface as a non-blocking warning. A link
|
||||
* that was created but could not be persisted is deliberately NOT copied onto
|
||||
* the invoice: a link rendered on the PDF but missing from the DB could never
|
||||
* be matched back to this invoice when the payment event arrives.
|
||||
*/
|
||||
export async function applyPaymentLinkToInvoice(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
log: MinimalLog,
|
||||
opts: ApplyPaymentLinkOptions = {},
|
||||
): Promise<{ failure: string | null }> {
|
||||
const linkInvoice =
|
||||
opts.invoiceNumber !== undefined
|
||||
? { ...invoice, invoice_number: opts.invoiceNumber }
|
||||
: invoice
|
||||
|
||||
const outcome = await maybeCreatePaymentLinkForInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
linkInvoice,
|
||||
log,
|
||||
)
|
||||
if (!outcome) return { failure: null }
|
||||
if (!outcome.ok) return { failure: outcome.reason }
|
||||
|
||||
const { error: persistError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
payment_link_url: outcome.url,
|
||||
stripe_payment_link_id: outcome.externalId,
|
||||
})
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId)
|
||||
if (persistError) {
|
||||
log.warn(
|
||||
`${opts.logPrefix ?? ''}payment link created but not persisted; sending without it`,
|
||||
opts.logContext ? { ...opts.logContext, err: persistError } : persistError,
|
||||
)
|
||||
return { failure: persistError.message }
|
||||
}
|
||||
|
||||
invoice.payment_link_url = outcome.url
|
||||
invoice.stripe_payment_link_id = outcome.externalId
|
||||
return { failure: null }
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
|
||||
'cloud-backup': dynamic(
|
||||
() => import('@/extensions/general/cloud-backup/components/CloudBackupCard')
|
||||
),
|
||||
stripe: dynamic(
|
||||
() => import('@/extensions/general/stripe/components/StripeSettingsPanel')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user