Bug/vat selection warning (#583)
* refactor: update VAT handling logic for non-registered sellers and improve related comments * chore: gate automated email flows behind 503 responses Disables user-facing access to invoice payment reminders and salary payslip email sending. Underlying lib code (reminder-processor, PDF templates, notification_settings) is preserved for easy re-enable. - Invoice reminders cron route returns 503; settings UI section removed. - Payslip send route returns 503; original implementation kept as _sendPayslipsImpl for future re-enable. - Push notifications were already extension-disabled, no change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove Recapt feedback widget Strips the third-party Recapt SDK and its floating feedback bubble from the app. The in-app contact form keeps working via the existing email channel (/api/support/contact). Drops the Recapt entries from the CSP and the subprocessor list in the privacy policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: reject meaningless rättelser in correctEntry Guard against zero-economic-effect corrections in the storno engine: - Reject when proposed lines net to zero on every account (e.g. 1930 debit 100 / 1930 credit 100), which would erase the original posting without representing any affärshändelse (BFL 5 kap. 5 §). - Reject when proposed lines are an exact multiset match of the original entry — a rättelse must actually change something. New MeaninglessCorrectionError wired through bookkeepingErrorResponse (HTTP 400) and the Swedish error translator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add date-range picker to resultat- and balansrapport Adds optional from/to date filtering to the four operational financial reports (resultatrapport, balansrapport, income-statement, balance-sheet) so users can view a month, quarter, or custom range inside a fiscal year without leaving the report. Defaults to YTD; "Hela året" preserves the prior full-period behaviour (URL-identical, cache-stable). - trial-balance engine accepts optional fromDate/toDate, rolling prior in-period activity into IB and clamping period activity to the window - 12 API routes accept and validate from_date/to_date query params - ReportDateRange chip picker persists preset per company, only renders on the four relevant tabs - FiscalYearSelector now emits the period object so the range picker has bounds without an extra fetch - PDF/XLSX filenames reflect the chosen range - Resultatrapport drops the prior-year column when narrowed (full-year vs partial-year would mislead) - 11 new tests (engine + parser); all existing report tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add support for marking journal entries as "no document required" - Introduced a new sidecar table `journal_entry_no_doc_required` to track entries that do not require separate documentation (e.g., bank fees, interest). - Implemented API routes for creating and deleting exemptions, including validation and authorization checks. - Added a toggle component in the UI to allow users to mark entries as exempt, with an optional reason. - Updated relevant tests to cover the new functionality, including RLS checks and cascading deletes. - Enhanced existing schemas and types to accommodate the new `vat_amount` field for supplier invoice items. * fix: address PR review findings on no-doc-required + VAT changes - pg-real cascade test wraps DELETE in gnubok.allow_delete='true' txn so the immutability trigger bypass fires (mirrors delete_last_voucher RPC). - Clamp supplier-invoice item vat_amount to <= line_total * vat_rate via Zod refinement (with 1-öre rounding tolerance) so the manual override can't inflate the 2641 debit beyond the statutory ceiling. - groupVatByRate falls back to line_total * rate when stored vat_amount is 0 with a positive rate, so legacy/import paths leaving the column at its NOT NULL DEFAULT 0 don't silently understate ruta 48. - ReportDateRange todayIso() and preset endpoints use local date components instead of toISOString() (UTC) — fixes the midnight-to-02:00 off-by-one that truncated a day from YTD / this-month / this-quarter for Swedish users. - NoDocRequiredToggle restores the previous reason on failed POST/DELETE so the rolled-back toggle state stays consistent with the rendered reason. - Document the company-scoped (not user-scoped) DELETE authorization policy on the no-document-required route. 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
627109b5bd
commit
a9b43ebeb7
@@ -682,6 +682,57 @@ describe('CreateSupplierInvoiceItemSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts vat_amount up to line_total * vat_rate', () => {
|
||||
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 1250 })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts partial-deduction vat_amount (bilförmån 50%)', () => {
|
||||
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 625 })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects vat_amount above line_total * vat_rate', () => {
|
||||
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 2000 })
|
||||
)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts vat_amount with 1-öre rounding tolerance', () => {
|
||||
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ amount: 100.04, vat_rate: 0.25, vat_amount: 25.02 })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('works with quantity * unit_price line total', () => {
|
||||
const overByABit = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({
|
||||
amount: undefined,
|
||||
quantity: 4,
|
||||
unit_price: 100,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 200,
|
||||
})
|
||||
)
|
||||
expect(overByABit.success).toBe(false)
|
||||
const exact = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({
|
||||
amount: undefined,
|
||||
quantity: 4,
|
||||
unit_price: 100,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 100,
|
||||
})
|
||||
)
|
||||
expect(exact.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkSupplierInvoicePaidSchema', () => {
|
||||
|
||||
+23
-1
@@ -349,11 +349,33 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
amount: z.number().optional(),
|
||||
account_number: accountNumber,
|
||||
vat_rate: z.number().min(0).max(100).optional(),
|
||||
// Manual VAT override. When provided, the engine books this exact amount to
|
||||
// 2641/2645 instead of recomputing line_total × vat_rate. Use for partial-
|
||||
// deductible cases (bilförmån 50%, representation 300 kr-tak), foreign-
|
||||
// currency rounding, or POS receipts where supplier-side rounding makes the
|
||||
// VAT off by öre.
|
||||
vat_amount: z.number().min(0).optional(),
|
||||
vat_code: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
unit_price: z.number().optional(),
|
||||
})
|
||||
}).refine(
|
||||
(item) => {
|
||||
if (item.vat_amount == null) return true
|
||||
const lineTotal = item.amount != null
|
||||
? item.amount
|
||||
: (item.quantity ?? 1) * (item.unit_price ?? 0)
|
||||
const vatRate = item.vat_rate ?? 0.25
|
||||
const maxVat = Math.round(lineTotal * vatRate * 100) / 100
|
||||
// 1-öre tolerance covers POS rounding; anything beyond is an upstream bug
|
||||
// or a client trying to inflate 2641 debit beyond the statutory ceiling.
|
||||
return item.vat_amount <= maxVat + 0.01
|
||||
},
|
||||
{
|
||||
message: 'vat_amount cannot exceed line_total × vat_rate',
|
||||
path: ['vat_amount'],
|
||||
},
|
||||
)
|
||||
|
||||
export const CreateSupplierInvoiceSchema = z.object({
|
||||
supplier_id: uuid,
|
||||
|
||||
@@ -87,6 +87,13 @@ const {
|
||||
} = await import('../supplier-invoice-entries')
|
||||
|
||||
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
|
||||
// Mirror the API: vat_amount derives from line_total × vat_rate unless the
|
||||
// test overrides it explicitly (manual-override cases). This keeps multi-
|
||||
// item and mixed-rate fixtures self-consistent with the engine, which now
|
||||
// reads stored vat_amount directly rather than recomputing from line_total.
|
||||
const lineTotal = overrides.line_total ?? 8000
|
||||
const vatRate = overrides.vat_rate ?? 0.25
|
||||
const vatAmount = overrides.vat_amount ?? Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
id: 'si-item-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
@@ -94,12 +101,12 @@ function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoice
|
||||
description: 'Consulting services',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 8000,
|
||||
line_total: 8000,
|
||||
unit_price: lineTotal,
|
||||
line_total: lineTotal,
|
||||
account_number: '6200',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 2000,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
@@ -170,6 +177,128 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('books manual VAT override (bilförmån 50%) instead of recomputing from rate', async () => {
|
||||
// Personbilsleasing: leverantören fakturerar 25% moms (2 500 kr), men
|
||||
// endast 50% (1 250 kr) är avdragsgill enligt ML 8 kap 16§. Användaren
|
||||
// anger 1 250 kr manuellt. Det resterande beloppet förblir på
|
||||
// kostnadskontot (10 000 + 1 250 ej avdragsgill moms = 11 250 brutto-
|
||||
// kostnad om användaren även justerar line_total; här testar vi enbart
|
||||
// att momsöverskridningen genererar rätt 2641-belopp).
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 1250,
|
||||
total: 11250,
|
||||
})
|
||||
const items = [
|
||||
makeItem({
|
||||
line_total: 10000,
|
||||
account_number: '5615', // Leasing av personbilar
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 1250, // manual override (50% av 2 500)
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
|
||||
const debit5615 = findByAccount(input.lines, '5615')
|
||||
expect(debit5615[0].debit_amount).toBe(10000)
|
||||
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641).toHaveLength(1)
|
||||
// Avgörande: 1 250 (manual) — INTE 2 500 (10 000 × 0.25).
|
||||
expect(debit2641[0].debit_amount).toBe(1250)
|
||||
|
||||
const credit2440 = findByAccount(input.lines, '2440')
|
||||
expect(credit2440[0].credit_amount).toBe(11250)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('recomputes 2641 from line_total × rate when stored vat_amount is 0 (legacy/import path)', async () => {
|
||||
// Schema default is vat_amount=0; SIE imports and demo seeders sometimes
|
||||
// leave it that way. Silently posting 0 to 2641 would understate ruta 48
|
||||
// in the momsdeklaration. The engine recovers by recomputing from the
|
||||
// base when the stored amount is missing.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '5410', vat_rate: 0.25, vat_amount: 0 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const debit2641 = findByAccount(input.lines, '2641')
|
||||
expect(debit2641).toHaveLength(1)
|
||||
expect(debit2641[0].debit_amount).toBe(2500)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('aggregates manual VAT overrides per rate group on mixed-rate invoice', async () => {
|
||||
// Restaurangkvitto med två olika momsöverskridningar pga representation-
|
||||
// tak och egen avrundning. 25%-raden får manuell 100 kr, 12%-raden får
|
||||
// manuell 50 kr.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 1000,
|
||||
vat_amount: 150,
|
||||
total: 1150,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', line_total: 400, account_number: '6071', vat_rate: 0.25, vat_amount: 100 }),
|
||||
makeItem({ id: 'item-2', line_total: 600, account_number: '6071', vat_rate: 0.12, vat_amount: 50 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const vat2641 = findByAccount(input.lines, '2641')
|
||||
expect(vat2641).toHaveLength(2)
|
||||
expect(vat2641.find((l) => l.line_description?.includes('25%'))?.debit_amount).toBe(100)
|
||||
expect(vat2641.find((l) => l.line_description?.includes('12%'))?.debit_amount).toBe(50)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('reverse charge ignores manual vat_amount and uses statutory base × rate', async () => {
|
||||
// RC: fiktiv moms beräknas alltid på basbeloppet med lagstadgad sats —
|
||||
// ett manuellt vat_amount på posten är meningslöst (köparen redovisar
|
||||
// själv) och får inte påverka 2645/2614.
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
reverse_charge: true,
|
||||
})
|
||||
const items = [
|
||||
makeItem({
|
||||
line_total: 10000,
|
||||
account_number: '6540',
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 999, // ska ignoreras
|
||||
}),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '2645')[0].debit_amount).toBe(2500)
|
||||
expect(findByAccount(input.lines, '2614')[0].credit_amount).toBe(2500)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('creates domestic entry with zero VAT (no 2641 line)', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 5000,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
|
||||
export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const
|
||||
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
|
||||
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
|
||||
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
|
||||
|
||||
// ============================================================================
|
||||
// AccountsNotInChartError — kept for back-compat (many existing call sites)
|
||||
@@ -138,6 +139,20 @@ export class CurrencyRevaluationAlreadyExistsError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type MeaninglessCorrectionReason = 'net_zero_per_account' | 'identical_to_original'
|
||||
|
||||
export class MeaninglessCorrectionError extends Error {
|
||||
readonly code = MEANINGLESS_CORRECTION
|
||||
constructor(public readonly reason: MeaninglessCorrectionReason) {
|
||||
super(
|
||||
reason === 'net_zero_per_account'
|
||||
? 'Correction lines net to zero on every account — no economic event represented (BFL 5 kap. 5 §).'
|
||||
: 'Correction lines are identical to the original entry — nothing to correct.'
|
||||
)
|
||||
this.name = 'MeaninglessCorrectionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidMappingResultError extends Error {
|
||||
readonly code = INVALID_MAPPING_RESULT
|
||||
constructor(
|
||||
@@ -206,7 +221,8 @@ export function isBookkeepingError(err: unknown): boolean {
|
||||
err instanceof EntryAlreadyReversedError ||
|
||||
err instanceof CurrencyRevaluationAlreadyExistsError ||
|
||||
err instanceof InvalidMappingResultError ||
|
||||
err instanceof BookkeepingDatabaseError
|
||||
err instanceof BookkeepingDatabaseError ||
|
||||
err instanceof MeaninglessCorrectionError
|
||||
)
|
||||
}
|
||||
|
||||
@@ -356,6 +372,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof MeaninglessCorrectionError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: { reason: err.reason },
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof BookkeepingDatabaseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -125,12 +125,16 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
|
||||
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
|
||||
// larger than ruta 21 by the 6540 portion — the exact FK004 pattern.
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
//
|
||||
// Drive iteration off the basis (line_total per rate), not stored
|
||||
// vat_amount — fiktiv moms is always statutory base × rate. This keeps
|
||||
// RC immune to per-line manual VAT overrides (which only make sense for
|
||||
// domestic deductible-VAT adjustments).
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
const baseAmount = amount / rate
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||||
lines.push(...rcLines)
|
||||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||||
@@ -335,12 +339,13 @@ export async function createSupplierInvoiceCashEntry(
|
||||
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
|
||||
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
|
||||
// Per-rate bucketing: see registration entry above for the FK004 rationale.
|
||||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
// Drive iteration off the basis (line_total per rate) — fiktiv moms is
|
||||
// always statutory base × rate; manual vat_amount overrides don't apply.
|
||||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
const baseAmount = amount / rate
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||||
lines.push(...rcLines)
|
||||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||||
@@ -523,16 +528,18 @@ export async function createSupplierCreditNoteEntry(
|
||||
if (isReverseCharge) {
|
||||
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
|
||||
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
|
||||
// Drive iteration off the basis — fiktiv moms is always statutory base × rate.
|
||||
const inputAccount = isDomesticRC ? '2647' : '2645'
|
||||
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const baseByRate = groupBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
// Only reverse basbeloppsraderna for the portion the registration would
|
||||
// have emitted them — namely the non-basis-account base per rate. Items
|
||||
// booked directly to 44xx/45xx had no parallel basis lines in registration
|
||||
// and so are reversed only via the expense credit line above.
|
||||
for (const [rate, amount] of vatByRate) {
|
||||
if (rate > 0 && amount > 0) {
|
||||
for (const [rate, baseAmount] of baseByRate) {
|
||||
if (rate > 0 && baseAmount > 0) {
|
||||
const fiktivVat = Math.round(baseAmount * rate * 100) / 100
|
||||
// Determine the output account for this rate
|
||||
let outputAccount: string
|
||||
switch (rate) {
|
||||
@@ -543,12 +550,12 @@ export async function createSupplierCreditNoteEntry(
|
||||
creditLines.push({
|
||||
account_number: inputAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
credit_amount: fiktivVat,
|
||||
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||||
})
|
||||
lines.push({
|
||||
account_number: outputAccount,
|
||||
debit_amount: amount,
|
||||
debit_amount: fiktivVat,
|
||||
credit_amount: 0,
|
||||
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
|
||||
})
|
||||
@@ -613,8 +620,21 @@ export async function createSupplierCreditNoteEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by VAT rate and sum the VAT amount per rate.
|
||||
* Returns a Map<rate, totalVatAmount> for generating per-rate journal lines.
|
||||
* Group items by VAT rate and sum the stored VAT amount per rate.
|
||||
* Returns a Map<rate, totalVatAmount> in SEK for per-rate 2641 journal lines.
|
||||
*
|
||||
* Reads `item.vat_amount` directly — set by the API from the line's manual
|
||||
* override when present, else computed line_total × rate. This is the path
|
||||
* for partial-deductible cases (bilförmån 50%, representation 300 kr-tak),
|
||||
* foreign-currency rounding, and supplier POS rounding.
|
||||
*
|
||||
* Fallback to line_total × rate when vat_amount is null/0 but rate > 0 —
|
||||
* legacy import paths (SIE, CSV, demo seed) sometimes leave vat_amount at
|
||||
* the column DEFAULT of 0. Silently dropping input VAT to 2641 would
|
||||
* understate ruta 48 in the momsdeklaration.
|
||||
*
|
||||
* Reverse-charge fiktiv moms doesn't use this — see groupBaseByRate, which
|
||||
* derives the basis directly so fiktiv VAT is always base × statutory rate.
|
||||
*/
|
||||
function groupVatByRate(
|
||||
items: SupplierInvoiceItem[],
|
||||
@@ -625,14 +645,39 @@ function groupVatByRate(
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 0.25
|
||||
let itemSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
|
||||
const itemVat = Math.round(itemSek * rate * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + itemVat)
|
||||
const storedVat = item.vat_amount ?? 0
|
||||
const computedVat = rate > 0
|
||||
? Math.round((item.line_total ?? 0) * rate * 100) / 100
|
||||
: 0
|
||||
const sourceVat = storedVat > 0 ? storedVat : computedVat
|
||||
let vatSek = resolveSekAmount(sourceVat, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
|
||||
}
|
||||
return vatByRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by VAT rate and sum the base (line_total) per rate.
|
||||
* Used by reverse-charge paths to compute fiktiv moms from the basis,
|
||||
* decoupled from any manual VAT override on the items themselves.
|
||||
*/
|
||||
function groupBaseByRate(
|
||||
items: SupplierInvoiceItem[],
|
||||
currency: string,
|
||||
exchangeRate: number | null,
|
||||
useAbsoluteValues = false
|
||||
): Map<number, number> {
|
||||
const baseByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? 0.25
|
||||
let baseSek = resolveSekAmount(item.line_total, null, currency, exchangeRate)
|
||||
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
|
||||
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
|
||||
}
|
||||
return baseByRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
|
||||
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
|
||||
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
|
||||
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
@@ -212,6 +212,70 @@ describe('correctEntry', () => {
|
||||
expect(journalEntryInserts[1]).toMatchObject({ source_type: 'correction', entry_date: '2024-06-15' })
|
||||
})
|
||||
|
||||
it('rejects rättelse where every account nets to zero (1930 → 1930)', async () => {
|
||||
const supabase = makeClient()
|
||||
const noOpLines = [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
|
||||
]
|
||||
await expect(
|
||||
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
|
||||
).rejects.toBeInstanceOf(MeaninglessCorrectionError)
|
||||
|
||||
// Guard runs before any DB call — original must not be fetched.
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects rättelse where multiple accounts each net to zero', async () => {
|
||||
const supabase = makeClient()
|
||||
const noOpLines = [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
|
||||
{ account_number: '5410', debit_amount: 50, credit_amount: 0 },
|
||||
{ account_number: '5410', debit_amount: 0, credit_amount: 50 },
|
||||
]
|
||||
await expect(
|
||||
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', noOpLines)
|
||||
).rejects.toMatchObject({
|
||||
code: 'MEANINGLESS_CORRECTION',
|
||||
reason: 'net_zero_per_account',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects rättelse identical to the original entry', async () => {
|
||||
const supabase = makeClient()
|
||||
// Only the fetch-original result is needed — guard runs right after.
|
||||
results = [{ data: originalEntry, error: null }]
|
||||
|
||||
const identicalLines = [
|
||||
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
||||
]
|
||||
|
||||
await expect(
|
||||
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', identicalLines)
|
||||
).rejects.toMatchObject({
|
||||
code: 'MEANINGLESS_CORRECTION',
|
||||
reason: 'identical_to_original',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows rättelse that shifts amounts between different accounts', async () => {
|
||||
setupResults()
|
||||
const supabase = makeClient()
|
||||
// correctedLines moves expense from 5410 → 5420 — net effect per account
|
||||
// is non-zero (5420 +1200, 5410 0 since absent, 1930 -1200), and the lines
|
||||
// differ from the original, so both guards must pass.
|
||||
const result = await correctEntry(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'orig-1',
|
||||
correctedLines
|
||||
)
|
||||
expect(result.corrected).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits journal_entry.corrected event', async () => {
|
||||
setupResults()
|
||||
|
||||
|
||||
@@ -13,8 +13,51 @@ import {
|
||||
EntryAlreadyReversedError,
|
||||
JournalEntryNotBalancedError,
|
||||
JournalEntryNotFoundError,
|
||||
MeaninglessCorrectionError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
|
||||
/**
|
||||
* Round to 2dp using cents-integer math to avoid 0.1+0.2 drift.
|
||||
*/
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every account's (debit − credit) sum across the proposed lines is
|
||||
* zero. Such a rättelse describes no real affärshändelse and would erase the
|
||||
* original posting without representing anything in its place — disallowed by
|
||||
* BFL 5 kap. 5 § / BFNAR 2013:2.
|
||||
*/
|
||||
function netsToZeroPerAccount(lines: CreateJournalEntryLineInput[]): boolean {
|
||||
const nets = new Map<string, number>()
|
||||
for (const line of lines) {
|
||||
const delta = round2(line.debit_amount || 0) - round2(line.credit_amount || 0)
|
||||
nets.set(line.account_number, (nets.get(line.account_number) || 0) + delta)
|
||||
}
|
||||
return Array.from(nets.values()).every((n) => Math.abs(n) < 0.005)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when proposed lines are the same multiset as the original lines
|
||||
* (account_number + debit + credit). A rättelse must actually change something.
|
||||
*/
|
||||
function isIdenticalToOriginal(
|
||||
proposed: CreateJournalEntryLineInput[],
|
||||
original: JournalEntryLine[]
|
||||
): boolean {
|
||||
if (proposed.length !== original.length) return false
|
||||
const key = (acc: string, d: number, c: number) =>
|
||||
`${acc}|${round2(d).toFixed(2)}|${round2(c).toFixed(2)}`
|
||||
const proposedKeys = proposed
|
||||
.map((l) => key(l.account_number, l.debit_amount || 0, l.credit_amount || 0))
|
||||
.sort()
|
||||
const originalKeys = original
|
||||
.map((l) => key(l.account_number, Number(l.debit_amount) || 0, Number(l.credit_amount) || 0))
|
||||
.sort()
|
||||
return proposedKeys.every((k, i) => k === originalKeys[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* Storno Service - 3-step correction flow per Bokföringslagen
|
||||
*
|
||||
@@ -65,6 +108,13 @@ export async function correctEntry(
|
||||
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'correction')
|
||||
}
|
||||
|
||||
// Reject a rättelse with no economic effect (e.g. 1930 debit 100 / 1930
|
||||
// credit 100). Such an entry would erase the original posting without
|
||||
// representing any affärshändelse — disallowed by BFL 5 kap. 5 §.
|
||||
if (netsToZeroPerAccount(correctedLines)) {
|
||||
throw new MeaninglessCorrectionError('net_zero_per_account')
|
||||
}
|
||||
|
||||
// Fetch original entry with lines
|
||||
const { data: original, error: fetchError } = await supabase
|
||||
.from('journal_entries')
|
||||
@@ -83,6 +133,12 @@ export async function correctEntry(
|
||||
|
||||
const originalLines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// Reject when the proposed lines are identical to the original entry —
|
||||
// a rättelse must actually change something.
|
||||
if (isIdenticalToOriginal(correctedLines, originalLines)) {
|
||||
throw new MeaninglessCorrectionError('identical_to_original')
|
||||
}
|
||||
|
||||
// ===== Step 1: Create storno (reversal) entry =====
|
||||
const reversalVoucherNumber = await getNextVoucherNumber(
|
||||
supabase,
|
||||
|
||||
@@ -324,6 +324,14 @@ export function getErrorMessage(
|
||||
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
|
||||
}
|
||||
|
||||
if (structured.code === 'MEANINGLESS_CORRECTION') {
|
||||
const details = structured.details as { reason?: string } | undefined
|
||||
if (details?.reason === 'identical_to_original') {
|
||||
return 'Rättelsen är identisk med originalverifikationen — inget har ändrats.'
|
||||
}
|
||||
return 'Rättelsen saknar ekonomisk innebörd: varje konto netto till noll. En rättelse måste beskriva en faktisk affärshändelse (BFL 5 kap. 5 §).'
|
||||
}
|
||||
|
||||
if (structured.code === 'BOOKKEEPING_DATABASE_ERROR') {
|
||||
// A DB-layer error may carry a user-relevant cause (e.g. period lock
|
||||
// trigger). Try the known-pattern map before falling back to the
|
||||
|
||||
@@ -67,27 +67,14 @@ describe('getAvailableVatRates', () => {
|
||||
expect(rates).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('collapses to 0%/exempt for non-registered sellers regardless of customer type', () => {
|
||||
// ML 1 kap. 1§ — only a skattskyldig may charge output VAT. The picker
|
||||
// must never offer non-zero rates when vat_registered=false; the API
|
||||
// route (route.ts) and preview-pdf route enforce the same gate so a
|
||||
// client bypassing the UI also gets rejected.
|
||||
for (const customerType of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
|
||||
const rates = getAvailableVatRates(customerType, false, false)
|
||||
expect(rates).toHaveLength(1)
|
||||
expect(rates[0]).toEqual({
|
||||
rate: 0,
|
||||
label: '0% (ej momsregistrerad)',
|
||||
treatment: 'exempt',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults vatRegistered to true (legitimate VAT path)', () => {
|
||||
// Explicit default-arg pin so a future signature change doesn't silently
|
||||
// flip the default and strip VAT from invoices for registered sellers.
|
||||
const rates = getAvailableVatRates('swedish_business', false)
|
||||
it('does not gate on seller VAT-registration status', () => {
|
||||
// ML 16 kap. 23 § (faktureringsmoms): the picker offers the full
|
||||
// customer-type-based rate set regardless of whether the seller is
|
||||
// momsregistrerad. The invoice form surfaces a warning at submit time
|
||||
// when a non-registered seller picks a non-zero rate.
|
||||
const rates = getAvailableVatRates('swedish_business')
|
||||
expect(rates).toHaveLength(4)
|
||||
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,23 +166,11 @@ describe('getVatRules', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('short-circuits to exempt / rate 0 / empty momsRuta for non-registered sellers', () => {
|
||||
// ML 1 kap. 1§ — non-skattskyldig cannot charge output VAT. momsRuta is
|
||||
// intentionally empty so a downstream momsdeklaration generator never
|
||||
// mis-files a non-registered seller's revenue into ruta 05.
|
||||
for (const customerType of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
|
||||
const rules = getVatRules(customerType, false, false)
|
||||
expect(rules).toEqual({
|
||||
treatment: 'exempt',
|
||||
rate: 0,
|
||||
momsRuta: '',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults vatRegistered to true (legitimate VAT path)', () => {
|
||||
// Explicit default-arg pin (see corresponding getAvailableVatRates test).
|
||||
const rules = getVatRules('swedish_business', false)
|
||||
it('does not gate on seller VAT-registration status', () => {
|
||||
// ML 16 kap. 23 § (faktureringsmoms): a non-registered seller who states
|
||||
// VAT still owes it. The rule output reflects the customer-type rate so
|
||||
// the booking is consistent with what the buyer sees on the invoice.
|
||||
const rules = getVatRules('swedish_business')
|
||||
expect(rules.rate).toBe(25)
|
||||
expect(rules.treatment).toBe('standard_25')
|
||||
expect(rules.momsRuta).toBe('05')
|
||||
|
||||
@@ -868,12 +868,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
// Suppress the "Moms 0%" row entirely when the seller is not
|
||||
// VAT-registered. ML 1 kap. 1§ — a non-skattskyldig may not
|
||||
// charge output VAT, so a "Moms 0%" line would imply VAT
|
||||
// accounting that doesn't exist. The notice block below the
|
||||
// payment section explains the absence of VAT.
|
||||
company.vat_registered !== false && (
|
||||
// Suppress the "Moms 0%" row only when the seller is not
|
||||
// VAT-registered AND the invoice actually carries no VAT.
|
||||
// A non-registered seller who states VAT (warned at create time
|
||||
// per ML 16 kap. 23 §) still gets the totals row so the printed
|
||||
// invoice matches what the customer is being asked to pay.
|
||||
!(company.vat_registered === false && invoice.vat_amount === 0) && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
|
||||
@@ -1063,17 +1063,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)}
|
||||
|
||||
{/* Reverse charge / export / exempt / not-registered notice.
|
||||
"Not VAT-registered" trumps the others — when the seller is
|
||||
outside the VAT system entirely (vat_registered=false in
|
||||
company_settings), reverse-charge and ML 3 kap. exempt notices
|
||||
don't apply, and a single dedicated notice is clearer for the
|
||||
customer than reusing the exempt notice (which implies the sale
|
||||
specifically is exempt while the seller is otherwise within the
|
||||
VAT system). Server-side enforcement (api/invoices/route.ts +
|
||||
preview-pdf/route.ts) coerces vat_amount to 0 for non-registered
|
||||
sellers, so this branch always lines up with what's in the
|
||||
totals block. */}
|
||||
{company.vat_registered === false ? (
|
||||
"Not VAT-registered" trumps the others ONLY when the invoice
|
||||
actually carries no VAT — a non-registered seller who chose to
|
||||
state VAT on the invoice (warned at create time per ML 16 kap.
|
||||
23 §) gets the normal reverse-charge / exempt notices instead,
|
||||
since the "ej momsregistrerad" line would contradict the VAT
|
||||
shown in the totals block. */}
|
||||
{company.vat_registered === false && invoice.vat_amount === 0 ? (
|
||||
<View style={styles.reverseChargeBox}>
|
||||
<Text style={styles.reverseChargeText}>{L.notVatRegisteredNotice}</Text>
|
||||
</View>
|
||||
|
||||
@@ -12,22 +12,16 @@ export interface VatRateOption {
|
||||
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
|
||||
* Reverse charge and export customers are locked to 0%.
|
||||
*
|
||||
* When the seller is not VAT-registered (`vatRegistered=false`), every customer
|
||||
* type collapses to a single 0% / exempt option. ML 1 kap. 1§ — only a
|
||||
* skattskyldig person may charge VAT, so the picker must never offer non-zero
|
||||
* rates in that mode. ML 16 kap. 23 § (faktureringsmoms) imposes liability for
|
||||
* VAT erroneously stated on a document, but does NOT grant the right to charge
|
||||
* it — so we block at source rather than allow + warn.
|
||||
* The picker does NOT gate on the seller's VAT registration status. A
|
||||
* non-momsregistrerad seller is shown the same options as a registered one —
|
||||
* the form surfaces a warning at submit time (ML 16 kap. 23 § faktureringsmoms:
|
||||
* stated VAT is owed even by non-registered sellers, but the buyer cannot
|
||||
* deduct it as input VAT).
|
||||
*/
|
||||
export function getAvailableVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
vatRegistered: boolean = true,
|
||||
): VatRateOption[] {
|
||||
if (!vatRegistered) {
|
||||
return [{ rate: 0, label: '0% (ej momsregistrerad)', treatment: 'exempt' }]
|
||||
}
|
||||
|
||||
// EU business with validated VAT → reverse charge, locked to 0%
|
||||
if (customerType === 'eu_business' && vatNumberValidated) {
|
||||
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
|
||||
@@ -81,25 +75,14 @@ export interface VatRule {
|
||||
* - EU business without validated VAT: 25% VAT, moms ruta 05
|
||||
* - Non-EU business: 0% export, moms ruta 40
|
||||
*
|
||||
* When the seller is not VAT-registered (`vatRegistered=false`), the rules
|
||||
* short-circuit to `{ treatment: 'exempt', rate: 0, momsRuta: '' }` regardless
|
||||
* of customer type — ML 1 kap. 1§ bars a non-skattskyldig from charging output
|
||||
* VAT. `momsRuta` is empty so a downstream momsdeklaration generator never
|
||||
* mis-files a non-registered seller's "sales" into ruta 05.
|
||||
* Independent of the seller's VAT registration status. A non-momsregistrerad
|
||||
* seller who charges VAT still owes it under ML 16 kap. 23 § (faktureringsmoms),
|
||||
* so the rule output must reflect the rate actually charged on the line.
|
||||
*/
|
||||
export function getVatRules(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
vatRegistered: boolean = true,
|
||||
): VatRule {
|
||||
if (!vatRegistered) {
|
||||
return {
|
||||
treatment: 'exempt',
|
||||
rate: 0,
|
||||
momsRuta: '',
|
||||
}
|
||||
}
|
||||
|
||||
switch (customerType) {
|
||||
case 'individual':
|
||||
case 'swedish_business':
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export function clearRecaptIdentity(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window.recapt !== 'function') return
|
||||
try {
|
||||
window.recapt('identify', {
|
||||
uid: undefined,
|
||||
email: undefined,
|
||||
nickname: undefined,
|
||||
})
|
||||
} catch {
|
||||
// best-effort — we're already in a logout flow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseReportDateRange } from '../date-range'
|
||||
|
||||
const period = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
function paramsOf(obj: Record<string, string>): URLSearchParams {
|
||||
return new URLSearchParams(obj)
|
||||
}
|
||||
|
||||
describe('parseReportDateRange', () => {
|
||||
it('returns an empty range when no params are provided', () => {
|
||||
const result = parseReportDateRange(paramsOf({}), period)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.range).toEqual({})
|
||||
})
|
||||
|
||||
it('accepts valid in-period dates', () => {
|
||||
const result = parseReportDateRange(
|
||||
paramsOf({ from_date: '2026-03-01', to_date: '2026-05-31' }),
|
||||
period,
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.range.fromDate).toBe('2026-03-01')
|
||||
expect(result.range.toDate).toBe('2026-05-31')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed from_date', () => {
|
||||
const result = parseReportDateRange(paramsOf({ from_date: '2026/03/01' }), period)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects from_date before period start', () => {
|
||||
const result = parseReportDateRange(paramsOf({ from_date: '2025-12-31' }), period)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects to_date after period end', () => {
|
||||
const result = parseReportDateRange(paramsOf({ to_date: '2027-01-01' }), period)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects reversed range', () => {
|
||||
const result = parseReportDateRange(
|
||||
paramsOf({ from_date: '2026-06-01', to_date: '2026-05-01' }),
|
||||
period,
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a single boundary date (only from_date)', () => {
|
||||
const result = parseReportDateRange(paramsOf({ from_date: '2026-06-01' }), period)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.range.fromDate).toBe('2026-06-01')
|
||||
expect(result.range.toDate).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ let mockResults: Record<string, MockResult[]>
|
||||
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range']) {
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
const consume = (): MockResult => {
|
||||
@@ -389,4 +389,186 @@ describe('generateTrialBalance', () => {
|
||||
expect(result.totalCredit).toBe(5000)
|
||||
expect(result.isBalanced).toBe(true)
|
||||
})
|
||||
|
||||
// ── Date-range tests ─────────────────────────────────────────────
|
||||
// The 4 reports (resultatrapport/balansrapport/income-statement/balance-
|
||||
// sheet) thread an optional { fromDate, toDate } through to the trial
|
||||
// balance. The engine must (a) skip the roll-forward query when fromDate
|
||||
// equals period_start, (b) roll prior in-period lines into IB when
|
||||
// fromDate is later, and (c) clamp period activity to the window.
|
||||
|
||||
it('treats omitted range as parity with the full period', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
||||
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1')
|
||||
|
||||
// Same as the existing "balanced two-account" case — no roll-forward query
|
||||
// is consumed because no range is requested.
|
||||
expect(result.rows).toHaveLength(2)
|
||||
expect(result.totalDebit).toBe(1000)
|
||||
expect(result.totalCredit).toBe(1000)
|
||||
expect(result.isBalanced).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the roll-forward query when fromDate equals period_start', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// Only the period query — no roll-forward fetch should be triggered.
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
||||
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
fromDate: '2024-01-01',
|
||||
toDate: '2024-06-30',
|
||||
})
|
||||
|
||||
expect(result.rows[0].opening_debit).toBe(0)
|
||||
expect(result.rows[0].closing_debit).toBe(500)
|
||||
})
|
||||
|
||||
it('rolls prior in-period lines into IB when fromDate is after period_start', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// 1st consumption — roll-forward query for [2024-01-01, 2024-04-01).
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 2000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 2nd consumption — period activity for [2024-04-01, 2024-06-30].
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
||||
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
fromDate: '2024-04-01',
|
||||
toDate: '2024-06-30',
|
||||
})
|
||||
|
||||
// 1930: IB carries 2000 from Q1, period adds 500 → UB 2500
|
||||
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
||||
expect(acc1930.opening_debit).toBe(2000)
|
||||
expect(acc1930.period_debit).toBe(500)
|
||||
expect(acc1930.closing_debit).toBe(2500)
|
||||
|
||||
// 3001: IB carries 2000 from Q1, period adds 500 → UB 2500
|
||||
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
|
||||
expect(acc3001.opening_credit).toBe(2000)
|
||||
expect(acc3001.period_credit).toBe(500)
|
||||
expect(acc3001.closing_credit).toBe(2500)
|
||||
|
||||
expect(result.isBalanced).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty period activity when the range matches no lines', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// Roll-forward query — has prior activity
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 750 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// Period query — no lines inside [2024-11-01, 2024-11-30]
|
||||
{ data: [], error: null },
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
||||
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
||||
fromDate: '2024-11-01',
|
||||
toDate: '2024-11-30',
|
||||
})
|
||||
|
||||
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
||||
expect(acc1930.opening_debit).toBe(750)
|
||||
expect(acc1930.period_debit).toBe(0)
|
||||
expect(acc1930.closing_debit).toBe(750)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,9 +12,13 @@ import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@
|
||||
export async function generateBalanceSheet(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
): Promise<BalanceSheetReport> {
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
|
||||
// Filter to balance sheet accounts (class 1-2)
|
||||
const balanceRows = rows.filter(
|
||||
|
||||
@@ -27,7 +27,8 @@ const CLASS_LABELS: Record<number, string> = {
|
||||
export async function generateBalansrapport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
): Promise<BalansrapportReport> {
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -40,7 +41,13 @@ export async function generateBalansrapport(
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const effectiveFromDate = options?.fromDate ?? period.period_start
|
||||
const effectiveToDate = options?.toDate ?? period.period_end
|
||||
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
const balanceRows = trialBalance.rows.filter((r) => r.account_class === 1 || r.account_class === 2)
|
||||
|
||||
const groups: BalansrapportGroup[] = []
|
||||
@@ -95,7 +102,7 @@ export async function generateBalansrapport(
|
||||
total_equity_liabilities_ub: totalEquityLiabilitiesUb,
|
||||
beraknat_resultat: beraknatResultat,
|
||||
is_balanced: trialBalance.isBalanced,
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
period: { start: effectiveFromDate, end: effectiveToDate },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Parse and validate optional `from_date` / `to_date` query params for the
|
||||
* date-range-aware financial reports (resultat- and balansrapport).
|
||||
*
|
||||
* Returns the bounds clamped against the fiscal period. Both params are
|
||||
* optional — when omitted, the report falls back to the period as a whole.
|
||||
* Returns a `{ error }` shape on invalid input so callers can map it to a
|
||||
* 400 response without each route duplicating the same checks.
|
||||
*/
|
||||
export type DateRange = { fromDate?: string; toDate?: string }
|
||||
|
||||
export type DateRangeResult =
|
||||
| { ok: true; range: DateRange }
|
||||
| { ok: false; error: string }
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
export function parseReportDateRange(
|
||||
searchParams: URLSearchParams,
|
||||
period: { period_start: string; period_end: string }
|
||||
): DateRangeResult {
|
||||
const rawFrom = searchParams.get('from_date')
|
||||
const rawTo = searchParams.get('to_date')
|
||||
|
||||
if (rawFrom && !ISO_DATE.test(rawFrom)) {
|
||||
return { ok: false, error: 'from_date måste vara på formen YYYY-MM-DD.' }
|
||||
}
|
||||
if (rawTo && !ISO_DATE.test(rawTo)) {
|
||||
return { ok: false, error: 'to_date måste vara på formen YYYY-MM-DD.' }
|
||||
}
|
||||
|
||||
const fromDate = rawFrom ?? undefined
|
||||
const toDate = rawTo ?? undefined
|
||||
|
||||
if (fromDate && (fromDate < period.period_start || fromDate > period.period_end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `from_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
|
||||
}
|
||||
}
|
||||
if (toDate && (toDate < period.period_start || toDate > period.period_end)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `to_date måste ligga inom räkenskapsåret (${period.period_start} — ${period.period_end}).`,
|
||||
}
|
||||
}
|
||||
if (fromDate && toDate && fromDate > toDate) {
|
||||
return { ok: false, error: 'from_date får inte vara efter to_date.' }
|
||||
}
|
||||
|
||||
return { ok: true, range: { fromDate, toDate } }
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } f
|
||||
export async function generateIncomeStatement(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
): Promise<IncomeStatementReport> {
|
||||
// Exclude year-end closing entries: after closing, P&L accounts (3-8) are
|
||||
// zeroed by the closing verifikat (8999 → 2099). Including them collapses
|
||||
@@ -22,6 +23,8 @@ export async function generateIncomeStatement(
|
||||
// pre-closing activity for the year.
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
excludeYearEndClosing: true,
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
|
||||
// Filter to income/expense accounts (class 3-8)
|
||||
|
||||
@@ -31,7 +31,8 @@ const CLASS_LABELS: Record<number, string> = {
|
||||
export async function generateResultatrapport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
fiscalPeriodId: string,
|
||||
options?: { fromDate?: string; toDate?: string }
|
||||
): Promise<ResultatrapportReport> {
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -44,12 +45,23 @@ export async function generateResultatrapport(
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const effectiveFromDate = options?.fromDate ?? period.period_start
|
||||
const effectiveToDate = options?.toDate ?? period.period_end
|
||||
|
||||
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
|
||||
fromDate: options?.fromDate,
|
||||
toDate: options?.toDate,
|
||||
})
|
||||
const currentRows = filterPnl(currentTb.rows)
|
||||
|
||||
// Prior-period comparison stays full-year. A narrower current window
|
||||
// compared against a full prior year would be misleading; until we ship a
|
||||
// proper "same window, prior year" comparison the cleanest move is to
|
||||
// drop the prior column entirely when the user narrows the range.
|
||||
let priorRows: TrialBalanceRow[] = []
|
||||
let priorPeriodInfo: { start: string; end: string } | null = null
|
||||
if (period.previous_period_id) {
|
||||
const isFullPeriod = !options?.fromDate && !options?.toDate
|
||||
if (isFullPeriod && period.previous_period_id) {
|
||||
const { data: prior } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
@@ -76,7 +88,7 @@ export async function generateResultatrapport(
|
||||
groups,
|
||||
net_result_current: round2(netResultCurrent),
|
||||
net_result_prior: round2(netResultPrior),
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
period: { start: effectiveFromDate, end: effectiveToDate },
|
||||
prior_period: priorPeriodInfo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,19 @@ import { getOpeningBalances } from './opening-balances'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
/**
|
||||
* Generate trial balance (Saldobalans) for a fiscal period.
|
||||
* Generate trial balance (Saldobalans) for a fiscal period or a date range
|
||||
* inside one.
|
||||
*
|
||||
* Computes IB (ingående balans), period movements, and UB (utgående balans)
|
||||
* per BFNAR 2013:2 requirements. Uses the opening_balance_entry set by
|
||||
* year-end closing when available; falls back to summing prior-period entries.
|
||||
*
|
||||
* When `fromDate`/`toDate` are passed, they must lie inside the fiscal
|
||||
* period. The function rolls the IB forward from `period_start` to
|
||||
* `fromDate − 1` (so "opening" reflects the state at `fromDate`) and limits
|
||||
* period activity to `[fromDate, toDate]`. Defaults equal `period_start` and
|
||||
* `period_end` — identical to the no-options behaviour.
|
||||
*
|
||||
* Uses joined queries with pagination to handle any number of entries.
|
||||
* Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows.
|
||||
*/
|
||||
@@ -17,7 +24,11 @@ export async function generateTrialBalance(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
options?: { excludeYearEndClosing?: boolean }
|
||||
options?: {
|
||||
excludeYearEndClosing?: boolean
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
}
|
||||
): Promise<{
|
||||
rows: TrialBalanceRow[]
|
||||
totalDebit: number
|
||||
@@ -28,16 +39,59 @@ export async function generateTrialBalance(
|
||||
// Fetch period for opening balance computation
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, opening_balance_entry_id')
|
||||
.select('period_start, period_end, opening_balance_entry_id')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// ── Opening balances (IB) ──────────────────────────────────────
|
||||
// ── Opening balances (IB) at period_start ──────────────────────
|
||||
const { balances: openingBalances, obEntryId } = await getOpeningBalances(
|
||||
supabase, companyId, period
|
||||
)
|
||||
|
||||
// ── Roll IB forward from period_start up to fromDate ───────────
|
||||
// When the caller requests a sub-range starting after period_start, the
|
||||
// "opening" of that window must include all activity since the period
|
||||
// started. We additively fold those lines into openingBalances so the
|
||||
// downstream IB/period split stays correct without changing call sites.
|
||||
if (
|
||||
options?.fromDate &&
|
||||
period?.period_start &&
|
||||
options.fromDate > period.period_start
|
||||
) {
|
||||
const priorLines = await fetchAllRows<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', period.period_start)
|
||||
.lt('journal_entries.entry_date', options.fromDate)
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('journal_entry_id', obEntryId)
|
||||
}
|
||||
|
||||
if (options?.excludeYearEndClosing) {
|
||||
query = query.neq('journal_entries.source_type', 'year_end')
|
||||
}
|
||||
|
||||
return query.range(from, to)
|
||||
})
|
||||
|
||||
for (const line of priorLines) {
|
||||
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
openingBalances.set(line.account_number, existing)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Period lines (excluding opening balance entry) ─────────────
|
||||
// If year-end closing set an OB entry, exclude it from period lines so
|
||||
// its values aren't double-counted (they're already captured as IB).
|
||||
@@ -52,11 +106,24 @@ export async function generateTrialBalance(
|
||||
}>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type)')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
// Date filters are only applied when the caller explicitly asks. The
|
||||
// period itself is already enforced via the fiscal_period_id join, so
|
||||
// adding redundant entry_date bounds for the default case would just
|
||||
// increase query complexity (and break older mocks that don't stub gte
|
||||
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
|
||||
// the engine keep activity inside the period.
|
||||
if (options?.fromDate) {
|
||||
query = query.gte('journal_entries.entry_date', options.fromDate)
|
||||
}
|
||||
if (options?.toDate) {
|
||||
query = query.lte('journal_entries.entry_date', options.toDate)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('journal_entry_id', obEntryId)
|
||||
}
|
||||
|
||||
@@ -11,30 +11,19 @@ describe('submitFeedback', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubRecapt(impl: (...args: unknown[]) => void) {
|
||||
vi.stubGlobal('window', { recapt: impl })
|
||||
}
|
||||
|
||||
function stubNoRecapt() {
|
||||
vi.stubGlobal('window', {})
|
||||
}
|
||||
|
||||
function stubFetchOk() {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
return fetchSpy
|
||||
}
|
||||
|
||||
it('sends to both Recapt and email when SDK is present', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
it('posts subject and message to the contact endpoint', async () => {
|
||||
const fetchSpy = stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels.sort()).toEqual(['email', 'recapt'])
|
||||
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/support/contact',
|
||||
expect.objectContaining({
|
||||
@@ -44,57 +33,21 @@ describe('submitFeedback', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('omits subject prefix in Recapt payload when subject not provided', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
stubFetchOk()
|
||||
|
||||
await submitFeedback({ message: 'plain' })
|
||||
|
||||
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
|
||||
})
|
||||
|
||||
it('still reports success via email when Recapt throws', async () => {
|
||||
stubRecapt(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ subject: 'X', message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
})
|
||||
|
||||
it('uses email only when Recapt SDK is absent', async () => {
|
||||
stubNoRecapt()
|
||||
it('omits subject when not provided', async () => {
|
||||
const fetchSpy = stubFetchOk()
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
const result = await submitFeedback({ message: 'plain' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports success when Recapt succeeds even if email fails', async () => {
|
||||
const recapt = vi.fn()
|
||||
stubRecapt(recapt)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/support/contact',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ message: 'plain' }),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['recapt'])
|
||||
})
|
||||
|
||||
it('returns failure with email error when both channels fail', async () => {
|
||||
stubRecapt(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
it('returns failure with server error message when the endpoint rejects', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
@@ -110,8 +63,7 @@ describe('submitFeedback', () => {
|
||||
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
|
||||
})
|
||||
|
||||
it('returns failure when fetch itself throws and Recapt is absent', async () => {
|
||||
stubNoRecapt()
|
||||
it('returns failure when fetch itself throws', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface SubmitFeedbackInput {
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export type SupportChannel = 'recapt' | 'email'
|
||||
export type SupportChannel = 'email'
|
||||
|
||||
export interface SubmitFeedbackResult {
|
||||
ok: boolean
|
||||
@@ -11,58 +11,23 @@ export interface SubmitFeedbackResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
|
||||
if (!subject) return message
|
||||
return `[${subject}]\n\n${message}`
|
||||
}
|
||||
|
||||
async function submitViaEmail(
|
||||
{ message, subject }: SubmitFeedbackInput
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
||||
try {
|
||||
const res = await fetch('/api/support/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject, message }),
|
||||
body: JSON.stringify({ subject: input.subject, message: input.message }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
|
||||
return { ok: false, channels: [], error: data.error || 'Kunde inte skicka meddelandet' }
|
||||
}
|
||||
return { ok: true }
|
||||
return { ok: true, channels: ['email'] }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
|
||||
}
|
||||
}
|
||||
|
||||
function submitViaRecapt(
|
||||
input: SubmitFeedbackInput
|
||||
): { ok: true } | { ok: false; error: string } | null {
|
||||
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
|
||||
if (typeof recapt !== 'function') return null
|
||||
try {
|
||||
recapt('feedback', { message: composeMessage(input) })
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
||||
const recaptResult = submitViaRecapt(input)
|
||||
const emailResult = await submitViaEmail(input)
|
||||
|
||||
const channels: SupportChannel[] = []
|
||||
if (recaptResult?.ok) channels.push('recapt')
|
||||
if (emailResult.ok) channels.push('email')
|
||||
|
||||
if (channels.length > 0) {
|
||||
return { ok: true, channels }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
channels: [],
|
||||
error: emailResult.ok ? undefined : emailResult.error,
|
||||
return {
|
||||
ok: false,
|
||||
channels: [],
|
||||
error: err instanceof Error ? err.message : 'Nätverksfel',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user