* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
export async function GET(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const status = searchParams.get('status')
|
|
|
|
let query = supabase
|
|
.from('supplier_invoices')
|
|
.select('*, supplier:suppliers(id, name)')
|
|
.eq('company_id', companyId)
|
|
|
|
if (status && status !== 'all') {
|
|
if (status === 'to_pay') {
|
|
query = query.in('status', ['approved', 'overdue'])
|
|
} else {
|
|
query = query.eq('status', status)
|
|
}
|
|
}
|
|
|
|
const { data, error } = await query.order('due_date', { ascending: true })
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
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)
|
|
|
|
const validation = await validateBody(request, CreateSupplierInvoiceSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
// Validate supplier exists and belongs to user
|
|
const { data: supplier, error: supplierError } = await supabase
|
|
.from('suppliers')
|
|
.select('*')
|
|
.eq('id', body.supplier_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (supplierError || !supplier) {
|
|
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get next arrival number
|
|
const { data: arrivalNum, error: arrivalError } = await supabase
|
|
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
|
|
|
if (arrivalError) {
|
|
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
|
}
|
|
|
|
// Calculate totals from items (supports both amount-based and legacy quantity*price)
|
|
const items = body.items.map((item, index) => {
|
|
const vatRate = item.vat_rate ?? 0.25
|
|
const lineTotal = item.amount != null
|
|
? Math.round(item.amount * 100) / 100
|
|
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
|
|
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
|
return {
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
|
|
unit: item.amount != null ? 'st' : (item.unit || 'st'),
|
|
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
|
|
line_total: lineTotal,
|
|
account_number: item.account_number,
|
|
vat_code: item.vat_code || null,
|
|
vat_rate: vatRate,
|
|
vat_amount: vatAmount,
|
|
}
|
|
})
|
|
|
|
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
|
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
|
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
|
|
|
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
|
|
|
|
// Insert supplier invoice
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('supplier_invoices')
|
|
.insert({
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
supplier_id: body.supplier_id,
|
|
arrival_number: arrivalNum,
|
|
supplier_invoice_number: body.supplier_invoice_number,
|
|
invoice_date: body.invoice_date,
|
|
due_date: body.due_date,
|
|
delivery_date: body.delivery_date || null,
|
|
status: '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,
|
|
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_sek: totalSek,
|
|
remaining_amount: Math.round(total * 100) / 100,
|
|
notes: body.notes || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) {
|
|
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
|
}
|
|
|
|
// Insert line items
|
|
const itemInserts = items.map((item) => ({
|
|
supplier_invoice_id: invoice.id,
|
|
...item,
|
|
}))
|
|
|
|
const { error: itemsError } = await supabase
|
|
.from('supplier_invoice_items')
|
|
.insert(itemInserts)
|
|
|
|
if (itemsError) {
|
|
// Clean up invoice on items failure
|
|
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
|
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
|
}
|
|
|
|
// Accrual method: create registration journal entry
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('accounting_method')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
|
let registrationJournalEntryId: string | null = null
|
|
|
|
if (accountingMethod === 'accrual') {
|
|
try {
|
|
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
|
supabase,
|
|
companyId,
|
|
user.id,
|
|
invoice as SupplierInvoice,
|
|
items as SupplierInvoiceItem[],
|
|
supplier.supplier_type,
|
|
supplier.name
|
|
)
|
|
if (journalEntry) {
|
|
registrationJournalEntryId = journalEntry.id
|
|
await supabase
|
|
.from('supplier_invoices')
|
|
.update({ registration_journal_entry_id: journalEntry.id })
|
|
.eq('id', invoice.id)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create registration journal entry:', err)
|
|
}
|
|
}
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.registered',
|
|
payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId: user.id },
|
|
})
|
|
} catch {
|
|
// Non-blocking — event emission failure should not affect the response
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
...invoice,
|
|
items: itemInserts,
|
|
registration_journal_entry_id: registrationJournalEntryId,
|
|
},
|
|
})
|
|
}
|