diff --git a/DECISIONS.md b/DECISIONS.md index f4344151..f1373518 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -749,10 +749,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] Issue #1267 localizes the latest-posted-voucher label in the web views while PDF and spreadsheet exports remain Swedish: translating one export label would create mixed-language files, so full export localization stays a separate surface-wide change. [2026-08-03] Wise imports fail closed on refunded or unknown statuses, unknown directions, and cross-currency transaction-history rows: the available export contract cannot establish their signed balance effect, and v1 must not silently discard a business event. Ordinary balance-statement rows share the canonical wise_ID external ID with transaction history to prevent overlapping cross-format imports; only conversion legs with explicit Exchange From/To metadata add the statement currency because the same Wise ID represents one movement per balance. [2026-08-03] Wise balance-statement "Total fees" stays description-only, guarded by a running-balance continuity warning: Running Balance moves by exactly the signed Amount per row, so the fee is a breakdown of Amount and booking it separately (as wise.ts does for the "(after fees)" history export) would double-count the cost and desync the imported account from the real Wise balance. - [2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies. [2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here. [2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation. [2026-08-03] Issue #314 zeroes the F-skatt avgifter basis at the calculation boundary as well as the rate: a rate-only exemption would stop the 7510/2731 charge but leave a false contribution basis in salary reports and AGI totals; the separate FK011/FK131 XML rendering defect remains scoped to issue #315. +[2026-08-03] Issue #814 ships the custom inbox-domain dialog polish (i18n, role gating, load-error state) while INBOX_CUSTOM_DOMAINS_ENABLED stays off: the 2026-07-02 gate decision holds until Emil flips the flag and restores the workspace entry point, so the feature is ship-ready but dormant. diff --git a/components/extensions/general/InboxCustomDomainDialog.tsx b/components/extensions/general/InboxCustomDomainDialog.tsx index 654a3571..d5061eeb 100644 --- a/components/extensions/general/InboxCustomDomainDialog.tsx +++ b/components/extensions/general/InboxCustomDomainDialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useEffect } from 'react' +import { useTranslations } from 'next-intl' import { Dialog, DialogContent, @@ -14,19 +15,21 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { AlertTriangle, Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react' -import { formatDateLong } from '@/lib/utils' import type { CompanyInboundDomain, InboundDomainDnsRecord } from '@/types' -import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { useFormat } from '@/lib/hooks/use-format' +import { useCompany } from '@/contexts/CompanyContext' +import { copyToClipboard } from '@/lib/browser/copy-to-clipboard' const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain' -const STATUS_BADGE: Record< +const STATUS_VARIANT: Record< CompanyInboundDomain['status'], - { label: string; variant: 'secondary' | 'success' | 'destructive' } + 'secondary' | 'success' | 'destructive' > = { - pending: { label: 'Väntar på DNS', variant: 'secondary' }, - verified: { label: 'Verifierad', variant: 'success' }, - failed: { label: 'Misslyckades', variant: 'destructive' }, + pending: 'secondary', + verified: 'success', + failed: 'destructive', } interface Props { @@ -40,7 +43,13 @@ interface Props { // server-side: this surface only manages the claim lifecycle. export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { const { toast } = useToast() + const t = useTranslations('inbox_custom_domain') + const { locale, formatDateLong } = useFormat() + const errorLocale = locale as ErrorLocale + const { role } = useCompany() + const canManage = role === 'owner' || role === 'admin' const [isLoading, setIsLoading] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) const [domain, setDomain] = useState(null) const [domainInput, setDomainInput] = useState('') const [isClaiming, setIsClaiming] = useState(false) @@ -49,12 +58,17 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { const fetchDomain = useCallback(async () => { setIsLoading(true) + setLoadFailed(false) try { const res = await fetch(BASE) + if (!res.ok) { + setLoadFailed(true) + return + } const json = await res.json() - if (res.ok) setDomain(json.data ?? null) + setDomain(json.data ?? null) } catch { - // Leave the previous state; the dialog shows the claim form on null. + setLoadFailed(true) } finally { setIsLoading(false) } @@ -74,86 +88,98 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { body: JSON.stringify({ domain: domainInput }), }) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Kunde inte lägga till domänen') + if (!res.ok) throw new Error(json.error ?? t('claim_error_title')) setDomain(json.data) setDomainInput('') toast({ - title: 'Domän tillagd', - description: 'Lägg till DNS-posterna nedan hos din domänleverantör.', + title: t('claim_success_title'), + description: t('claim_success_description'), }) } catch (err) { toast({ - title: 'Kunde inte lägga till domänen', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + title: t('claim_error_title'), + description: err instanceof Error ? getUserErrorMessage(err, { locale: errorLocale }) : t('try_again'), variant: 'destructive', }) } finally { setIsClaiming(false) } - }, [domainInput, toast]) + }, [domainInput, errorLocale, t, toast]) const handleVerify = useCallback(async () => { setIsChecking(true) try { const res = await fetch(`${BASE}/verify`, { method: 'POST' }) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Kontrollen misslyckades') + if (!res.ok) throw new Error(json.error ?? t('verify_error_title')) setDomain(json.data) toast( json.data.status === 'verified' - ? { title: 'Domänen är verifierad', description: 'E-post till domänen landar nu i dokumentinkorgen.' } - : { title: 'Inte verifierad än', description: 'DNS-ändringar kan ta upp till någon timme att slå igenom.' } + ? { title: t('verify_success_title'), description: t('verify_success_description') } + : { title: t('verify_pending_title'), description: t('verify_pending_description') } ) } catch (err) { toast({ - title: 'Kontrollen misslyckades', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + title: t('verify_error_title'), + description: err instanceof Error ? getUserErrorMessage(err, { locale: errorLocale }) : t('try_again'), variant: 'destructive', }) } finally { setIsChecking(false) } - }, [toast]) + }, [errorLocale, t, toast]) const handleRemove = useCallback(async () => { if (!domain) return - if (!confirm(`Ta bort ${domain.domain}? E-post till domänen slutar landa i Accounted.`)) return + if (!confirm(t('remove_confirm', { domain: domain.domain }))) return setIsRemoving(true) try { const res = await fetch(BASE, { method: 'DELETE' }) const json = await res.json() - if (!res.ok) throw new Error(json.error ?? 'Borttagningen misslyckades') + if (!res.ok) throw new Error(json.error ?? t('remove_error_title')) setDomain(null) - toast({ title: 'Domänen borttagen' }) + toast({ title: t('remove_success_title') }) } catch (err) { toast({ - title: 'Borttagningen misslyckades', - description: err instanceof Error ? getUserErrorMessage(err) : 'Försök igen.', + title: t('remove_error_title'), + description: err instanceof Error ? getUserErrorMessage(err, { locale: errorLocale }) : t('try_again'), variant: 'destructive', }) } finally { setIsRemoving(false) } - }, [domain, toast]) + }, [domain, errorLocale, t, toast]) const handleCopy = useCallback( - (value: string) => { - navigator.clipboard.writeText(value).catch(() => {}) - toast({ title: 'Kopierat' }) + async (value: string) => { + const result = await copyToClipboard(value) + toast( + result === 'copied' + ? { title: t('copied') } + : { + title: t('copy_failed_title'), + description: t('copy_failed_description'), + variant: 'destructive', + } + ) }, - [toast] + [t, toast] ) const records: InboundDomainDnsRecord[] = domain?.dns_records ?? [] + const statusLabels: Record = { + pending: t('status_pending'), + verified: t('status_verified'), + failed: t('status_failed'), + } return ( - Egen domän för inkorgen + {t('title')} - Ta emot leverantörsfakturor direkt på bolagets egen adress, t.ex. - faktura@dittbolag.se: utan vidarebefordran. + {t('description')} @@ -162,71 +188,94 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { + ) : loadFailed ? ( +
+

{t('load_error')}

+ +
) : !domain ? (
-

Viktigt om din domän redan tar emot e-post

+

{t('warning_title')}

- Domänens MX-poster pekas om till Accounted. Om domänen redan används för - e-post (Google Workspace, Microsoft 365) slutar din vanliga e-post att - fungera: använd då en underdomän, t.ex.{' '} - faktura.dittbolag.se, eller - fortsätt vidarebefordra till din vanliga inkorgsadress. + {t('warning_before_subdomain')}{' '} + faktura.dittbolag.se{' '} + {t('warning_after_subdomain')}

-
- setDomainInput(e.target.value)} - placeholder="faktura.dittbolag.se" - onKeyDown={(e) => { - if (e.key === 'Enter') handleClaim() - }} - /> - -
+ {canManage ? ( +
+ setDomainInput(e.target.value)} + placeholder="faktura.dittbolag.se" + aria-label={t('domain_input_aria')} + onKeyDown={(e) => { + if (e.key === 'Enter') handleClaim() + }} + /> + +
+ ) : ( +

{t('manage_permission')}

+ )}
) : (
{domain.domain} - - {STATUS_BADGE[domain.status].label} + + {statusLabels[domain.status]}
-
- - -
+ {canManage ? ( +
+ + +
+ ) : null}
{domain.status === 'verified' ? ( @@ -234,31 +283,32 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {

- Klart: ge dina leverantörer{' '} + {t('verified_title')}{' '} faktura@{domain.domain}

- Alla adresser på domänen fungerar; allt landar i dokumentinkorgen. - {domain.verified_at ? ` Verifierad ${formatDateLong(domain.verified_at)}.` : ''} + {domain.verified_at + ? t('verified_description_with_date', { + date: formatDateLong(domain.verified_at), + }) + : t('verified_description')}

) : (

- Lägg till posterna nedan hos din domänleverantör (Loopia, one.com, - Cloudflare …) och klicka sedan på Kontrollera igen. Ändringar kan ta upp - till någon timme att slå igenom. + {t('dns_instructions')}

{records.length > 0 ? (
- - - - + + + + @@ -272,14 +322,17 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { {r.priority ?? '-'} ))} @@ -288,7 +341,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) { ) : (

- Inga DNS-poster tillgängliga: klicka på Kontrollera igen. + {t('dns_empty')}

)} diff --git a/messages/en.json b/messages/en.json index 33e7aafb..4e8091e1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2724,6 +2724,48 @@ "email_body_label": "Email content", "email_body_empty": "The email had no text." }, + "inbox_custom_domain": { + "title": "Custom inbox domain", + "description": "Receive supplier invoices directly at your company's own address, such as invoices@yourcompany.com, without forwarding.", + "warning_title": "Important if your domain already receives email", + "warning_before_subdomain": "The domain's MX records will point to Accounted. If the domain already uses email, such as Google Workspace or Microsoft 365, your regular email will stop working. Use a subdomain instead, such as", + "warning_after_subdomain": "or keep forwarding to your regular inbox address.", + "status_pending": "Waiting for DNS", + "status_verified": "Verified", + "status_failed": "Failed", + "add_button": "Add", + "check_again": "Check again", + "remove_aria": "Remove domain", + "verified_title": "Ready: give your suppliers", + "verified_description": "Every address on the domain works. All email lands in the document inbox.", + "verified_description_with_date": "Every address on the domain works. All email lands in the document inbox. Verified {date}.", + "dns_instructions": "Add the records below through your domain provider, such as Loopia, one.com, or Cloudflare, then click Check again. DNS changes can take up to an hour to take effect.", + "dns_type": "Type", + "dns_name": "Name", + "dns_value": "Value", + "dns_priority": "Priority", + "copy_record_aria": "Copy the {type} value", + "copy_failed_title": "Could not copy", + "copy_failed_description": "Select the value in the table and copy it manually.", + "dns_empty": "No DNS records are available. Click Check again.", + "load_error": "Could not load the domain settings.", + "retry": "Try again", + "manage_permission": "Only an administrator or owner can add a domain.", + "domain_input_aria": "Document inbox domain", + "claim_success_title": "Domain added", + "claim_success_description": "Add the DNS records below through your domain provider.", + "claim_error_title": "Could not add the domain", + "verify_success_title": "The domain is verified", + "verify_success_description": "Email sent to the domain now lands in the document inbox.", + "verify_pending_title": "Not verified yet", + "verify_pending_description": "DNS changes can take up to an hour to take effect.", + "verify_error_title": "Verification failed", + "remove_confirm": "Remove {domain}? Email sent to the domain will stop reaching Accounted.", + "remove_success_title": "Domain removed", + "remove_error_title": "Removal failed", + "copied": "Copied", + "try_again": "Try again." + }, "tx_match_allocation": { "title": "Split payment", "description_customer": "Allocate the incoming payment across one or more customer invoices. The verifikat lands as a samlingsverifikation per BFL 5 kap 6§.", diff --git a/messages/sv.json b/messages/sv.json index 7d29aae0..84649a5a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2724,6 +2724,48 @@ "email_body_label": "Mejlets innehåll", "email_body_empty": "Mejlet hade ingen text." }, + "inbox_custom_domain": { + "title": "Egen domän för inkorgen", + "description": "Ta emot leverantörsfakturor direkt på bolagets egen adress, till exempel faktura@dittbolag.se, utan vidarebefordran.", + "warning_title": "Viktigt om din domän redan tar emot e-post", + "warning_before_subdomain": "Domänens MX-poster pekas om till Accounted. Om domänen redan används för e-post, till exempel Google Workspace eller Microsoft 365, slutar din vanliga e-post att fungera. Använd då en underdomän, till exempel", + "warning_after_subdomain": "eller fortsätt vidarebefordra till din vanliga inkorgsadress.", + "status_pending": "Väntar på DNS", + "status_verified": "Verifierad", + "status_failed": "Misslyckades", + "add_button": "Lägg till", + "check_again": "Kontrollera igen", + "remove_aria": "Ta bort domän", + "verified_title": "Klart: ge dina leverantörer", + "verified_description": "Alla adresser på domänen fungerar. Allt landar i dokumentinkorgen.", + "verified_description_with_date": "Alla adresser på domänen fungerar. Allt landar i dokumentinkorgen. Verifierad {date}.", + "dns_instructions": "Lägg till posterna nedan hos din domänleverantör, till exempel Loopia, one.com eller Cloudflare, och klicka sedan på Kontrollera igen. Ändringar kan ta upp till någon timme att slå igenom.", + "dns_type": "Typ", + "dns_name": "Namn", + "dns_value": "Värde", + "dns_priority": "Prio", + "copy_record_aria": "Kopiera värdet för {type}", + "copy_failed_title": "Kunde inte kopiera", + "copy_failed_description": "Markera värdet i tabellen och kopiera det manuellt.", + "dns_empty": "Inga DNS-poster är tillgängliga. Klicka på Kontrollera igen.", + "load_error": "Kunde inte läsa in domäninställningen.", + "retry": "Försök igen", + "manage_permission": "Endast en administratör eller ägare kan lägga till en domän.", + "domain_input_aria": "Domän för dokumentinkorgen", + "claim_success_title": "Domän tillagd", + "claim_success_description": "Lägg till DNS-posterna nedan hos din domänleverantör.", + "claim_error_title": "Kunde inte lägga till domänen", + "verify_success_title": "Domänen är verifierad", + "verify_success_description": "E-post till domänen landar nu i dokumentinkorgen.", + "verify_pending_title": "Inte verifierad än", + "verify_pending_description": "DNS-ändringar kan ta upp till någon timme att slå igenom.", + "verify_error_title": "Kontrollen misslyckades", + "remove_confirm": "Ta bort {domain}? E-post till domänen slutar landa i Accounted.", + "remove_success_title": "Domänen borttagen", + "remove_error_title": "Borttagningen misslyckades", + "copied": "Kopierat", + "try_again": "Försök igen." + }, "tx_match_allocation": { "title": "Dela betalning", "description_customer": "Fördela inbetalningen på en eller flera kundfakturor. Verifikationen skapas som en samlingsverifikation per BFL 5 kap 6§.",
TypNamnVärdePrio{t('dns_type')}{t('dns_name')}{t('dns_value')}{t('dns_priority')}
- +