Feat/voucher docs (#664)

* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
+12 -22
View File
@@ -190,28 +190,16 @@ export default function BankIdCompanyPicker({
const entityLabel = humanTicEntityType(role.legalEntityType)
const mappable = mapEntityType(role.legalEntityType) !== null
if (status === 'exists') {
return (
<li key={cleaned}>
<div className="w-full rounded-lg border bg-muted/20 p-4 text-left opacity-70">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-2">
{t('ask_admin_invite')}
</p>
</div>
</li>
)
}
// Companies already in gnubok under another account are still
// offered for setup — org-number reuse is allowed (a director
// may keep a separate test copy). Keep a muted note so the
// "also already in {app}" context isn't lost.
const existsNote =
status === 'exists' ? (
<p className="text-xs text-muted-foreground/70 mt-2">
{t('already_in_app', { appName: branding.appName.toLowerCase() })}
</p>
) : null
if (!mappable) {
return (
@@ -236,6 +224,7 @@ export default function BankIdCompanyPicker({
{t('setup_manually')}
</span>
</div>
{existsNote}
</Link>
</li>
)
@@ -263,6 +252,7 @@ export default function BankIdCompanyPicker({
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
{existsNote}
</button>
</li>
)
+14 -18
View File
@@ -12,11 +12,8 @@ import { Label } from '@/components/ui/label'
import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'lucide-react'
import type { EntityType } from '@/types'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getBranding } from '@/lib/branding/service'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
const branding = getBranding()
const schema = z.object({
company_name: z.string().min(1, 'Företagsnamn krävs'),
org_number: z.string()
@@ -82,7 +79,7 @@ export default function Step2CompanyDetails({
const [isLooking, setIsLooking] = useState(false)
const [lookupError, setLookupError] = useState<string | null>(null)
const [lookupDone, setLookupDone] = useState<CompanyLookupResult | null>(null)
const [orgNumberExists, setOrgNumberExists] = useState(false)
const [existingOwn, setExistingOwn] = useState<{ id: string; name: string } | null>(null)
const abortRef = useRef<AbortController | null>(null)
const dupAbortRef = useRef<AbortController | null>(null)
// Tracks an orgnr that's been pre-verified (BankID CompanyRoles match) so
@@ -96,13 +93,14 @@ export default function Step2CompanyDetails({
const orgNumber = watch('org_number')
// Debounced duplicate check against Accounted's own companies table. Runs in
// parallel with the TIC lookup — they don't conflict. On match, the submit
// button is disabled; the server action would also reject ('org_number_exists')
// but blocking client-side avoids a wasted roundtrip.
// Soft, account-scoped duplicate warning: if THIS user already has a
// (non-archived) company with the same org number, surface a non-blocking
// note. Org-number reuse is allowed (see lib/company/actions.ts), so this
// never disables submit. The endpoint is RLS-scoped to the caller's own
// companies, so it can't reveal or count another account's.
useEffect(() => {
if (!orgNumber || normalizeOrgNumber(orgNumber) === null) {
setOrgNumberExists(false)
setExistingOwn(null)
return
}
const timer = setTimeout(() => {
@@ -115,10 +113,10 @@ export default function Step2CompanyDetails({
.then(async (res) => {
if (controller.signal.aborted || !res.ok) return
const { data } = await res.json()
setOrgNumberExists(!!data?.exists)
setExistingOwn(data?.companies?.[0] ?? null)
})
.catch(() => {
// Network failure is non-fatal — the server action will re-check.
// Advisory only — never blocks creation.
})
}, 500)
return () => {
@@ -267,12 +265,10 @@ export default function Step2CompanyDetails({
{ticEnabled && lookupError && (
<p className="text-xs text-muted-foreground">{lookupError}</p>
)}
{orgNumberExists && (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
{t('step2_company_exists', { appName: branding.appName.toLowerCase() })}
</span>
{existingOwn && (
<div className="flex items-start gap-2 rounded-md bg-warning/15 px-3 py-2 text-sm text-warning-foreground">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0 text-warning" />
<span>{t('step2_company_exists_own', { name: existingOwn.name })}</span>
</div>
)}
</div>
@@ -338,7 +334,7 @@ export default function Step2CompanyDetails({
</Button>
<Button
type="submit"
disabled={isSaving || orgNumberExists}
disabled={isSaving}
className="w-full sm:w-auto"
>
{isSaving ? (