feat: implement 4 export extensions with engines, API routes, and workspaces
- EU Sales List: engine, CSV/XML generators, Skatteverket format, tests - VAT Monitor: moms box mapping, revenue breakdown, validation, tests - Intrastat: product registry, SCB CSV generator, threshold tracking, tests - Currency Receivables: FX exposure, unrealized gain/loss calc, tests - Shared utilities: EU country list, moms box mapping - API routes for report generation and file downloads - Workspace UI components for all 4 extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateReceivablesReport,
|
||||
type ReceivableInvoice,
|
||||
type ReceivableCustomer,
|
||||
type GLLine,
|
||||
type ExchangeRateInfo,
|
||||
} from '@/extensions/export/currency-receivables/lib/receivables-engine'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import type { Currency } from '@/types'
|
||||
|
||||
const SUPPORTED_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const FX_ACCOUNTS = ['3960', '7960']
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/currency-receivables/report
|
||||
*
|
||||
* Generate a multi-currency receivables report showing FX exposure,
|
||||
* unrealized gains/losses, and realized FX from GL.
|
||||
*
|
||||
* Query params:
|
||||
* year (optional) — Year for realized FX trend (default: current year)
|
||||
*/
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const year = yearStr ? parseInt(yearStr, 10) : new Date().getFullYear()
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
const referenceDate = new Date().toISOString().split('T')[0]
|
||||
|
||||
try {
|
||||
// Fetch open foreign-currency invoices
|
||||
const invoices = await fetchAllRows<ReceivableInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, currency, total, total_sek, exchange_rate, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.neq('currency', 'SEK')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: ReceivableCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as ReceivableCustomer[]
|
||||
}
|
||||
|
||||
// Fetch current Riksbanken rates for all supported currencies
|
||||
const currentRates: ExchangeRateInfo[] = []
|
||||
const ratePromises = SUPPORTED_CURRENCIES.map(async (currency) => {
|
||||
const rate = await fetchExchangeRate(currency)
|
||||
if (rate) {
|
||||
currentRates.push({
|
||||
currency: rate.currency,
|
||||
rate: rate.rate,
|
||||
date: rate.date,
|
||||
})
|
||||
}
|
||||
})
|
||||
await Promise.all(ratePromises)
|
||||
|
||||
// Fetch realized FX GL lines for the year
|
||||
const realizedFXLines = await fetchFXLines(supabase, user.id, year)
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
invoices,
|
||||
customers,
|
||||
currentRates,
|
||||
realizedFXLines,
|
||||
referenceDate,
|
||||
year,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating currency receivables report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GL lines on accounts 3960 (FX gains) and 7960 (FX losses)
|
||||
* for posted journal entries in the given year.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchFXLines(supabase: any, userId: string, year: number): Promise<GLLine[]> {
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
// Get posted journal entry IDs in the year
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryDateMap = new Map<string, string>()
|
||||
for (const e of entries as Array<{ id: string; entry_date: string }>) {
|
||||
entryDateMap.set(e.id, e.entry_date)
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: GLLine[] = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', FX_ACCOUNTS)
|
||||
|
||||
if (lines) {
|
||||
for (const line of lines as Array<{ journal_entry_id: string; account_number: string; debit_amount: number; credit_amount: number }>) {
|
||||
allLines.push({
|
||||
account_number: line.account_number,
|
||||
debit: Number(line.debit_amount) || 0,
|
||||
credit: Number(line.credit_amount) || 0,
|
||||
entry_date: entryDateMap.get(line.journal_entry_id) || startDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allLines
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateECSalesListReport,
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
type ECSalesListInvoice,
|
||||
type ECSalesListCustomer,
|
||||
type GLAccountTotal,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
import { generateCSV, generateCSVFilename } from '@/extensions/export/eu-sales-list/lib/csv-generator'
|
||||
import { generateSKVXml, generateXMLFilename } from '@/extensions/export/eu-sales-list/lib/skv-xml-generator'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/eu-sales-list/download
|
||||
*
|
||||
* Download an EC Sales List (periodisk sammanställning) as CSV or XML.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (optional) — 1-12, for monthly filing
|
||||
* quarter (optional) — 1-4, for quarterly filing
|
||||
* format (required) — 'csv' or 'xml'
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
const format = searchParams.get('format')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!format || !['csv', 'xml'].includes(format)) {
|
||||
return NextResponse.json({ error: 'format is required and must be csv or xml' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices
|
||||
const invoices = await fetchAllRows<ECSalesListInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: ECSalesListCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, customer_type, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as ECSalesListCustomer[]
|
||||
}
|
||||
|
||||
// Generate report
|
||||
const report = generateECSalesListReport({
|
||||
invoices,
|
||||
customers,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
})
|
||||
|
||||
// Generate file content
|
||||
if (format === 'csv') {
|
||||
const content = generateCSV(report)
|
||||
const filename = generateCSVFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// XML format
|
||||
const content = generateSKVXml(report)
|
||||
const filename = generateXMLFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating EC Sales List download:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate download' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateECSalesListReport,
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
getFilingDeadline,
|
||||
daysUntilDeadline,
|
||||
type ECSalesListInvoice,
|
||||
type ECSalesListCustomer,
|
||||
type GLAccountTotal,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/eu-sales-list/report
|
||||
*
|
||||
* Generate an EC Sales List (periodisk sammanställning) report.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year, e.g. 2026
|
||||
* month (optional) — 1-12, for monthly filing (goods)
|
||||
* quarter (optional) — 1-4, for quarterly filing (services)
|
||||
*
|
||||
* Either month or quarter must be provided, not both.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year. Must be between 2000 and 2100' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month. Must be 1-12' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter. Must be 1-4' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Determine date range
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch company settings for reporter info
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices for the period
|
||||
const invoices = await fetchAllRows<ECSalesListInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Collect unique customer IDs
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
|
||||
// Fetch customers (only if we have invoices)
|
||||
let customers: ECSalesListCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, customer_type, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
if (customerError) {
|
||||
return NextResponse.json({ error: 'Failed to fetch customers' }, { status: 500 })
|
||||
}
|
||||
|
||||
customers = (customerData || []) as ECSalesListCustomer[]
|
||||
}
|
||||
|
||||
// Fetch GL account totals for cross-check
|
||||
// Query posted journal entries in the period, then sum credit amounts
|
||||
// on the relevant revenue accounts (3108, 3308, 3109, 3521)
|
||||
const glTotals = await fetchGLTotals(supabase, user.id, period.start, period.end)
|
||||
|
||||
// Generate report
|
||||
const report = generateECSalesListReport({
|
||||
invoices,
|
||||
customers,
|
||||
glTotals: glTotals.length > 0 ? glTotals : undefined,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
})
|
||||
|
||||
// Add deadline info
|
||||
const deadline = getFilingDeadline(year, month, quarter)
|
||||
const daysLeft = daysUntilDeadline(deadline)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...report,
|
||||
deadline,
|
||||
daysUntilDeadline: daysLeft,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating EC Sales List report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL cross-check helper ───────────────────────────────────
|
||||
|
||||
const CROSS_CHECK_ACCOUNTS = ['3108', '3109', '3308', '3521']
|
||||
|
||||
/**
|
||||
* Fetch credit totals for cross-check accounts from posted journal entries.
|
||||
* Mirrors the approach used by /api/bookkeeping/account-totals.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchGLTotals(supabase: any, userId: string, startDate: string, endDate: string): Promise<GLAccountTotal[]> {
|
||||
// Get posted journal entry IDs in the period
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches (same pattern as account-totals route)
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: Array<{ account_number: string; credit_amount: number }> = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', CROSS_CHECK_ACCOUNTS)
|
||||
|
||||
if (lines) allLines.push(...lines)
|
||||
}
|
||||
|
||||
// Aggregate credits per account
|
||||
const totals = new Map<string, number>()
|
||||
for (const line of allLines) {
|
||||
const credit = Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, (totals.get(line.account_number) ?? 0) + credit)
|
||||
}
|
||||
|
||||
return Array.from(totals.entries()).map(([account_number, credit]) => ({
|
||||
account_number,
|
||||
credit: Math.round(credit * 100) / 100,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateIntrastatReport,
|
||||
type IntrastatInvoice,
|
||||
type IntrastatCustomer,
|
||||
type IntrastatInvoiceItem,
|
||||
type ProductMetadata,
|
||||
} from '@/extensions/export/intrastat/lib/intrastat-engine'
|
||||
import { generateSCBCsv, generateSCBFilename } from '@/extensions/export/intrastat/lib/scb-csv-generator'
|
||||
import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/intrastat/download
|
||||
*
|
||||
* Download an Intrastat declaration as SCB-compatible CSV.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (required) — 1-12
|
||||
*/
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
|
||||
if (!yearStr || !monthStr) {
|
||||
return NextResponse.json({ error: 'year and month are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const month = parseInt(monthStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
|
||||
const period = getMonthPeriod(year, month)
|
||||
|
||||
try {
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices
|
||||
const invoices = await fetchAllRows<IntrastatInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch invoice items
|
||||
const invoiceIds = invoices.map(inv => inv.id)
|
||||
let invoiceItems: IntrastatInvoiceItem[] = []
|
||||
if (invoiceIds.length > 0) {
|
||||
const BATCH_SIZE = 200
|
||||
for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) {
|
||||
const batch = invoiceIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: items } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id, invoice_id, description, quantity, unit_price, total, total_sek')
|
||||
.in('invoice_id', batch)
|
||||
|
||||
if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[]))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: IntrastatCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as IntrastatCustomer[]
|
||||
}
|
||||
|
||||
// Fetch product metadata
|
||||
const { data: productData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.ilike('key', 'product:%')
|
||||
|
||||
const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record<string, unknown> }) => ({
|
||||
productId: d.key.replace('product:', ''),
|
||||
cnCode: (d.value.cn_code as string) || null,
|
||||
description: (d.value.description as string) || '',
|
||||
netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
||||
countryOfOrigin: (d.value.country_of_origin as string) || 'SE',
|
||||
supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null,
|
||||
supplementaryUnitType: (d.value.supplementary_unit_type as string) || null,
|
||||
}))
|
||||
|
||||
// Fetch settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.eq('key', 'settings')
|
||||
.maybeSingle()
|
||||
|
||||
const settings = settingsData?.value as Record<string, unknown> | undefined
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
invoices,
|
||||
invoiceItems,
|
||||
customers,
|
||||
products,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
defaultTransactionNature: (settings?.default_transaction_nature as string) || '11',
|
||||
defaultDeliveryTerms: (settings?.default_delivery_terms as string) || 'FCA',
|
||||
})
|
||||
|
||||
const content = generateSCBCsv(report)
|
||||
const filename = generateSCBFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating Intrastat download:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate download' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateIntrastatReport,
|
||||
type IntrastatInvoice,
|
||||
type IntrastatCustomer,
|
||||
type IntrastatInvoiceItem,
|
||||
type ProductMetadata,
|
||||
} from '@/extensions/export/intrastat/lib/intrastat-engine'
|
||||
import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/intrastat/report
|
||||
*
|
||||
* Generate an Intrastat declaration report for the specified month.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (required) — 1-12 (Intrastat is always monthly)
|
||||
*/
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
|
||||
if (!yearStr || !monthStr) {
|
||||
return NextResponse.json({ error: 'year and month are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const month = parseInt(monthStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
|
||||
const period = getMonthPeriod(year, month)
|
||||
|
||||
try {
|
||||
// Fetch company settings
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch reverse-charge invoices for the period
|
||||
const invoices = await fetchAllRows<IntrastatInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch invoice items for those invoices
|
||||
const invoiceIds = invoices.map(inv => inv.id)
|
||||
let invoiceItems: IntrastatInvoiceItem[] = []
|
||||
if (invoiceIds.length > 0) {
|
||||
const BATCH_SIZE = 200
|
||||
for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) {
|
||||
const batch = invoiceIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: items } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id, invoice_id, description, quantity, unit_price, total, total_sek')
|
||||
.in('invoice_id', batch)
|
||||
|
||||
if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[]))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: IntrastatCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as IntrastatCustomer[]
|
||||
}
|
||||
|
||||
// Fetch product metadata from extension_data
|
||||
const { data: productData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.ilike('key', 'product:%')
|
||||
|
||||
const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record<string, unknown> }) => ({
|
||||
productId: d.key.replace('product:', ''),
|
||||
cnCode: (d.value.cn_code as string) || null,
|
||||
description: (d.value.description as string) || '',
|
||||
netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
||||
countryOfOrigin: (d.value.country_of_origin as string) || 'SE',
|
||||
supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null,
|
||||
supplementaryUnitType: (d.value.supplementary_unit_type as string) || null,
|
||||
}))
|
||||
|
||||
// Fetch extension settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.eq('key', 'settings')
|
||||
.maybeSingle()
|
||||
|
||||
const settings = settingsData?.value as Record<string, unknown> | undefined
|
||||
const defaultTransactionNature = (settings?.default_transaction_nature as string) || '11'
|
||||
const defaultDeliveryTerms = (settings?.default_delivery_terms as string) || 'FCA'
|
||||
|
||||
// Calculate prior cumulative value (rolling 12 months excluding current)
|
||||
const priorCumulativeValue = await calculatePriorCumulative(supabase, user.id, year, month)
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
invoices,
|
||||
invoiceItems,
|
||||
customers,
|
||||
products,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
defaultTransactionNature,
|
||||
defaultDeliveryTerms,
|
||||
priorCumulativeValue,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating Intrastat report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cumulative dispatch value for the 11 months prior to the
|
||||
* current period (rolling 12-month window for threshold monitoring).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function calculatePriorCumulative(supabase: any, userId: string, year: number, month: number): Promise<number> {
|
||||
// Calculate 11-month lookback window
|
||||
let startMonth = month - 11
|
||||
let startYear = year
|
||||
while (startMonth < 1) {
|
||||
startMonth += 12
|
||||
startYear--
|
||||
}
|
||||
const startDate = `${startYear}-${String(startMonth).padStart(2, '0')}-01`
|
||||
|
||||
// End date is the day before the current period
|
||||
let prevMonth = month - 1
|
||||
let prevYear = year
|
||||
if (prevMonth < 1) {
|
||||
prevMonth = 12
|
||||
prevYear--
|
||||
}
|
||||
const lastDay = new Date(prevYear, prevMonth, 0).getDate()
|
||||
const endDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
if (startDate > endDate) return 0
|
||||
|
||||
// Sum total_sek for reverse_charge invoices to EU in the lookback window
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('total_sek, subtotal_sek, subtotal')
|
||||
.eq('user_id', userId)
|
||||
.gte('invoice_date', startDate)
|
||||
.lte('invoice_date', endDate)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.eq('moms_ruta', '35')
|
||||
|
||||
if (error || !data) return 0
|
||||
|
||||
let total = 0
|
||||
for (const inv of data) {
|
||||
if (inv.subtotal_sek !== null) {
|
||||
total += Number(inv.subtotal_sek) || 0
|
||||
} else {
|
||||
total += Number(inv.subtotal) || 0
|
||||
}
|
||||
}
|
||||
|
||||
return Math.round(total * 100) / 100
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateVatMonitorReport,
|
||||
VAT_MONITOR_ACCOUNTS,
|
||||
type GLLine,
|
||||
type VatMonitorInvoice,
|
||||
type VatMonitorCustomer,
|
||||
} from '@/extensions/export/vat-monitor/lib/vat-monitor-engine'
|
||||
import {
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/vat-monitor/report
|
||||
*
|
||||
* Generate a VAT Monitor report for the specified period.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (optional) — 1-12
|
||||
* quarter (optional) — 1-4
|
||||
* compare (optional) — 'previous' to include period comparison
|
||||
*/
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
const compare = searchParams.get('compare')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch GL lines for current period
|
||||
const glLines = await fetchGLLines(supabase, user.id, period.start, period.end)
|
||||
|
||||
// Fetch invoices for validation
|
||||
const invoices = await fetchAllRows<VatMonitorInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, vat_treatment, moms_ruta, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers for those invoices
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: VatMonitorCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as VatMonitorCustomer[]
|
||||
}
|
||||
|
||||
// Fetch previous period GL lines for comparison
|
||||
let previousGlLines: GLLine[] | undefined
|
||||
if (compare === 'previous') {
|
||||
const prevPeriod = getPreviousPeriod(year, month, quarter)
|
||||
previousGlLines = await fetchGLLines(supabase, user.id, prevPeriod.start, prevPeriod.end)
|
||||
}
|
||||
|
||||
const report = generateVatMonitorReport({
|
||||
glLines,
|
||||
invoices,
|
||||
customers,
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
previousGlLines,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating VAT Monitor report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchGLLines(supabase: any, userId: string, startDate: string, endDate: string): Promise<GLLine[]> {
|
||||
// Fetch posted journal entry IDs for the period
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: GLLine[] = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', VAT_MONITOR_ACCOUNTS)
|
||||
|
||||
if (lines) allLines.push(...lines)
|
||||
}
|
||||
|
||||
return allLines
|
||||
}
|
||||
|
||||
function getPreviousPeriod(year: number, month?: number, quarter?: number): { start: string; end: string } {
|
||||
if (month !== undefined) {
|
||||
let prevMonth = month - 1
|
||||
let prevYear = year
|
||||
if (prevMonth < 1) {
|
||||
prevMonth = 12
|
||||
prevYear--
|
||||
}
|
||||
return getMonthPeriod(prevYear, prevMonth)
|
||||
}
|
||||
|
||||
let prevQuarter = quarter! - 1
|
||||
let prevYear = year
|
||||
if (prevQuarter < 1) {
|
||||
prevQuarter = 4
|
||||
prevYear--
|
||||
}
|
||||
return getQuarterPeriod(prevYear, prevQuarter)
|
||||
}
|
||||
Reference in New Issue
Block a user