fix(invoices): resolve ROT/RUT strings and hide the payout action when it does not apply (#1429)

The ROT/RUT payout feature shipped in #1380 with all 44 of its strings
written into the invoice_editor namespace, while RotRutPayoutDialog and
the invoices page both read useTranslations('invoices'). next-intl falls
back to rendering the key path, so production showed literal
"invoices.rot_rut_payout_title" text where every label should be, and the
header button read "invoices.rot_rut_payout_action".

Move the keys to the invoices namespace, textually rather than through a
JSON round trip: the message files contain duplicate keys that
JSON.parse/stringify would silently drop.

Three other dialogs had the same namespace mismatch and are fixed the
same way, by adding the strings they reference to the namespace they read
from (TemplateBookDialog reads bookkeeping, Correction/StrikeLines read
journal_detail).

Add i18n/__tests__/message-keys.test.ts, which resolves every literal t()
key in app/, components/ and extensions/ against both locales. next-intl
has no build-time check and degrades by rendering the key path, so this
class of bug reaches users silently otherwise.

Also gate the payout action. Begäran om utbetalning (Lag 2009:194 8 §)
only concerns companies selling ROT/RUT-eligible work to consumers, so
the header button now appears only when the company has invoiced a
deduction or has opted into ROT/RUT in tax settings. The flag comes from
the company_settings row the page already fetches for ore_rounding, so
there is no extra request. ?rot-rut=1 still opens the dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 11:13:25 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent ab712f829a
commit 902b3ee986
5 changed files with 237 additions and 101 deletions
+29 -11
View File
@@ -154,6 +154,7 @@ export default function InvoicesPage() {
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [rotRutEnabled, setRotRutEnabled] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [sort, setSort] = useState<InvoiceListSort | null>(null)
@@ -192,6 +193,16 @@ export default function InvoicesPage() {
const closeRotRutPayout = () => router.replace('/invoices', { scroll: false })
const openRotRutPayout = () => router.push('/invoices?rot-rut=1', { scroll: false })
// Begäran om utbetalning (Lag 2009:194 8 §) only concerns companies selling
// ROT/RUT-eligible work to consumers, so the action stays out of the header
// for everyone else. It appears once the company has invoiced a deduction
// (the payout can never precede that invoice), or once ROT/RUT is opted into
// in tax settings. Not scoped to the fiscal-year filter: a payout is claimed
// the year after payment, so last year's invoices are exactly the relevant
// ones. ?rot-rut=1 keeps working regardless, so nothing is unreachable.
const showRotRutAction =
rotRutEnabled || invoices.some((invoice) => (invoice.deduction_total ?? 0) > 0)
async function fetchInvoices() {
if (!company) return
setIsLoading(true)
@@ -209,7 +220,7 @@ export default function InvoicesPage() {
),
supabase
.from('company_settings')
.select('ore_rounding')
.select('ore_rounding, rot_rut_enabled')
.eq('company_id', company.id)
.maybeSingle(),
])
@@ -228,6 +239,11 @@ export default function InvoicesPage() {
? (settingsResult.value.data?.ore_rounding ?? true)
: true,
)
setRotRutEnabled(
settingsResult.status === 'fulfilled'
? (settingsResult.value.data?.rot_rut_enabled ?? false)
: false,
)
setIsLoading(false)
}
@@ -382,16 +398,18 @@ export default function InvoicesPage() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={openRotRutPayout}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<FileDown className="mr-2 h-4 w-4" />
{t('rot_rut_payout_action')}
</Button>
{showRotRutAction && (
<Button
type="button"
variant="outline"
onClick={openRotRutPayout}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<FileDown className="mr-2 h-4 w-4" />
{t('rot_rut_payout_action')}
</Button>
)}
<SplitButton
key={uiStateLoaded ? 'loaded' : 'initial'}
persistKey="invoices"