diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 3118a87f..bdaa9a16 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -211,22 +211,39 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i - {lines.map((line) => ( - - - {line.line_description || ''} - - {Number(line.debit_amount) > 0 - ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) - : ''} - - - {Number(line.credit_amount) > 0 - ? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) - : ''} - - - ))} + {lines.map((line) => { + const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null + return ( + + + {line.line_description || ''} + + {Number(line.debit_amount) > 0 && ( + <> + {Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + {hasForeignCurrency && Number(line.debit_amount) > 0 && ( + + {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} + + )} + + )} + + + {Number(line.credit_amount) > 0 && ( + <> + {Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + {hasForeignCurrency && Number(line.credit_amount) > 0 && ( + + {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} + + )} + + )} + + + ) + })} @@ -244,24 +261,41 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {/* Mobile cards */}
- {lines.map((line) => ( -
-
-
- {line.line_description && ( -

{line.line_description}

- )} + {lines.map((line) => { + const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null + return ( +
+
+
+ {line.line_description && ( +

{line.line_description}

+ )} +
+
+ {Number(line.debit_amount) > 0 && ( +

+ {Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D + {hasForeignCurrency && ( + + {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} + + )} +

+ )} + {Number(line.credit_amount) > 0 && ( +

+ {Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K + {hasForeignCurrency && ( + + {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} + + )} +

+ )} +
-
- {Number(line.debit_amount) > 0 && ( -

{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D

- )} - {Number(line.credit_amount) > 0 && ( -

{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K

- )} -
-
- ))} + ) + })}
Summa
diff --git a/app/(dashboard)/settings/company/page.tsx b/app/(dashboard)/settings/company/page.tsx index d7176d26..fcc58266 100644 --- a/app/(dashboard)/settings/company/page.tsx +++ b/app/(dashboard)/settings/company/page.tsx @@ -16,6 +16,7 @@ export default function CompanySettingsPage() { function handleSave(formData: FormData) { const updates: Record = { ...(formData.has('company_name') && { company_name: formData.get('company_name') as string }), + trade_name: (formData.get('trade_name') as string) || null, ...(formData.has('org_number') && { org_number: formData.get('org_number') as string }), address_line1: formData.get('address_line1') as string, postal_code: formData.get('postal_code') as string, diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index a567c9a4..d132ce3c 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { X } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView' @@ -88,6 +89,7 @@ export default function TransactionsPage() { const [exitingIds, setExitingIds] = useState>(new Set()) const { toast } = useToast() + const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm() const supabase = createClient() // Computed lists @@ -508,6 +510,40 @@ export default function TransactionsPage() { setIsCreating(false) } + async function handleDeleteTransaction(id: string) { + const transaction = transactions.find((t) => t.id === id) + if (!transaction) return + + const ok = await confirmDelete({ + title: 'Ta bort transaktion', + description: `Är du säker på att du vill ta bort "${transaction.description}"? Åtgärden kan inte ångras.`, + confirmLabel: 'Ta bort', + variant: 'destructive', + }) + if (!ok) return + + try { + const response = await fetch(`/api/transactions/${id}`, { method: 'DELETE' }) + if (!response.ok) { + const result = await response.json() + toast({ + title: 'Kunde inte ta bort', + description: result.error || 'Försök igen.', + variant: 'destructive', + }) + return + } + setTransactions((prev) => prev.filter((t) => t.id !== id)) + toast({ title: 'Borttagen', description: 'Transaktionen har tagits bort' }) + } catch { + toast({ + title: 'Kunde inte ta bort', + description: 'Transaktionen kunde inte tas bort. Försök igen.', + variant: 'destructive', + }) + } + } + function handleTransactionBooked(transactionId: string, journalEntryId: string) { setExitingIds((prev) => new Set(prev).add(transactionId)) setTimeout(() => { @@ -786,7 +822,7 @@ export default function TransactionsPage() { onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} - + onDelete={handleDeleteTransaction} onOpenQuickReview={handleOpenQuickReview} onOpenTemplateReview={handleOpenTemplateReview} onToggleSelect={toggleBatchSelect} @@ -917,6 +953,8 @@ export default function TransactionsPage() { + +
) } diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 6ae0cdd5..a8772202 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -141,7 +141,7 @@ export async function POST( html: generateInvoiceEmailHtml(emailData), text: generateInvoiceEmailText(emailData), replyTo: company.email || undefined, - fromName: company.company_name, + fromName: company.trade_name || company.company_name, attachments: [ { filename, diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 61a75939..a491f1dc 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -211,6 +211,7 @@ export async function POST(request: Request) { .single() if (invoiceError) { + console.error('Invoice insert error:', invoiceError) return NextResponse.json({ error: invoiceError.message }, { status: 500 }) } diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index c49acf81..16ee8872 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -94,11 +94,11 @@ export async function GET(request: Request) { // Get company name for the consent page const { data: settings } = await supabase .from('company_settings') - .select('company_name') + .select('company_name, trade_name') .eq('company_id', companyId) .single() - const companyName = settings?.company_name || user.email + const companyName = settings?.trade_name || settings?.company_name || user.email // Render consent page const html = ` diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index 1ada2794..17942a13 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -559,7 +559,7 @@ async function commitSendInvoice( html: generateInvoiceEmailHtml(emailData), text: generateInvoiceEmailText(emailData), replyTo: company.email || undefined, - fromName: company.company_name, + fromName: company.trade_name || company.company_name, attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }], }) diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..59392042 --- /dev/null +++ b/app/api/transactions/[id]/__tests__/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { DELETE } from '../route' + +describe('DELETE /api/transactions/[id]', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(404) + expect(body).toEqual({ error: 'Transaction not found' }) + }) + + it('returns 409 when transaction has a journal entry', async () => { + const tx = makeTransaction({ journal_entry_id: 'je-1', bank_connection_id: null, import_source: null }) + enqueue({ data: tx, error: null }) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toContain('booked') + }) + + it('returns 409 when transaction is bank-synced', async () => { + const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null }) + enqueue({ data: tx, error: null }) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toContain('bank-synced') + }) + + it('returns 409 when transaction was imported', async () => { + const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null }) + enqueue({ data: tx, error: null }) + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toContain('imported') + }) + + it('deletes a manually added unbooked transaction', async () => { + const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: null }) // delete + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }) + + it('returns 500 when deletion fails', async () => { + const tx = makeTransaction({ journal_entry_id: null, bank_connection_id: null, import_source: null }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: { message: 'DB error' } }) // delete fails + + const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body).toEqual({ error: 'Failed to delete transaction' }) + }) +}) diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts new file mode 100644 index 00000000..5e694e8a --- /dev/null +++ b/app/api/transactions/[id]/route.ts @@ -0,0 +1,62 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + // Fetch the transaction with ownership check + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('id, journal_entry_id, bank_connection_id, import_source') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + // Guard: only manually added, unbooked transactions can be deleted + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Cannot delete a booked transaction. Use reversal (storno) instead.' }, + { status: 409 } + ) + } + if (transaction.bank_connection_id) { + return NextResponse.json( + { error: 'Cannot delete a bank-synced transaction' }, + { status: 409 } + ) + } + if (transaction.import_source) { + return NextResponse.json( + { error: 'Cannot delete an imported transaction' }, + { status: 409 } + ) + } + + const { error: deleteError } = await supabase + .from('transactions') + .delete() + .eq('id', id) + .eq('company_id', companyId) + + if (deleteError) { + return NextResponse.json({ error: 'Failed to delete transaction' }, { status: 500 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 5c8549a6..e89558cf 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -23,6 +23,9 @@ export interface FormLine { debit_amount: string credit_amount: string line_description: string + currency?: string + amount_in_currency?: number + exchange_rate?: number } interface Props { @@ -159,6 +162,9 @@ export default function JournalEntryForm({ debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, line_description: l.line_description || undefined, + ...(l.currency ? { currency: l.currency } : {}), + ...(l.amount_in_currency != null ? { amount_in_currency: l.amount_in_currency } : {}), + ...(l.exchange_rate != null ? { exchange_rate: l.exchange_rate } : {}), })) const url = submitUrl ?? '/api/bookkeeping/journal-entries' diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 3d93bcbd..8de81471 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -390,11 +390,18 @@ export default function JournalEntryList({ periodId }: Props) { {Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'} - - {Number(line.debit_amount) > 0 - ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) - : Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} - +
+ + {Number(line.debit_amount) > 0 + ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + + {line.currency && line.currency !== 'SEK' && line.amount_in_currency != null && ( + + {Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency} + + )} +
) diff --git a/components/settings/CompanyInfoForm.tsx b/components/settings/CompanyInfoForm.tsx index 52f1c024..e5b62a04 100644 --- a/components/settings/CompanyInfoForm.tsx +++ b/components/settings/CompanyInfoForm.tsx @@ -28,6 +28,18 @@ export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {

Kan inte ändras efter att kontot skapats

)}
+
+ + +

+ Valfritt. Visas som huvudnamn på fakturor och e-post, med det juridiska namnet i parentes. +

+
void onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void - + onDelete?: (id: string) => void onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void onOpenTemplateReview?: (transaction: TransactionWithInvoice, templateId: string) => void onToggleSelect: (id: string) => void @@ -45,7 +45,7 @@ export default function TransactionInboxCard({ onMarkPrivate, onOpenMatchDialog, onOpenCategoryDialog, - + onDelete, onOpenQuickReview, onOpenTemplateReview, onToggleSelect, @@ -60,6 +60,7 @@ export default function TransactionInboxCard({ const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const showCheckbox = isBatchMode && isUncategorized const hasDocumentMatch = !!transaction.matched_inbox_item + const isManualTransaction = !transaction.bank_connection_id && !transaction.import_source && !transaction.journal_entry_id function handleSuggestionClick(suggestion: SuggestedCategory) { if (onOpenQuickReview) { @@ -249,6 +250,20 @@ export default function TransactionInboxCard({ > Välj mall... + + {/* Delete button — only for manually added, unbooked transactions */} + {isManualTransaction && onDelete && ( + + )}
)} diff --git a/extensions/general/email/lib/invoice-templates.ts b/extensions/general/email/lib/invoice-templates.ts index 1da8ab53..6915f2cb 100644 --- a/extensions/general/email/lib/invoice-templates.ts +++ b/extensions/general/email/lib/invoice-templates.ts @@ -1,5 +1,5 @@ import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types' -import { formatCurrency, formatDate } from '@/lib/utils' +import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' function getDocumentLabel(invoice: Invoice): string { if (invoice.credited_invoice_id) return 'Kreditfaktura' @@ -41,7 +41,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

- ${documentType} från ${company.company_name} + ${documentType} från ${getCompanyPrimaryName(company)}

${documentType}nummer: ${invoice.invoice_number} @@ -136,7 +136,8 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

Med vänliga hälsningar,
- ${company.company_name} + ${getCompanyPrimaryName(company)} + ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

@@ -165,7 +166,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string { const isProforma = docType === 'proforma' const hidePayment = isCreditNote || isDeliveryNote || isProforma - let text = `${documentType} från ${company.company_name}\n` + let text = `${documentType} från ${getCompanyPrimaryName(company)}\n` text += `${documentType}nummer: ${invoice.invoice_number}\n\n` text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n` @@ -197,7 +198,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string { text += `Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.\n\n` text += `Med vänliga hälsningar,\n` - text += `${company.company_name}\n` + text += `${getCompanyDisplayName(company)}\n` if (company.org_number) { text += `\nOrg.nr: ${company.org_number}` @@ -216,5 +217,5 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string { const { invoice, company } = data const documentType = getDocumentLabel(invoice) - return `${documentType} ${invoice.invoice_number} från ${company.company_name}` + return `${documentType} ${invoice.invoice_number} från ${getCompanyPrimaryName(company)}` } diff --git a/extensions/general/email/lib/reminder-templates.ts b/extensions/general/email/lib/reminder-templates.ts index 8ee3a8ef..5d5cc6e3 100644 --- a/extensions/general/email/lib/reminder-templates.ts +++ b/extensions/general/email/lib/reminder-templates.ts @@ -1,5 +1,5 @@ import type { Invoice, Customer, CompanySettings } from '@/types' -import { formatCurrency, formatDate } from '@/lib/utils' +import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' export interface ReminderEmailData { invoice: Invoice @@ -179,7 +179,8 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {

Med vänliga hälsningar,
- ${company.company_name} + ${getCompanyPrimaryName(company)} + ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

@@ -247,7 +248,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string { text += `Har du frågor? Svara direkt på detta mejl så hjälper vi dig.\n\n` text += `Med vänliga hälsningar,\n` - text += `${company.company_name}\n` + text += `${getCompanyDisplayName(company)}\n` if (company.org_number) { text += `\nOrg.nr: ${company.org_number}` diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 5919d25a..af0ce6f2 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -153,11 +153,13 @@ export const CreateInvoiceItemSchema = z.object({ vat_rate: z.number().min(0).max(100).optional(), }) +const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional() + export const CreateInvoiceSchema = z.object({ customer_id: uuid, invoice_date: isoDate, due_date: isoDate, - delivery_date: isoDate.optional(), + delivery_date: optionalIsoDate, currency: CurrencySchema, document_type: InvoiceDocumentTypeSchema.optional(), your_reference: z.string().optional(), @@ -254,7 +256,7 @@ export const CreateSupplierInvoiceSchema = z.object({ supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'), invoice_date: isoDate, due_date: isoDate, - delivery_date: isoDate.optional(), + delivery_date: optionalIsoDate, currency: CurrencySchema.optional(), exchange_rate: z.number().positive().optional(), vat_treatment: VatTreatmentSchema.optional(), @@ -275,7 +277,7 @@ export const UpdateSupplierInvoiceSchema = z.object({ supplier_invoice_number: z.string().min(1).optional(), invoice_date: isoDate.optional(), due_date: isoDate.optional(), - delivery_date: isoDate.optional(), + delivery_date: optionalIsoDate, payment_reference: z.string().optional(), notes: z.string().optional(), }) @@ -349,6 +351,7 @@ export const MatchSupplierInvoiceSchema = z.object({ export const UpdateSettingsSchema = z.object({ entity_type: EntityTypeSchema.optional(), company_name: z.string().optional(), + trade_name: z.string().nullable().optional(), org_number: z.string().optional(), address_line1: z.string().optional(), address_line2: z.string().optional(), diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 5abd3d27..b3fbc66c 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -196,6 +196,7 @@ export async function createTransactionJournalEntry( debit_amount: absAmount, credit_amount: 0, line_description: transaction.description, + ...(debitAccount === '1930' ? currencyMeta : {}), }) // Credit revenue for net amount lines.push({ @@ -221,6 +222,7 @@ export async function createTransactionJournalEntry( debit_amount: absAmount, credit_amount: 0, line_description: transaction.description, + ...(debitAccount === '1930' ? currencyMeta : {}), }, { account_number: creditAccount, diff --git a/lib/email/invoice-templates.ts b/lib/email/invoice-templates.ts index 1da8ab53..6915f2cb 100644 --- a/lib/email/invoice-templates.ts +++ b/lib/email/invoice-templates.ts @@ -1,5 +1,5 @@ import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types' -import { formatCurrency, formatDate } from '@/lib/utils' +import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' function getDocumentLabel(invoice: Invoice): string { if (invoice.credited_invoice_id) return 'Kreditfaktura' @@ -41,7 +41,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

- ${documentType} från ${company.company_name} + ${documentType} från ${getCompanyPrimaryName(company)}

${documentType}nummer: ${invoice.invoice_number} @@ -136,7 +136,8 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {

Med vänliga hälsningar,
- ${company.company_name} + ${getCompanyPrimaryName(company)} + ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

@@ -165,7 +166,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string { const isProforma = docType === 'proforma' const hidePayment = isCreditNote || isDeliveryNote || isProforma - let text = `${documentType} från ${company.company_name}\n` + let text = `${documentType} från ${getCompanyPrimaryName(company)}\n` text += `${documentType}nummer: ${invoice.invoice_number}\n\n` text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n` @@ -197,7 +198,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string { text += `Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.\n\n` text += `Med vänliga hälsningar,\n` - text += `${company.company_name}\n` + text += `${getCompanyDisplayName(company)}\n` if (company.org_number) { text += `\nOrg.nr: ${company.org_number}` @@ -216,5 +217,5 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string { const { invoice, company } = data const documentType = getDocumentLabel(invoice) - return `${documentType} ${invoice.invoice_number} från ${company.company_name}` + return `${documentType} ${invoice.invoice_number} från ${getCompanyPrimaryName(company)}` } diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts index 8ee3a8ef..5d5cc6e3 100644 --- a/lib/email/reminder-templates.ts +++ b/lib/email/reminder-templates.ts @@ -1,5 +1,5 @@ import type { Invoice, Customer, CompanySettings } from '@/types' -import { formatCurrency, formatDate } from '@/lib/utils' +import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' export interface ReminderEmailData { invoice: Invoice @@ -179,7 +179,8 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {

Med vänliga hälsningar,
- ${company.company_name} + ${getCompanyPrimaryName(company)} + ${company.trade_name && company.company_name ? `
(${company.company_name})` : ''}

${company.org_number ? `

@@ -247,7 +248,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string { text += `Har du frågor? Svara direkt på detta mejl så hjälper vi dig.\n\n` text += `Med vänliga hälsningar,\n` - text += `${company.company_name}\n` + text += `${getCompanyDisplayName(company)}\n` if (company.org_number) { text += `\nOrg.nr: ${company.org_number}` diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 2a6a6cb6..bdc68305 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -317,7 +317,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {company.logo_url && ( )} - {company.company_name} + {company.trade_name || company.company_name} + {company.trade_name && company.company_name && ( + ({company.company_name}) + )} {company.address_line1 && {company.address_line1}} {(company.postal_code || company.city) && ( {company.postal_code} {company.city} @@ -604,7 +607,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {/* Footer */} - {company.company_name} + {company.trade_name || company.company_name} + {company.trade_name && company.company_name ? ` (${company.company_name})` : ''} {company.org_number ? ` | Org.nr: ${formatOrgNumber(company.org_number)}` : ''} {company.f_skatt ? ' | Godkänd för F-skatt' : ''} {company.vat_number ? ` | Momsreg.nr: ${company.vat_number}` : ''} diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index b09c386c..d51b08aa 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -116,7 +116,7 @@ export async function sendReminder( html: generateReminderEmailHtml(emailData), text: generateReminderEmailText(emailData), replyTo: company.email || undefined, - fromName: company.company_name || undefined + fromName: company.trade_name || company.company_name || undefined }) return result diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 9c3921c6..9026c85f 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -58,7 +58,7 @@ export async function generateFullArchive( // Fetch company settings const { data: company } = await supabase .from('company_settings') - .select('company_name, org_number, moms_period') + .select('company_name, trade_name, org_number, moms_period') .eq('company_id', companyId) .single() @@ -72,6 +72,7 @@ export async function generateFullArchive( const sieContent = await generateSIEExport(supabase, companyId, { fiscal_period_id: period_id, company_name: company.company_name || 'Unknown', + trade_name: company.trade_name, org_number: company.org_number, program_name: 'ERPBase', }) diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index 95467dd9..169ed5d6 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -710,7 +710,7 @@ export async function generateINK2Declaration( // Fetch company settings const { data: settings } = await supabase .from('company_settings') - .select('company_name, org_number, entity_type, address_line1, postal_code, city, email') + .select('company_name, trade_name, org_number, entity_type, address_line1, postal_code, city, email') .eq('company_id', companyId) .single() @@ -931,7 +931,7 @@ export async function generateINK2Declaration( resultAfterFinancial, }, companyInfo: { - companyName: settings?.company_name || 'Okänt företag', + companyName: settings?.trade_name || settings?.company_name || 'Okänt företag', orgNumber: settings?.org_number || null, addressLine1: settings?.address_line1 || null, postalCode: settings?.postal_code || null, diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index e4e16f83..97dea6b4 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -175,7 +175,7 @@ export async function generateNEDeclaration( // Fetch company settings const { data: settings } = await supabase .from('company_settings') - .select('company_name, org_number, entity_type') + .select('company_name, trade_name, org_number, entity_type') .eq('company_id', companyId) .single() @@ -320,7 +320,7 @@ export async function generateNEDeclaration( rutor, breakdown, companyInfo: { - companyName: settings?.company_name || 'Okänt företag', + companyName: settings?.trade_name || settings?.company_name || 'Okänt företag', orgNumber: settings?.org_number || null, }, warnings, diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index d80f1d8f..e05e6c11 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -88,7 +88,7 @@ export async function generateSIEExport( lines.push(`#ORGNR ${options.org_number}`) } - lines.push(`#FNAMN "${escapeQuotes(options.company_name)}"`) + lines.push(`#FNAMN "${escapeQuotes(options.trade_name || options.company_name)}"`) // === Fiscal year === // #RAR 0 = current year, #RAR -1 = previous year (both should be present per spec) diff --git a/lib/utils.ts b/lib/utils.ts index b255032a..9655bbfc 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -29,6 +29,27 @@ export function formatOrgNumber(orgNumber: string): string { return orgNumber } +/** + * Returns the display name for a company, using trade name as primary + * with legal name in parentheses if both exist. + */ +export function getCompanyDisplayName(settings: { trade_name?: string | null; company_name?: string | null }): string { + const tradeName = settings.trade_name?.trim() + const legalName = settings.company_name?.trim() + if (tradeName && legalName) { + return `${tradeName} (${legalName})` + } + return legalName || tradeName || '' +} + +/** + * Returns just the primary name for contexts where a short name is needed + * (e.g. email from name). Uses trade name if set, otherwise legal name. + */ +export function getCompanyPrimaryName(settings: { trade_name?: string | null; company_name?: string | null }): string { + return settings.trade_name?.trim() || settings.company_name?.trim() || '' +} + export function generateInvoiceNumber(): string { const year = new Date().getFullYear() const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0') diff --git a/supabase/migrations/20260402010000_provider_consents.sql b/supabase/migrations/20260402010000_provider_consents.sql index 4ff14ce4..02d7cb3a 100644 --- a/supabase/migrations/20260402010000_provider_consents.sql +++ b/supabase/migrations/20260402010000_provider_consents.sql @@ -73,6 +73,13 @@ CREATE POLICY provider_consent_tokens_update ON provider_consent_tokens ) )); +CREATE POLICY provider_consent_tokens_delete ON provider_consent_tokens + FOR DELETE USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); + CREATE TRIGGER update_provider_consent_tokens_updated_at BEFORE UPDATE ON provider_consent_tokens FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); @@ -109,3 +116,10 @@ CREATE POLICY provider_otc_update ON provider_otc SELECT company_id FROM team_members WHERE user_id = auth.uid() ) )); + +CREATE POLICY provider_otc_delete ON provider_otc + FOR DELETE USING (consent_id IN ( + SELECT id FROM provider_consents WHERE company_id IN ( + SELECT company_id FROM team_members WHERE user_id = auth.uid() + ) + )); diff --git a/supabase/migrations/20260409130001_add_trade_name.sql b/supabase/migrations/20260409130001_add_trade_name.sql new file mode 100644 index 00000000..e45d2c4b --- /dev/null +++ b/supabase/migrations/20260409130001_add_trade_name.sql @@ -0,0 +1,5 @@ +-- Add trade_name column to company_settings +-- Allows companies to display a trade name (handelsnamn) on invoices +-- and other external-facing documents instead of the legal company name. +ALTER TABLE public.company_settings + ADD COLUMN trade_name text; diff --git a/tests/helpers.ts b/tests/helpers.ts index f002d88b..7fe25d1e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -488,6 +488,7 @@ export function makeCompanySettings( company_id: 'company-1', entity_type: 'enskild_firma', company_name: 'Test Firma', + trade_name: null, org_number: '199001011234', address_line1: 'Testgatan 1', address_line2: null, diff --git a/types/index.ts b/types/index.ts index 20f9dd55..4a97c951 100644 --- a/types/index.ts +++ b/types/index.ts @@ -136,6 +136,7 @@ export interface CompanySettings { // Entity info entity_type: EntityType company_name: string | null + trade_name: string | null org_number: string | null // Address @@ -1119,6 +1120,7 @@ export interface BalanceSheetReport { export interface SIEExportOptions { fiscal_period_id: string company_name: string + trade_name?: string | null org_number: string | null program_name?: string }