From 98d0c7f2d090551581e10bf2e2361f68c762490c Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Sun, 12 Jul 2026 19:14:12 +0200
Subject: [PATCH] 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
---------
Co-authored-by: Claude Fable 5
---
DECISIONS.md | 23 +
app/(dashboard)/salary/page.tsx | 72 ++-
app/(dashboard)/settings/backup/page.tsx | 19 +-
app/(dashboard)/settings/layout.tsx | 1 +
app/(dashboard)/settings/payments/page.tsx | 5 +
.../auto-sync/cron/__tests__/route.test.ts | 371 ++++++++------
.../cloud-backup/auto-sync/cron/route.ts | 140 +++++-
.../kvittenser/cron/__tests__/route.test.ts | 126 ++++-
.../skatteverket/agi/kvittenser/cron/route.ts | 101 +++-
.../skattekonto/sync/cron/route.ts | 151 ++++--
.../kvittenser/cron/__tests__/route.test.ts | 378 ++++++++++++++
.../skatteverket/vat/kvittenser/cron/route.ts | 297 +++++++++++
app/api/extensions/stripe/callback/route.ts | 197 ++++++++
app/api/extensions/stripe/sync/cron/route.ts | 136 ++++++
.../[id]/mark-paid/__tests__/route.test.ts | 7 +-
app/api/invoices/[id]/mark-paid/route.ts | 227 ++-------
app/api/invoices/[id]/send/route.ts | 17 +
.../rot-rut/beslut/__tests__/route.test.ts | 129 +++++
app/api/rot-rut/beslut/import/route.ts | 44 ++
.../salary/runs/[id]/payment/pain001/route.ts | 16 +-
.../tax-payments/[period]/mark-paid/route.ts | 7 +-
.../[companyId]/invoices/[id]/send/route.ts | 33 +-
components/dashboard/BackupHealthBanner.tsx | 66 +++
components/dashboard/DashboardContent.tsx | 13 +
components/dashboard/DashboardNav.tsx | 52 +-
.../dashboard/SkatteverketPromoCard.tsx | 91 ++++
.../general/CloudBackupWorkspace.tsx | 8 +-
components/invoices/InvoiceEditor.tsx | 43 +-
components/reports/SkatteverketPanel.tsx | 185 +++++--
components/salary/AGIPanel.tsx | 21 +-
.../settings/SkatteverketConnectPanel.tsx | 154 ++++++
.../sections/PaymentsSettingsContent.tsx | 42 ++
components/settings/sections/index.ts | 2 +
components/settings/useSettingsNavItems.ts | 2 +
extensions.config.json | 2 +-
.../__tests__/schedule-route.test.ts | 104 ++++
.../components/CloudBackupCard.tsx | 357 +++++++++-----
extensions/general/cloud-backup/index.ts | 82 +++-
.../lib/__tests__/backup-alert.test.ts | 192 ++++++++
.../lib/__tests__/google-drive.test.ts | 232 ++++++++-
.../lib/__tests__/schedule.test.ts | 135 +++++
.../cloud-backup/lib/__tests__/sync.test.ts | 460 +++++++++++++++---
.../general/cloud-backup/lib/backup-alert.ts | 152 ++++++
.../general/cloud-backup/lib/google-drive.ts | 247 ++++++++--
.../general/cloud-backup/lib/schedule.ts | 83 ++++
extensions/general/cloud-backup/lib/sync.ts | 438 +++++++++++++++--
extensions/general/cloud-backup/types.ts | 58 ++-
extensions/general/mcp-server/server.ts | 80 ++-
.../__tests__/agi-tax-settlement.test.ts | 277 +++++++++++
.../skatteverket/__tests__/api-client.test.ts | 77 ++-
.../__tests__/capability-gate.test.ts | 1 +
.../__tests__/grant-probe.test.ts | 117 +++++
.../__tests__/kvittens-notification.test.ts | 208 ++++++++
.../__tests__/resolve-auth.test.ts | 147 ++++++
.../__tests__/system-auth-transport.test.ts | 110 +++++
.../__tests__/system-auth.test.ts | 155 ++++++
.../skatteverket/__tests__/vat-submit.test.ts | 166 +++++++
extensions/general/skatteverket/index.ts | 381 ++++++++++++---
.../general/skatteverket/lib/agi-client.ts | 14 +-
.../skatteverket/lib/agi-tax-settlement.ts | 131 +++++
.../general/skatteverket/lib/api-client.ts | 108 +++-
.../skatteverket/lib/connection-store.ts | 235 +++++++++
.../general/skatteverket/lib/error-map.ts | 6 +
.../general/skatteverket/lib/grant-probe.ts | 140 ++++++
.../skatteverket/lib/kvittens-notification.ts | 206 ++++++++
.../general/skatteverket/lib/resolve-auth.ts | 109 +++++
.../skatteverket/lib/skattekonto-client.ts | 25 +-
.../skatteverket/lib/skattekonto-sync.ts | 27 +-
.../skatteverket/lib/system-auth/config.ts | 133 +++++
.../lib/system-auth/token-provider.ts | 76 +++
.../skatteverket/lib/system-auth/transport.ts | 193 ++++++++
.../general/skatteverket/lib/vat-submit.ts | 169 +++++++
extensions/general/skatteverket/manifest.json | 12 +-
.../stripe/__tests__/connect-routes.test.ts | 270 ++++++++++
.../stripe/__tests__/payment-links.test.ts | 260 ++++++++++
.../general/stripe/__tests__/payouts.test.ts | 249 ++++++++++
.../general/stripe/__tests__/sync.test.ts | 329 +++++++++++++
.../stripe/components/StripeSettingsPanel.tsx | 346 +++++++++++++
extensions/general/stripe/index.ts | 406 ++++++++++++++++
extensions/general/stripe/lib/connect.ts | 107 ++++
.../general/stripe/lib/payment-links.ts | 198 ++++++++
extensions/general/stripe/lib/payouts.ts | 250 ++++++++++
extensions/general/stripe/lib/sync.ts | 432 ++++++++++++++++
extensions/general/stripe/manifest.json | 20 +
extensions/general/stripe/types.ts | 46 ++
lib/api/schemas.ts | 33 ++
lib/api/v1/invoice-columns.ts | 2 +-
lib/bookkeeping/invoice-entries.ts | 19 +-
.../__tests__/complete-tax-deadline.test.ts | 81 +++
lib/deadlines/complete-tax-deadline.ts | 57 +++
lib/entitlements/keys.ts | 3 +
lib/errors/structured-errors.ts | 7 +
lib/events/types.ts | 5 +
.../__tests__/payment-links.test.ts | 200 ++++++++
lib/extensions/__tests__/sectors.test.ts | 6 +-
.../_generated/enabled-extensions.ts | 1 +
lib/extensions/_generated/extension-list.ts | 2 +
.../_generated/sector-definitions.ts | 12 +
lib/extensions/payment-links.ts | 147 ++++++
lib/extensions/settings-panel-registry.tsx | 3 +
.../__tests__/rot-rut-beslut-import.test.ts | 271 +++++++++++
.../__tests__/settle-invoice-payment.test.ts | 167 +++++++
lib/invoices/build-invoice-write.ts | 6 +
lib/invoices/rot-rut-beslut-import.ts | 265 ++++++++++
lib/invoices/settle-invoice-payment.ts | 299 ++++++++++++
lib/reports/__tests__/archive-csv.test.ts | 151 ++++++
.../__tests__/full-archive-export.test.ts | 168 +++++--
lib/reports/archive-csv.ts | 158 ++++++
lib/reports/archive-readme.ts | 129 +++++
lib/reports/full-archive-export.ts | 398 ++++++++++++---
lib/salary/__tests__/pain001.test.ts | 97 +++-
lib/salary/agi/generate-declaration.ts | 21 +-
.../payment/__tests__/bank-account.test.ts | 43 ++
lib/salary/payment/bank-account.ts | 48 ++
lib/salary/payment/bg-lb-generator.ts | 49 +-
lib/salary/payment/pain001-generator.ts | 111 ++++-
.../__tests__/subscription-sync.test.ts | 2 +-
messages/en.json | 122 ++++-
messages/sv.json | 122 ++++-
...2090000_notification_type_skv_kvittens.sql | 30 ++
...60712091000_rot_rut_skv_referensnummer.sql | 21 +
...92000_skatteverket_company_connections.sql | 72 +++
.../20260712100000_stripe_connections.sql | 79 +++
...00_stripe_payments_capability_backfill.sql | 28 ++
...0712100200_invoice_stripe_payment_link.sql | 25 +
.../20260712100300_stripe_payment_events.sql | 66 +++
.../20260712100400_stripe_payouts.sql | 60 +++
...0500_journal_source_type_stripe_payout.sql | 40 ++
...0260712112000_rot_rut_apply_beslut_rpc.sql | 75 +++
...00_notification_log_kvittens_dedup_idx.sql | 32 ++
tests/pg/full-archive-coverage.pg.test.ts | 152 ++++++
tests/pg/rot-rut-apply-beslut-rpc.pg.test.ts | 223 +++++++++
...katteverket-company-connections.pg.test.ts | 140 ++++++
tests/pg/stripe-integration.pg.test.ts | 246 ++++++++++
types/index.ts | 9 +
vercel.json | 8 +
136 files changed, 15312 insertions(+), 1133 deletions(-)
create mode 100644 app/(dashboard)/settings/payments/page.tsx
create mode 100644 app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
create mode 100644 app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
create mode 100644 app/api/extensions/stripe/callback/route.ts
create mode 100644 app/api/extensions/stripe/sync/cron/route.ts
create mode 100644 app/api/rot-rut/beslut/__tests__/route.test.ts
create mode 100644 app/api/rot-rut/beslut/import/route.ts
create mode 100644 components/dashboard/BackupHealthBanner.tsx
create mode 100644 components/dashboard/SkatteverketPromoCard.tsx
create mode 100644 components/settings/sections/PaymentsSettingsContent.tsx
create mode 100644 extensions/general/cloud-backup/__tests__/schedule-route.test.ts
create mode 100644 extensions/general/cloud-backup/lib/__tests__/backup-alert.test.ts
create mode 100644 extensions/general/cloud-backup/lib/__tests__/schedule.test.ts
create mode 100644 extensions/general/cloud-backup/lib/backup-alert.ts
create mode 100644 extensions/general/cloud-backup/lib/schedule.ts
create mode 100644 extensions/general/skatteverket/__tests__/agi-tax-settlement.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/grant-probe.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/kvittens-notification.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/resolve-auth.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/system-auth-transport.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/system-auth.test.ts
create mode 100644 extensions/general/skatteverket/__tests__/vat-submit.test.ts
create mode 100644 extensions/general/skatteverket/lib/agi-tax-settlement.ts
create mode 100644 extensions/general/skatteverket/lib/connection-store.ts
create mode 100644 extensions/general/skatteverket/lib/grant-probe.ts
create mode 100644 extensions/general/skatteverket/lib/kvittens-notification.ts
create mode 100644 extensions/general/skatteverket/lib/resolve-auth.ts
create mode 100644 extensions/general/skatteverket/lib/system-auth/config.ts
create mode 100644 extensions/general/skatteverket/lib/system-auth/token-provider.ts
create mode 100644 extensions/general/skatteverket/lib/system-auth/transport.ts
create mode 100644 extensions/general/skatteverket/lib/vat-submit.ts
create mode 100644 extensions/general/stripe/__tests__/connect-routes.test.ts
create mode 100644 extensions/general/stripe/__tests__/payment-links.test.ts
create mode 100644 extensions/general/stripe/__tests__/payouts.test.ts
create mode 100644 extensions/general/stripe/__tests__/sync.test.ts
create mode 100644 extensions/general/stripe/components/StripeSettingsPanel.tsx
create mode 100644 extensions/general/stripe/index.ts
create mode 100644 extensions/general/stripe/lib/connect.ts
create mode 100644 extensions/general/stripe/lib/payment-links.ts
create mode 100644 extensions/general/stripe/lib/payouts.ts
create mode 100644 extensions/general/stripe/lib/sync.ts
create mode 100644 extensions/general/stripe/manifest.json
create mode 100644 extensions/general/stripe/types.ts
create mode 100644 lib/deadlines/__tests__/complete-tax-deadline.test.ts
create mode 100644 lib/deadlines/complete-tax-deadline.ts
create mode 100644 lib/extensions/__tests__/payment-links.test.ts
create mode 100644 lib/extensions/payment-links.ts
create mode 100644 lib/invoices/__tests__/rot-rut-beslut-import.test.ts
create mode 100644 lib/invoices/__tests__/settle-invoice-payment.test.ts
create mode 100644 lib/invoices/rot-rut-beslut-import.ts
create mode 100644 lib/invoices/settle-invoice-payment.ts
create mode 100644 lib/reports/__tests__/archive-csv.test.ts
create mode 100644 lib/reports/archive-csv.ts
create mode 100644 lib/reports/archive-readme.ts
create mode 100644 supabase/migrations/20260712090000_notification_type_skv_kvittens.sql
create mode 100644 supabase/migrations/20260712091000_rot_rut_skv_referensnummer.sql
create mode 100644 supabase/migrations/20260712092000_skatteverket_company_connections.sql
create mode 100644 supabase/migrations/20260712100000_stripe_connections.sql
create mode 100644 supabase/migrations/20260712100100_stripe_payments_capability_backfill.sql
create mode 100644 supabase/migrations/20260712100200_invoice_stripe_payment_link.sql
create mode 100644 supabase/migrations/20260712100300_stripe_payment_events.sql
create mode 100644 supabase/migrations/20260712100400_stripe_payouts.sql
create mode 100644 supabase/migrations/20260712100500_journal_source_type_stripe_payout.sql
create mode 100644 supabase/migrations/20260712112000_rot_rut_apply_beslut_rpc.sql
create mode 100644 supabase/migrations/20260712113000_notification_log_kvittens_dedup_idx.sql
create mode 100644 tests/pg/full-archive-coverage.pg.test.ts
create mode 100644 tests/pg/rot-rut-apply-beslut-rpc.pg.test.ts
create mode 100644 tests/pg/skatteverket-company-connections.pg.test.ts
create mode 100644 tests/pg/stripe-integration.pg.test.ts
diff --git a/DECISIONS.md b/DECISIONS.md
index 8bd3ef7c..5874728e 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -72,3 +72,26 @@ One line per decision: `[YYYY-MM-DD] : `. 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 .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.
diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx
index fb8c1cb5..31bfe681 100644
--- a/app/(dashboard)/salary/page.tsx
+++ b/app/(dashboard)/salary/page.tsx
@@ -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(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) })}
+ {!taxPayment?.tax_paid_at && skvNeedsReconsent && (
+
+ {t('card_tax_reconnect')}
+
+ )}
+ {!taxPayment?.tax_paid_at && canWrite && (
+ markTaxPaid(periodOf(latestBooked))}
+ disabled={markingPaid}
+ >
+ {markingPaid ? (
+
+ ) : (
+
+ )}
+ {tp('tax_mark_paid_button')}
+
+ )}
>
) : (
{t('card_tax_none')}
diff --git a/app/(dashboard)/settings/backup/page.tsx b/app/(dashboard)/settings/backup/page.tsx
index 722cf0ff..df0d4ffe 100644
--- a/app/(dashboard)/settings/backup/page.tsx
+++ b/app/(dashboard)/settings/backup/page.tsx
@@ -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>
+}) {
+ 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`)
}
diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx
index b8520a17..91fc14b3 100644
--- a/app/(dashboard)/settings/layout.tsx
+++ b/app/(dashboard)/settings/layout.tsx
@@ -9,6 +9,7 @@ import { SettingsShell } from '@/components/settings/SettingsShell'
const TAB_TO_ROUTE: Record = {
company: '/settings/company',
invoicing: '/settings/invoicing',
+ payments: '/settings/payments',
bookkeeping: '/settings/bookkeeping',
tax: '/settings/tax',
team: '/settings/team',
diff --git a/app/(dashboard)/settings/payments/page.tsx b/app/(dashboard)/settings/payments/page.tsx
new file mode 100644
index 00000000..6a508f08
--- /dev/null
+++ b/app/(dashboard)/settings/payments/page.tsx
@@ -0,0 +1,5 @@
+import { PaymentsSettingsContent } from '@/components/settings/sections/PaymentsSettingsContent'
+
+export default function PaymentsSettingsPage() {
+ return
+}
diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
index 3b2c1c8a..314a0cad 100644
--- a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
+++ b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts
@@ -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()
+ 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 = {}) {
+ 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,
diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
index 786a1c52..3fca910b 100644
--- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
+++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts
@@ -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()
+ 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 => {
+ 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) => {
diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
index 6499cc22..2bfc5640 100644
--- a/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
+++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts
@@ -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 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 () => {
diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
index 6afb2b0e..7208bf99 100644
--- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
+++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts
@@ -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,
diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
index 11ea8674..6f82e870 100644
--- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
+++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
@@ -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()
+ 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()
+ 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,
})
diff --git a/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
new file mode 100644
index 00000000..e9102435
--- /dev/null
+++ b/app/api/extensions/skatteverket/vat/kvittenser/cron/__tests__/route.test.ts
@@ -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,
+) {
+ 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 = 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
+ let logSpy: ReturnType
+ let warnSpy: ReturnType
+
+ 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)
+ })
+})
diff --git a/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
new file mode 100644
index 00000000..804d7525
--- /dev/null
+++ b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts
@@ -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,
+ })
+}
diff --git a/app/api/extensions/stripe/callback/route.ts b/app/api/extensions/stripe/callback/route.ts
new file mode 100644
index 00000000..5ee06d86
--- /dev/null
+++ b/app/api/extensions/stripe/callback/route.ts
@@ -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')}`,
+ )
+ }
+}
diff --git a/app/api/extensions/stripe/sync/cron/route.ts b/app/api/extensions/stripe/sync/cron/route.ts
new file mode 100644
index 00000000..54a69b67
--- /dev/null
+++ b/app/api/extensions/stripe/sync/cron/route.ts
@@ -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 })
+})
diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
index 6a7c4ed5..9c45113e 100644
--- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts
@@ -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
)
})
diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts
index 29cb2b58..e12105e6 100644
--- a/app/api/invoices/[id]/mark-paid/route.ts
+++ b/app/api/invoices/[id]/mark-paid/route.ts
@@ -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 },
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 9c60787f..7a4b4b7b 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -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')
diff --git a/app/api/rot-rut/beslut/__tests__/route.test.ts b/app/api/rot-rut/beslut/__tests__/route.test.ts
new file mode 100644
index 00000000..6dcd1c2e
--- /dev/null
+++ b/app/api/rot-rut/beslut/__tests__/route.test.ts
@@ -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 = {}) {
+ 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> }
+ }>(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,
+ })
+ })
+})
diff --git a/app/api/rot-rut/beslut/import/route.ts b/app/api/rot-rut/beslut/import/route.ts
new file mode 100644
index 00000000..11a23b70
--- /dev/null
+++ b/app/api/rot-rut/beslut/import/route.ts
@@ -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 },
+)
diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts
index f0ef2019..efe759cc 100644
--- a/app/api/salary/runs/[id]/payment/pain001/route.ts
+++ b/app/api/salary/runs/[id]/payment/pain001/route.ts
@@ -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')
diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
index 952d01fa..214fc65f 100644
--- a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
+++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts
@@ -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',
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
index f463c518..701705a7 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
@@ -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
diff --git a/components/dashboard/BackupHealthBanner.tsx b/components/dashboard/BackupHealthBanner.tsx
new file mode 100644
index 00000000..15f49112
--- /dev/null
+++ b/components/dashboard/BackupHealthBanner.tsx
@@ -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(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 (
+
+
+
+
+ {status.needs_reauth
+ ? t('ext_cloud_backup_banner_reauth')
+ : t('ext_cloud_backup_banner_failing')}
+
+
+ {t('ext_cloud_backup_banner_action')}
+
+
+
+ )
+}
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 1c225e9a..2f76bc05 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -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 (
+
{/* 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
+ {/* 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 && (
+
+ )}
+
{/* Att göra: the unified worklist. One section, every actionable item,
same counts as the sidebar badges (lib/worklist). */}
`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 (
+
+
+
+
+
+
+
+
{t('skv_promo_title')}
+
{t('skv_promo_description')}
+
+
+ {/* 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 */}
+
+ {t('skv_promo_cta')}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/extensions/general/CloudBackupWorkspace.tsx b/components/extensions/general/CloudBackupWorkspace.tsx
index 6c658910..f50cace5 100644
--- a/components/extensions/general/CloudBackupWorkspace.tsx
+++ b/components/extensions/general/CloudBackupWorkspace.tsx
@@ -11,13 +11,13 @@ export default function CloudBackupWorkspace(_props: WorkspaceComponentProps) {
Molnsynkronisering
- 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.
-
+
- Gå till säkerhetsbackup
+ Gå till säkerhetskopiering
diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx
index 30a0ca1a..8b55b9d6 100644
--- a/components/invoices/InvoiceEditor.tsx
+++ b/components/invoices/InvoiceEditor.tsx
@@ -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 ? (
{errors.payment_link_url.message}
) : (
- {t('payment_link_hint')}
+
+ {stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}
+
+ )}
+ {stripeConnected && !watch('payment_link_url')?.trim() && (
+
+
+ setValue('payment_link_auto', v, { shouldDirty: true })
+ }
+ />
+
+ {t('payment_link_auto_label')}
+
+
)}
)}
diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx
index 1d8c5c53..4bc77de8 100644
--- a/components/reports/SkatteverketPanel.tsx
+++ b/components/reports/SkatteverketPanel.tsx
@@ -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 = {
+ 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({
+ {/* 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. */}
+ Steg för steg
+ handleValidate()}
+ >
+
+
Validera
+
+ Kontrollera deklarationen hos Skatteverket utan att spara
+
+
+
+ handleSaveDraft()}
+ >
+
+
Spara utkast
+
+ Spara deklarationen som utkast i Eget utrymme
+
+
+
+ handleLock()}
+ >
+
+
Lås och signera
+
+ Lås det sparade utkastet och hämta signeringslänken
+
+
+
+
Status hos Skatteverket
)}
- {/* Forward lifecycle: the only always-visible action row. */}
-
+ {/* Forward lifecycle: one primary action. The individual steps live
+ in the overflow menu under "Steg för steg". */}
+
- {actionLoading === 'validate' ? (
-
- ) : (
-
- )}
- Validera
-
-
-
- {actionLoading === 'draft' ? (
+ {actionLoading === 'submit' ? (
) : (
)}
- Spara utkast
-
-
-
- {actionLoading === 'lock' ? (
-
- ) : (
-
- )}
- Lås och signera
+ Skicka till Skatteverket
+
+ Deklarationen kontrolleras, sparas som utkast i{' '}
+
+ Eget utrymme
+ {' '}
+ och låses för signering med BankID hos Skatteverket. Inget lämnas in
+ förrän du har signerat.
+
+
{/* Visible disabled-state explanations: title attributes never show
on disabled buttons. */}
{localBlocked && (
Åtgärda felen under Kontroll av underlaget högst upp på sidan innan du
- validerar eller skickar in.
+ skickar in.
)}
{hasErrors && !localBlocked && (
- Valideringsfelen ovan måste åtgärdas innan utkastet kan låsas.
+ Valideringsfelen ovan måste åtgärdas innan deklarationen kan lämnas in.
)}
diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx
index f67bae7e..6edf1b07 100644
--- a/components/salary/AGIPanel.tsx
+++ b/components/salary/AGIPanel.tsx
@@ -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 && (
-
{t('draft_locked_title')}
+
+
+ {t('draft_locked_title')}
+
+
{t('draft_locked_description')}
@@ -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' ? (
@@ -977,13 +978,7 @@ export function AGIPanel(props: AGIPanelProps) {
)}
{!readOnly && !isSigned && !hasSkatteverket && (
-
- {t('upgrade_hint_before')}{' '}
-
- {t('upgrade_hint_link')}
- {' '}
- {t('upgrade_hint_after')}
-
+
{t('upgrade_note')}
)}
diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx
index f3f17b9f..70c0d2e4 100644
--- a/components/settings/SkatteverketConnectPanel.tsx
+++ b/components/settings/SkatteverketConnectPanel.tsx
@@ -28,6 +28,15 @@ type Status =
}
export function SkatteverketConnectPanel() {
+ return (
+
+
+
+
+ )
+}
+
+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
(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 (
+
+
+ {t('system_status_granted')}
+
+ )
+ case 'denied':
+ return {t('system_status_denied')}
+ case 'error':
+ return {t('system_status_error')}
+ default:
+ return {t('system_status_unknown')}
+ }
+ }
+
+ return (
+
+
+ {t('system_title')}
+
+
+ {t('system_intro')}
+
+ {state.ombud_org_number && (
+
+
{t('system_org_label')}
+
{state.ombud_org_number}
+
+ )}
+
+
+
+ {t('system_behorighet_lasombud')}
+ {grantBadge(state.connection?.lasombud_status)}
+
+
+ {t('system_behorighet_moms')}
+ {grantBadge(state.connection?.moms_ombud_status)}
+
+
+
+ {state.cert?.expiresSoon && (
+
+
+
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}
+
+ )}
+
+
+
+
+ )
+}
+
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
const t = useTranslations('settings_skatteverket_connect')
if (disabled) {
diff --git a/components/settings/sections/PaymentsSettingsContent.tsx b/components/settings/sections/PaymentsSettingsContent.tsx
new file mode 100644
index 00000000..c544a303
--- /dev/null
+++ b/components/settings/sections/PaymentsSettingsContent.tsx
@@ -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 (
+
+ {hasStripeExtension && StripePanel ? (
+
+ ) : (
+
+
+
+
+
+
+ {t('go_to_extensions')}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/components/settings/sections/index.ts b/components/settings/sections/index.ts
index c8ed7183..7fe9e0e2 100644
--- a/components/settings/sections/index.ts
+++ b/components/settings/sections/index.ts
@@ -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 = {
salary: SalarySettingsContent,
invoicing: InvoicingSettingsContent,
templates: TemplatesSettingsContent,
+ payments: PaymentsSettingsContent,
banking: BankingSettingsContent,
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
diff --git a/components/settings/useSettingsNavItems.ts b/components/settings/useSettingsNavItems.ts
index 6eb5f003..dec36f67 100644
--- a/components/settings/useSettingsNavItems.ts
+++ b/components/settings/useSettingsNavItems.ts
@@ -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 },
diff --git a/extensions.config.json b/extensions.config.json
index 4175392d..eb21f8ce 100644
--- a/extensions.config.json
+++ b/extensions.config.json
@@ -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"]}
diff --git a/extensions/general/cloud-backup/__tests__/schedule-route.test.ts b/extensions/general/cloud-backup/__tests__/schedule-route.test.ts
new file mode 100644
index 00000000..0e568b3b
--- /dev/null
+++ b/extensions/general/cloud-backup/__tests__/schedule-route.test.ts
@@ -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
+} {
+ 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()
+ })
+})
diff --git a/extensions/general/cloud-backup/components/CloudBackupCard.tsx b/extensions/general/cloud-backup/components/CloudBackupCard.tsx
index a1c36919..57c8501a 100644
--- a/extensions/general/cloud-backup/components/CloudBackupCard.tsx
+++ b/extensions/general/cloud-backup/components/CloudBackupCard.tsx
@@ -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(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 | 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 => {
+ 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 (
+
{/* Identity */}
@@ -157,7 +240,7 @@ export default function CloudBackupCard() {
Google Drive
- Säkerhetskopia till din egen Drive.
+ {t('ext_cloud_backup_card_tagline')}
@@ -165,7 +248,7 @@ export default function CloudBackupCard() {
{/* Controls */}
{isLoading ? (
-
Laddar…
+
{t('ext_cloud_backup_loading')}
) : status?.connected ? (
<>
{status.needs_reauth && (
@@ -186,7 +269,7 @@ export default function CloudBackupCard() {
{isConnecting ? (
<>
- Omdirigerar…
+ {t('ext_cloud_backup_redirecting')}
>
) : (
<>
@@ -200,29 +283,22 @@ export default function CloudBackupCard() {
)}
-
Konto
+
+ {t('ext_cloud_backup_account_label')}
+
{status.account_email}
-
Senaste synk
+
+ {t('ext_cloud_backup_last_sync_label')}
+
{status.last_sync ? (
- <>
-
- {formatDate(status.last_sync.at)}
-
-
-
- {formatMb(status.last_sync.file_size_bytes)}
-
- >
+
) : (
- Aldrig
+
+ {t('ext_cloud_backup_never')}
+
)}
@@ -241,12 +317,12 @@ export default function CloudBackupCard() {
{isSyncing ? (
<>
- Synkar…
+ {t('ext_cloud_backup_syncing')}
>
) : (
<>
- Synka nu
+ {t('ext_cloud_backup_sync_now')}
>
)}
@@ -259,12 +335,12 @@ export default function CloudBackupCard() {
{isDisconnecting ? (
<>
- Kopplar bort…
+ {t('ext_cloud_backup_disconnecting')}
>
) : (
<>
- Koppla bort
+ {t('ext_cloud_backup_disconnect')}
>
)}
@@ -273,21 +349,19 @@ export default function CloudBackupCard() {
) : (
<>
- 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{' '}
- drive.file ).
+ {t('ext_cloud_backup_connect_description')}
{isConnecting ? (
<>
- Omdirigerar…
+ {t('ext_cloud_backup_redirecting')}
>
) : (
<>
- Koppla Google Drive
+ {t('ext_cloud_backup_connect')}
>
)}
@@ -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 (
+ <>
+
+ {formatDateTime(lastSync.at)}
+
+
+
+ {formatMb(sizeBytes)}
+ {archiveCount !== null &&
+ ` · ${t('ext_cloud_backup_files_count', { count: archiveCount })}`}
+ {verified && ` · ${t('ext_cloud_backup_verified')}`}
+
+ {anyNoDocs && (
+
+ {t('ext_cloud_backup_last_sync_no_documents')}
+
+ )}
+ >
+ )
+}
+
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
- Automatisk synkronisering
+ {t('ext_cloud_backup_auto_sync_title')}
- Daglig säkerhetsbackup till din Drive.
+ {t('ext_cloud_backup_auto_sync_description')}
- Tid (lokal)
+ {t('ext_cloud_backup_time_label')}
- 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' ? (
- · lyckades
+ · {t('ext_cloud_backup_auto_sync_success')}
) : schedule.last_auto_sync_status === 'error' ? (
- · 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()
-}
diff --git a/extensions/general/cloud-backup/index.ts b/extensions/general/cloud-backup/index.ts
index 3263f78d..e637fa20 100644
--- a/extensions/general/cloud-backup/index.ts
+++ b/extensions/general/cloud-backup/index.ts
@@ -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(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(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) {
diff --git a/extensions/general/cloud-backup/lib/__tests__/backup-alert.test.ts b/extensions/general/cloud-backup/lib/__tests__/backup-alert.test.ts
new file mode 100644
index 00000000..c6cfc5ad
--- /dev/null
+++ b/extensions/general/cloud-backup/lib/__tests__/backup-alert.test.ts
@@ -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: '',
+ })
+ const options = sendEmail.mock.calls[0][0]
+ expect(options.html).not.toContain('