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:
co-authored by
Claude Opus 4.7
parent
e4186523f9
commit
cd96e5ec26
@@ -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) {
|
if (authenticated) {
|
||||||
let redirectPath = next
|
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()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (user) {
|
if (user) {
|
||||||
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
|
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { Suspense, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -18,7 +18,17 @@ import { getBranding } from '@/lib/branding/service'
|
|||||||
const branding = getBranding()
|
const branding = getBranding()
|
||||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
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() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginPageContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPageContent() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -29,6 +39,8 @@ export default function LoginPage() {
|
|||||||
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
|
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const callbackError = searchParams.get('error')
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const bankIdEnabled = isBankIdEnabled()
|
const bankIdEnabled = isBankIdEnabled()
|
||||||
|
|
||||||
@@ -353,6 +365,24 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
<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 && (
|
{bankIdEnabled && (
|
||||||
<>
|
<>
|
||||||
{bankIdNoAccount ? (
|
{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'
|
import '@/app/api/v1/companies/[companyId]/customers/[id]/route'
|
||||||
// Phase 2 PR-B-2b — invoice action verbs.
|
// 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-sent/route'
|
||||||
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route'
|
||||||
|
import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route'
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import {
|
import {
|
||||||
type ApiKeyMode,
|
type ApiKeyMode,
|
||||||
type ApiKeyScope,
|
type ApiKeyScope,
|
||||||
@@ -41,6 +42,14 @@ import {
|
|||||||
hasScope,
|
hasScope,
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
} from '@/lib/auth/api-keys'
|
} 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 { resolveRequiredScope } from '@/lib/auth/scopes'
|
||||||
import {
|
import {
|
||||||
checkIdempotencyKey,
|
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)
|
// 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.
|
// 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-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)
|
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
|
||||||
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type CoreEvent =
|
|||||||
// Invoicing
|
// Invoicing
|
||||||
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
||||||
| { type: 'invoice.sent'; 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 } }
|
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
|
||||||
// Banking
|
// Banking
|
||||||
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
|
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
|
||||||
|
|||||||
Reference in New Issue
Block a user