Compare commits

...
2 Commits
Author SHA1 Message Date
joshua 5cf982e191 Merge branch 'feat/billing-income-bands' 2026-11-03 12:18:01 +02:00
joshuaandClaude Opus 5.5 ff4c0c76d3 Billing income bands up to R10m, 12-month event income in the heartbeat (2.0.2)
- Heartbeat income bands widened from "over R100 000" to 14 bands up to
  "over R10 000 000"; every licence-tier edge is a band edge (tested).
- New income.eventIncome12m: last 12 months' event income without donations,
  sponsorships or donation legs, which the licence tier is billed on.
- Fix: donation legs were counted twice in the heartbeat income bands.
- Protocol doc, roadmap pricing decisions, changelog, what's new, handover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:18:01 +02:00
13 changed files with 95 additions and 28 deletions
+20
View File
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.0.2] - 2026-11-03
### Changed
- **Wider income bands in the Hub heartbeat**, from `R0–R1 000` up to `over R10 000 000` (was
`over R100 000`): 14 bands, edges at R1 000, R5 000, R20 000, R50 000, R100 000, R150 000,
R250 000, R400 000, R700 000, R1m, R2.5m, R5m and R10m (`BAND_TOPS` in `src/hub/report.js`).
Every Cross Code licence-tier edge is a band edge; a test checks it.
### Added
- **`income.eventIncome12m`** in the heartbeat: the band of the last 12 months' event income
(payments less refunds, without donations, sponsorships or donation legs), which the licence
tier is billed on. Shown on the Hub from Hub 1.6.2.
### Fixed
- The heartbeat's income bands counted a donation twice once it was applied to a registration
(the leg payment); they now leave legs out, like every report.
## [2.0.1] - 2026-11-02
### Changed
+9
View File
@@ -12,6 +12,15 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 2.0.2
- **Licence information sent to Cross Code.** Your site now tells Cross Code which range your event
income falls in over the last 12 months (for example "R150 001–R250 000"), which is what your
licence tier is based on. Donations and sponsorships aren't counted, and the exact amount never
leaves your site. The ranges now go up to over R10 million.
- Fixed: a donation that was applied to a registration was counted twice in the income range sent
to Cross Code.
## 2.0.1
- **Summarise this report on every report.** Reports that list people — payments, attendees,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "2.0.1",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "2.0.1",
"version": "2.0.2",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "2.0.1",
"version": "2.0.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+28 -13
View File
@@ -13,21 +13,29 @@ const DAY = 24 * 60 * 60 * 1000;
const APP_VERSION = require('../../package.json').version;
const STARTED_AT = Date.now();
const INCOME_BANDS = [
[1000, 'R0–R1 000'],
[5000, 'R1 001–R5 000'],
[20000, 'R5 001–R20 000'],
[50000, 'R20 001–R50 000'],
[100000, 'R50 001–R100 000'],
];
// The tops of the income bands, in rand (docs/hub-protocol.md §3). Cross Code's licence tiers
// are billed on the 12-month event-income band, so every tier edge (R50 000, R150 000, R400 000,
// R1m, R2.5m, R5m, R10m) is also a band edge: change them together.
const BAND_TOPS = [1000, 5000, 20000, 50000, 100000, 150000, 250000, 400000, 700000, 1000000, 2500000, 5000000, 10000000];
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
const randText = n => `R${String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`;
const INCOME_BANDS = BAND_TOPS.map((top, i) => [top, `${randText(i ? BAND_TOPS[i - 1] + 1 : 0)}–${randText(top)}`]);
const TOP_BAND = `over ${randText(BAND_TOPS[BAND_TOPS.length - 1])}`;
/** The range an amount falls in: "R5 001–R20 000", or "over R10 000 000". */
function incomeBand(amount) {
const rand = Math.max(0, Math.round(Number(amount) || 0));
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
return 'over R100 000';
return TOP_BAND;
}
// Applying an earlier donation to a registration adds a "leg" payment that isn't new money
// (cashupUtils.isDonationLeg), so every money total leaves it out.
const NOT_DONATION_LEG = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
// Event income, what the licence tier is billed on: payments less refunds, without donations or
// sponsorships (both isDonation) and without the legs that apply a donation to a registration.
const EVENT_INCOME = { isDonation: false, ...NOT_DONATION_LEG };
async function postgresVersion() {
try {
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
@@ -98,8 +106,9 @@ async function health() {
async function stats(now = new Date()) {
const { campusesEnabled } = require('../utils/campuses');
const since = new Date(now.getTime() - 30 * DAY);
const yearAgo = new Date(now.getTime() - 365 * DAY);
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, eventIncome12m, providers] = await Promise.all([
prisma.user.count({ where: realUser }),
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
@@ -109,8 +118,9 @@ async function stats(now = new Date()) {
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
prisma.ticket.count(),
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
prisma.payment.aggregate({ _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: since }, ...NOT_DONATION_LEG }, _sum: { amount: true } }),
prisma.payment.aggregate({ where: NOT_DONATION_LEG, _sum: { amount: true } }),
prisma.payment.aggregate({ where: { createdAt: { gte: yearAgo }, ...EVENT_INCOME }, _sum: { amount: true } }),
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
]);
const campusesOn = await campusesEnabled().catch(() => false);
@@ -126,7 +136,12 @@ async function stats(now = new Date()) {
paymentProviders: providers,
campuses: campusesOn,
campusCount,
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
income: {
last30d: incomeBand(income30._sum.amount),
allTime: incomeBand(incomeAll._sum.amount),
// 2.0.2+: event income over the last 12 months, what the licence tier is billed on.
eventIncome12m: incomeBand(eventIncome12m._sum.amount),
},
};
}
+15 -1
View File
@@ -72,9 +72,23 @@ describe('heartbeat report', () => {
expect(incomeBand(1001)).toBe('R1 001–R5 000');
expect(incomeBand(19999)).toBe('R5 001–R20 000');
expect(incomeBand(100000)).toBe('R50 001–R100 000');
expect(incomeBand(250000)).toBe('over R100 000');
expect(incomeBand(100001)).toBe('R100 001–R150 000');
expect(incomeBand(250000)).toBe('R150 001–R250 000');
expect(incomeBand(400001)).toBe('R400 001–R700 000');
expect(incomeBand(1000000)).toBe('R700 001–R1 000 000');
expect(incomeBand(1000001)).toBe('R1 000 001–R2 500 000');
expect(incomeBand(5000000)).toBe('R2 500 001–R5 000 000');
expect(incomeBand(9999999)).toBe('R5 000 001–R10 000 000');
expect(incomeBand(10000001)).toBe('over R10 000 000');
expect(incomeBand(null)).toBe('R0–R1 000');
});
test('every licence tier edge is a band edge', () => {
// Cross Code bills the tiers on these (crosscode-site src/data/pricing.mjs).
for (const edge of [50000, 150000, 400000, 1000000, 2500000, 5000000, 10000000]) {
expect(incomeBand(edge)).not.toBe(incomeBand(edge + 1));
}
});
});
describe('read-only mode', () => {
+2 -1
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-11-02, after **2.0.1** and Hub **1.6.1**. The plan itself is in
Last updated: 2026-11-03, after **2.0.2** and Hub **1.6.2**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 2.0.2 | **Billing bands:** heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
| 2.0.1 | **Summarise this report on every report:** the eight reports that list people are sent as anonymised totals built on the screen (`frontend/src/components/reports/aiReportData.ts`; attendees in `buildAiPayload` in `ReportsV2.tsx`); the server refuses personal columns on those (`personalColumns` in `backend/src/ai/helpers.ts`, 403 `AI_REPORT_HAS_PEOPLE`) and logs each (`ai_report`) |
| **2.0.0** | **AI helpers** (draft an event, draft a message, summarise a report or feedback, improve text — `backend/src/ai/`, through the Hub with a rand cap per licence; eval set `npm run ai:eval`); **API v1 removed** (410 `API_V1_REMOVED`; provider webhooks and links in sent messages still answered — [v1 removed](#v1-removed-20)); **Argon2id passwords** with re-hashing at sign-in ([Passwords](#passwords-20)); `node-fetch` removed; React Compiler lint rules back to errors (`lib/hooks.ts`, see [Next.js 16](#nextjs-16-1515)); campus-scope route test on v2 (`tests/v2CampusScope.test.ts`); migration guide `MIGRATING-TO-2.md`. Migration `20261030120000_ai_helpers`. Needs Hub 1.6.0+ for AI |
| 1.52.3 | **Urgent fix:** licence read-only mode blocks the `/api/v2` routes too (`src/hub/readOnly.js` only listed v1 paths, and the screens have used v2 since 1.51.15). A test checks each blocked v2 path is a real route |
+2 -2
View File
@@ -1047,10 +1047,10 @@ Part of 2.0 (decided 2026-10-30): a **simple static HTML site** for selling, not
| Question | Answer |
|----------|--------|
| Domain | **crosscode.co.za** — the main site for Cross Code (Joshua's business), with a **Cross Events page** on it (e.g. `/cross-events`) |
| Other products | **Church website** and **church app** shown as **"coming later"** (cards or a section, no pages of their own yet) |
| Other products | **Church website** and **church app** shown as **"coming later"** (cards or a section, no pages of their own yet). 2026-11-03: the church app is a **web app** at the church's own address (e.g. app.yourchurch.co.za), not an app-store app, to keep costs down; bundles save 15% (two products) or 25% (all three) |
| Repository | **`joshua/crosscode-site`** on c-git.crosscode.co.za — its own repo, not this one |
| Hosting | **Joshua's own server** (nginx serving the files). Mostly information, so as simple a build as possible |
| Prices | **Not shown for now** — "get in touch for a licence" instead |
| Prices | ~~Not shown for now~~ → **changed 2026-11-03: shown.** Eight tiers billed on the 12-month event-income band (`income.eventIncome12m`, Cross Events 2.0.2), founding-church prices, a one-month free trial and a PDF brochure; the prices live in `crosscode-site/src/data/pricing.mjs` |
| Contact | **A form** that sends to **info@crosscode.co.za** |
| Legal | A **privacy notice** and **terms of use** for the site |
| Cookies, analytics | **None for now** (so no cookie banner) |
+10 -2
View File
@@ -100,7 +100,8 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"paymentProviders": ["yoco", "payfast"],
"campuses": false, // Site settings → Campuses on/off
"campusCount": 0, // active campuses (0 while off; Cross Events 1.43.0+)
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
"income": { "last30d": "R5 001–R20 000", "allTime": "R150 001–R250 000",
"eventIncome12m": "R100 001–R150 000" } // eventIncome12m: Cross Events 2.0.2+
}
}
→ 200 {
@@ -131,7 +132,14 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
- **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`,
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`, `over R100 000`.
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`,
`R100 001–R150 000`, `R150 001–R250 000`, `R250 001–R400 000`, `R400 001–R700 000`,
`R700 001–R1 000 000`, `R1 000 001–R2 500 000`, `R2 500 001–R5 000 000`,
`R5 000 001–R10 000 000`, `over R10 000 000`. Before Cross Events 2.0.2 the bands stopped at
`over R100 000`, and donations applied to a registration were counted twice.
- **`eventIncome12m`** (Cross Events 2.0.2+) is the band of the last 365 days' event income:
payments less refunds, without donations or sponsorships. Cross Code's licence tiers are billed
on it, so every tier edge is a band edge. Older installs don't send it.
- The Hub keeps every heartbeat for 30 days, then one per install per day.
## 3a. Update request
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "2.0.1",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "2.0.1",
"version": "2.0.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"scripts": {
"dev": "next dev",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "2.0.1",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "2.0.1",
"version": "2.0.2",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "2.0.1",
"version": "2.0.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",