Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf982e191 | ||
|
|
ff4c0c76d3 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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,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
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user