Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries * fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`, every extension that called `settings.set(key, null)` to clear stored state (cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration consent reset) silently failed — the upsert hit the NOT NULL constraint and the error was swallowed, leaving users stuck with stale connection rows. Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a real DELETE, switches the four affected handlers, and makes `set()` throw on Supabase error so this class of silent failure can't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(journal-entries): add draft saving functionality to journal entry form * feat: add periodisk sammanställning report generation and CSV export - Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly). - Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling. - Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format. - Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration. - Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses. - Updated journal entries to include the new source type for privately paid supplier invoices. * feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK * fix(ai_requests): drop existing policies and trigger before creating new ones * fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear() * fix(supplier-invoices): update error handling for invalid input in POST request * fix: correct capitalization in project title * fix(migrations): resolve duplicate version 20260513120000 Two migrations shared the same timestamp prefix, causing schema_migrations_pkey collision on Supabase preview branches. Bump extension_data_delete_policy to 20260513120001. 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
ba45b8f661
commit
c8461397c8
@@ -31,6 +31,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isCommitting, setIsCommitting] = useState(false)
|
||||
const [isLastInSeries, setIsLastInSeries] = useState(false)
|
||||
const [attachmentCount, setAttachmentCount] = useState(0)
|
||||
const [editingNotes, setEditingNotes] = useState(false)
|
||||
@@ -79,6 +80,28 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
}, [id, toast])
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
setIsCommitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/commit`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (res.ok) {
|
||||
const posted = result.data
|
||||
toast({
|
||||
title: 'Verifikat bokfört',
|
||||
description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`,
|
||||
})
|
||||
await fetchData()
|
||||
} else {
|
||||
toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCommitting(false)
|
||||
}
|
||||
}, [id, toast, fetchData])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
@@ -184,6 +207,18 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
{(entry.status === 'posted' || entry.status === 'draft') && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
{entry.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleCommit}
|
||||
disabled={!canWrite || isCommitting}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isCommitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Bokför
|
||||
</Button>
|
||||
)}
|
||||
{(entry.status === 'draft' || isLastInSeries) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
||||
@@ -198,6 +198,14 @@ const glossaryTerms: GlossaryTerm[] = [
|
||||
'Ditt företags unika identitetsnummer. För enskild firma är det ditt personnummer + 100 på århundradesiffran (199001011234 blir 199101011234).',
|
||||
category: 'företag',
|
||||
},
|
||||
{
|
||||
term: 'Eget utlägg',
|
||||
simpleTerm: 'Betalat privat för bolagets räkning',
|
||||
definition:
|
||||
'När du som ägare lägger ut pengar privat för en kostnad som bolaget ska stå för. Registrera under Leverantörsfakturor → Ny, kryssa i "Jag har betalat detta privat". Verifikatet bokförs då direkt mot skuld till ägare (2893 för AB, 2018 för EF) istället för via leverantörsskuld. När bolaget senare ersätter dig kategoriserar du den utgående banktransaktionen mot samma konto.',
|
||||
category: 'bokföring',
|
||||
relatedTerms: ['Aktiebolag', 'Enskild firma'],
|
||||
},
|
||||
]
|
||||
|
||||
const categoryConfig = {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { ReportsNav } from '@/components/reports/ReportsNav'
|
||||
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
|
||||
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
|
||||
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
|
||||
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
|
||||
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
|
||||
@@ -186,6 +187,7 @@ export default function ReportsPage() {
|
||||
<BalanceSheetView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
|
||||
)}
|
||||
{activeTab === 'vat-declaration' && <VatDeclarationView />}
|
||||
{activeTab === 'periodisk-sammanstallning' && <PeriodiskSammanstallningView />}
|
||||
{isEnskildFirma && activeTab === 'ne-declaration' && (
|
||||
<NEDeclarationView periodId={selectedPeriod} />
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,11 @@ export default function TaxSettingsPage() {
|
||||
vat_registered: vatRegistered,
|
||||
vat_number: vatRegistered ? ((formData.get('vat_number') as string) || null) : null,
|
||||
moms_period: vatRegistered ? ((formData.get('moms_period') as string) || null) : null,
|
||||
periodisk_sammanstallning_period:
|
||||
(formData.get('periodisk_sammanstallning_period') as string) || 'monthly',
|
||||
tax_contact_name: (formData.get('tax_contact_name') as string) || null,
|
||||
tax_contact_phone: (formData.get('tax_contact_phone') as string) || null,
|
||||
tax_contact_email: (formData.get('tax_contact_email') as string) || null,
|
||||
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
|
||||
pays_salaries: formData.get('pays_salaries') === 'true',
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
|
||||
@@ -42,6 +42,7 @@ interface FormData {
|
||||
reverse_charge: boolean
|
||||
payment_reference: string
|
||||
notes: string
|
||||
paid_with_private_funds: boolean
|
||||
items: LineItem[]
|
||||
}
|
||||
|
||||
@@ -155,6 +156,7 @@ export default function NewSupplierInvoicePage() {
|
||||
reverse_charge: false,
|
||||
payment_reference: '',
|
||||
notes: '',
|
||||
paid_with_private_funds: false,
|
||||
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
|
||||
},
|
||||
})
|
||||
@@ -165,6 +167,7 @@ export default function NewSupplierInvoicePage() {
|
||||
const watchedItems = watch('items')
|
||||
const watchedSupplierId = watch('supplier_id')
|
||||
const watchedCurrency = watch('currency')
|
||||
const watchedPaidPrivately = watch('paid_with_private_funds')
|
||||
|
||||
const isEF = entityType === 'enskild_firma'
|
||||
|
||||
@@ -468,11 +471,16 @@ export default function NewSupplierInvoicePage() {
|
||||
|
||||
function buildPayload(data: FormData) {
|
||||
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
|
||||
// When paid privately, due_date is irrelevant — but the API still requires
|
||||
// a YYYY-MM-DD value. Default to invoice_date so the field passes validation.
|
||||
const dueDate = data.paid_with_private_funds && !data.due_date
|
||||
? data.invoice_date
|
||||
: data.due_date
|
||||
return {
|
||||
supplier_id: data.supplier_id,
|
||||
supplier_invoice_number: data.supplier_invoice_number,
|
||||
invoice_date: data.invoice_date,
|
||||
due_date: data.due_date,
|
||||
due_date: dueDate,
|
||||
delivery_date: data.delivery_date || undefined,
|
||||
currency: data.currency,
|
||||
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
|
||||
@@ -480,6 +488,7 @@ export default function NewSupplierInvoicePage() {
|
||||
reverse_charge: data.reverse_charge,
|
||||
payment_reference: data.payment_reference || undefined,
|
||||
notes: data.notes || undefined,
|
||||
paid_with_private_funds: data.paid_with_private_funds,
|
||||
items: data.items.map((item) => ({
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
@@ -572,7 +581,10 @@ export default function NewSupplierInvoicePage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEF) {
|
||||
// Privately-paid skips the AB review dialog — the toggle itself is the
|
||||
// explicit user intent, and the resulting verifikat is just expense + VAT
|
||||
// against the owner account (2893/2018). Same path for EF.
|
||||
if (isEF || data.paid_with_private_funds) {
|
||||
setPendingData(data)
|
||||
handleDirectSubmit(data)
|
||||
} else {
|
||||
@@ -581,7 +593,8 @@ export default function NewSupplierInvoicePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// EF: create + auto-approve, no review dialog
|
||||
// EF: create + auto-approve, no review dialog. Privately-paid invoices land
|
||||
// here too and skip auto-approve since they're already in status='paid'.
|
||||
async function handleDirectSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
await patchInboxFieldsIfChanged(data)
|
||||
@@ -597,11 +610,22 @@ export default function NewSupplierInvoicePage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-approve for EF
|
||||
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
|
||||
// Clear dirty state so useUnsavedChanges doesn't fire the
|
||||
// beforeunload prompt while we navigate away on a successful submit.
|
||||
reset(data)
|
||||
|
||||
if (data.paid_with_private_funds) {
|
||||
toast({
|
||||
title: 'Utlägg registrerat',
|
||||
description: `Ankomstnummer: ${result.data.arrival_number}`,
|
||||
})
|
||||
router.push('/supplier-invoices')
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-approve for EF
|
||||
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
|
||||
if (!approveRes.ok) {
|
||||
toast({
|
||||
title: 'Varning',
|
||||
@@ -856,6 +880,30 @@ export default function NewSupplierInvoicePage() {
|
||||
<CardTitle className="text-lg">Faktura</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Eget utlägg-toggle. När den är på bokas verifikatet direkt mot
|
||||
skuld till ägare (2893/2018) istället för leverantörsskuld (2440),
|
||||
och fakturan får status "Betalad" direkt. */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-md border bg-muted/30">
|
||||
<Controller
|
||||
name="paid_with_private_funds"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="paid_with_private_funds"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="paid_with_private_funds" className="cursor-pointer flex-1">
|
||||
<span className="text-sm font-medium">Jag har betalat detta privat</span>
|
||||
<span className="block text-[11px] text-muted-foreground font-normal mt-0.5">
|
||||
Bokförs som skuld från bolaget till dig ({isEF ? '2018 Egen insättning' : '2893 Skuld till ägare'}). Återbetalas senare manuellt från företagskontot.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantör<RequiredMark /></Label>
|
||||
@@ -905,19 +953,26 @@ export default function NewSupplierInvoicePage() {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
watchedPaidPrivately ? 'sm:grid-cols-1' : 'sm:grid-cols-3',
|
||||
)}>
|
||||
<div className="space-y-2">
|
||||
<Label>Fakturadatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>OCR / Betalningsreferens</Label>
|
||||
<Input placeholder="OCR-nummer" {...register('payment_reference')} />
|
||||
</div>
|
||||
{!watchedPaidPrivately && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum<RequiredMark /></Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>OCR / Betalningsreferens</Label>
|
||||
<Input placeholder="OCR-nummer" {...register('payment_reference')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1237,17 +1292,19 @@ export default function NewSupplierInvoicePage() {
|
||||
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push('/supplier-invoices')}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isSubmitting || !canWrite}
|
||||
onClick={() => { submitModeRef.current = 'register_and_match' }}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Registrera & markera som betald
|
||||
</Button>
|
||||
{!watchedPaidPrivately && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={isSubmitting || !canWrite}
|
||||
onClick={() => { submitModeRef.current = 'register_and_match' }}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Registrera & markera som betald
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canWrite}
|
||||
@@ -1263,8 +1320,10 @@ export default function NewSupplierInvoicePage() {
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{isEF ? 'Registrera faktura' : 'Granska & registrera'}
|
||||
{watchedPaidPrivately ? 'Registrera utlägg' : isEF ? 'Registrera faktura' : 'Granska & registrera'}
|
||||
</>
|
||||
) : watchedPaidPrivately ? (
|
||||
'Registrera utlägg'
|
||||
) : isEF ? (
|
||||
'Registrera faktura'
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCommitEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns posted entry on success', async () => {
|
||||
const postedEntry = makeJournalEntry({
|
||||
id: 'entry-1',
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 42,
|
||||
})
|
||||
mockCommitEntry.mockResolvedValue(postedEntry)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(postedEntry)
|
||||
expect(mockCommitEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'entry-1',
|
||||
'manual'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
mockCommitEntry.mockRejectedValue(new Error('Entry not balanced'))
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Entry not balanced')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { commitEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
try {
|
||||
const posted = await commitEntry(supabase, companyId, user.id, id, 'manual')
|
||||
return NextResponse.json({ data: posted })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to commit entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -141,8 +141,13 @@ export async function POST(request: Request) {
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const asDraft = searchParams.get('as_draft') === 'true'
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(supabase, companyId, user.id, body)
|
||||
const entry = asDraft
|
||||
? await createDraftEntry(supabase, companyId, user.id, body)
|
||||
: await createJournalEntry(supabase, companyId, user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
generatePeriodiskSammanstallning,
|
||||
type PsPeriodType,
|
||||
} from '@/lib/reports/periodisk-sammanstallning'
|
||||
import {
|
||||
buildPeriodiskSammanstallningCsv,
|
||||
PsCsvBuildError,
|
||||
} from '@/lib/reports/periodisk-sammanstallning-csv'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/reports/periodisk-sammanstallning/csv
|
||||
*
|
||||
* Returns the SKV574008-formatted CSV file for upload to Skatteverket.
|
||||
* Refuses (400) if the report has any blocking warnings.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'report.periodisk_sammanstallning.csv',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodType = searchParams.get('periodType') as PsPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
|
||||
}
|
||||
if (periodType !== 'monthly' && periodType !== 'quarterly') {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
|
||||
requestId, details: { received: periodType },
|
||||
})
|
||||
}
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, { requestId })
|
||||
}
|
||||
if (isNaN(period)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, tax_contact_name, tax_contact_phone, tax_contact_email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number
|
||||
|| !settings?.tax_contact_name
|
||||
|| !settings?.tax_contact_phone
|
||||
|| !settings?.tax_contact_email) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, { requestId })
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generatePeriodiskSammanstallning(
|
||||
supabase, companyId, periodType, year, period,
|
||||
)
|
||||
|
||||
const csv = buildPeriodiskSammanstallningCsv(report, {
|
||||
organizationNumber: settings.org_number,
|
||||
contactName: settings.tax_contact_name,
|
||||
contactPhone: settings.tax_contact_phone,
|
||||
contactEmail: settings.tax_contact_email,
|
||||
})
|
||||
|
||||
return new Response(csv.content as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': csv.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${csv.filename}"`,
|
||||
'X-Request-Id': requestId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof PsCsvBuildError) {
|
||||
if (err.reason === 'BLOCKING_WARNINGS') {
|
||||
return errorResponseFromCode('PS_REPORT_CSV_BLOCKED_BY_ERRORS', log, {
|
||||
requestId, details: { message: err.message },
|
||||
})
|
||||
}
|
||||
if (err.reason === 'MISSING_FILER_INFO') {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, {
|
||||
requestId, details: { message: err.message },
|
||||
})
|
||||
}
|
||||
}
|
||||
log.error('periodisk sammanställning CSV failed', err as Error, {
|
||||
periodType, year, period,
|
||||
})
|
||||
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
generatePeriodiskSammanstallning,
|
||||
reconcilePsAgainstVatDeclaration,
|
||||
type PsPeriodType,
|
||||
} from '@/lib/reports/periodisk-sammanstallning'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { MomsPeriod } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/periodisk-sammanstallning
|
||||
*
|
||||
* Query parameters:
|
||||
* periodType: 'monthly' | 'quarterly'
|
||||
* year: number (e.g., 2025)
|
||||
* period: number (1-12 for monthly, 1-4 for quarterly)
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'report.periodisk_sammanstallning',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodType = searchParams.get('periodType') as PsPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
|
||||
}
|
||||
|
||||
if (periodType !== 'monthly' && periodType !== 'quarterly') {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
|
||||
requestId,
|
||||
details: { received: periodType },
|
||||
})
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, {
|
||||
requestId,
|
||||
details: { received: yearStr },
|
||||
})
|
||||
}
|
||||
if (isNaN(period)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { received: periodStr },
|
||||
})
|
||||
}
|
||||
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { periodType, received: period, allowed: '1-12' },
|
||||
})
|
||||
}
|
||||
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
||||
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
|
||||
requestId,
|
||||
details: { periodType, received: period, allowed: '1-4' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await generatePeriodiskSammanstallning(
|
||||
supabase, companyId, periodType, year, period,
|
||||
)
|
||||
|
||||
// Best-effort reconciliation against momsdeklaration when periods coincide.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('moms_period')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const momsPeriod = (settings?.moms_period ?? null) as MomsPeriod | null
|
||||
const reconciled = await reconcilePsAgainstVatDeclaration(
|
||||
supabase, companyId, report, momsPeriod,
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: reconciled })
|
||||
} catch (err) {
|
||||
log.error('periodisk sammanställning calculation failed', err as Error, {
|
||||
periodType, year, period,
|
||||
})
|
||||
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -31,9 +31,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
|
||||
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceRegistrationEntry(...args),
|
||||
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -457,4 +460,126 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(status).toBe(500)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
|
||||
})
|
||||
|
||||
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: supplier, error: null })
|
||||
// Fetch company.entity_type (paidPrivately branch)
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 12 })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
|
||||
// Update invoice with payment_journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert supplier_invoice_payments row
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Kontorsmaterial',
|
||||
quantity: 1,
|
||||
unit_price: 400,
|
||||
account_number: '6110',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
|
||||
expect(body.data.registration_journal_entry_id).toBeNull()
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
|
||||
// The classic registration path must NOT be touched.
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('aktiebolag')
|
||||
})
|
||||
|
||||
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: 13 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-002',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Lunch klient',
|
||||
quantity: 1,
|
||||
unit_price: 200,
|
||||
account_number: '5810',
|
||||
vat_rate: 0.12,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('enskild_firma')
|
||||
})
|
||||
|
||||
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'LF-RC',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
paid_with_private_funds: true,
|
||||
reverse_charge: true,
|
||||
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6540', vat_rate: 0.25 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
// Make sure we never touched the engine paths.
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import {
|
||||
createSupplierInvoiceRegistrationEntry,
|
||||
createSupplierInvoicePrivatelyPaidEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -54,6 +57,17 @@ export const POST = withRouteContext(
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const paidPrivately = body.paid_with_private_funds === true
|
||||
|
||||
if (paidPrivately && body.reverse_charge) {
|
||||
// RC invoices come from registered businesses with formal invoices and
|
||||
// go through normal AP. "Privately paid" only makes sense for
|
||||
// out-of-pocket kvitton — combining the two is a UI bug. 400, not 500.
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'paid_with_private_funds is not supported with reverse_charge' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: supplier, error: supplierError } = await supabase
|
||||
.from('suppliers')
|
||||
@@ -66,6 +80,25 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('SUPPLIER_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// Entity type drives the credit account for privately-paid invoices:
|
||||
// AB → 2893 (skuld till aktieägare), EF → 2018 (egen insättning). Loaded
|
||||
// up front so we can fail early if the company row is missing.
|
||||
let entityType: 'aktiebolag' | 'enskild_firma' | null = null
|
||||
if (paidPrivately) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (!company?.entity_type) {
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: 'company entity_type missing — cannot pick owner account' },
|
||||
})
|
||||
}
|
||||
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
|
||||
}
|
||||
|
||||
const { data: arrivalNum, error: arrivalError } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||||
|
||||
@@ -101,11 +134,33 @@ export const POST = withRouteContext(
|
||||
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
||||
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
||||
|
||||
// Representation (BAS 6070–6079): ingående moms is only deductible up to
|
||||
// 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was
|
||||
// abolished in 2017 (IL 16 kap. 2 §). The engine debits 2641 for the full
|
||||
// VAT; we surface a non-blocking warning so the user can adjust manually.
|
||||
// Only emit on the new private-funds path for now — other AP paths share
|
||||
// the flaw and are tracked separately.
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
if (paidPrivately) {
|
||||
const repItems = items.filter(i => /^607\d$/.test(i.account_number))
|
||||
if (repItems.length > 0) {
|
||||
warnings.push({
|
||||
code: 'REPRESENTATION_VAT_CAP',
|
||||
message:
|
||||
'Representation (konto 6070–6079): ingående moms är endast avdragsgill ' +
|
||||
'upp till 300 kr/person (ML 8 kap. 1 §) och kostnaden är inte ' +
|
||||
'inkomstskattemässigt avdragsgill (IL 16 kap. 2 §). Justera bokföringen ' +
|
||||
'manuellt om beloppet överstiger gränsen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeRate = body.exchange_rate || null
|
||||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||||
|
||||
const totalRounded = Math.round(total * 100) / 100
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
@@ -117,19 +172,22 @@ export const POST = withRouteContext(
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date || null,
|
||||
status: 'registered',
|
||||
status: paidPrivately ? 'paid' : 'registered',
|
||||
currency: body.currency || 'SEK',
|
||||
exchange_rate: exchangeRate,
|
||||
vat_treatment: body.vat_treatment || 'standard_25',
|
||||
reverse_charge: body.reverse_charge || false,
|
||||
payment_reference: body.payment_reference || null,
|
||||
paid_with_private_funds: paidPrivately,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total: Math.round(total * 100) / 100,
|
||||
total: totalRounded,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: Math.round(total * 100) / 100,
|
||||
paid_amount: paidPrivately ? totalRounded : 0,
|
||||
remaining_amount: paidPrivately ? 0 : totalRounded,
|
||||
paid_at: paidPrivately ? new Date().toISOString() : null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
@@ -214,6 +272,11 @@ export const POST = withRouteContext(
|
||||
// is fatal — an orphan supplier_invoices row without a registration JE
|
||||
// silently understates leverantörsskuld (2440) and ingående moms (2641)
|
||||
// for the momsdeklaration. Roll back instead.
|
||||
//
|
||||
// Privately-paid path bypasses both accrual and cash flows: a single
|
||||
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at
|
||||
// registration time, regardless of accounting_method. mark-paid is never
|
||||
// invoked for these (status='paid' from the start).
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
@@ -222,8 +285,59 @@ export const POST = withRouteContext(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
let registrationJournalEntryId: string | null = null
|
||||
let paymentJournalEntryId: string | null = null
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (paidPrivately && entityType) {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
entityType,
|
||||
supplier.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
paymentJournalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ payment_journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
// Mirror the payment in supplier_invoice_payments so AR/AP and
|
||||
// payment-history queries stay consistent with the mark-paid path.
|
||||
await supabase.from('supplier_invoice_payments').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_invoice_id: invoice.id,
|
||||
// For an eget utlägg the actual out-of-pocket date may differ from
|
||||
// the invoice/receipt date — accept an explicit payment_date and
|
||||
// fall back to invoice_date for the common kvitto case.
|
||||
payment_date: body.payment_date ?? invoice.invoice_date,
|
||||
amount: totalRounded,
|
||||
currency: invoice.currency,
|
||||
exchange_rate_difference: 0,
|
||||
journal_entry_id: journalEntry.id,
|
||||
notes: 'Eget utlägg — betalat privat',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
log.error('failed to create privately-paid journal entry', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
step: 'privately_paid_journal_entry',
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
supabase,
|
||||
@@ -264,6 +378,17 @@ export const POST = withRouteContext(
|
||||
type: 'supplier_invoice.registered',
|
||||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
if (paidPrivately) {
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.paid',
|
||||
payload: {
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
paymentAmount: totalRounded,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('supplier_invoice.registered event emission failed', err as Error)
|
||||
}
|
||||
@@ -273,7 +398,9 @@ export const POST = withRouteContext(
|
||||
...invoice,
|
||||
items: itemInserts,
|
||||
registration_journal_entry_id: registrationJournalEntryId,
|
||||
payment_journal_entry_id: paymentJournalEntryId,
|
||||
},
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
Reference in New Issue
Block a user