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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user