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 && ( + + )} ) : (

{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). */}