diff --git a/CHANGELOG.md b/CHANGELOG.md index c841b43..8c61eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/WHATS-NEW.md b/WHATS-NEW.md index 4b00e64..8ab9b20 100644 --- a/WHATS-NEW.md +++ b/WHATS-NEW.md @@ -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, diff --git a/backend/package-lock.json b/backend/package-lock.json index fdf267d..fab8f42 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 9665f1a..a7fc17a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/hub/report.js b/backend/src/hub/report.js index da53be3..0760296 100644 --- a/backend/src/hub/report.js +++ b/backend/src/hub/report.js @@ -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), + }, }; } diff --git a/backend/tests/hub.test.js b/backend/tests/hub.test.js index 83cc908..c917045 100644 --- a/backend/tests/hub.test.js +++ b/backend/tests/hub.test.js @@ -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', () => { diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 7cc6677..4833f4e 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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 | diff --git a/docs/ROADMAP-v2.md b/docs/ROADMAP-v2.md index 94eebcb..117bc68 100644 --- a/docs/ROADMAP-v2.md +++ b/docs/ROADMAP-v2.md @@ -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) | diff --git a/docs/hub-protocol.md b/docs/hub-protocol.md index 342c101..a30ae63 100644 --- a/docs/hub-protocol.md +++ b/docs/hub-protocol.md @@ -100,7 +100,8 @@ X-Hub-Signature: "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: 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0de4ead..1b10a05 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 67e6adc..87e505f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hope-events-frontend", - "version": "2.0.1", + "version": "2.0.2", "private": true, "scripts": { "dev": "next dev", diff --git a/package-lock.json b/package-lock.json index eaab826..4f7f5d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 61a26c0..0922093 100644 --- a/package.json +++ b/package.json @@ -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",