Files
joshuaandClaude Opus 5.5 e697564ff5 Hub 2.0 step 7: dashboard per product, revenue, linked products
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>
2026-11-05 09:03:39 +02:00

394 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});