feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined) (#455)

* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)

Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).

POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
  paid (or partially_paid when remaining_amount > 0). Three booking paths:
  - Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
    createInvoicePaymentJournalEntry — settles AR.
  - Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
    createInvoiceCashEntry — revenue recognition happens HERE under cash.
  - Custom lines (partial payment): caller-supplied balanced journal lines
    via createJournalEntry directly. Validated for balance (sum debits ==
    sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
  'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
  paymentAmount + paymentDate in the payload).

POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
  (ML 17 kap 22–23§). Creates a NEW invoice row with:
  - invoice_number = "KR-<original>"
  - credited_invoice_id = original id
  - status = 'sent'
  - All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
  step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
  if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
  only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).

Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
  pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
  recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
  the caller via meta.warnings.

Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.

URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.

17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.

3194/3194 vitest pass; build clean; lint clean on v1 paths.

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

* fix(api): address PR #455 review + include password-recovery fixes

PR #455 review fixes:

- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
  via eventBus must call ensureInitialized() at module level to wire
  extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
  (credit_note.created) had it — nor did the already-merged :mark-sent,
  POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
  ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
  so EVERY v1 route gets the init at import time. Single source of truth
  prevents future routes from forgetting (idempotent guard makes the
  repeated call safe). Cleaner than per-route copy of the call.

- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
  a comment explaining: after rounding to 2 decimals, newRemaining is in
  steps of 0.01; values ≤ half-an-öre only arise from float artefacts.

Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
  impossible by construction. The wrapper sets ctx.companyId FROM the URL
  params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
  alerts — same architectural / cross-surface deferred work as prior PRs;
  matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
  failure naming, test scope coverage — all recurring triage.

Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):

- app/(auth)/auth/callback/route.ts: when the callback exchanges a
  recovery token (type='recovery' or next='/reset-password'), redirect
  directly to /reset-password instead of running onboarding/MFA/
  dashboard checks. Previously users clicking the password-reset email
  got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
  authenticated users to / (the recovery flow lands here with a fresh
  session by design — the user is *supposed* to call updateUser({
  password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
  is set (expired/used recovery link), with a button to request a new
  one. Wrapped the page in <Suspense> because useSearchParams() now
  forces dynamic rendering (Next.js 16 static-prerender bail-out
  otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
  covering the recovery callback path.

3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.

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

* fix(api): mark-paid uses remaining_amount as default payment, not total

Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:

1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
   remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
   UPDATE matches partially_paid (race guard allows it). With the old
   logic the journal entry was for total=12500 against an AR balance
   of only 7500 — a 5000 over-credit.

Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.

3197/3197 vitest pass.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-12 23:52:18 +02:00
committed by GitHub
parent e4186523f9
commit cd96e5ec26
11 changed files with 1713 additions and 2 deletions
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const verifyOtp = vi.fn()
const exchangeCodeForSession = vi.fn()
vi.mock('@supabase/ssr', () => ({
createServerClient: vi.fn(() => ({
auth: {
verifyOtp,
exchangeCodeForSession,
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
mfa: {
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }),
listFactors: vi.fn().mockResolvedValue({ data: null }),
},
},
from: vi.fn(),
rpc: vi.fn(),
})),
}))
vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: vi.fn(),
}))
import { GET } from '../route'
describe('GET /auth/callback — recovery flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('redirects to /reset-password after a successful recovery OTP (token-hash flow)', async () => {
verifyOtp.mockResolvedValue({ error: null })
const request = new NextRequest(
'http://localhost:3000/auth/callback?token_hash=abc&type=recovery&next=/reset-password'
)
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'recovery' })
})
it('redirects to /reset-password after a successful PKCE exchange when next=/reset-password (no type param)', async () => {
exchangeCodeForSession.mockResolvedValue({ error: null })
const request = new NextRequest(
'http://localhost:3000/auth/callback?code=xyz&next=/reset-password'
)
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
expect(exchangeCodeForSession).toHaveBeenCalledWith('xyz')
})
it('redirects to /login?error=auth_error when the recovery OTP is expired or already consumed', async () => {
verifyOtp.mockResolvedValue({ error: { message: 'Token has expired or is invalid' } })
const request = new NextRequest(
'http://localhost:3000/auth/callback?token_hash=expired&type=recovery&next=/reset-password'
)
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('http://localhost:3000/login?error=auth_error')
})
})
+13
View File
@@ -54,6 +54,19 @@ export async function GET(request: NextRequest) {
if (authenticated) {
let redirectPath = next
// Password recovery flow: the user just exchanged a recovery token, so they
// have a fresh session whose only purpose is to call updateUser({ password })
// on /reset-password. Skip onboarding / team setup / dashboard redirect.
// The token-hash flow signals this via type=recovery; PKCE has no type, so
// also gate on next === '/reset-password' (only the reset request sets it).
if (type === 'recovery' || next === '/reset-password') {
const response = NextResponse.redirect(new URL('/reset-password', origin))
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
return response
}
const { data: { user } } = await supabase.auth.getUser()
if (user) {
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
+32 -2
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Suspense, useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -18,7 +18,17 @@ import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
// Wrapping in Suspense is required because useSearchParams() forces
// dynamic rendering in Next.js 16; static prerender bails out otherwise.
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginPageContent />
</Suspense>
)
}
function LoginPageContent() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
@@ -29,6 +39,8 @@ export default function LoginPage() {
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const callbackError = searchParams.get('error')
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
@@ -353,6 +365,24 @@ export default function LoginPage() {
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm font-medium text-destructive">
Återställningslänken fungerade inte
</p>
<p className="mt-1 text-sm text-destructive/90">
Länken har gått ut eller använts redan.{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
Begär en ny återställningslänk
</button>
.
</p>
</div>
)}
{bankIdEnabled && (
<>
{bankIdNoAccount ? (
@@ -0,0 +1,332 @@
/**
* Integration tests for POST /api/v1/companies/:companyId/invoices/:id/credit.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`credit route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createCreditNoteJournalEntry: vi.fn().mockResolvedValue({
id: 'mmmmmmmm-mmmm-4mmm-8mmm-mmmmmmmmmmmm',
}),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
createCreditNoteJournalEntry as mockedCreditEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { POST as creditInvoice } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreditEntry = mockedCreditEntry as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(url: string, body?: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-2020-4abc-8def-1234567890ab',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
const ORIGINAL_SENT_INVOICE = {
id: INVOICE_ID,
invoice_number: '2026-0042',
customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'sent',
document_type: 'invoice',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
moms_ruta: '05',
credited_invoice_id: null,
customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB' },
items: [{ sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }],
}
const CREATED_CREDIT_NOTE = {
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
invoice_number: 'KR-2026-0042',
customer_id: ORIGINAL_SENT_INVOICE.customer_id,
status: 'sent',
credited_invoice_id: INVOICE_ID,
total: -12500,
subtotal: -10000,
vat_amount: -2500,
document_type: 'invoice',
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:write'],
mode: 'live',
})
})
describe('POST /api/v1/companies/:companyId/invoices/:id/credit', () => {
it('issues a credit note with reversed amounts and posts the reverse journal entry', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: ORIGINAL_SENT_INVOICE, error: null }, // pre-flight read
{ data: CREATED_CREDIT_NOTE, error: null }, // insert returning
],
invoice_items: { data: null, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
{ reason: 'Felaktig kund' },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.invoice_number).toBe('KR-2026-0042')
expect(body.data.credited_invoice_id).toBe(INVOICE_ID)
expect(body.data.total).toBe(-12500)
expect(body.data.journal_entry_id).toBe('mmmmmmmm-mmmm-4mmm-8mmm-mmmmmmmmmmmm')
expect(mockCreditEntry).toHaveBeenCalledTimes(1)
})
it('returns 404 INVOICE_CREDIT_ORIGINAL_NOT_FOUND when the original is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: null, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREDIT_ORIGINAL_NOT_FOUND')
})
it('returns 409 INVOICE_CREDIT_ALREADY_CREDITED when original.status=credited', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...ORIGINAL_SENT_INVOICE, status: 'credited' }, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
// INVOICE_CREDIT_ALREADY_CREDITED is httpStatus 400 in the registry
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREDIT_ALREADY_CREDITED')
})
it('returns 400 INVOICE_CREDIT_NOT_SENT for drafts', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...ORIGINAL_SENT_INVOICE, status: 'draft' }, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('rejects crediting a credit note', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...ORIGINAL_SENT_INVOICE, credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' },
error: null,
},
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREDIT_NOT_INVOICE')
})
it('rejects delivery notes', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...ORIGINAL_SENT_INVOICE, document_type: 'delivery_note' }, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREDIT_NOT_INVOICE')
})
it('dry-run previews the credit note without inserting', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: ORIGINAL_SENT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit?dry_run=true`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.total).toBe(-12500)
expect(body.data.preview.invoice_number).toBe('KR-2026-0042')
expect(body.data.preview.credited_invoice_id).toBe(INVOICE_ID)
expect(body.data.preview.would_create_journal_entry).toBe(true)
expect(mockCreditEntry).not.toHaveBeenCalled()
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await creditInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(403)
})
it('rejects requests without Idempotency-Key', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
},
)
const res = await creditInvoice(req, detailParams(COMPANY_ID, INVOICE_ID))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
@@ -0,0 +1,460 @@
/**
* POST /api/v1/companies/{companyId}/invoices/{id}/credit
*
* Issues a credit note (kreditfaktura) against the invoice identified by `:id`.
* Per ML 17 kap 2223§, a kreditfaktura references the original invoice's
* löpnummer and carries reversed-sign amounts.
*
* Behaviour:
* 1. Validates the target is a real invoice (document_type='invoice') and
* currently sent / paid / overdue (already-credited rows are rejected).
* 2. Creates a NEW invoice row with credited_invoice_id set, status='sent',
* invoice_number='KR-<original>', and negated subtotal / vat / total.
* 3. Mirrors the items table with negated quantity + line_total + vat_amount.
* On items failure, rolls back the credit-note row (scoped DELETE).
* 4. Flips the original invoice to status='credited'.
* 5. Posts the reverse journal entry via createCreditNoteJournalEntry
* (accrual only — cash basis defers recognition to refund time).
* 6. Emits invoice.credited.
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable. The credit-note row
* gets created via INSERT — under dry-run NO row is created.
*
* Optional body: { reason?: string } — populates the credit note's `notes`
* field. Defaults to "Krediterar faktura <original>".
*/
import { z } from 'zod'
import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { eventBus } from '@/lib/events'
import type { AccountingMethod, CreditNote, EntityType, Invoice } from '@/types'
const CreditNoteRequest = z.object({
reason: z.string().max(2000).optional(),
})
const ORIGINAL_INVOICE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type'
const CREDIT_NOTE_RESPONSE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, created_at, updated_at'
const ORIGINAL_ITEMS_COLUMNS =
'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount'
const CreditNoteCreated = z.object({
id: z.string().uuid(),
invoice_number: z.string(),
credited_invoice_id: z.string().uuid(),
status: z.literal('sent'),
total: z.number(),
journal_entry_id: z.string().uuid().nullable(),
warnings: z
.array(z.object({ code: z.string(), message: z.string() }))
.optional(),
})
registerEndpoint({
operation: 'invoices.credit',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices/:id/credit',
summary: 'Issue a credit note (kreditfaktura) against an invoice.',
description:
'Creates a credit note referencing the original invoice. The credit note carries reversed-sign amounts (matching the original line for line) and gets invoice_number=KR-<original>. The original invoice transitions to status=credited. Under faktureringsmetoden, posts a reversing journal entry (Credit AR 1510 / Debit revenue + Debit output VAT). Under kontantmetoden the credit note still creates the row but defers the reversal entry until refund. Idempotent and dry-runnable. Emits invoice.credited.',
useWhen:
'You need to legally cancel an issued invoice (ML 17 kap 2223§). The original invoice cannot be edited once issued — credit it and reissue corrected.',
doNotUseFor:
'Cancelling a draft (DELETE the draft instead). Refunding a partial payment without invalidating the whole invoice (book the refund manually via the journal-entries API in a future PR).',
pitfalls: [
'Idempotency-Key is mandatory. Retried credits with the same key replay the cached response — no duplicate credit note is created.',
'The original invoice must be in sent / paid / overdue status. Drafts, cancelled invoices, and already-credited invoices are rejected with specific error codes.',
'Credit-note items mirror the original\'s lines with negated values. To credit only part of an invoice (line-level), credit the full invoice first then reissue with the corrected lines.',
'Under kontantmetoden no journal entry is created here — refund booking is deferred. A `JOURNAL_ENTRY_NOT_POSTED` warning is NOT emitted in this case (the deferral is correct, not a failure).',
],
example: {
request: { reason: 'Felaktig kund' },
response: {
data: {
id: 'ccccccc-c…',
invoice_number: 'KR-2026-0042',
credited_invoice_id: '0e9c…',
status: 'sent',
total: -12500,
journal_entry_id: '8b4b…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: CreditNoteRequest },
response: { success: CreditNoteCreated },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.credit',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const originalId = idParse.data
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
// Body is optional. Empty POST is valid (uses default notes).
let rawBody: unknown = null
try {
const text = await request.text()
if (text.trim()) rawBody = JSON.parse(text)
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
let reason: string | undefined
if (rawBody) {
const parsed = CreditNoteRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
reason = parsed.data.reason
}
// Pre-flight: fetch original invoice + items.
const { data: originalInvoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(`${ORIGINAL_INVOICE_COLUMNS}, customer:customers(id, name), items:invoice_items(${ORIGINAL_ITEMS_COLUMNS})`)
.eq('company_id', ctx.companyId!)
.eq('id', originalId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!originalInvoice) {
ctx.log.warn('invoices.credit: original not found', {
invoiceId: originalId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
})
}
type OriginalShape = Invoice & {
customer?: { name?: string }
items?: Array<{
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
vat_rate?: number | null
vat_amount?: number | null
}>
}
const original = originalInvoice as unknown as OriginalShape
// Document-shape guards.
if (original.document_type && original.document_type !== 'invoice') {
return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', ctx.log, {
requestId: ctx.requestId,
details: { document_type: original.document_type },
})
}
if (original.credited_invoice_id) {
// Original IS itself a credit note — can't credit a credit.
return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'cannot credit a credit note' },
})
}
if (original.status === 'credited') {
return v1ErrorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', ctx.log, {
requestId: ctx.requestId,
})
}
if (!['sent', 'paid', 'overdue'].includes(original.status)) {
return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_SENT', ctx.log, {
requestId: ctx.requestId,
details: { current_status: original.status },
})
}
const today = new Date().toISOString().split('T')[0]
const creditNoteNumber = `KR-${original.invoice_number ?? original.id.slice(0, 8)}`
const negate = (n: number | null | undefined): number =>
n == null ? 0 : -Math.abs(n)
const negateNullable = (n: number | null | undefined): number | null =>
n == null ? null : -Math.abs(n)
// Compute credit-note items + totals up front (used in both dry-run and commit).
const creditNoteRow = {
user_id: ctx.userId,
company_id: ctx.companyId!,
customer_id: original.customer_id,
invoice_number: creditNoteNumber,
invoice_date: today,
due_date: today,
delivery_date: original.delivery_date ?? null,
currency: original.currency,
exchange_rate: original.exchange_rate ?? null,
exchange_rate_date: original.exchange_rate_date ?? null,
subtotal: negate(original.subtotal),
subtotal_sek: negateNullable(original.subtotal_sek),
vat_amount: negate(original.vat_amount),
vat_amount_sek: negateNullable(original.vat_amount_sek),
total: negate(original.total),
total_sek: negateNullable(original.total_sek),
vat_treatment: original.vat_treatment,
vat_rate: original.vat_rate,
moms_ruta: original.moms_ruta,
reverse_charge_text: original.reverse_charge_text ?? null,
your_reference: original.your_reference ?? null,
our_reference: original.our_reference ?? null,
notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`,
credited_invoice_id: originalId,
status: 'sent' as const,
document_type: 'invoice' as const,
}
const creditNoteItems = (original.items ?? []).map((item) => ({
sort_order: item.sort_order,
description: item.description,
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -Math.abs(item.vat_amount ?? 0),
}))
// Fetch settings for accounting method + entity type.
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod =
((settings as { accounting_method?: string } | null)?.accounting_method ??
'accrual') as AccountingMethod
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ??
'enskild_firma') as EntityType
const wouldCreateJournalEntry = accountingMethod === 'accrual'
if (ctx.dryRun) {
return dryRunPreview(
{
id: '(allocated on commit)',
...creditNoteRow,
// Strip internal ids from the preview.
user_id: undefined,
company_id: undefined,
items: creditNoteItems,
would_create_journal_entry: wouldCreateJournalEntry,
accounting_method: accountingMethod,
original_invoice_number: original.invoice_number,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
// Commit. Step 1: insert credit note header.
const { data: creditNote, error: insertErr } = await ctx.supabase
.from('invoices')
.insert(creditNoteRow)
.select(CREDIT_NOTE_RESPONSE_COLUMNS)
.single()
if (insertErr) {
ctx.log.error('credit-note insert failed', insertErr, {
invoiceId: originalId,
companyId: ctx.companyId,
pgCode: insertErr.code,
})
return v1ErrorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: insertErr.code },
})
}
const creditNoteId = (creditNote as { id: string }).id
// Step 2: insert items. Roll back on failure.
const itemsToInsert = creditNoteItems.map((r) => ({ ...r, invoice_id: creditNoteId }))
const { error: itemsErr } = await ctx.supabase.from('invoice_items').insert(itemsToInsert)
if (itemsErr) {
const { error: rollbackErr } = await ctx.supabase
.from('invoices')
.delete()
.eq('id', creditNoteId)
.eq('company_id', ctx.companyId!)
if (rollbackErr) {
ctx.log.error(
'credit-note items insert failed AND rollback delete failed — orphaned header',
rollbackErr,
{
creditNoteId,
originalInvoiceId: originalId,
companyId: ctx.companyId,
originalPgCode: itemsErr.code,
},
)
} else {
ctx.log.error('credit-note items insert failed; rolled back', itemsErr, {
creditNoteId,
companyId: ctx.companyId,
})
}
return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { pg_code: itemsErr.code },
})
}
// Step 3: flip original invoice to credited.
const warnings: { code: string; message: string }[] = []
const { error: flipErr } = await ctx.supabase
.from('invoices')
.update({ status: 'credited', updated_at: new Date().toISOString() })
.eq('id', originalId)
.eq('company_id', ctx.companyId!)
if (flipErr) {
ctx.log.error('credit: failed to mark original as credited', flipErr as Error, {
invoiceId: originalId,
creditNoteId,
companyId: ctx.companyId,
})
warnings.push({
code: 'ORIGINAL_NOT_FLIPPED',
message: 'Credit note was created but the original invoice could not be marked credited. Reconcile manually.',
})
}
// Step 4: post the reverse journal entry (accrual only). Best-effort.
let journalEntryId: string | null = null
if (wouldCreateJournalEntry) {
try {
const refreshedCreditNote = {
...creditNote,
items: itemsToInsert,
customer: original.customer,
} as unknown as Invoice
const entry = await createCreditNoteJournalEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
refreshedCreditNote,
entityType,
original.customer?.name,
)
if (entry) {
journalEntryId = entry.id
const { error: writeBackErr } = await ctx.supabase
.from('invoices')
.update({ journal_entry_id: entry.id })
.eq('id', creditNoteId)
.eq('company_id', ctx.companyId!)
if (writeBackErr) {
ctx.log.error('credit: journal_entry_id write-back failed', writeBackErr as Error, {
creditNoteId,
journalEntryId: entry.id,
})
warnings.push({
code: 'JOURNAL_ENTRY_ID_WRITEBACK_FAILED',
message: 'Credit-note journal entry was posted but the row could not be updated with its id. Re-fetch and reconcile.',
})
}
} else {
ctx.log.error('credit: journal entry not created (engine returned null)', new Error('null entry'), {
creditNoteId,
})
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message: 'Credit note was created but no journal entry was posted. Check fiscal period and the engine logs (BFL 5 kap reconciliation required).',
})
}
} catch (err) {
ctx.log.error('credit: journal entry creation failed', err as Error, {
creditNoteId,
companyId: ctx.companyId,
})
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message: 'Credit note was created but the journal entry failed. Reconcile manually.',
})
}
}
// Step 5: emit credit_note.created (existing event in the bus). The
// payload carries the new credit note; subscribers can read
// credited_invoice_id off it to find the original.
try {
await eventBus.emit({
type: 'credit_note.created',
payload: {
creditNote: { ...(creditNote as object), customer: original.customer } as unknown as CreditNote,
companyId: ctx.companyId!,
userId: ctx.userId,
},
})
} catch (err) {
ctx.log.error('credit_note.created emit failed', err as Error, {
creditNoteId,
companyId: ctx.companyId,
})
warnings.push({
code: 'EVENT_EMIT_FAILED',
message: 'credit_note.created event did not reach the bus; downstream subscribers may miss this transition.',
})
}
ctx.log.info('invoices.credit success', {
creditNoteId,
originalInvoiceId: originalId,
companyId: ctx.companyId,
userId: ctx.userId,
creditNoteNumber,
journalEntryId,
hadWarnings: warnings.length > 0,
})
return created(
{
...(creditNote as object),
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,331 @@
/**
* Integration tests for POST /api/v1/companies/:companyId/invoices/:id/mark-paid.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`mark-paid route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
// Stub the journal-entry helpers; route flow is what we're testing.
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoicePaymentJournalEntry: vi.fn().mockResolvedValue({
id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj',
}),
createInvoiceCashEntry: vi.fn().mockResolvedValue({
id: 'kkkkkkkk-kkkk-4kkk-8kkk-kkkkkkkkkkkk',
}),
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'llllllll-llll-4lll-8lll-llllllllllll',
}),
findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
createInvoicePaymentJournalEntry as mockedPayment,
createInvoiceCashEntry as mockedCash,
} from '@/lib/bookkeeping/invoice-entries'
import { POST as markPaid } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockPayment = mockedPayment as ReturnType<typeof vi.fn>
const mockCash = mockedCash as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
function makeRequest(url: string, body?: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
const SENT_INVOICE = {
id: INVOICE_ID,
invoice_number: '2026-0042',
customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
invoice_date: '2026-05-12',
due_date: '2026-06-11',
status: 'sent',
document_type: 'invoice',
currency: 'SEK',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
paid_at: null,
vat_treatment: 'standard_25',
moms_ruta: '05',
credited_invoice_id: null,
customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB' },
items: [{ sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }],
}
const PAID_INVOICE = {
...SENT_INVOICE,
status: 'paid',
remaining_amount: 0,
paid_amount: 12500,
paid_at: '2026-05-12',
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['invoices:write'],
mode: 'live',
})
})
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
it('books a full payment under faktureringsmetoden (accrual default)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: SENT_INVOICE, error: null },
{ data: PAID_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{ payment_date: '2026-05-12' },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('paid')
expect(body.data.remaining_amount).toBe(0)
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockPayment).toHaveBeenCalled()
expect(mockCash).not.toHaveBeenCalled()
})
it('uses the cash-basis booking when accounting_method=cash', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: SENT_INVOICE, error: null },
{ data: PAID_INVOICE, error: null },
],
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(mockCash).toHaveBeenCalled()
expect(mockPayment).not.toHaveBeenCalled()
})
it('returns 400 INVOICE_PAID_LINES_UNBALANCED when custom lines do not balance', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: SENT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{
lines: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 4000 }, // unbalanced
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_PAID_LINES_UNBALANCED')
})
it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: { ...SENT_INVOICE, status: 'draft' }, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
})
it('rejects credit notes', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: {
data: { ...SENT_INVOICE, credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' },
error: null,
},
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('credited_invoice_id')
})
it('dry-run previews the post-payment state without booking', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: SENT_INVOICE, error: null },
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid?dry_run=true`,
{ payment_date: '2026-05-12' },
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.dry_run).toBe(true)
expect(body.data.preview.status).toBe('paid')
expect(body.data.preview.remaining_amount).toBe(0)
expect(body.data.preview.would_create_journal_entry).toBe(true)
expect(mockPayment).not.toHaveBeenCalled()
})
it('returns 404 INVOICE_PAID_NOT_FOUND when invoice does not belong to company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: null, error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_PAID_NOT_FOUND')
})
it('rejects keys without invoices:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(403)
})
})
@@ -0,0 +1,460 @@
/**
* POST /api/v1/companies/{companyId}/invoices/{id}/mark-paid
*
* Manually marks an invoice as paid — for payments received outside the
* bank-sync flow.
*
* Accounting:
* - Faktureringsmetoden (accrual): Debit 1930 / Credit 1510. The invoice
* was already booked as revenue at :mark-sent; this just settles the AR.
* - Kontantmetoden (cash): Debit 1930 / Credit 30xx + Credit 26xx. Revenue
* recognition happens here (no entry at :mark-sent under cash basis).
*
* Optional request body (all fields optional — empty POST = book full payment
* on today's date with default lines):
* - payment_date ISO date; defaults to today
* - exchange_rate_difference SEK adjustment for foreign-currency invoices
* - lines Custom balanced journal lines (partial payments)
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
*
* On commit:
* 1. Build journal entry (default 1930/1510 split, or custom lines).
* 2. Post via createInvoicePaymentJournalEntry / createJournalEntry.
* 3. Update invoice: status → 'paid' (or 'partially_paid' for partial),
* remaining_amount decremented, paid_at set, paid_amount accumulated.
* 4. Emit invoice.paid.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import {
createInvoiceCashEntry,
createInvoicePaymentJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { eventBus } from '@/lib/events'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
const INVOICE_MARK_PAID_RESPONSE_COLUMNS =
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at'
const InvoiceMarkPaidResponse = z.object({
id: z.string().uuid(),
invoice_number: z.string(),
status: z.enum(['paid', 'partially_paid']),
total: z.number(),
paid_amount: z.number(),
remaining_amount: z.number(),
paid_at: z.string().nullable(),
journal_entry_id: z.string().uuid().nullable(),
warnings: z
.array(z.object({ code: z.string(), message: z.string() }))
.optional(),
})
registerEndpoint({
operation: 'invoices.mark-paid',
method: 'POST',
path: '/api/v1/companies/:companyId/invoices/:id/mark-paid',
summary: 'Record a payment against an invoice.',
description:
'Marks a sent / overdue invoice as paid (or partially_paid). Books the payment via Debit 1930 / Credit 1510 under faktureringsmetoden, or Debit 1930 / Credit revenue + Credit output VAT under kontantmetoden. Optional body supports partial payments via custom balanced journal lines and exchange-rate adjustments for foreign-currency invoices. Idempotent and dry-runnable. Emits invoice.paid.',
useWhen:
'A customer paid an invoice via a channel other than the synced bank account (cash, manual transfer, separate processor). Use dry-run to confirm the booking before committing.',
doNotUseFor:
'Reverting a payment — the public API does not expose unmark-paid. Issue a credit note via POST /:id/credit to cancel the underlying invoice instead. Bank-matched payments — those flow through the transactions endpoints.',
pitfalls: [
'Idempotency-Key is mandatory. Retried marks with the same key replay the cached response.',
'Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED.',
'For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice\'s booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss.',
'Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method.',
],
example: {
request: { payment_date: '2026-05-12' },
response: {
data: {
id: '0e9c…',
invoice_number: '2026-0042',
status: 'paid',
total: 12500,
paid_amount: 12500,
remaining_amount: 0,
paid_at: '2026-05-12',
journal_entry_id: '7b3a…',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'invoices:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: MarkInvoicePaidSchema },
response: { success: InvoiceMarkPaidResponse },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'invoices.mark-paid',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const invoiceId = idParse.data
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
// Body is optional. Empty POST → book full payment today.
let rawBody: unknown = null
try {
const text = await request.text()
if (text.trim()) rawBody = JSON.parse(text)
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
let exchangeRateDifference: number | undefined
let bodyPaymentDate: string | undefined
let customLines:
| {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}[]
| undefined
if (rawBody) {
const parsed = MarkInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
customLines = parsed.data.lines
}
// Pre-flight: fetch invoice with relations needed for journal entry.
const { data: invoice, error: fetchErr } = await ctx.supabase
.from('invoices')
.select(
`${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`,
)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
if (fetchErr) {
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
}
if (!invoice) {
ctx.log.warn('invoices.mark-paid: not found', { invoiceId, companyId: ctx.companyId })
return v1ErrorResponseFromCode('INVOICE_PAID_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
})
}
const typed = invoice as unknown as Invoice & { customer?: { name?: string } }
// Document-shape guards before status check (consistent with mark-sent).
if (typed.document_type === 'delivery_note') {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'document_type',
message: 'Delivery notes do not have payment lifecycle.',
},
})
}
if (typed.credited_invoice_id) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'credited_invoice_id',
message: 'Credit notes cannot be marked paid; the original invoice they credit was already accounted for.',
},
})
}
if (typed.status !== 'sent' && typed.status !== 'overdue') {
return v1ErrorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', ctx.log, {
requestId: ctx.requestId,
details: { current_status: typed.status },
})
}
// Validate custom lines balance (if supplied).
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', ctx.log, {
requestId: ctx.requestId,
details: { total_debit: totalDebit, total_credit: totalCredit },
})
}
}
const today = new Date().toISOString().split('T')[0]
const paymentDate = bodyPaymentDate || today
// Fetch settings for accounting method + entity type.
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', ctx.companyId!)
.maybeSingle()
const accountingMethod =
(settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ??
'enskild_firma') as EntityType
// Compute the would-be payment amount. Default path (no customLines):
// use remaining_amount, not total — protects against over-crediting AR
// when a concurrent partial payment slips through the pre-flight check
// (pre-flight sees status='sent' but the race-guard UPDATE later sees
// status='partially_paid' so a second full-total amount would be booked
// against an already-reduced AR balance).
const paymentAmount = customLines
? customLines.reduce((s, l) => s + l.debit_amount, 0)
: (typed.remaining_amount ?? typed.total)
const isPartial =
customLines !== undefined &&
Math.abs(paymentAmount - (typed.remaining_amount ?? typed.total)) > 0.005 // same half-öre epsilon as above
const newRemaining = Math.max(
0,
Math.round(((typed.remaining_amount ?? typed.total) - paymentAmount) * 100) / 100,
)
// 0.005 epsilon = half an öre. After rounding to 2 decimals above,
// newRemaining is in steps of 0.01; values ≤ 0.005 only arise from
// floating-point artefacts (e.g. 0.0000000001 from a SEK 99.99 payment
// against a SEK 99.99 invoice). Treating those as 'paid' avoids
// permanently-partially_paid invoices on full payment.
const newStatus: 'paid' | 'partially_paid' = newRemaining <= 0.005 ? 'paid' : 'partially_paid'
const newPaidAmount =
Math.round(((typed.paid_amount ?? 0) + paymentAmount) * 100) / 100
if (ctx.dryRun) {
return dryRunPreview(
{
...typed,
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
paid_at: paymentDate,
would_create_journal_entry: !typed.document_type || typed.document_type === 'invoice',
accounting_method: accountingMethod,
would_use_custom_lines: customLines !== undefined,
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
const warnings: { code: string; message: string }[] = []
// Commit path. Step 1: book the journal entry. Three flavors:
// - Custom lines (partial payment etc.) → createJournalEntry directly
// - Cash basis → createInvoiceCashEntry (recognizes revenue here)
// - Accrual basis → createInvoicePaymentJournalEntry (settles AR)
let journalEntryId: string | null = null
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
if (isRealInvoice) {
try {
if (customLines) {
const fiscalPeriodId = await findFiscalPeriod(
ctx.supabase,
ctx.companyId!,
paymentDate,
)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', ctx.log, {
requestId: ctx.requestId,
details: { payment_date: paymentDate },
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Delbetalning faktura ${typed.invoice_number ?? typed.id}`,
source_type: 'invoice_paid',
source_id: invoiceId,
lines: customLines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? undefined,
})),
}
const entry = await createJournalEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
input,
)
journalEntryId = entry?.id ?? null
} else if (accountingMethod === 'cash') {
const entry = await createInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
typed as Invoice,
paymentDate,
entityType,
typed.customer?.name,
)
journalEntryId = entry?.id ?? null
} else {
const entry = await createInvoicePaymentJournalEntry(
ctx.supabase,
ctx.companyId!,
ctx.userId,
typed as Invoice,
paymentDate,
exchangeRateDifference,
typed.customer?.name,
// Pass full or partial amount depending on path.
customLines ? paymentAmount : undefined,
)
journalEntryId = entry?.id ?? null
}
if (!journalEntryId) {
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message:
'Payment journal entry was not created (likely no open fiscal period). Verify the period and book manually if required.',
})
}
} catch (err) {
ctx.log.error('mark-paid: journal entry creation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'JOURNAL_ENTRY_NOT_POSTED',
message:
'Payment was recorded but the journal entry posting failed. Check the engine logs; reconcile before period close.',
})
}
}
// Step 2: update the invoice row.
const updatePayload: Record<string, unknown> = {
status: newStatus,
remaining_amount: newRemaining,
paid_amount: newPaidAmount,
updated_at: new Date().toISOString(),
}
if (newStatus === 'paid') {
updatePayload.paid_at = paymentDate
}
if (journalEntryId) {
updatePayload.journal_entry_id = journalEntryId
}
const { data: updated, error: updateErr } = await ctx.supabase
.from('invoices')
.update(updatePayload)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
// Race guard: only flip from a payable status.
.in('status', ['sent', 'overdue', 'partially_paid'])
.select(INVOICE_MARK_PAID_RESPONSE_COLUMNS)
.maybeSingle()
if (updateErr) {
ctx.log.error('mark-paid: invoice update failed', updateErr as Error, {
invoiceId,
companyId: ctx.companyId,
pgCode: (updateErr as { code?: string }).code,
})
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, {
requestId: ctx.requestId,
})
}
if (!updated) {
// Race: status transitioned (concurrent mark-paid / credit) between
// pre-flight and our update. Surface as 409.
ctx.log.warn('mark-paid: race — invoice status transitioned during request', {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_PAID_RACE', ctx.log, {
requestId: ctx.requestId,
})
}
// Step 3: emit invoice.paid (best-effort, surfaces in warnings on fail).
try {
await eventBus.emit({
type: 'invoice.paid',
payload: {
invoice: updated as unknown as Invoice,
companyId: ctx.companyId!,
userId: ctx.userId,
paymentAmount,
paymentDate,
},
})
} catch (err) {
ctx.log.error('invoice.paid emit failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'EVENT_EMIT_FAILED',
message: 'invoice.paid event did not reach the bus; downstream subscribers may miss this transition.',
})
}
ctx.log.info('invoices.mark-paid success', {
invoiceId,
companyId: ctx.companyId,
userId: ctx.userId,
newStatus,
journalEntryId,
paymentAmount,
isPartial,
hadWarnings: warnings.length > 0,
})
return ok(
{
...(updated as object),
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
+2
View File
@@ -22,5 +22,7 @@ import '@/app/api/v1/companies/[companyId]/customers/route'
import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
// Phase 2 PR-B-2b — invoice action verbs.
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
export {}
+9
View File
@@ -33,6 +33,7 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import {
type ApiKeyMode,
type ApiKeyScope,
@@ -41,6 +42,14 @@ import {
hasScope,
validateApiKey,
} from '@/lib/auth/api-keys'
// Per CLAUDE.md: any route that emits events via eventBus must call
// ensureInitialized() at module level to wire extension event handlers
// (email, cloud-backup, push-notifications, etc.). Calling it here in the
// wrapper guarantees every v1 route gets the init at import time — a single
// source of truth so future routes can't forget. The function itself is
// idempotent (guarded by a module-level boolean).
ensureInitialized()
import { resolveRequiredScope } from '@/lib/auth/scopes'
import {
checkIdempotencyKey,
+2
View File
@@ -61,6 +61,8 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb)
// because Next.js routes don't support `:` in folder names.
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write',
'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write',
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
+1
View File
@@ -30,6 +30,7 @@ export type CoreEvent =
// Invoicing
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } }
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
// Banking
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }