Bug/resend and invoices (#1192)

* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-25 22:56:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f07a34c51b
commit d54b43f80f
89 changed files with 6399 additions and 4682 deletions
+40 -15
View File
@@ -298,6 +298,19 @@
"terms_and": "and",
"privacy_link": "privacy policy"
},
"settings_intro": {
"account": "Your personal details and security. Applies to you, not the company.",
"billing": "Each company has its own subscription.",
"company": "Shown on invoices, in email and in files to the authorities.",
"bookkeeping": "Framework, method and series. Most of this is set once.",
"tax": "Drives the VAT return, the employer declaration and which deadlines are tracked.",
"salary": "Defaults for payroll runs. Tax table and vacation are set per employee.",
"invoicing": "Numbers, terms and the look of your invoices.",
"templates": "Reusable postings and patterns learned from your bookkeeping.",
"banking": "Transactions are fetched automatically. Accounted can only read, never move money.",
"assistant": "What the assistant knows, remembers and can do.",
"api": "Keys for MCP clients like Claude and Cursor, and for your own integrations."
},
"settings_nav": {
"aria_label": "Settings",
"company": "Company",
@@ -325,7 +338,7 @@
},
"settings_payments": {
"title": "Stripe",
"description": "Connect your company Stripe account and a payment link is created automatically when you send an invoice. Payments are matched to the right invoice and payouts are booked with fees.",
"description": "Connect your company Stripe account and your Stripe transactions are imported into the transactions inbox, like a bank feed for your Stripe balance. A payment link is also created automatically when you send an invoice.",
"not_configured": "The Stripe integration is not configured on this installation. Contact your administrator.",
"coming_soon_title": "Stripe payments coming soon",
"coming_soon_description": "Connect your company Stripe account and a payment link is created automatically when you send an invoice, payments are matched to the right invoice, and payouts are booked with fees and VAT. The feature will be enabled shortly.",
@@ -343,7 +356,7 @@
"connected_since": "Connected {date}",
"unnamed_account": "Stripe account",
"connected_toast_title": "Stripe connected",
"connected_toast_description": "Payment links are now created automatically when you send invoices.",
"connected_toast_description": "Your Stripe transactions are now fetched nightly, and payment links are created automatically when you send invoices.",
"disconnected_toast_title": "Stripe disconnected",
"connect_failed_title": "Connection failed",
"disconnect_failed_title": "Disconnect failed",
@@ -356,31 +369,24 @@
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_description": "{settled} payment(s) booked, {review} need review.",
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected.",
"sync_failed_title": "Sync failed",
"needs_review_title": "Needs review",
"needs_review_hint": "Payments that could not be matched automatically. Handle them manually via the invoice or in Stripe.",
"reason_invoice_not_found": "Payment without a matching invoice",
"reason_invoice_already_paid": "The invoice is already marked as paid",
"reason_amount_mismatch": "The amount does not match the invoice remaining balance",
"reason_currency_mismatch": "The currency does not match the invoice",
"reason_non_sek_invoice": "Foreign-currency invoice (book manually)",
"reason_unknown": "Unknown reason",
"transaction_sync_title": "Transactions from Stripe",
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. Already-booked payments are linked to their vouchers; you book the rest as usual.",
"transaction_sync_description": "Import all Stripe transactions (payments, fees, refunds and payouts) into the transactions inbox every night, like a bank feed for your Stripe balance. You book the rows from the inbox as usual.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock date.",
"transaction_sync_last_synced": "Last synced {date}",
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Transaction sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
},
"settings_modal": {
"title": "Settings",
"description": "Manage your company and account"
},
"settings": {
"group_profile": "Profile",
"section_name": "Name",
"name_label": "Your name",
"name_description": "Used when we address you and shown as the contact person on some documents.",
@@ -1469,6 +1475,7 @@
"wrapper_save_failed_title": "Could not save",
"wrapper_save_failed_default": "Could not save settings",
"wrapper_try_again": "Please try again.",
"wrapper_unsaved": "Unsaved changes",
"wrapper_saved": "Saved",
"wrapper_saving": "Saving...",
"wrapper_save_changes": "Save changes",
@@ -1486,6 +1493,8 @@
"iframe_title": "Invoice PDF preview"
},
"settings_bookkeeping": {
"group_basics": "Basics",
"group_automation": "Automation",
"method_heading": "Accounting method",
"method_label": "Method",
"method_accrual": "Faktureringsmetoden",
@@ -2247,6 +2256,7 @@
"link_button": "Link BankID"
},
"settings_security": {
"group_security": "Security",
"toast_weak_password_title": "Password is too weak",
"toast_weak_password_description": "The password must be at least 8 characters and include uppercase, lowercase, digits and a special character.",
"toast_mismatch_title": "Passwords do not match",
@@ -3018,6 +3028,21 @@
"delivery_status_sent": "Sent",
"delivery_status_failed": "Failed",
"delivery_status_marked_sent": "Manual",
"delivery_status_delivered": "Delivered",
"delivery_status_delayed": "Delayed",
"delivery_status_complained": "Marked as spam",
"delivery_status_bounced": "Bounced",
"delivery_status_suppressed": "Blocked",
"delivery_status_explanation_sent": "The email provider accepted the message. No word yet on whether the recipient's server took it.",
"delivery_status_explanation_delivered": "The recipient's server accepted the message. If the customer still cannot find it, ask them to check their spam folder. Quarantine on the recipient's side is never visible to the sender.",
"delivery_status_explanation_delayed": "The recipient's server has not accepted the message yet, but delivery is still being retried. If nothing changes within a few hours, contact the customer.",
"delivery_status_explanation_complained": "The recipient marked the message as spam. Further sends to this address may be blocked.",
"delivery_status_explanation_bounced": "The recipient's server rejected the message. The invoice did not arrive.",
"delivery_status_explanation_failed": "The message could not be sent. The invoice did not arrive.",
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
"delivery_provider_status_label": "Delivery status",
"delivery_provider_reason_label": "Reason from the recipient",
"delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.",
"delivery_to_label": "To",
"delivery_cc_label": "Cc",
@@ -4746,7 +4771,7 @@
"update_failed_title": "Could not update article",
"retry": "Please try again.",
"deactivate_confirm_title": "Deactivate {name}",
"deactivate_confirm_description": "The article is hidden from lists and invoice pickers but its history is kept. You can reactivate it later.",
"deactivate_confirm_description": "The article can no longer be picked on new invoices. Existing invoices are unaffected, and you can activate it again whenever you want.",
"deactivate_confirm_label": "Deactivate",
"deactivated_title": "Article deactivated",
"activated_title": "Article activated",
+40 -15
View File
@@ -298,6 +298,19 @@
"terms_and": "och",
"privacy_link": "integritetspolicy"
},
"settings_intro": {
"account": "Dina personliga uppgifter och din säkerhet. Gäller dig, inte företaget.",
"billing": "Varje företag har sitt eget abonnemang.",
"company": "Uppgifterna visas på fakturor, i e-post och i filer till myndigheter.",
"bookkeeping": "Regelverk, metod och serier. Det mesta sätts en gång.",
"tax": "Styr momsdeklarationen, arbetsgivardeklarationen och vilka datum som bevakas.",
"salary": "Standarder för lönekörningen. Skattetabell och semester ställs in per anställd.",
"invoicing": "Nummer, villkor och utseende på dina fakturor.",
"templates": "Återanvändbara konteringar och mönster som lärts in från din bokföring.",
"banking": "Transaktioner hämtas automatiskt. Accounted kan bara läsa, aldrig flytta pengar.",
"assistant": "Vad assistenten vet, minns och kan.",
"api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer."
},
"settings_nav": {
"aria_label": "Inställningar",
"company": "Företag",
@@ -325,7 +338,7 @@
},
"settings_payments": {
"title": "Stripe",
"description": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura. Betalningar prickas av mot rätt faktura och utbetalningar bokförs med avgifter.",
"description": "Koppla företagets Stripe-konto så hämtas dina Stripe-transaktioner till transaktionsinkorgen, som ett bankflöde för ditt Stripe-saldo. Dessutom skapas en betalningslänk automatiskt när du skickar en faktura.",
"not_configured": "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
"coming_soon_title": "Stripe-betalningar kommer snart",
"coming_soon_description": "Koppla företagets Stripe-konto så skapas en betalningslänk automatiskt när du skickar en faktura, betalningar prickas av mot rätt faktura och utbetalningar bokförs med avgifter och moms. Funktionen aktiveras inom kort.",
@@ -343,7 +356,7 @@
"connected_since": "Ansluten {date}",
"unnamed_account": "Stripe-konto",
"connected_toast_title": "Stripe anslutet",
"connected_toast_description": "Betalningslänkar skapas nu automatiskt när du skickar fakturor.",
"connected_toast_description": "Dina Stripe-transaktioner hämtas nu varje natt, och betalningslänkar skapas automatiskt när du skickar fakturor.",
"disconnected_toast_title": "Stripe frånkopplat",
"connect_failed_title": "Anslutningen misslyckades",
"disconnect_failed_title": "Frånkopplingen misslyckades",
@@ -356,31 +369,24 @@
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_description": "{settled} betalning(ar) bokförda, {review} kräver granskning.",
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner.",
"sync_failed_title": "Synkroniseringen misslyckades",
"needs_review_title": "Kräver granskning",
"needs_review_hint": "Betalningar som inte kunde prickas av automatiskt. Hantera dem manuellt via fakturan eller i Stripe.",
"reason_invoice_not_found": "Betalning utan matchande faktura",
"reason_invoice_already_paid": "Fakturan är redan markerad som betald",
"reason_amount_mismatch": "Beloppet stämmer inte med fakturans restbelopp",
"reason_currency_mismatch": "Valutan stämmer inte med fakturan",
"reason_non_sek_invoice": "Faktura i utländsk valuta (bokförs manuellt)",
"reason_unknown": "Okänd orsak",
"transaction_sync_title": "Transaktioner från Stripe",
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Redan bokförda betalningar länkas till sina verifikat; övriga bokför du som vanligt.",
"transaction_sync_description": "Hämta alla Stripe-transaktioner (betalningar, avgifter, återbetalningar och utbetalningar) till transaktionsinkorgen varje natt, som ett bankflöde för ditt Stripe-saldo. Du bokför raderna som vanligt från inkorgen.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.",
"transaction_sync_last_synced": "Senast synkad {date}",
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat."
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
},
"settings_modal": {
"title": "Inställningar",
"description": "Hantera ditt företag och konto"
},
"settings": {
"group_profile": "Profil",
"section_name": "Namn",
"name_label": "Ditt namn",
"name_description": "Används när vi tilltalar dig och visas som kontaktperson på vissa underlag.",
@@ -1469,6 +1475,7 @@
"wrapper_save_failed_title": "Kunde inte spara",
"wrapper_save_failed_default": "Kunde inte spara inställningar",
"wrapper_try_again": "Försök igen.",
"wrapper_unsaved": "Osparade ändringar",
"wrapper_saved": "Sparat",
"wrapper_saving": "Sparar...",
"wrapper_save_changes": "Spara ändringar",
@@ -1486,6 +1493,8 @@
"iframe_title": "PDF-förhandsvisning av faktura"
},
"settings_bookkeeping": {
"group_basics": "Grunder",
"group_automation": "Automatik",
"method_heading": "Bokföringsmetod",
"method_label": "Metod",
"method_accrual": "Faktureringsmetoden",
@@ -2247,6 +2256,7 @@
"link_button": "Koppla BankID"
},
"settings_security": {
"group_security": "Säkerhet",
"toast_weak_password_title": "Lösenordet är för svagt",
"toast_weak_password_description": "Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.",
"toast_mismatch_title": "Lösenorden matchar inte",
@@ -3018,6 +3028,21 @@
"delivery_status_sent": "Skickad",
"delivery_status_failed": "Misslyckad",
"delivery_status_marked_sent": "Manuell",
"delivery_status_delivered": "Levererad",
"delivery_status_delayed": "Fördröjd",
"delivery_status_complained": "Spam-anmäld",
"delivery_status_bounced": "Studsade",
"delivery_status_suppressed": "Blockerad",
"delivery_status_explanation_sent": "Mailet är accepterat av e-posttjänsten. Besked om att mottagarens server tagit emot det har inte kommit in ännu.",
"delivery_status_explanation_delivered": "Mottagarens server tog emot mailet. Hittar kunden det ändå inte: be dem titta i skräpposten. Karantän hos mottagarens IT-avdelning syns aldrig för avsändaren.",
"delivery_status_explanation_delayed": "Mottagarens server har inte tagit emot mailet ännu, men nya försök pågår. Kommer inget besked inom några timmar: hör av dig till kunden.",
"delivery_status_explanation_complained": "Mottagaren markerade mailet som skräppost. Fortsatta utskick till adressen kan komma att blockeras.",
"delivery_status_explanation_bounced": "Mottagarens server avvisade mailet. Fakturan kom inte fram.",
"delivery_status_explanation_failed": "Mailet kunde inte skickas ut. Fakturan kom inte fram.",
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
"delivery_provider_status_label": "Leveransstatus",
"delivery_provider_reason_label": "Besked från mottagaren",
"delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.",
"delivery_to_label": "Till",
"delivery_cc_label": "Kopia",
@@ -4746,7 +4771,7 @@
"update_failed_title": "Kunde inte uppdatera artikel",
"retry": "Försök igen.",
"deactivate_confirm_title": "Inaktivera {name}",
"deactivate_confirm_description": "Artikeln döljs i listor och fakturaval men historiken bevaras. Du kan aktivera den igen senare.",
"deactivate_confirm_description": "Artikeln går inte längre att välja på nya fakturor. Befintliga fakturor påverkas inte, och du kan aktivera artikeln igen när du vill.",
"deactivate_confirm_label": "Inaktivera",
"deactivated_title": "Artikel inaktiverad",
"activated_title": "Artikel aktiverad",