Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/engine/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,20 @@ export async function getProfitAndLoss(opts: {
from_date?: string;
to_date?: string;
}): Promise<ProfitAndLossResult> {
// When a date range is provided, the transactions.date filters below
// select across periods — so we must NOT also restrict to a single
// transaction_lines.period_id, which would neutralise the date range.
// When no date range is given, fall back to the single-period filter
// so callers still get "just this period" behaviour by default.
let q = db('transaction_lines')
.join('accounts', 'transaction_lines.account_code', 'accounts.code')
.join('transactions', 'transaction_lines.transaction_id', 'transactions.transaction_id')
.whereIn('accounts.type', ['REVENUE', 'EXPENSE'])
.where('transaction_lines.period_id', opts.period_id)
.modify((qb) => {
if (!opts.from_date && !opts.to_date) {
qb.where('transaction_lines.period_id', opts.period_id);
}
})
.select(
'accounts.code',
'accounts.name',
Expand Down
75 changes: 75 additions & 0 deletions tests/integration/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,81 @@ describe('getProfitAndLoss', () => {
});
});

// ---------------------------------------------------------------------------
// Profit and Loss report — date range spanning multiple periods (Bug 12)
// ---------------------------------------------------------------------------

describe('getProfitAndLoss — date range across multiple periods', () => {
let periodA: string;
let periodB: string;

beforeAll(async () => {
periodA = uniquePeriod();
periodB = uniquePeriod();
await createPeriod(periodA);
await createPeriod(periodB);

// Period A: CUSTOMER_INVOICE £1,200 gross → £1,000 revenue + £200 VAT
await post({
transaction_type: 'CUSTOMER_INVOICE',
date: `${periodA}-10`,
period_id: periodA,
amount: 1200,
idempotency_key: `pl-range-a-${periodA}`,
});

// Period B: CUSTOMER_INVOICE £2,400 gross → £2,000 revenue + £400 VAT
await post({
transaction_type: 'CUSTOMER_INVOICE',
date: `${periodB}-10`,
period_id: periodB,
amount: 2400,
idempotency_key: `pl-range-b-${periodB}`,
});
});

afterAll(async () => {
await deletePeriod(periodA);
await deletePeriod(periodB);
});

it('no date range: returns only the single period specified in period_id', async () => {
const report = await getProfitAndLoss({ period_id: periodA });
expect(report.total_revenue).toBe('1000.00');
});

it('date range spanning both periods: aggregates revenue across them', async () => {
const report = await getProfitAndLoss({
period_id: periodA,
from_date: `${periodA}-01`,
to_date: `${periodB}-28`,
});
// £1,000 (period A) + £2,000 (period B) = £3,000
expect(report.total_revenue).toBe('3000.00');
});

it('date range confined to period B: returns only period B revenue (period_id is ignored when dates are set)', async () => {
const report = await getProfitAndLoss({
period_id: periodA,
from_date: `${periodB}-01`,
to_date: `${periodB}-28`,
});
expect(report.total_revenue).toBe('2000.00');
});

it('aggregate across range equals sum of individual monthly reports', async () => {
const a = await getProfitAndLoss({ period_id: periodA });
const b = await getProfitAndLoss({ period_id: periodB });
const combined = await getProfitAndLoss({
period_id: periodA,
from_date: `${periodA}-01`,
to_date: `${periodB}-28`,
});
const expected = new Decimal(a.total_revenue).plus(b.total_revenue).toFixed(2);
expect(combined.total_revenue).toBe(expected);
});
});

// ---------------------------------------------------------------------------
// Balance Sheet report
// ---------------------------------------------------------------------------
Expand Down
Loading