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
@@ -1,50 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { createClient } from '@/lib/supabase/server'
import { GET } from '../route'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
function mockAuth(user: { id: string } | null) {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
/**
* Service-role mock. Returns a match only if the incoming `.eq('org_number', X)`
* value matches `existing`. Anything else (or empty `existing`) returns null.
* Minimal authenticated-client mock. `companies.data` seeds what the RLS-scoped
* `from('companies').select().eq().is()` chain resolves to. In production RLS
* filters this to the caller's own memberships; the route does no extra
* filtering, so the test just controls what the query returns.
*/
function mockService(existing?: string) {
let lastOrgNumber: string | null = null
function buildSupabase(opts: {
user: { id: string } | null
companies?: { data?: unknown; error?: unknown }
}) {
const result = {
data: opts.companies?.data ?? null,
error: opts.companies?.error ?? null,
}
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'limit', 'maybeSingle']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
if (m === 'eq' && args[0] === 'org_number') {
lastOrgNumber = String(args[1])
}
if (m === 'maybeSingle') {
return Promise.resolve({
data: existing && lastOrgNumber === existing ? { id: 'other' } : null,
error: null,
})
}
return chain
}
for (const m of ['select', 'eq', 'is', 'limit', 'order']) {
chain[m] = () => chain
}
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }) },
from: vi.fn(() => chain),
}
mockCreateServiceClient.mockReturnValue({
from: vi.fn().mockReturnValue(chain),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
beforeEach(() => {
@@ -53,68 +41,65 @@ beforeEach(() => {
describe('GET /api/company/check-org-number', () => {
it('returns 401 when unauthenticated', async () => {
mockAuth(null)
mockService()
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status } = await parseJsonResponse(await GET(req))
mockCreateClient.mockResolvedValue(buildSupabase({ user: null }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when org_number is missing', async () => {
mockAuth({ id: 'user-1' })
mockService()
const req = createMockRequest('/api/company/check-org-number')
const { status } = await parseJsonResponse(await GET(req))
mockCreateClient.mockResolvedValue(buildSupabase({ user: { id: 'u1' } }) as never)
const res = await GET(createMockRequest('/api/company/check-org-number'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns exists=false when the org number is not registered', async () => {
mockAuth({ id: 'user-1' })
mockService(undefined) // no existing match
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status, body } = await parseJsonResponse(await GET(req))
it('returns exists:false for malformed org_number without querying', async () => {
const supabase = buildSupabase({ user: { id: 'u1' } })
mockCreateClient.mockResolvedValue(supabase as never)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=not-a-number'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: unknown[] }
}>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
expect(body.data.exists).toBe(false)
expect(body.data.companies).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns exists=true when the org number is already registered', async () => {
mockAuth({ id: 'user-1' })
mockService('5560125790')
const req = createMockRequest('/api/company/check-org-number?org_number=5560125790')
const { status, body } = await parseJsonResponse(await GET(req))
it("reports the user's own matching companies (account-scoped via RLS)", async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({
user: { id: 'u1' },
companies: { data: [{ id: 'c1', name: 'Acme AB' }] },
}) as never,
)
// Hyphenated input still matches the stored 10-digit canonical.
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=556012-5790'))
const { status, body } = await parseJsonResponse<{
data: { exists: boolean; companies: { id: string; name: string }[] }
}>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
expect(body.data.exists).toBe(true)
expect(body.data.companies).toEqual([{ id: 'c1', name: 'Acme AB' }])
})
it('normalizes formatted org numbers before lookup (strips hyphens/spaces)', async () => {
mockAuth({ id: 'user-1' })
mockService('5560125790')
const req = createMockRequest('/api/company/check-org-number?org_number=556012-5790')
const { status, body } = await parseJsonResponse(await GET(req))
it('returns exists:false when the user has no company with that org number', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { data: [] } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(res)
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
expect(body.data.exists).toBe(false)
})
it('normalizes 12-digit input to 10-digit canonical before lookup', async () => {
// Stored form is 10-digit canonical (8001011231); user types 12-digit
// personnummer with century prefix.
mockAuth({ id: 'user-1' })
mockService('8001011231')
const req = createMockRequest('/api/company/check-org-number?org_number=198001011231')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(true)
})
it('returns exists=false for Luhn-invalid input (not a duplicate of anything)', async () => {
// The submit-time server action will reject this as org_number_invalid;
// here we just confirm the check endpoint doesn't produce a misleading
// "exists=true" result by accidentally matching an invalid number.
mockAuth({ id: 'user-1' })
mockService('5560125790') // a real registered number
const req = createMockRequest('/api/company/check-org-number?org_number=5560125791')
const { status, body } = await parseJsonResponse(await GET(req))
expect(status).toBe(200)
expect((body as { data: { exists: boolean } }).data.exists).toBe(false)
it('returns 500 when the query errors', async () => {
mockCreateClient.mockResolvedValue(
buildSupabase({ user: { id: 'u1' }, companies: { error: { message: 'boom' } } }) as never,
)
const res = await GET(createMockRequest('/api/company/check-org-number?org_number=5560125790'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(500)
})
})
+31 -25
View File
@@ -1,30 +1,31 @@
import { NextResponse } from 'next/server'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
/**
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
*
* Returns `{ data: { exists: boolean } }` indicating whether the given
* organisation number is already registered in any non-archived Accounted
* company. Used by the onboarding wizard to warn users before they try to
* create a duplicate.
* Returns `{ data: { exists: boolean, companies: { id, name }[] } }` for the
* companies the CURRENT USER already has with the given organisation number —
* scoped to their own account only.
*
* Normalizes the input with the same rule as the server action
* (`normalizeOrgNumber`) so that a 12-digit form typed in the UI still
* matches a 10-digit stored canonical. Returns `exists: false` for
* malformed input — the submit-time server action will reject it with
* `org_number_invalid`, which is the right place to surface the error.
* Org-number reuse across the platform is intentionally allowed (see
* lib/company/actions.ts), so this is a soft, account-scoped warning, NOT a
* uniqueness gate. It uses the normal authenticated client on purpose: the
* `companies` SELECT RLS policy limits results to companies the caller is a
* member of (id IN user_company_ids()), so it can never reveal another user's
* companies and can't be used to enumerate org numbers platform-wide.
*
* Requires authentication so the endpoint can't be used to enumerate the
* full set of org numbers on the platform. Uses the service role internally
* because RLS hides rows the caller isn't a member of — which is exactly
* what we need to detect ("owned by someone else").
* Normalizes input with the same rule as the create action so a 12-digit form
* still matches a stored 10-digit canonical. Returns no matches for malformed
* input — the create action rejects that separately as `org_number_invalid`.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// requireAuth() (not a raw getUser()) so MFA AAL2 is enforced on hosted before
// we run the account-scoped lookup. The returned client carries the caller's
// RLS context, which is what scopes the companies SELECT below.
const { supabase, error: authError } = await requireAuth()
if (authError) return authError
const url = new URL(request.url)
const raw = url.searchParams.get('org_number') ?? ''
@@ -34,22 +35,27 @@ export async function GET(request: Request) {
const canonical = normalizeOrgNumber(raw)
if (!canonical) {
// Invalid format/Luhn — not a duplicate of anything by definition.
return NextResponse.json({ data: { exists: false } })
// Malformed input is not a duplicate of anything by definition.
return NextResponse.json({ data: { exists: false, companies: [] } })
}
const service = createServiceClient()
const { data, error } = await service
// RLS scopes this SELECT to the caller's own memberships (companies_select:
// id IN user_company_ids()), so the result is inherently account-scoped.
const { data, error } = await supabase
.from('companies')
.select('id')
.select('id, name')
.eq('org_number', canonical)
.is('archived_at', null)
.limit(1)
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { exists: !!data } })
const companies = (data ?? []).map((c: { id: string; name: string }) => ({
id: c.id,
name: c.name,
}))
return NextResponse.json({
data: { exists: companies.length > 0, companies },
})
}