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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 22–23§, 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 22–23§). 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 },
|
||||
)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
Reference in New Issue
Block a user