Dashboard: monthly recurring revenue after discounts, overdue invoices, per-product installs, latest version, behind, critical and licences. Install page: the church's other products and whether they're listed to this site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
394 lines
24 KiB
JavaScript
394 lines
24 KiB
JavaScript
jest.mock('../src/config/db', () => ({}));
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { hub } = require('@crosscode/contracts');
|
||
const { TIERS, WEBSITE_PLANS, APP_TIERS, tierForBand, appTierForBand, planPrice, extraStorageBlocks } = require('../src/utils/billing/tiers');
|
||
const { day, ymd, addMonths, addDays, today, saHour, periodLabel, money } = require('../src/utils/billing/dates');
|
||
const { draftCustomerInvoice, customerInvoiceLines, planChangesOf, cleanLines, totals, invoiceState, statement, recipientFor } = require('../src/utils/billing/compute');
|
||
|
||
const GB = 1024 ** 3;
|
||
const customer = (extra = {}) => ({
|
||
id: 'c1', name: 'Hope Church', email: 'office@hope.example', founding: false,
|
||
billingCycle: 'monthly', billingStart: day('2026-01-01'), nextInvoiceDate: day('2026-12-01'), reviewDate: day('2027-01-01'), ...extra,
|
||
});
|
||
const install = (band, email = 'admin@hope.example') => ({ product: 'events', adminContact: { email }, stats: band ? { income: { eventIncome12m: band } } : {} });
|
||
const events = (extra = {}) => ({
|
||
id: 'l1', product: 'events', status: 'active', plan: 'Sprout', customMonthlyCents: null,
|
||
billedFrom: day('2026-01-01'), billedUntil: day('2026-11-30'), install: install('R100 001–R150 000'), ...extra,
|
||
});
|
||
const website = (extra = {}) => ({
|
||
id: 'l2', product: 'website', status: 'active', plan: 'Website', customMonthlyCents: null,
|
||
billedFrom: day('2026-06-01'), billedUntil: day('2026-11-30'), install: { product: 'website', stats: {} }, ...extra,
|
||
});
|
||
const app = (extra = {}) => ({
|
||
id: 'l3', product: 'app', status: 'active', plan: 'Mustard Seed', customMonthlyCents: null,
|
||
billedFrom: day('2026-06-01'), billedUntil: day('2026-11-30'), install: { product: 'app', stats: { activeMembers90d: '0–100' } }, ...extra,
|
||
});
|
||
const accounts = () => ({ id: 'l4', product: 'accounts', status: 'active', plan: 'included', billedFrom: null, billedUntil: null, install: null });
|
||
const settings = { payment_terms_days: '7' };
|
||
const draft = (c, licences) => draftCustomerInvoice({ customer: c, licences, settings });
|
||
const amounts = invoice => invoice.lines.map(l => [l.description, l.amountCents]);
|
||
|
||
describe('tiers', () => {
|
||
const front = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'tiers.ts'), 'utf8');
|
||
const num = v => (v === 'null' ? null : Number(v.replace(/_/g, '')));
|
||
|
||
test('the server and the Hub screens have the same Cross Events tiers', () => {
|
||
const rows = [...front.matchAll(/\{ name: "(\w+)", upTo: ([\d_]+|null), monthly: (\d+|null), founding: (\d+|null) \}/g)].map(m => [m[1], num(m[2]), num(m[3]), num(m[4])]);
|
||
expect(rows).toEqual(TIERS.map(t => [t.name, t.upTo, t.monthly, t.founding]));
|
||
});
|
||
|
||
test('...and the same Church App tiers and Website price', () => {
|
||
const appRows = [...front.matchAll(/\{ name: "([\w ]+)", band: "([^"]+)", monthly: (\d+|null), founding: (\d+|null) \}/g)].map(m => [m[1], m[2], num(m[3]), num(m[4])]);
|
||
expect(appRows).toEqual(APP_TIERS.map(t => [t.name, t.band, t.monthly, t.founding]));
|
||
const site = /WEBSITE_PLAN = \{ name: "Website", monthly: (\d+), founding: (\d+) \}/.exec(front);
|
||
expect([num(site[1]), num(site[2])]).toEqual([WEBSITE_PLANS[0].monthly, WEBSITE_PLANS[0].founding]);
|
||
});
|
||
|
||
test('a band falls in the tier whose top it fits under', () => {
|
||
expect(tierForBand('R0–R1 000').name).toBe('Seed');
|
||
expect(tierForBand('R20 001–R50 000').name).toBe('Seed');
|
||
expect(tierForBand('R100 001–R150 000').name).toBe('Sprout');
|
||
expect(tierForBand('R700 001–R1 000 000').name).toBe('Flourish');
|
||
expect(tierForBand('over R10 000 000').name).toBe('Network');
|
||
expect(tierForBand('over R100 000')).toBeNull(); // an old install
|
||
expect(tierForBand(null)).toBeNull();
|
||
});
|
||
|
||
test('every App active-member band (contracts) has a tier', () => {
|
||
expect(APP_TIERS.map(t => t.band)).toEqual([...hub.ACTIVE_MEMBER_BANDS]);
|
||
expect(appTierForBand('101–300').name).toBe('Small Group');
|
||
expect(appTierForBand(null)).toBeNull();
|
||
});
|
||
|
||
test('prices: the tier\'s, or a custom one with 35% off for founding', () => {
|
||
expect(planPrice('Grow')).toEqual({ monthlyCents: 85000, foundingCents: 55000, custom: false });
|
||
expect(planPrice('Network')).toBeNull();
|
||
expect(planPrice('Network', 750000)).toEqual({ monthlyCents: 750000, foundingCents: 487500, custom: true });
|
||
expect(planPrice('standard')).toBeNull();
|
||
expect(planPrice('Website', null, 'website')).toEqual({ monthlyCents: 9900, foundingCents: 6400, custom: false });
|
||
expect(planPrice('Small Group', null, 'app')).toEqual({ monthlyCents: 24900, foundingCents: 16200, custom: false });
|
||
expect(planPrice('Grow', null, 'app')).toBeNull(); // another product's tier
|
||
});
|
||
|
||
test('extra storage: whole 5 GB blocks above what is included', () => {
|
||
expect(extraStorageBlocks({ usedBytes: 4 * GB, includedBytes: 5 * GB })).toBe(0);
|
||
expect(extraStorageBlocks({ usedBytes: 5 * GB + 1, includedBytes: 5 * GB })).toBe(1);
|
||
expect(extraStorageBlocks({ usedBytes: 11 * GB })).toBe(2);
|
||
expect(extraStorageBlocks(null)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('dates', () => {
|
||
test('months keep the billing day, or the month\'s last day', () => {
|
||
expect(ymd(addMonths(day('2026-01-31'), 1, 31))).toBe('2026-02-28');
|
||
expect(ymd(addMonths(day('2026-02-28'), 1, 31))).toBe('2026-03-31');
|
||
expect(ymd(addMonths(day('2028-01-31'), 1, 31))).toBe('2028-02-29');
|
||
expect(ymd(addMonths(day('2026-12-15'), 12))).toBe('2027-12-15');
|
||
});
|
||
|
||
test('today and the hour are South African', () => {
|
||
const late = new Date('2026-11-30T23:30:00Z'); // 01:30 on 1 December in South Africa
|
||
expect(ymd(today(late))).toBe('2026-12-01');
|
||
expect(saHour(late)).toBe(1);
|
||
});
|
||
|
||
test('labels and money', () => {
|
||
expect(periodLabel(day('2026-12-01'), day('2026-12-31'))).toBe('December 2026');
|
||
expect(periodLabel(day('2026-12-15'), day('2027-01-14'))).toBe('15 December 2026 – 14 January 2027');
|
||
expect(money(135000)).toBe('R1 350.00');
|
||
expect(money(-30000)).toBe('-R300.00');
|
||
expect(() => day('2026-02-30')).toThrow('Not a valid date.');
|
||
});
|
||
});
|
||
|
||
describe('drafting an invoice (one product, as before Hub 2.0)', () => {
|
||
test('monthly, in advance: the period starting on its date, due after the payment terms', () => {
|
||
const { invoice, customerUpdate, licenceUpdates } = draft(customer(), [events(), accounts()]);
|
||
expect(ymd(invoice.issueDate)).toBe('2026-12-01');
|
||
expect(ymd(invoice.dueDate)).toBe('2026-12-08');
|
||
expect([ymd(invoice.periodStart), ymd(invoice.periodEnd)]).toEqual(['2026-12-01', '2026-12-31']);
|
||
expect(invoice.lines).toEqual([{ description: 'Cross Events, Sprout tier, December 2026', quantity: 1, unitCents: 55000, amountCents: 55000 }]);
|
||
expect(invoice).toMatchObject({ licenceId: null, plan: 'Sprout', totalCents: 55000, vatCents: 0, reviewed: false, planChange: null, recipientEmail: 'admin@hope.example', warnings: [] });
|
||
expect(ymd(customerUpdate.nextInvoiceDate)).toBe('2027-01-01');
|
||
expect(ymd(customerUpdate.reviewDate)).toBe('2027-01-01'); // not reviewed yet
|
||
expect(licenceUpdates.map(u => [u.id, ymd(u.data.billedUntil)])).toEqual([['l1', '2026-12-31']]);
|
||
expect(invoice.basis.licences).toEqual([{ id: 'l1', billedUntil: '2026-11-30' }, { id: 'l4', billedUntil: null }]);
|
||
});
|
||
|
||
test('between reviews the tier stays, whatever the income', () => {
|
||
const { invoice } = draft(customer(), [events({ install: install('R1 000 001–R2 500 000') })]);
|
||
expect(invoice.plan).toBe('Sprout');
|
||
expect(invoice.incomeBand).toBeNull();
|
||
});
|
||
|
||
test('at the yearly review the income moves the tier up, and the next review is a year on', () => {
|
||
const { invoice, customerUpdate } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [events({ billedUntil: day('2026-12-31'), install: install('R250 001–R400 000') })]);
|
||
expect(invoice).toMatchObject({ reviewed: true, plan: 'Grow', incomeBand: 'R250 001–R400 000', totalCents: 85000 });
|
||
expect(invoice.planChange).toEqual([{ licenceId: 'l1', product: 'events', from: 'Sprout', to: 'Grow', fromMonthlyCents: 55000, toMonthlyCents: 85000, band: 'R250 001–R400 000' }]);
|
||
expect(ymd(customerUpdate.reviewDate)).toBe('2028-01-01');
|
||
});
|
||
|
||
test('...or down', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [events({ billedUntil: day('2026-12-31'), install: install('R5 001–R20 000') })]);
|
||
expect(invoice.planChange[0]).toMatchObject({ from: 'Sprout', to: 'Seed' });
|
||
expect(invoice.totalCents).toBe(35000);
|
||
});
|
||
|
||
test('no band at the review: the tier stays and Joshua is warned', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [events({ billedUntil: day('2026-12-31'), install: install(null) })]);
|
||
expect(invoice.plan).toBe('Sprout');
|
||
expect(invoice.planChange).toBeNull();
|
||
expect(invoice.warnings[0]).toMatch(/hasn't reported its 12-month event income/);
|
||
});
|
||
|
||
test('a founding church gets the founding price as its own line', () => {
|
||
const { invoice } = draft(customer({ founding: true }), [events()]);
|
||
expect(invoice.lines[1]).toEqual({ description: 'Founding church discount (35%)', quantity: 1, unitCents: -19000, amountCents: -19000 });
|
||
expect(invoice.totalCents).toBe(36000);
|
||
expect(invoice.founding).toBe(true);
|
||
});
|
||
|
||
test('yearly: 10 months\' price for 12 months, founding on all of it', () => {
|
||
const { invoice, customerUpdate } = draft(
|
||
customer({ founding: true, billingCycle: 'yearly', nextInvoiceDate: day('2026-12-01'), reviewDate: day('2027-12-01') }),
|
||
[events({ plan: 'Grow' })],
|
||
);
|
||
expect([ymd(invoice.periodStart), ymd(invoice.periodEnd)]).toEqual(['2026-12-01', '2027-11-30']);
|
||
expect(invoice.lines.map(l => [l.quantity, l.unitCents, l.amountCents])).toEqual([[10, 85000, 850000], [10, -30000, -300000]]);
|
||
expect(invoice.lines[0].description).toBe('Cross Events, Grow tier, 12 months (1 December 2026 – 30 November 2027), paid yearly: 2 months free');
|
||
expect(invoice.totalCents).toBe(550000);
|
||
expect(ymd(customerUpdate.nextInvoiceDate)).toBe('2027-12-01');
|
||
});
|
||
|
||
test('a custom price is kept at the review, with a note if the income points elsewhere', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [events({ plan: 'Network', customMonthlyCents: 750000, billedUntil: day('2026-12-31'), install: install('R5 000 001–R10 000 000') })]);
|
||
expect(invoice).toMatchObject({ plan: 'Network', totalCents: 750000, planChange: null });
|
||
expect(invoice.warnings[0]).toMatch(/Movement tier, but the licence has a custom price/);
|
||
});
|
||
|
||
test('an unpriced plan makes an empty draft with a warning (so it isn\'t skipped)', () => {
|
||
const { invoice, empty } = draft(customer(), [events({ plan: 'standard' })]);
|
||
expect(invoice.lines).toEqual([]);
|
||
expect(invoice.warnings[0]).toMatch(/no price for the "standard" plan/);
|
||
expect(empty).toBe(false);
|
||
});
|
||
|
||
test('month-end billing day stays on the last day', () => {
|
||
const { invoice, customerUpdate } = draft(customer({ billingStart: day('2026-01-31'), nextInvoiceDate: day('2027-01-31'), reviewDate: day('2028-01-31') }), [events({ billedUntil: day('2027-01-30') })]);
|
||
expect(ymd(invoice.periodEnd)).toBe('2027-02-27');
|
||
expect(ymd(customerUpdate.nextInvoiceDate)).toBe('2027-02-28');
|
||
});
|
||
|
||
test('nothing billed (only Accounts, a trial or a suspended licence): no invoice, the customer still moves on', () => {
|
||
for (const licences of [[accounts()], [events({ billedFrom: null })], [events({ status: 'suspended' })], []]) {
|
||
const { empty, customerUpdate } = draft(customer(), licences);
|
||
expect(empty).toBe(true);
|
||
expect(ymd(customerUpdate.nextInvoiceDate)).toBe('2027-01-01');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('drafting an invoice (several products, Hub 2.0)', () => {
|
||
test('two products: each at its price, then 15% off them', () => {
|
||
const { invoice } = draft(customer(), [events(), website(), accounts()]);
|
||
expect(amounts(invoice)).toEqual([
|
||
['Cross Events, Sprout tier, December 2026', 55000],
|
||
['Church Website, December 2026', 9900],
|
||
['Bundle discount (15%: Cross Events and Church Website)', -9735],
|
||
]);
|
||
expect(invoice.totalCents).toBe(55165);
|
||
expect(invoice.plan).toBe('Cross Events Sprout · Church Website');
|
||
});
|
||
|
||
test('three products: 25% off, after the founding prices (decision 1)', () => {
|
||
const { invoice } = draft(customer({ founding: true }), [events(), website(), app()]);
|
||
// Founding: 360 + 64 + 64 = R488, less 25% = R366.
|
||
expect(invoice.totalCents).toBe(36600);
|
||
expect(invoice.lines.at(-1)).toMatchObject({ description: 'Bundle discount (25%: Cross Events, Church Website and Church App)', amountCents: -12200 });
|
||
});
|
||
|
||
test('Accounts never counts towards a bundle, and is never on the invoice', () => {
|
||
const { invoice } = draft(customer(), [events(), accounts()]);
|
||
expect(invoice.lines.some(l => /Bundle|Accounts/.test(l.description))).toBe(false);
|
||
});
|
||
|
||
test('extra website storage: R20 per 5 GB a month, never discounted', () => {
|
||
const { invoice } = draft(customer({ founding: true }), [events(), website({ install: { product: 'website', stats: { storage: { usedBytes: 12 * GB, includedBytes: 5 * GB } } } })]);
|
||
expect(invoice.lines.at(-1)).toEqual({ description: 'Extra website storage: 2 × 5 GB, December 2026', quantity: 2, unitCents: 2000, amountCents: 4000 });
|
||
// The bundle comes off the products only: (360 + 64) × 15% = R63.60.
|
||
expect(invoice.lines.find(l => /Bundle/.test(l.description)).amountCents).toBe(-6360);
|
||
});
|
||
|
||
test('yearly: add-ons are 12 months\' price (decision 2), products 10', () => {
|
||
const { invoice } = draft(customer({ billingCycle: 'yearly', reviewDate: day('2027-12-01') }), [website({ install: { product: 'website', stats: { storage: { usedBytes: 8 * GB } } } })]);
|
||
expect(invoice.lines.map(l => [l.quantity, l.unitCents])).toEqual([[10, 9900], [12, 2000]]);
|
||
});
|
||
|
||
test('the App is reviewed on its active members', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [app({ billedUntil: day('2026-12-31'), install: { product: 'app', stats: { activeMembers90d: '301–750' } } })]);
|
||
expect(invoice.planChange).toEqual([expect.objectContaining({ licenceId: 'l3', product: 'app', from: 'Mustard Seed', to: 'Congregation', toMonthlyCents: 39900, band: '301–750' })]);
|
||
expect(invoice.totalCents).toBe(39900);
|
||
});
|
||
|
||
test('several tier changes at one review: one per product', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01') }), [
|
||
events({ billedUntil: day('2026-12-31'), install: install('R250 001–R400 000') }),
|
||
app({ billedUntil: day('2026-12-31'), install: { product: 'app', stats: { activeMembers90d: '101–300' } } }),
|
||
]);
|
||
expect(invoice.planChange.map(pc => `${pc.product}:${pc.to}`)).toEqual(['events:Grow', 'app:Small Group']);
|
||
});
|
||
});
|
||
|
||
describe('a product added part-way (decision 3)', () => {
|
||
test('starting inside the period being drafted: charged from its first day', () => {
|
||
const { invoice, licenceUpdates } = draft(customer(), [events(), website({ billedFrom: day('2026-12-20'), billedUntil: null })]);
|
||
// R99 a month = R1 188 a year; 12 days of it.
|
||
expect(amounts(invoice)[1]).toEqual(['Church Website, 20 Dec 2026 – 31 Dec 2026 (12 days, from its start)', Math.round((9900 * 12 / 365) * 12)]);
|
||
expect(licenceUpdates.find(u => u.id === 'l2').data.billedUntil).toEqual(day('2026-12-31'));
|
||
// It counts for the bundle in that period.
|
||
expect(invoice.lines.some(l => /Bundle discount \(15%/.test(l.description))).toBe(true);
|
||
});
|
||
|
||
test('starting in a period invoiced already: those days on the next invoice, then the full period', () => {
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01'), reviewDate: day('2027-06-01') }), [
|
||
events({ billedUntil: day('2026-12-31') }),
|
||
app({ billedFrom: day('2026-12-15'), billedUntil: null }),
|
||
]);
|
||
expect(amounts(invoice).slice(1, 3)).toEqual([
|
||
['Church App, Mustard Seed tier, 15 Dec 2026 – 31 Dec 2026 (17 days before this period)', Math.round((9900 * 12 / 365) * 17)],
|
||
['Church App, Mustard Seed tier, January 2027', 9900],
|
||
]);
|
||
});
|
||
|
||
test('starting after the period: not on this invoice', () => {
|
||
const { invoice } = draft(customer(), [events(), website({ billedFrom: day('2027-01-05'), billedUntil: null })]);
|
||
expect(invoice.lines.map(l => l.description)).toEqual(['Cross Events, Sprout tier, December 2026']);
|
||
});
|
||
|
||
test('days are never charged twice', () => {
|
||
// Invoiced up to the end of December already: nothing owed from before January.
|
||
const { invoice } = draft(customer({ nextInvoiceDate: day('2027-01-01'), reviewDate: day('2027-06-01') }), [app({ billedFrom: day('2026-12-15'), billedUntil: day('2026-12-31') })]);
|
||
expect(invoice.lines.map(l => l.description)).toEqual(['Church App, Mustard Seed tier, January 2027']);
|
||
});
|
||
|
||
test('a founding church\'s founding price is prorated the same way', () => {
|
||
const { lines } = customerInvoiceLines({
|
||
customer: customer({ founding: true }), licences: [website({ billedFrom: day('2026-12-20'), billedUntil: null })],
|
||
period: { start: day('2026-12-01'), end: day('2026-12-31'), cycle: 'monthly' }, reviewed: false,
|
||
});
|
||
const full = Math.round((9900 * 12 / 365) * 12);
|
||
expect(lines.map(l => l.amountCents)).toEqual([full, Math.round((full * -3500) / 9900)]);
|
||
});
|
||
});
|
||
|
||
describe('tier changes stored before Hub 2.0', () => {
|
||
test('one object reads as a list of one Cross Events change', () => {
|
||
expect(planChangesOf({ licenceId: 'l1', planChange: { from: 'Seed', to: 'Sprout' } })).toEqual([{ licenceId: 'l1', product: 'events', from: 'Seed', to: 'Sprout' }]);
|
||
expect(planChangesOf({ planChange: null })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('who it goes to', () => {
|
||
test('billing email, then the site\'s admin contact, then the customer\'s email', () => {
|
||
expect(recipientFor(customer({ billingEmail: 'accounts@hope.example' }), install(null))).toBe('accounts@hope.example');
|
||
expect(recipientFor(customer(), install(null))).toBe('admin@hope.example');
|
||
expect(recipientFor(customer(), null)).toBe('office@hope.example');
|
||
});
|
||
});
|
||
|
||
describe('lines and VAT', () => {
|
||
test('lines are checked and their amounts worked out', () => {
|
||
expect(cleanLines([{ description: ' Standby support ', quantity: 2, unitCents: 50000 }, { description: '' }])).toEqual([{ description: 'Standby support', quantity: 2, unitCents: 50000, amountCents: 100000 }]);
|
||
expect(() => cleanLines([{ description: 'x', quantity: 0, unitCents: 1 }])).toThrow('quantity');
|
||
});
|
||
|
||
test('not VAT registered: no VAT; registered: the same total, VAT included', () => {
|
||
const lines = [{ amountCents: 85000 }, { amountCents: -30000 }];
|
||
expect(totals(lines, {})).toEqual({ totalCents: 55000, vatCents: 0, vatRate: null });
|
||
expect(totals(lines, { vat_registered: 'true', vat_rate: '15' })).toEqual({ totalCents: 55000, vatCents: 7174, vatRate: 0.15 });
|
||
});
|
||
});
|
||
|
||
describe('payments and statements', () => {
|
||
const inv = (number, issue, total, payments = [], status = 'sent') => ({ number, status, issueDate: day(issue), dueDate: addDays(day(issue), 7), periodStart: day(issue), periodEnd: addDays(addMonths(day(issue), 1), -1), totalCents: total, payments });
|
||
|
||
test('where an invoice stands', () => {
|
||
expect(invoiceState(inv('A', '2026-12-01', 55000), '2026-12-05')).toMatchObject({ state: 'unpaid', balanceCents: 55000 });
|
||
expect(invoiceState(inv('A', '2026-12-01', 55000, [{ amountCents: 20000 }]), '2026-12-05')).toMatchObject({ state: 'part_paid', paidCents: 20000, balanceCents: 35000 });
|
||
expect(invoiceState(inv('A', '2026-12-01', 55000, [{ amountCents: 20000 }]), '2026-12-09').state).toBe('overdue');
|
||
expect(invoiceState(inv('A', '2026-12-01', 55000, [{ amountCents: 55000 }]), '2026-12-20').state).toBe('paid');
|
||
expect(invoiceState(inv('A', '2026-12-01', 55000, [], 'draft'), '2026-12-20').state).toBe('draft');
|
||
});
|
||
|
||
test('a statement: brought forward, running balance, what\'s still owing', () => {
|
||
const p1 = { receiptNumber: 'REC-1', amountCents: 55000, paidOn: day('2026-11-05'), invoice: { number: 'INV-1' } };
|
||
const p2 = { receiptNumber: 'REC-2', amountCents: 20000, paidOn: day('2026-12-10'), reference: 'EFT 123', invoice: { number: 'INV-2' } };
|
||
const invoices = [
|
||
inv('INV-1', '2026-11-01', 55000, [p1]),
|
||
inv('INV-2', '2026-12-01', 55000, [p2]),
|
||
inv('INV-3', '2027-01-01', 55000, []),
|
||
inv('INV-X', '2026-12-15', 99900, [], 'void'),
|
||
inv('INV-D', '2026-12-20', 55000, [], 'draft'),
|
||
];
|
||
const st = statement({ invoices, payments: [p1, p2], from: '2026-12-01', to: '2026-12-31' });
|
||
expect(st.openingCents).toBe(0);
|
||
expect(st.entries.map(e => [e.date, e.reference, e.debitCents, e.creditCents, e.balanceCents])).toEqual([
|
||
['2026-12-01', 'INV-2', 55000, 0, 55000],
|
||
['2026-12-10', 'REC-2', 0, 20000, 35000],
|
||
]);
|
||
expect(st.entries[1].description).toBe('Payment received for INV-2 (ref EFT 123)');
|
||
expect(st.closingCents).toBe(35000);
|
||
expect(st.outstanding).toEqual([{ number: 'INV-2', issueDate: '2026-12-01', dueDate: '2026-12-08', totalCents: 55000, balanceCents: 35000, overdue: true }]);
|
||
|
||
const later = statement({ invoices, payments: [p1, p2], from: '2027-01-01', to: '2027-01-05' });
|
||
expect(later.openingCents).toBe(35000);
|
||
expect(later.closingCents).toBe(90000);
|
||
});
|
||
});
|
||
|
||
describe('the church\'s address (one per church, never discounted)', () => {
|
||
test('own domain: nothing to charge', () => {
|
||
const { invoice } = draft(customer({ addressType: 'own_domain', domain: 'hope.co.za' }), [events()]);
|
||
expect(invoice.lines.some(l => /domain|address/i.test(l.description))).toBe(false);
|
||
});
|
||
|
||
test('Cross Code address R10 and domain included R25 a month, after the bundle, not discounted', () => {
|
||
const { invoice } = draft(customer({ founding: true, addressType: 'crosscode', domain: 'hope.crosscode.co.za' }), [events(), website()]);
|
||
expect(invoice.lines.at(-1)).toEqual({ description: 'Cross Code address (hope.crosscode.co.za), December 2026', quantity: 1, unitCents: 1000, amountCents: 1000 });
|
||
const { invoice: inc } = draft(customer({ addressType: 'included', domain: 'hope.co.za' }), [events()]);
|
||
expect(inc.lines.at(-1)).toMatchObject({ description: 'Domain included (hope.co.za), December 2026', amountCents: 2500 });
|
||
});
|
||
|
||
test('yearly: 12 months\' price; a custom price (e.g. a .com at cost) wins', () => {
|
||
const { invoice } = draft(customer({ billingCycle: 'yearly', reviewDate: day('2027-12-01'), addressType: 'included', domain: 'hope.church', addressCustomCents: 4500 }), [events()]);
|
||
expect(invoice.lines.at(-1)).toMatchObject({ description: 'Domain included (hope.church), 12 months', quantity: 12, unitCents: 4500, amountCents: 54000 });
|
||
});
|
||
|
||
test('a church with no product charged gets no invoice just for its address', () => {
|
||
const { empty } = draft(customer({ addressType: 'crosscode' }), [events({ billedFrom: null })]);
|
||
expect(empty).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('monthly recurring revenue (dashboard)', () => {
|
||
const { monthlyRecurringCents } = require('../src/utils/billing/compute');
|
||
const now = new Date('2026-11-20T10:00:00Z');
|
||
|
||
test('a full month of each product charged now, after founding and bundle, plus add-ons', () => {
|
||
// Founding: 360 + 64 = R424, less 15% = R360.40, plus a Cross Code address R10.
|
||
expect(monthlyRecurringCents(customer({ founding: true, addressType: 'crosscode' }), [events(), website(), accounts()], now)).toBe(37040);
|
||
});
|
||
|
||
test('days owed from before a period aren\'t recurring; a product not charged yet doesn\'t count', () => {
|
||
expect(monthlyRecurringCents(customer(), [events(), app({ billedFrom: day('2026-11-10'), billedUntil: null })], now)).toBe(Math.round((55000 + 9900) * 0.85));
|
||
expect(monthlyRecurringCents(customer(), [events(), app({ billedFrom: day('2026-12-15'), billedUntil: null })], now)).toBe(55000);
|
||
});
|
||
|
||
test('yearly is a twelfth of the year\'s invoice; not billed is 0', () => {
|
||
expect(monthlyRecurringCents(customer({ billingCycle: 'yearly' }), [events()], now)).toBe(Math.round((55000 * 10) / 12));
|
||
expect(monthlyRecurringCents(customer({ billingCycle: 'none' }), [events()], now)).toBe(0);
|
||
});
|
||
});
|