From 6fce0fe90e9f9947f1d0e8ea2baabfcdf1535834 Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 3 Nov 2026 13:42:45 +0200 Subject: [PATCH] Billing: invoices, approval, receipts, statements, licence changes (1.7.0) - Invoices drafted 14 days before each licence's invoice date and emailed to the alert email for approval; approved ones go out on their date from 08:00 SA time. Drafts can be edited, recalculated, sent early, put back to draft or voided; one-off invoices too. - Annual tier review from the 12-month event-income band; approving a tier change emails a notice of licence change, applied when the invoice goes out. Notices can also be sent by hand. - Founding churches (35% off as its own line), payments with receipts, statements for any dates. - Every billing email is styled HTML with a PDF (invoice / tax invoice, receipt, statement, licence change) and logged per customer. - Settings -> Billing: business and bank details, VAT (off: plain invoices; on: VAT-inclusive tax invoices), timing and numbering. - Migration 20261103111645_invoicing; pdfkit 0.17.2; tests/billing.test.js; scripts/sample-billing-pdfs.js. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 34 ++ README.md | 31 + backend/assets/logo.png | Bin 0 -> 35375 bytes backend/package-lock.json | 187 +++++- backend/package.json | 3 +- .../20261103111645_invoicing/migration.sql | 169 ++++++ backend/prisma/schema.prisma | 232 ++++++-- backend/scripts/sample-billing-pdfs.js | 33 ++ backend/src/app.js | 1 + backend/src/index.js | 5 +- backend/src/routes/billing.js | 309 ++++++++++ backend/src/utils/billing/compute.js | 190 ++++++ backend/src/utils/billing/dates.js | 79 +++ backend/src/utils/billing/email.js | 99 ++++ backend/src/utils/billing/index.js | 555 ++++++++++++++++++ backend/src/utils/billing/pdf.js | 312 ++++++++++ backend/src/utils/billing/tiers.js | 56 ++ backend/src/utils/mail.js | 15 +- backend/src/utils/settings.js | 31 + backend/tests/billing.test.js | 201 +++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../app/(hub)/billing/invoices/[id]/page.tsx | 264 +++++++++ frontend/src/app/(hub)/billing/page.tsx | 197 +++++++ .../src/app/(hub)/customers/[id]/page.tsx | 7 +- frontend/src/app/(hub)/layout.tsx | 3 +- frontend/src/app/(hub)/settings/page.tsx | 5 +- .../components/billing/BillingSettings.tsx | 108 ++++ .../components/billing/CustomerBilling.tsx | 253 ++++++++ .../src/components/billing/LinesEditor.tsx | 45 ++ .../src/components/billing/RecordPayment.tsx | 101 ++++ frontend/src/lib/api.ts | 21 + frontend/src/lib/billing.tsx | 80 +++ 33 files changed, 3579 insertions(+), 53 deletions(-) create mode 100644 backend/assets/logo.png create mode 100644 backend/prisma/migrations/20261103111645_invoicing/migration.sql create mode 100644 backend/scripts/sample-billing-pdfs.js create mode 100644 backend/src/routes/billing.js create mode 100644 backend/src/utils/billing/compute.js create mode 100644 backend/src/utils/billing/dates.js create mode 100644 backend/src/utils/billing/email.js create mode 100644 backend/src/utils/billing/index.js create mode 100644 backend/src/utils/billing/pdf.js create mode 100644 backend/src/utils/billing/tiers.js create mode 100644 backend/tests/billing.test.js create mode 100644 frontend/src/app/(hub)/billing/invoices/[id]/page.tsx create mode 100644 frontend/src/app/(hub)/billing/page.tsx create mode 100644 frontend/src/components/billing/BillingSettings.tsx create mode 100644 frontend/src/components/billing/CustomerBilling.tsx create mode 100644 frontend/src/components/billing/LinesEditor.tsx create mode 100644 frontend/src/components/billing/RecordPayment.tsx create mode 100644 frontend/src/lib/billing.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ab15fa1..c1eb4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.7.0] - 2026-11-03 + +### Added + +- **Billing** (README "Billing"; nav → Billing). Invoices are drafted automatically **14 days** + before each licence's invoice date and emailed to the alert email with the draft PDF for + **approval**; approved invoices go out **on their date from 08:00** SA time. Drafts and approved + invoices can be edited (lines, dates, recipient, notes), recalculated, sent early, put back to + draft or voided; one-off invoices too. Unapproved invoices whose date has come are emailed about + once and wait. +- **Annual tier review**: the first invoice for a period on or after a licence's review date uses + the site's 12-month event-income band (Cross Events 2.0.2+) and can move the tier up or down; + approving it emails the church a **notice of licence change**, and the tier changes when the + invoice goes out. Notices can also be sent by hand (customer page → Billing). No band reported → + the tier stays and the draft says so. +- **Founding churches** (customer page → Billing, or Billing → Churches and founding): 35% off on + every tier, as its own invoice line; since-date and a note. +- **Payments** with **receipts** (REC-…), against an invoice or on account; invoices show paid, + part paid, overdue. **Statements** for any dates, as a PDF or emailed. +- Every billing email is **styled HTML with a PDF** (invoice / tax invoice, receipt, statement, + notice of licence change), logged per customer with any send error. +- Licences: billing cycle (none, monthly, yearly at 10 months' price), billing start, next invoice + date, next review, custom monthly price. Customers: billing email, postal address, founding. +- Settings → **Billing**: on/off, business and banking details, VAT (off: plain invoices; on: + prices VAT-inclusive, tax invoices with the VAT number), draft days, send hour, payment terms, + number prefixes, invoice footer. +- Migration `20261103111645_invoicing`; dependency `pdfkit` 0.17.2. Tests `tests/billing.test.js` + (rules, dates, statements, and that the server's tiers match `frontend/src/lib/tiers.ts`); + `scripts/sample-billing-pdfs.js` writes sample PDFs. + +### Changed + +- `sendMail` takes `html`, `attachments`, `replyTo` and `fromName` (alerts are unchanged). + ## [1.6.2] - 2026-11-03 ### Added diff --git a/README.md b/README.md index a21303d..2f3dfc4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,37 @@ optional markup override, on the customer's page. The cap counts what the church Use per site (real cost, charged, profit) is on Settings → AI and on each install's page. No prompt or response text is stored. +## Billing (1.7.0) + +Invoices, receipts, statements and notices of licence change, each emailed (styled HTML) with a PDF. +The code is in `backend/src/utils/billing/` (`compute.js` the rules, `pdf.js` the documents, `email.js` +the emails, `index.js` the job and actions) and `backend/src/routes/billing.js`. + +1. **Set up** Settings → **Billing**: business and banking details, VAT, timing and numbering. Set the + **alert email** (Settings → Alerts): approval requests go there. SMTP must work. +2. **Per church** (customer page → Billing): founding church (35% off every tier), billing email and + postal address, and for each licence: monthly or yearly (10 months' price for 12), the date billing + started (its day of the month is the billing day), the next invoice date, the next annual tier + review and an optional custom monthly price (e.g. the Network tier). +3. **The job** (every 15 minutes): **14 days** before a licence's next invoice date it drafts the + invoice and emails it to you with the draft PDF. At the yearly **review** the draft uses the site's + 12-month event-income band (Cross Events 2.0.2+) and can move the tier up or down; drafts in between + keep the tier. **Approve** it on Billing (you can change its lines, dates, recipient and notes + first); a tier change sends the church a notice of licence change at once. Approved invoices go out + **on their date from 08:00** (South African time) to the billing email, else the site's admin + contact, else the customer's email; the tier change takes effect when that invoice goes out. If a + date comes and it isn't approved, you're emailed once and it waits. +4. **Payments**: Billing → Record payment (or on an invoice or customer). A receipt is emailed with + its PDF unless you untick it. **Statements**: customer page → Billing → Statement (any dates; + open the PDF or email it). One-off invoices (e.g. standby support): Billing → One-off invoice. + +Invoices are **in advance** (the invoice dated the 1st covers that month). **VAT**: not registered → +plain invoices with no VAT; once registered, tick it in Settings → Billing: prices are then +VAT-inclusive (totals unchanged), documents become tax invoices with the VAT number and the VAT +included. The tiers are in `backend/src/utils/billing/tiers.js` and `frontend/src/lib/tiers.ts` (a test +checks they match) and on crosscode-site `src/data/pricing.mjs`. `node backend/scripts/sample-billing-pdfs.js ` +writes sample PDFs (no database needed) to check the layout. + ## The licence signing key Licences are tokens signed with an Ed25519 key. **The private key exists only on the Hub server**; diff --git a/backend/assets/logo.png b/backend/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ad87db77e761189a4441c6d24387cc29ced92115 GIT binary patch literal 35375 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" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", @@ -3008,6 +3038,24 @@ "node": "20 || >=22" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.29.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", @@ -3266,6 +3314,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3384,6 +3441,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3655,6 +3719,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -4079,7 +4149,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4196,6 +4265,23 @@ "node": ">=8" } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5343,6 +5429,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5488,6 +5581,25 @@ "node": ">=6" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5996,6 +6108,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6083,6 +6201,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", + "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -6250,6 +6381,14 @@ "devOptional": true, "license": "MIT" }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -6652,6 +6791,12 @@ "node": ">=8" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/ret": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.1.tgz", @@ -7212,6 +7357,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7231,9 +7382,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", @@ -7277,6 +7426,32 @@ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index bb6a36a..929bca7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "events-hub-backend", - "version": "1.6.2", + "version": "1.7.0", "private": true, "description": "Cross Events Hub — API for managing Cross Events installs, licences and heartbeats", "main": "src/index.js", @@ -28,6 +28,7 @@ "express-rate-limit": "^8.6.2", "jsonwebtoken": "^9.0.3", "nodemailer": "^10.0.16", + "pdfkit": "^0.17.2", "qrcode": "^1.5.4" }, "devDependencies": { diff --git a/backend/prisma/migrations/20261103111645_invoicing/migration.sql b/backend/prisma/migrations/20261103111645_invoicing/migration.sql new file mode 100644 index 0000000..9ceeedb --- /dev/null +++ b/backend/prisma/migrations/20261103111645_invoicing/migration.sql @@ -0,0 +1,169 @@ +-- CreateEnum +CREATE TYPE "BillingCycle" AS ENUM ('none', 'monthly', 'yearly'); + +-- CreateEnum +CREATE TYPE "InvoiceStatus" AS ENUM ('draft', 'approved', 'sent', 'void'); + +-- AlterTable +ALTER TABLE "Customer" ADD COLUMN "billingAddress" TEXT, +ADD COLUMN "billingEmail" TEXT, +ADD COLUMN "founding" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "foundingNote" TEXT, +ADD COLUMN "foundingSince" DATE; + +-- AlterTable +ALTER TABLE "Licence" ADD COLUMN "billingCycle" "BillingCycle" NOT NULL DEFAULT 'none', +ADD COLUMN "billingStart" DATE, +ADD COLUMN "customMonthlyCents" INTEGER, +ADD COLUMN "nextInvoiceDate" DATE, +ADD COLUMN "reviewDate" DATE; + +-- CreateTable +CREATE TABLE "Invoice" ( + "id" TEXT NOT NULL, + "number" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "licenceId" TEXT, + "status" "InvoiceStatus" NOT NULL DEFAULT 'draft', + "issueDate" DATE NOT NULL, + "dueDate" DATE NOT NULL, + "periodStart" DATE, + "periodEnd" DATE, + "cycle" "BillingCycle" NOT NULL DEFAULT 'monthly', + "plan" TEXT, + "incomeBand" TEXT, + "reviewed" BOOLEAN NOT NULL DEFAULT false, + "founding" BOOLEAN NOT NULL DEFAULT false, + "planChange" JSONB, + "lines" JSONB NOT NULL, + "totalCents" INTEGER NOT NULL, + "vatCents" INTEGER NOT NULL DEFAULT 0, + "vatRate" DOUBLE PRECISION, + "notes" TEXT, + "warnings" TEXT[] DEFAULT ARRAY[]::TEXT[], + "recipientEmail" TEXT, + "approvedAt" TIMESTAMP(3), + "approvedById" TEXT, + "approvalEmailedAt" TIMESTAMP(3), + "overdueEmailedAt" TIMESTAMP(3), + "sentAt" TIMESTAMP(3), + "sendError" TEXT, + "voidedAt" TIMESTAMP(3), + "voidReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "receiptNumber" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "invoiceId" TEXT, + "amountCents" INTEGER NOT NULL, + "paidOn" DATE NOT NULL, + "method" TEXT NOT NULL DEFAULT 'eft', + "reference" TEXT, + "notes" TEXT, + "receiptSentAt" TIMESTAMP(3), + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LicenceChange" ( + "id" TEXT NOT NULL, + "number" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "licenceId" TEXT, + "invoiceId" TEXT, + "fromPlan" TEXT NOT NULL, + "toPlan" TEXT NOT NULL, + "fromMonthlyCents" INTEGER, + "toMonthlyCents" INTEGER, + "effectiveDate" DATE NOT NULL, + "reason" TEXT, + "incomeBand" TEXT, + "appliedAt" TIMESTAMP(3), + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LicenceChange_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BillingEmail" ( + "id" TEXT NOT NULL, + "customerId" TEXT, + "kind" TEXT NOT NULL, + "refId" TEXT, + "to" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "error" TEXT, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BillingEmail_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentCounter" ( + "key" TEXT NOT NULL, + "value" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "DocumentCounter_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice_number_key" ON "Invoice"("number"); + +-- CreateIndex +CREATE INDEX "Invoice_status_issueDate_idx" ON "Invoice"("status", "issueDate"); + +-- CreateIndex +CREATE INDEX "Invoice_customerId_idx" ON "Invoice"("customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice_licenceId_periodStart_key" ON "Invoice"("licenceId", "periodStart"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_receiptNumber_key" ON "Payment"("receiptNumber"); + +-- CreateIndex +CREATE INDEX "Payment_customerId_paidOn_idx" ON "Payment"("customerId", "paidOn"); + +-- CreateIndex +CREATE UNIQUE INDEX "LicenceChange_number_key" ON "LicenceChange"("number"); + +-- CreateIndex +CREATE INDEX "LicenceChange_licenceId_appliedAt_idx" ON "LicenceChange"("licenceId", "appliedAt"); + +-- CreateIndex +CREATE INDEX "BillingEmail_customerId_sentAt_idx" ON "BillingEmail"("customerId", "sentAt"); + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_licenceId_fkey" FOREIGN KEY ("licenceId") REFERENCES "Licence"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LicenceChange" ADD CONSTRAINT "LicenceChange_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LicenceChange" ADD CONSTRAINT "LicenceChange_licenceId_fkey" FOREIGN KEY ("licenceId") REFERENCES "Licence"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LicenceChange" ADD CONSTRAINT "LicenceChange_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BillingEmail" ADD CONSTRAINT "BillingEmail_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2518a14..c0ad09b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,31 +11,43 @@ datasource db { // Someone who logs in to the Hub (Joshua, later support staff). Two-factor (authenticator // app) is required: totpSecret is set up on first login. model AdminUser { - id String @id @default(uuid()) - email String @unique - name String - password String + id String @id @default(uuid()) + email String @unique + name String + password String // AES-GCM encrypted (utils/crypto.js); null until two-factor is set up. - totpSecret String? - totpEnabled Boolean @default(false) + totpSecret String? + totpEnabled Boolean @default(false) // Bumped to sign out everywhere. - tokenVersion Int @default(0) - lastLoginAt DateTime? - createdAt DateTime @default(now()) - auditLogs AuditLog[] + tokenVersion Int @default(0) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + auditLogs AuditLog[] } model Customer { - id String @id @default(uuid()) - name String - contactName String? - email String? - phone String? - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - licences Licence[] - installs Install[] + id String @id @default(uuid()) + name String + contactName String? + email String? + phone String? + notes String? + // Billing (1.7.0). Invoices go to billingEmail, else the site's admin contact, else email. + billingEmail String? + // The postal address printed on invoices and statements. + billingAddress String? + // A founding church pays the founding price on every tier (35% less) while it stays. + founding Boolean @default(false) + foundingSince DateTime? @db.Date + foundingNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + licences Licence[] + installs Install[] + invoices Invoice[] + payments Payment[] + licenceChanges LicenceChange[] + billingEmails BillingEmail[] } enum LicenceStatus { @@ -45,25 +57,45 @@ enum LicenceStatus { } model Licence { - id String @id @default(uuid()) - customerId String - customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + id String @id @default(uuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) // CEH-XXXX-XXXX-XXXX-XXXX, generated by the Hub. - key String @unique - plan String @default("standard") - status LicenceStatus @default(active) + key String @unique + plan String @default("standard") + status LicenceStatus @default(active) // Null = no end date. - expiresAt DateTime? - graceDays Int @default(14) + expiresAt DateTime? + graceDays Int @default(14) // Cross Events 2.0: monthly AI spend cap (in cents, rand), counted in charged rand. 0 = AI off. - aiMonthlyCapCents Int @default(0) + aiMonthlyCapCents Int @default(0) // Markup on the real AI cost, in %. Null = the Hub default (Settings → AI). - aiMarkupPercent Int? - notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + aiMarkupPercent Int? + notes String? + // Billing (1.7.0). none = not invoiced (trial, free). Invoices are in advance: each covers + // the period starting on its date. + billingCycle BillingCycle @default(none) + // The day billing started; its day of the month is the billing day. + billingStart DateTime? @db.Date + // The start of the next period to invoice (the draft is made 14 days before). + nextInvoiceDate DateTime? @db.Date + // The next annual tier review: the first invoice for a period starting on or after it + // uses the last 12 months' event income. + reviewDate DateTime? @db.Date + // A price per month instead of the tier's (e.g. the Network tier), in cents. + customMonthlyCents Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // One licence = one install. - install Install? + install Install? + invoices Invoice[] + licenceChanges LicenceChange[] +} + +enum BillingCycle { + none + monthly + yearly } model Install { @@ -227,3 +259,135 @@ model Notice { @@index([endsAt]) } + +// ─── Billing (1.7.0) ─────────────────────────────────────────────────────────── + +enum InvoiceStatus { + // Made 14 days before its date and emailed to Joshua for approval. Can be edited. + draft + // Goes out automatically on its date. Can still be edited until then. + approved + sent + void +} + +// One invoice for one licence's period, in cents (rand). Payment state comes from its payments. +model Invoice { + id String @id @default(uuid()) + number String @unique + customerId String + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + licenceId String? + licence Licence? @relation(fields: [licenceId], references: [id], onDelete: SetNull) + status InvoiceStatus @default(draft) + // The day it's sent (and dated), and when payment is due. + issueDate DateTime @db.Date + dueDate DateTime @db.Date + periodStart DateTime? @db.Date + periodEnd DateTime? @db.Date + cycle BillingCycle @default(monthly) + // The tier billed, and how it was chosen (the income band at a review). + plan String? + incomeBand String? + reviewed Boolean @default(false) + founding Boolean @default(false) + // A tier change that comes with this invoice: { from, to, fromMonthlyCents, toMonthlyCents }. + planChange Json? + // [{ description, quantity, unitCents, amountCents }] + lines Json + totalCents Int + // VAT included in the total (0 unless VAT registered). + vatCents Int @default(0) + vatRate Float? + notes String? + // Things Joshua should check before approving (e.g. no income band reported). + warnings String[] @default([]) + recipientEmail String? + approvedAt DateTime? + approvedById String? + approvalEmailedAt DateTime? + // Emailed to Joshua when its date came and it still wasn't approved. + overdueEmailedAt DateTime? + sentAt DateTime? + sendError String? + voidedAt DateTime? + voidReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + payments Payment[] + licenceChanges LicenceChange[] + + @@unique([licenceId, periodStart]) + @@index([status, issueDate]) + @@index([customerId]) +} + +// Money received from a church, optionally against one invoice. Each gets a receipt number. +model Payment { + id String @id @default(uuid()) + receiptNumber String @unique + customerId String + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + amountCents Int + paidOn DateTime @db.Date + // eft, card, cash, other + method String @default("eft") + reference String? + notes String? + receiptSentAt DateTime? + createdById String? + createdAt DateTime @default(now()) + + @@index([customerId, paidOn]) +} + +// A notice to a church that its tier (and price) changes from a date. Sent when the invoice +// that brings the change is approved, or by hand from the customer page. +model LicenceChange { + id String @id @default(uuid()) + number String @unique + customerId String + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + licenceId String? + licence Licence? @relation(fields: [licenceId], references: [id], onDelete: SetNull) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + fromPlan String + toPlan String + fromMonthlyCents Int? + toMonthlyCents Int? + effectiveDate DateTime @db.Date + reason String? + incomeBand String? + // Set once the plan has been changed on the licence (on the effective date). + appliedAt DateTime? + sentAt DateTime? + createdAt DateTime @default(now()) + + @@index([licenceId, appliedAt]) +} + +// Every billing email: invoices, receipts, statements, licence-change notices and approval +// requests to Joshua. +model BillingEmail { + id String @id @default(uuid()) + customerId String? + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + // invoice, receipt, statement, licence_change, approval, reminder + kind String + refId String? + to String + subject String + error String? + sentAt DateTime @default(now()) + + @@index([customerId, sentAt]) +} + +// Document numbers (INV-2026-0001…): one counter per prefix and year, incremented atomically. +model DocumentCounter { + key String @id + value Int @default(0) +} diff --git a/backend/scripts/sample-billing-pdfs.js b/backend/scripts/sample-billing-pdfs.js new file mode 100644 index 0000000..b19f8ed --- /dev/null +++ b/backend/scripts/sample-billing-pdfs.js @@ -0,0 +1,33 @@ +/** + * Writes sample billing PDFs (invoice, receipt, statement, licence change) with made-up data, to + * check the layout after changing utils/billing/pdf.js: `node scripts/sample-billing-pdfs.js [dir]`. + * Needs no database. + */ +const fs = require('fs'); +const path = require('path'); +const { DEFAULTS } = require('../src/utils/settings'); +const pdf = require('../src/utils/billing/pdf'); +const { draftInvoice, invoiceState, statement } = require('../src/utils/billing/compute'); + +const out = path.resolve(process.argv[2] || 'billing-samples'); +fs.mkdirSync(out, { recursive: true }); +const settings = { ...DEFAULTS, bank_name: 'Sample Bank', bank_account_name: 'Cross Code', bank_account_number: '1234 567 890', bank_branch_code: '250655', bank_account_type: 'Cheque' }; +const customer = { id: 'c1', name: 'Cedarwood Community Church', contactName: 'Thandi Nkosi', billingAddress: '12 Oak Street, Meyerton, 1961', email: 'office@cedarwood.example', founding: true }; +const install = { adminContact: { email: 'admin@cedarwood.example' }, stats: { income: { eventIncome12m: 'R250 001–R400 000' } } }; +const licence = { id: 'l1', plan: 'Sprout', billingCycle: 'monthly', billingStart: new Date('2026-01-01'), nextInvoiceDate: new Date('2027-01-01'), reviewDate: new Date('2027-01-01'), customMonthlyCents: null }; + +(async () => { + const { invoice: draft } = draftInvoice({ licence, customer, install, settings }); + const invoice = { ...draft, id: 'i1', number: 'INV-2027-0001', status: 'sent', notes: null }; + const payment = { id: 'p1', receiptNumber: 'REC-2027-0001', amountCents: 30000, paidOn: new Date('2027-01-05'), method: 'eft', reference: 'INV-2027-0001' }; + const state = invoiceState({ ...invoice, payments: [payment] }, '2027-01-06'); + fs.writeFileSync(path.join(out, 'invoice.pdf'), await pdf.invoicePdf({ invoice, customer, settings, state })); + fs.writeFileSync(path.join(out, 'invoice-draft.pdf'), await pdf.invoicePdf({ invoice: { ...invoice, status: 'draft' }, customer, settings, state: invoiceState({ ...invoice, payments: [] }, '2027-01-06') })); + fs.writeFileSync(path.join(out, 'receipt.pdf'), await pdf.receiptPdf({ payment, customer, invoice, state, settings, recipientEmail: invoice.recipientEmail })); + const older = { ...invoice, number: 'INV-2026-0012', issueDate: new Date('2026-12-01'), dueDate: new Date('2026-12-08'), periodStart: new Date('2026-12-01'), periodEnd: new Date('2026-12-31'), totalCents: 36000, payments: [] }; + const st = statement({ invoices: [older, { ...invoice, payments: [payment] }], payments: [{ ...payment, invoice }], from: '2026-12-15', to: '2027-01-31' }); + fs.writeFileSync(path.join(out, 'statement.pdf'), await pdf.statementPdf({ statement: st, customer, settings, recipientEmail: invoice.recipientEmail })); + const change = { number: 'LC-2026-0001', fromPlan: 'Sprout', toPlan: 'Grow', fromMonthlyCents: 55000, toMonthlyCents: 85000, effectiveDate: new Date('2027-01-01'), incomeBand: 'R250 001–R400 000', createdAt: new Date('2026-12-18') }; + fs.writeFileSync(path.join(out, 'licence-change.pdf'), await pdf.licenceChangePdf({ change, customer, settings, founding: true, recipientEmail: invoice.recipientEmail, cycle: 'monthly' })); + console.log(`samples in ${out}`); +})(); diff --git a/backend/src/app.js b/backend/src/app.js index e86c77e..96e1a46 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -15,6 +15,7 @@ app.use(express.json({ limit: '300kb', verify: (req, res, buf) => { req.rawBody app.get('/api/health', (req, res) => res.json({ ok: true })); app.use('/api', require('./routes/installApi')); app.use('/api/auth', require('./routes/auth')); +app.use('/api/admin/billing', require('./routes/billing')); app.use('/api/admin', require('./routes/admin')); app.use((req, res) => res.status(404).json({ message: 'Not found', code: 'NOT_FOUND' })); diff --git a/backend/src/index.js b/backend/src/index.js index 3255f31..9eb8bde 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -12,6 +12,7 @@ const { keyPair } = require('./utils/licenceToken'); const { runAlerts, rollupHeartbeats } = require('./utils/alerts'); const { importSmtpFromEnv } = require('./utils/settings'); const { syncReleases } = require('./utils/releaseSync'); +const { runBilling } = require('./utils/billing'); // Fail early if the licence signing key is missing or wrong. try { @@ -29,13 +30,15 @@ importSmtpFromEnv() const PORT = parseInt(process.env.PORT || '5200', 10); app.listen(PORT, () => console.log(`[hub] Listening on ${PORT}`)); -// Background checks: alerts every 5 minutes, releases from git every 15, heartbeat roll-up once a day. The Hub runs as a +// Background checks: alerts every 5 minutes, releases from git and billing every 15, heartbeat roll-up once a day. The Hub runs as a // single process, so plain timers are enough. if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() !== 'false') { const every = (ms, name, fn) => setInterval(() => fn().catch(e => console.warn(`[hub] ${name} failed:`, e?.message || e)), ms); every(5 * 60 * 1000, 'alerts', () => runAlerts()); every(15 * 60 * 1000, 'release sync', () => syncReleases()); every(24 * 60 * 60 * 1000, 'heartbeat roll-up', () => rollupHeartbeats()); + every(15 * 60 * 1000, 'billing', () => runBilling()); + setTimeout(() => runBilling().catch(e => console.warn('[hub] billing failed:', e?.message || e)), 60 * 1000); setTimeout(() => runAlerts().catch(() => {}), 30 * 1000); setTimeout(() => syncReleases().catch(e => console.warn('[hub] release sync failed:', e?.message || e)), 20 * 1000); } diff --git a/backend/src/routes/billing.js b/backend/src/routes/billing.js new file mode 100644 index 0000000..f312268 --- /dev/null +++ b/backend/src/routes/billing.js @@ -0,0 +1,309 @@ +/** + * Billing API (1.7.0, admin login required): invoices, payments, statements, licence changes, + * founding churches and each licence's billing set-up. The work is in utils/billing/. + */ + +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const prisma = require('../config/db'); +const { protect } = require('../middleware/auth'); +const { audit } = require('../utils/audit'); +const billing = require('../utils/billing'); +const compute = require('../utils/billing/compute'); +const { ymd, day, today } = require('../utils/billing/dates'); +const { TIERS } = require('../utils/billing/tiers'); + +const router = express.Router(); +router.use(protect); + +// Emails to churches: generous for normal use, a brake on a runaway script. +const emailLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many emails — wait a few minutes.' } }); + +const wrap = fn => (req, res) => fn(req, res).catch(e => { + const status = e.status || 500; + if (status >= 500) console.error('[billing]', e); + res.status(status).json({ message: status >= 500 && !e.status ? 'Something went wrong.' : e.message }); +}); +const bad = (message, status = 400) => Object.assign(new Error(message), { status }); +const text = (v, max = 500) => (v == null || String(v).trim() === '' ? null : String(v).trim().slice(0, max)); +const dayOrNull = v => (v == null || v === '' ? null : day(v)); +const sendPdf = (res, { filename, content }) => { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename.replace(/[^\w .()-]/g, '_')}"`); + res.send(content); +}; + +/** An invoice for lists and the invoice page, with where it stands. */ +function invoiceRow(invoice, todayYmd = ymd(today())) { + const { licence, ...rest } = invoice; + return { + ...rest, + issueDate: ymd(invoice.issueDate), dueDate: ymd(invoice.dueDate), + periodStart: ymd(invoice.periodStart), periodEnd: ymd(invoice.periodEnd), + customer: invoice.customer ? { id: invoice.customer.id, name: invoice.customer.name, founding: invoice.customer.founding } : undefined, + payments: (invoice.payments || []).map(p => ({ ...p, paidOn: ymd(p.paidOn) })), + licence: licence ? { id: licence.id, key: licence.key, plan: licence.plan, billingCycle: licence.billingCycle, installUrl: licence.install?.url || null } : null, + ...compute.invoiceState(invoice, todayYmd), + }; +} + +const PAYMENT_STATES = ['unpaid', 'part_paid', 'overdue', 'paid']; + +// ─── Overview ───────────────────────────────────────────────────────────────── + +router.get('/overview', wrap(async (req, res) => { + const t = ymd(today()); + const [invoices, payments] = await Promise.all([ + prisma.invoice.findMany({ where: { status: { not: 'void' } }, include: { customer: true, payments: true } }), + prisma.payment.findMany({ where: { paidOn: { gte: day(`${t.slice(0, 7)}-01`) } } }), + ]); + const rows = invoices.map(i => invoiceRow(i, t)); + const sum = list => list.reduce((s, i) => s + i.balanceCents, 0); + res.json({ + drafts: rows.filter(i => i.state === 'draft').length, + approved: rows.filter(i => i.state === 'approved').length, + owingCents: sum(rows.filter(i => i.status === 'sent' && i.balanceCents > 0)), + overdueCents: sum(rows.filter(i => i.state === 'overdue')), + overdue: rows.filter(i => i.state === 'overdue').length, + receivedThisMonthCents: payments.reduce((s, p) => s + p.amountCents, 0), + tiers: TIERS, + }); +})); + +router.post('/run', wrap(async (req, res) => { + const out = await billing.runBilling(); + await audit(req, 'billing_run', null, out); + res.json(out); +})); + +// ─── Invoices ───────────────────────────────────────────────────────────────── + +router.get('/invoices', wrap(async (req, res) => { + const { status, customerId } = req.query; + const where = {}; + if (customerId) where.customerId = String(customerId); + if (['draft', 'approved', 'sent', 'void'].includes(status)) where.status = status; + else if (PAYMENT_STATES.includes(status)) where.status = 'sent'; + const invoices = await prisma.invoice.findMany({ where, include: { customer: true, payments: true, licence: { include: { install: true } } }, orderBy: [{ issueDate: 'desc' }, { number: 'desc' }], take: 500 }); + let rows = invoices.map(i => invoiceRow(i)); + if (PAYMENT_STATES.includes(status)) rows = rows.filter(i => (status === 'unpaid' ? ['unpaid', 'part_paid', 'overdue'].includes(i.state) : i.state === status)); + res.json(rows); +})); + +router.post('/invoices', wrap(async (req, res) => { + const invoice = await billing.createManualInvoice(req.body || {}); + await audit(req, 'invoice_created', invoice.id, { number: invoice.number }); + res.status(201).json(invoiceRow(invoice)); +})); + +router.get('/invoices/:id', wrap(async (req, res) => { + const invoice = await billing.loadInvoice(req.params.id); + const [changes, emails] = await Promise.all([ + prisma.licenceChange.findMany({ where: { invoiceId: invoice.id }, orderBy: { createdAt: 'desc' } }), + prisma.billingEmail.findMany({ where: { refId: invoice.id }, orderBy: { sentAt: 'desc' } }), + ]); + res.json({ ...invoiceRow(invoice), licenceChanges: changes.map(c => ({ ...c, effectiveDate: ymd(c.effectiveDate) })), emails, tiers: TIERS }); +})); + +router.put('/invoices/:id', wrap(async (req, res) => { + const invoice = await billing.updateInvoice(req.params.id, req.body || {}); + await audit(req, 'invoice_updated', invoice.id, { number: invoice.number, changed: Object.keys(req.body || {}) }); + res.json(invoiceRow(invoice)); +})); + +for (const [action, fn, auditName] of [ + ['approve', (req) => billing.approveInvoice(req.params.id, req.admin?.id).then(r => ({ invoice: r.invoice, notice: r.notice })), 'invoice_approved'], + ['unapprove', (req) => billing.unapproveInvoice(req.params.id).then(invoice => ({ invoice })), 'invoice_unapproved'], + ['recalculate', (req) => billing.recalculateInvoice(req.params.id).then(invoice => ({ invoice })), 'invoice_recalculated'], + ['void', (req) => billing.voidInvoice(req.params.id, req.body?.reason).then(invoice => ({ invoice })), 'invoice_voided'], +]) { + router.post(`/invoices/:id/${action}`, wrap(async (req, res) => { + const out = await fn(req); + await audit(req, auditName, req.params.id, { number: out.invoice.number }); + res.json({ invoice: invoiceRow(out.invoice), notice: out.notice || null }); + })); +} + +router.post('/invoices/:id/send', emailLimiter, wrap(async (req, res) => { + const { getSettings } = require('../utils/settings'); + const invoice = await billing.sendInvoice(req.params.id, await getSettings()); + await audit(req, 'invoice_sent', invoice.id, { number: invoice.number, to: invoice.recipientEmail }); + res.json({ invoice: invoiceRow(invoice) }); +})); + +router.post('/invoices/:id/resend', emailLimiter, wrap(async (req, res) => { + const invoice = await billing.resendInvoice(req.params.id); + await audit(req, 'invoice_resent', invoice.id, { number: invoice.number, to: invoice.recipientEmail }); + res.json({ message: `Sent again to ${invoice.recipientEmail}.` }); +})); + +router.get('/invoices/:id/pdf', wrap(async (req, res) => { + const { getSettings } = require('../utils/settings'); + const invoice = await billing.loadInvoice(req.params.id); + sendPdf(res, { filename: `${invoice.number}${invoice.status === 'draft' || invoice.status === 'approved' ? ' (draft)' : ''}.pdf`, content: await billing.invoicePdfFor(invoice, await getSettings()) }); +})); + +// ─── Payments ───────────────────────────────────────────────────────────────── + +router.get('/payments', wrap(async (req, res) => { + const where = req.query.customerId ? { customerId: String(req.query.customerId) } : {}; + const payments = await prisma.payment.findMany({ where, include: { customer: { select: { id: true, name: true } }, invoice: { select: { id: true, number: true } } }, orderBy: [{ paidOn: 'desc' }, { createdAt: 'desc' }], take: 500 }); + res.json(payments.map(p => ({ ...p, paidOn: ymd(p.paidOn) }))); +})); + +router.post('/payments', emailLimiter, wrap(async (req, res) => { + const { payment, receipt } = await billing.recordPayment(req.body || {}, req.admin?.id); + await audit(req, 'payment_recorded', payment.id, { receipt: payment.receiptNumber, amountCents: payment.amountCents, invoice: payment.invoice?.number || null }); + res.status(201).json({ payment: { ...payment, paidOn: ymd(payment.paidOn) }, receipt }); +})); + +router.delete('/payments/:id', wrap(async (req, res) => { + const payment = await billing.deletePayment(req.params.id); + await audit(req, 'payment_deleted', payment.id, { receipt: payment.receiptNumber, amountCents: payment.amountCents }); + res.json({ message: `Payment ${payment.receiptNumber} removed.` }); +})); + +router.post('/payments/:id/resend', emailLimiter, wrap(async (req, res) => { + await billing.resendReceipt(req.params.id); + await audit(req, 'receipt_resent', req.params.id); + res.json({ message: 'Receipt sent again.' }); +})); + +router.get('/payments/:id/pdf', wrap(async (req, res) => sendPdf(res, await billing.receiptPdfFor(req.params.id)))); + +// ─── Customers: founding, billing details, statements ───────────────────────── + +/** Every customer's billing at a glance: founding, licences' plans and next invoice, balance. */ +router.get('/customers', wrap(async (req, res) => { + const t = ymd(today()); + const customers = await prisma.customer.findMany({ + orderBy: { name: 'asc' }, + include: { licences: { include: { install: { select: { url: true } } } }, invoices: { where: { status: 'sent' }, include: { payments: true } } }, + }); + res.json(customers.map(c => { + const states = c.invoices.map(i => compute.invoiceState(i, t)); + return { + id: c.id, name: c.name, founding: c.founding, foundingSince: ymd(c.foundingSince), foundingNote: c.foundingNote, billingEmail: c.billingEmail, + balanceCents: states.reduce((s, x) => s + Math.max(0, x.balanceCents), 0), + overdue: states.filter(x => x.state === 'overdue').length, + licences: c.licences.map(l => ({ id: l.id, key: l.key, plan: l.plan, status: l.status, billingCycle: l.billingCycle, nextInvoiceDate: ymd(l.nextInvoiceDate), reviewDate: ymd(l.reviewDate), customMonthlyCents: l.customMonthlyCents, url: l.install?.url || null })), + }; + })); +})); + +router.get('/customers/:id', wrap(async (req, res) => { + const t = ymd(today()); + const customer = await prisma.customer.findUnique({ + where: { id: req.params.id }, + include: { + licences: { include: { install: true }, orderBy: { createdAt: 'desc' } }, + invoices: { include: { payments: true }, orderBy: [{ issueDate: 'desc' }, { number: 'desc' }] }, + payments: { include: { invoice: { select: { id: true, number: true } } }, orderBy: [{ paidOn: 'desc' }, { createdAt: 'desc' }] }, + licenceChanges: { orderBy: { createdAt: 'desc' } }, + billingEmails: { orderBy: { sentAt: 'desc' }, take: 50 }, + }, + }); + if (!customer) throw bad('Customer not found', 404); + const invoices = customer.invoices.map(i => invoiceRow(i, t)); + const install = customer.licences.find(l => l.install && !l.install.releasedAt)?.install || null; + res.json({ + id: customer.id, name: customer.name, contactName: customer.contactName, email: customer.email, + billingEmail: customer.billingEmail, billingAddress: customer.billingAddress, + founding: customer.founding, foundingSince: ymd(customer.foundingSince), foundingNote: customer.foundingNote, + sendsTo: compute.recipientFor(customer, install), + balanceCents: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Math.max(0, i.balanceCents), 0), + licences: customer.licences.map(l => ({ + id: l.id, key: l.key, plan: l.plan, status: l.status, billingCycle: l.billingCycle, billingStart: ymd(l.billingStart), + nextInvoiceDate: ymd(l.nextInvoiceDate), reviewDate: ymd(l.reviewDate), customMonthlyCents: l.customMonthlyCents, + url: l.install?.url || null, incomeBand: l.install?.stats?.income?.eventIncome12m || null, + })), + invoices, + payments: customer.payments.map(p => ({ ...p, paidOn: ymd(p.paidOn) })), + licenceChanges: customer.licenceChanges.map(c => ({ ...c, effectiveDate: ymd(c.effectiveDate) })), + emails: customer.billingEmails, + tiers: TIERS, + }); +})); + +router.put('/customers/:id', wrap(async (req, res) => { + const b = req.body || {}; + const data = {}; + if (b.founding !== undefined) { + data.founding = b.founding === true; + if (data.founding && b.foundingSince === undefined) data.foundingSince = today(); + } + if (b.foundingSince !== undefined) data.foundingSince = dayOrNull(b.foundingSince); + if (b.foundingNote !== undefined) data.foundingNote = text(b.foundingNote, 500); + if (b.billingAddress !== undefined) data.billingAddress = text(b.billingAddress, 500); + if (b.billingEmail !== undefined) { + const e = text(b.billingEmail, 200); + if (e && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) throw bad('That email address doesn\'t look right.'); + data.billingEmail = e; + } + const customer = await prisma.customer.update({ where: { id: req.params.id }, data }).catch(() => { throw bad('Customer not found', 404); }); + await audit(req, 'customer_billing_updated', customer.id, { changed: Object.keys(data), founding: customer.founding }); + res.json({ ok: true }); +})); + +router.get('/customers/:id/statement', wrap(async (req, res) => { + const { statement, recipientEmail } = await billing.customerStatement(req.params.id, req.query.from, req.query.to); + res.json({ ...statement, recipientEmail }); +})); + +router.get('/customers/:id/statement.pdf', wrap(async (req, res) => sendPdf(res, await billing.statementPdfFor(req.params.id, req.query.from, req.query.to)))); + +router.post('/customers/:id/statement/email', emailLimiter, wrap(async (req, res) => { + const out = await billing.emailStatement(req.params.id, req.body || {}); + await audit(req, 'statement_sent', req.params.id, { to: out.to, from: out.statement.from, until: out.statement.to }); + res.json({ message: `Statement sent to ${out.to}.` }); +})); + +// ─── Licence billing set-up ─────────────────────────────────────────────────── + +router.put('/licences/:id', wrap(async (req, res) => { + const b = req.body || {}; + const existing = await prisma.licence.findUnique({ where: { id: req.params.id } }); + if (!existing) throw bad('Licence not found', 404); + const data = {}; + if (b.billingCycle !== undefined) { + if (!['none', 'monthly', 'yearly'].includes(b.billingCycle)) throw bad('Choose monthly, yearly or not billed.'); + data.billingCycle = b.billingCycle; + } + for (const f of ['billingStart', 'nextInvoiceDate', 'reviewDate']) if (b[f] !== undefined) data[f] = dayOrNull(b[f]); + if (b.customMonthlyCents !== undefined) { + if (b.customMonthlyCents === null || b.customMonthlyCents === '') data.customMonthlyCents = null; + else { + const n = Math.round(Number(b.customMonthlyCents)); + if (!Number.isInteger(n) || n < 0 || n > 1e8) throw bad('Enter a monthly price.'); + data.customMonthlyCents = n; + } + } + const merged = { ...existing, ...data }; + if (merged.billingCycle !== 'none') { + if (!merged.billingStart) throw bad('Set the date billing starts.'); + if (!merged.nextInvoiceDate) data.nextInvoiceDate = merged.billingStart; + // The first review is a year after billing starts. + if (!merged.reviewDate && b.reviewDate === undefined) data.reviewDate = require('../utils/billing/dates').addMonths(merged.billingStart, 12); + } + const licence = await prisma.licence.update({ where: { id: existing.id }, data }); + await audit(req, 'licence_billing_updated', licence.id, { changed: Object.keys(data), billingCycle: licence.billingCycle }); + res.json({ ok: true }); +})); + +// ─── Licence changes ────────────────────────────────────────────────────────── + +router.post('/licence-changes', emailLimiter, wrap(async (req, res) => { + const { change, email } = await billing.createLicenceChange(req.body || {}, null, { send: req.body?.send !== false }); + await audit(req, 'licence_change_created', change.id, { number: change.number, from: change.fromPlan, to: change.toPlan, effective: ymd(change.effectiveDate) }); + res.status(201).json({ change: { ...change, effectiveDate: ymd(change.effectiveDate) }, email }); +})); + +router.post('/licence-changes/:id/resend', emailLimiter, wrap(async (req, res) => { + await billing.resendLicenceChange(req.params.id); + await audit(req, 'licence_change_resent', req.params.id); + res.json({ message: 'Notice sent again.' }); +})); + +router.get('/licence-changes/:id/pdf', wrap(async (req, res) => sendPdf(res, await billing.licenceChangePdfFor(req.params.id)))); + +module.exports = router; diff --git a/backend/src/utils/billing/compute.js b/backend/src/utils/billing/compute.js new file mode 100644 index 0000000..4f410b0 --- /dev/null +++ b/backend/src/utils/billing/compute.js @@ -0,0 +1,190 @@ +/** + * Billing calculations, pure (no database): what an invoice for a period contains, totals and + * VAT, where an invoice stands with its payments, and a statement. + * + * Rules (decided 2026-11-03): + * - Invoices are in advance: the invoice dated the 1st covers the period starting that day. + * - The tier is reviewed once a year: the first invoice for a period starting on or after the + * licence's reviewDate uses the 12-month event-income band; invoices in between keep the tier. + * - Yearly billing is 10 months' price for 12 months. Founding churches pay the founding price. + * - Not VAT registered: no VAT. Once registered, prices are VAT-inclusive (totals unchanged). + */ + +const { tierNamed, tierForBand, planPrice, YEARLY_MONTHS, FOUNDING_PERCENT } = require('./tiers'); +const { ymd, day, addDays, addMonths, periodLabel } = require('./dates'); + +const anchorDayOf = licence => day(licence.billingStart || licence.nextInvoiceDate).getUTCDate(); +const monthsIn = cycle => (cycle === 'yearly' ? 12 : 1); + +/** Who an invoice goes to: the customer's billing email, the site's admin contact, the customer's email. */ +function recipientFor(customer, install) { + const contact = install?.adminContact && typeof install.adminContact === 'object' ? install.adminContact.email : null; + return customer?.billingEmail || contact || customer?.email || null; +} + +/** Cleans invoice lines from the form: [{ description, quantity, unitCents, amountCents }]. */ +function cleanLines(lines) { + if (!Array.isArray(lines)) throw Object.assign(new Error('Lines must be a list.'), { status: 400 }); + const out = lines + .filter(l => l && String(l.description || '').trim()) + .map((l, i) => { + const quantity = Number(l.quantity ?? 1); + const unitCents = Math.round(Number(l.unitCents)); + if (!Number.isFinite(quantity) || quantity <= 0 || quantity > 10000) throw Object.assign(new Error(`Line ${i + 1}: enter a quantity above 0.`), { status: 400 }); + if (!Number.isInteger(unitCents) || Math.abs(unitCents) > 1e9) throw Object.assign(new Error(`Line ${i + 1}: enter a price.`), { status: 400 }); + return { description: String(l.description).trim().slice(0, 300), quantity, unitCents, amountCents: Math.round(quantity * unitCents) }; + }); + if (out.length > 50) throw Object.assign(new Error('An invoice can have at most 50 lines.'), { status: 400 }); + return out; +} + +/** Total and VAT included in it. `settings.vat_registered` 'true' → VAT-inclusive at vat_rate %. */ +function totals(lines, settings = {}) { + const totalCents = lines.reduce((s, l) => s + l.amountCents, 0); + const registered = String(settings.vat_registered) === 'true'; + const rate = registered ? (parseFloat(settings.vat_rate) || 15) / 100 : null; + const vatCents = registered && totalCents > 0 ? Math.round((totalCents * rate) / (1 + rate)) : 0; + return { totalCents, vatCents, vatRate: rate }; +} + +/** + * The lines for a plan over a period: the tier's price (×10 for a year) and, for a founding + * church, the founding discount as its own line. Returns { lines, warning? }. + */ +function planLines({ plan, customMonthlyCents, cycle, periodStart, periodEnd, founding }) { + const price = planPrice(plan, customMonthlyCents); + if (!price) return { lines: [], warning: `There's no price for the "${plan}" plan. Set a custom monthly price on the licence, or add the lines yourself.` }; + const months = cycle === 'yearly' ? YEARLY_MONTHS : 1; + const label = periodLabel(periodStart, periodEnd); + const what = tierNamed(plan) ? `${tierNamed(plan).name} tier` : plan; + const lines = [{ + description: cycle === 'yearly' + ? `Cross Events licence: ${what}, 12 months (${label}), paid yearly: 2 months free` + : `Cross Events licence: ${what}, ${label}`, + quantity: months, unitCents: price.monthlyCents, amountCents: months * price.monthlyCents, + }]; + if (founding && price.foundingCents < price.monthlyCents) { + const off = price.foundingCents - price.monthlyCents; + lines.push({ description: `Founding church discount (${FOUNDING_PERCENT}%)`, quantity: months, unitCents: off, amountCents: months * off }); + } + return { lines }; +} + +/** + * The draft invoice for a licence's next period (licence.nextInvoiceDate), and how the licence + * moves on. `install` gives the income band (stats.income.eventIncome12m) and admin contact. + * Returns { invoice, licenceUpdate }. + */ +function draftInvoice({ licence, customer, install, settings = {} }) { + const cycle = licence.billingCycle; + if (cycle !== 'monthly' && cycle !== 'yearly') throw new Error('This licence isn\'t billed.'); + const anchor = anchorDayOf(licence); + const periodStart = day(licence.nextInvoiceDate); + const nextStart = addMonths(periodStart, monthsIn(cycle), anchor); + const periodEnd = addDays(nextStart, -1); + const warnings = []; + + // The yearly review: the first period starting on or after reviewDate. + const reviewed = !!licence.reviewDate && ymd(periodStart) >= ymd(licence.reviewDate); + const band = install?.stats?.income?.eventIncome12m || null; + let plan = licence.plan; + let planChange = null; + if (reviewed) { + const tier = tierForBand(band); + if (!tier) { + warnings.push(band + ? `The site reports its income as "${band}", which can't be placed in a tier (Cross Events before 2.0.2). The tier stays ${plan}.` + : `The site hasn't reported its 12-month event income (it needs Cross Events 2.0.2 or later). The tier stays ${plan}.`); + } else if (licence.customMonthlyCents != null) { + if (tier.name !== plan) warnings.push(`Its income (${band}) is in the ${tier.name} tier, but the licence has a custom price, so it's kept. Change it by hand if needed.`); + } else if (tier.name !== plan) { + const from = planPrice(plan); + const to = planPrice(tier.name); + planChange = { from: plan, to: tier.name, fromMonthlyCents: from?.monthlyCents ?? null, toMonthlyCents: to?.monthlyCents ?? null, band }; + plan = tier.name; + } + } + + const { lines, warning } = planLines({ plan, customMonthlyCents: licence.customMonthlyCents, cycle, periodStart, periodEnd, founding: !!customer.founding }); + if (warning) warnings.push(warning); + const recipientEmail = recipientFor(customer, install); + if (!recipientEmail) warnings.push('No email address to send it to: add a billing email on the customer.'); + const terms = parseInt(settings.payment_terms_days, 10); + + let reviewDate = licence.reviewDate ? day(licence.reviewDate) : null; + if (reviewed) while (ymd(reviewDate) <= ymd(periodStart)) reviewDate = addMonths(reviewDate, 12); + + return { + invoice: { + customerId: customer.id, + licenceId: licence.id, + issueDate: periodStart, + dueDate: addDays(periodStart, Number.isFinite(terms) && terms >= 0 ? terms : 7), + periodStart, + periodEnd, + cycle, + plan, + incomeBand: reviewed ? band : null, + reviewed, + founding: !!customer.founding, + planChange, + lines, + ...totals(lines, settings), + warnings, + recipientEmail, + }, + licenceUpdate: { nextInvoiceDate: nextStart, reviewDate }, + }; +} + +/** What's been paid on an invoice and where it stands. `todayYmd` decides "overdue". */ +function invoiceState(invoice, todayYmd) { + const paidCents = (invoice.payments || []).reduce((s, p) => s + p.amountCents, 0); + const balanceCents = invoice.totalCents - paidCents; + let state = invoice.status; + if (invoice.status === 'sent') { + if (balanceCents <= 0) state = 'paid'; + else if (ymd(invoice.dueDate) < todayYmd) state = 'overdue'; + else state = paidCents > 0 ? 'part_paid' : 'unpaid'; + } + return { paidCents, balanceCents, state }; +} + +function invoiceDescription(invoice) { + if (invoice.periodStart && invoice.periodEnd) return `Cross Events licence, ${periodLabel(invoice.periodStart, invoice.periodEnd)}`; + return (invoice.lines?.[0]?.description || 'Invoice').slice(0, 80); +} + +/** + * A statement from `from` to `to` (inclusive): the balance brought forward, each sent invoice + * (debit) and payment (credit) in date order with a running balance, the closing balance, and + * invoices still owing. Drafts, approved-but-unsent and void invoices aren't on it. + */ +function statement({ invoices, payments, from, to }) { + const f = ymd(from); + const t = ymd(to); + const live = invoices.filter(i => i.status === 'sent'); + const before = d => ymd(d) < f; + const within = d => ymd(d) >= f && ymd(d) <= t; + const opening = live.filter(i => before(i.issueDate)).reduce((s, i) => s + i.totalCents, 0) + - payments.filter(p => before(p.paidOn)).reduce((s, p) => s + p.amountCents, 0); + const entries = [ + ...live.filter(i => within(i.issueDate)).map(i => ({ date: ymd(i.issueDate), kind: 'invoice', reference: i.number, description: invoiceDescription(i), debitCents: i.totalCents, creditCents: 0 })), + ...payments.filter(p => within(p.paidOn)).map(p => ({ date: ymd(p.paidOn), kind: 'payment', reference: p.receiptNumber, description: `Payment received${p.invoice?.number ? ` for ${p.invoice.number}` : ''}${p.reference ? ` (ref ${p.reference})` : ''}`, debitCents: 0, creditCents: p.amountCents })), + ].sort((a, b) => a.date.localeCompare(b.date) || (a.kind === 'invoice' ? -1 : 1)); + let balance = opening; + for (const e of entries) { + balance += e.debitCents - e.creditCents; + e.balanceCents = balance; + } + // Payments up to the statement date only. + const paidBy = p => ymd(p.paidOn) <= t; + const outstanding = live + .filter(i => ymd(i.issueDate) <= t) + .map(i => ({ ...i, ...invoiceState({ ...i, payments: (i.payments || []).filter(paidBy) }, t) })) + .filter(i => i.balanceCents > 0) + .map(i => ({ number: i.number, issueDate: ymd(i.issueDate), dueDate: ymd(i.dueDate), totalCents: i.totalCents, balanceCents: i.balanceCents, overdue: ymd(i.dueDate) < t })); + return { from: f, to: t, openingCents: opening, entries, closingCents: balance, outstanding }; +} + +module.exports = { recipientFor, cleanLines, totals, planLines, draftInvoice, invoiceState, statement, invoiceDescription, anchorDayOf }; diff --git a/backend/src/utils/billing/dates.js b/backend/src/utils/billing/dates.js new file mode 100644 index 0000000..14d98e8 --- /dev/null +++ b/backend/src/utils/billing/dates.js @@ -0,0 +1,79 @@ +/** + * Calendar dates and money for billing. Date-only columns (@db.Date) come back as JavaScript + * Dates at UTC midnight, so dates here are UTC-midnight Dates and 'YYYY-MM-DD' strings. "Today" + * is today in South Africa. + */ + +const TZ = 'Africa/Johannesburg'; +const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +/** 'YYYY-MM-DD' of a date-only value (a UTC-midnight Date or a string). */ +function ymd(d) { + if (!d) return null; + if (typeof d === 'string') return d.slice(0, 10); + return d.toISOString().slice(0, 10); +} + +/** A date-only value as a UTC-midnight Date. Throws on nonsense. */ +function day(v) { + const s = ymd(v instanceof Date ? v : String(v || '')); + if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s)) throw Object.assign(new Error('Not a valid date.'), { status: 400 }); + const d = new Date(`${s}T00:00:00Z`); + if (Number.isNaN(d.getTime()) || ymd(d) !== s) throw Object.assign(new Error('Not a valid date.'), { status: 400 }); + return d; +} + +/** Today's date in South Africa, as a UTC-midnight Date. */ +function today(now = new Date()) { + return day(new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)); +} + +/** The hour (0–23) in South Africa. */ +function saHour(now = new Date()) { + return Number(new Intl.DateTimeFormat('en-GB', { timeZone: TZ, hour: '2-digit', hourCycle: 'h23' }).format(now)); +} + +const addDays = (d, n) => new Date(day(d).getTime() + n * 864e5); + +/** + * `n` months after `d`, on `anchorDay` (the billing day) or the month's last day when the month + * is shorter: 31 Jan + 1 month on day 31 → 28/29 Feb; then + 1 → 31 Mar. + */ +function addMonths(d, n, anchorDay = day(d).getUTCDate()) { + const base = day(d); + const y = base.getUTCFullYear(); + const m = base.getUTCMonth() + n; + const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); + return new Date(Date.UTC(y, m, Math.min(anchorDay, last))); +} + +/** "1 December 2026". */ +function longDate(d) { + if (!d) return ''; + const x = day(d); + return `${x.getUTCDate()} ${MONTHS[x.getUTCMonth()]} ${x.getUTCFullYear()}`; +} + +/** "15 Dec 2026". */ +function shortDate(d) { + if (!d) return ''; + const x = day(d); + return `${x.getUTCDate()} ${MONTHS[x.getUTCMonth()].slice(0, 3)} ${x.getUTCFullYear()}`; +} + +/** "December 2026" (a monthly period), or "1 December 2026 – 30 November 2027". */ +function periodLabel(start, end) { + const s = day(start); + const e = day(end); + const wholeMonth = s.getUTCDate() === 1 && ymd(addDays(e, 1)) === ymd(addMonths(s, 1, 1)); + return wholeMonth ? `${MONTHS[s.getUTCMonth()]} ${s.getUTCFullYear()}` : `${longDate(s)} – ${longDate(e)}`; +} + +/** R1 350.00 (cents in, South African style with a space between thousands). */ +function money(cents) { + const neg = cents < 0; + const [r, c] = (Math.abs(Math.round(cents)) / 100).toFixed(2).split('.'); + return `${neg ? '-' : ''}R${r.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}.${c}`; +} + +module.exports = { TZ, ymd, day, today, saHour, addDays, addMonths, longDate, shortDate, periodLabel, money }; diff --git a/backend/src/utils/billing/email.js b/backend/src/utils/billing/email.js new file mode 100644 index 0000000..ddec177 --- /dev/null +++ b/backend/src/utils/billing/email.js @@ -0,0 +1,99 @@ +/** + * Billing emails: one styled layout (HTML with a plain-text twin) and a sender that records every + * email in BillingEmail. The logo is embedded (cid:logo); the documents go as PDF attachments. + */ + +const fs = require('fs'); +const path = require('path'); +const prisma = require('../../config/db'); +const { sendMail } = require('../mail'); + +const LOGO = path.join(__dirname, '..', '..', '..', 'assets', 'logo.png'); +let logoBytes = null; +const logo = () => { + if (logoBytes === null) { + try { logoBytes = fs.readFileSync(LOGO); } catch { logoBytes = false; } + } + return logoBytes || null; +}; + +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; + +/** + * The email. `paragraphs` are plain text (escaped); `rows` a summary table [[label, value, bold?]]; + * `note` a highlighted box { title, text }; `button` { label, url }. + */ +function layout(settings, { preheader = '', heading, greeting, paragraphs = [], rows = [], note, button, signoff = true }) { + const s = settings || {}; + const business = s.business_name || 'Cross Code'; + const footer = [business, s.business_website, s.business_email, s.business_phone].filter(Boolean).map(esc).join('  ·  '); + const p = t => `

${esc(t)}

`; + const html = `${esc(heading)} + +
${esc(preheader)}
+
+ + + + + +
+ + ${logo() ? '' : ''} + +
${esc(business.split(' ')[0])} ${esc(business.split(' ').slice(1).join(' '))}
+
 
+

${esc(heading)}

+ ${greeting ? p(greeting) : ''} + ${paragraphs.map(p).join('')} + ${rows.length ? ` + ${rows.map(([k, v, bold], i) => ``).join('')} +
${esc(k)}${esc(v)}
` : ''} + ${note ? `
${note.title ? `${esc(note.title)}
` : ''}${esc(note.text).replace(/\n/g, '
')}
` : ''} + ${button ? `
${esc(button.label)}
` : ''} + ${signoff ? p(`Kind regards,\n${s.business_owner || ''}${s.business_owner ? ', ' : ''}${business}`).replace('\n', '
') : ''} +
${footer}
+
+`; + + const text = [ + heading, + '', + greeting, + ...paragraphs, + rows.length ? rows.map(([k, v]) => `${k}: ${v}`).join('\n') : null, + note ? `${note.title ? `${note.title}\n` : ''}${note.text}` : null, + button ? `${button.label}: ${button.url}` : null, + signoff ? `Kind regards,\n${s.business_owner ? `${s.business_owner}, ` : ''}${business}` : null, + '', + [business, s.business_website, s.business_email, s.business_phone].filter(Boolean).join(' · '), + ].filter(x => x != null).join('\n\n').replace(/\n{3,}/g, '\n\n'); + return { html, text }; +} + +/** + * Sends a billing email and records it. Returns { ok, error }; never throws for a mail failure + * (the caller decides what to do). + */ +async function sendBillingEmail(settings, { kind, customerId = null, refId = null, to, subject, content, pdfs = [] }) { + const attachments = [ + ...(logo() ? [{ filename: 'logo.png', content: logo(), contentType: 'image/png', cid: 'logo' }] : []), + ...pdfs.map(f => ({ filename: f.filename, content: f.content, contentType: 'application/pdf' })), + ]; + let error = null; + try { + if (!to) throw new Error('No email address.'); + await sendMail({ + to, subject, text: content.text, html: content.html, attachments, + replyTo: settings.billing_reply_to || settings.business_email || undefined, + fromName: settings.business_name || 'Cross Code', + }); + } catch (e) { + error = e?.message || String(e); + } + await prisma.billingEmail.create({ data: { kind, customerId, refId, to: to || '(none)', subject, error } }).catch(e => console.warn('[billing] could not log email:', e?.message || e)); + return { ok: !error, error }; +} + +module.exports = { layout, sendBillingEmail, esc }; diff --git a/backend/src/utils/billing/index.js b/backend/src/utils/billing/index.js new file mode 100644 index 0000000..6e6321b --- /dev/null +++ b/backend/src/utils/billing/index.js @@ -0,0 +1,555 @@ +/** + * Billing (1.7.0): invoices drafted automatically, approved by Joshua, sent on their date; + * payments with receipts; statements; notices of licence change. Calculations are in compute.js, + * PDFs in pdf.js, emails in email.js. + * + * The job (runBilling, every 15 minutes): + * 1. Drafts: for each billed, active licence whose next period starts within billing_draft_days, + * make the draft invoice and email it to alert_email for approval (the licence moves on to + * the next period at once, so a period is drafted once). + * 2. Sends approved invoices on their date, from billing_send_hour (South African time). + * 3. Emails Joshua once when an invoice's date has come and it still isn't approved. + * 4. Applies tier changes on their effective date (the licence's plan). + */ + +const { Prisma } = require('@prisma/client'); +const prisma = require('../../config/db'); +const { getSettings } = require('../settings'); +const { sendMail } = require('../mail'); +const compute = require('./compute'); +const pdf = require('./pdf'); +const { layout, sendBillingEmail } = require('./email'); +const { ymd, day, today, saHour, addDays, longDate, periodLabel, money } = require('./dates'); +const { planPrice, tierNamed } = require('./tiers'); + +const bad = (message, status = 400) => Object.assign(new Error(message), { status }); +const hubLink = p => `${(process.env.FRONTEND_URL || '').replace(/\/$/, '')}${p}`; +const EDITABLE = ['draft', 'approved']; + +/** The next number for a prefix in a year: INV-2026-0001. Atomic (one row per prefix and year). */ +async function nextNumber(prefix, date = new Date(), db = prisma) { + const year = ymd(date).slice(0, 4); + const key = `${prefix}-${year}`; + const row = await db.documentCounter.upsert({ where: { key }, create: { key, value: 1 }, update: { value: { increment: 1 } } }); + return `${key}-${String(row.value).padStart(4, '0')}`; +} + +const invoiceInclude = { customer: true, payments: { orderBy: { paidOn: 'asc' } }, licence: { include: { install: true } } }; + +async function loadInvoice(id) { + const invoice = await prisma.invoice.findUnique({ where: { id }, include: invoiceInclude }); + if (!invoice) throw bad('Invoice not found', 404); + return invoice; +} + +async function invoicePdfFor(invoice, settings) { + const state = compute.invoiceState(invoice, ymd(today())); + return pdf.invoicePdf({ invoice, customer: invoice.customer, settings, state }); +} + +// ─── Drafting ──────────────────────────────────────────────────────────────── + +/** Makes the draft for one licence's next period. Returns the invoice, or null if it already exists. */ +async function draftForLicence(licenceId, settings) { + return prisma.$transaction(async (tx) => { + const licence = await tx.licence.findUnique({ where: { id: licenceId }, include: { customer: true, install: true } }); + if (!licence || !licence.nextInvoiceDate) return null; + const { invoice, licenceUpdate } = compute.draftInvoice({ licence, customer: licence.customer, install: licence.install, settings }); + await tx.licence.update({ where: { id: licence.id }, data: licenceUpdate }); + const exists = await tx.invoice.findFirst({ where: { licenceId: licence.id, periodStart: invoice.periodStart } }); + if (exists) return null; + const number = await nextNumber(settings.invoice_prefix || 'INV', invoice.issueDate, tx); + return tx.invoice.create({ data: { ...invoice, number, planChange: invoice.planChange ?? undefined }, include: invoiceInclude }); + }); +} + +async function emailApproval(invoice, settings) { + const to = settings.alert_email; + if (!to) return; + const content = layout(settings, { + preheader: `${invoice.customer.name}: ${money(invoice.totalCents)}, goes out ${longDate(invoice.issueDate)}`, + heading: `Invoice ${invoice.number} is ready for approval`, + paragraphs: [ + `The draft for ${invoice.customer.name}${invoice.periodStart ? ` (${periodLabel(invoice.periodStart, invoice.periodEnd)})` : ''} is attached. Once you approve it, it goes out to ${invoice.recipientEmail || '(no email address yet)'} on ${longDate(invoice.issueDate)}.`, + ], + rows: [ + ['Church', invoice.customer.name], + ['Tier', `${invoice.plan || '—'}${invoice.founding ? ' (founding)' : ''}`], + ...(invoice.reviewed ? [['12-month event income', invoice.incomeBand || 'not reported']] : []), + ...(invoice.planChange ? [['Tier change', `${invoice.planChange.from} → ${invoice.planChange.to}`, true]] : []), + ['Total', money(invoice.totalCents), true], + ['Goes out', longDate(invoice.issueDate)], + ], + note: invoice.warnings?.length ? { title: 'Check before approving', text: invoice.warnings.join('\n') } : invoice.planChange ? { title: 'Tier change', text: 'Approving also sends the church a notice of licence change straight away.' } : null, + button: { label: 'Review and approve', url: hubLink(`/billing/invoices/${invoice.id}`) }, + signoff: false, + }); + const res = await sendBillingEmail(settings, { + kind: 'approval', customerId: invoice.customerId, refId: invoice.id, to, + subject: `Approve invoice ${invoice.number} for ${invoice.customer.name}: ${money(invoice.totalCents)}`, + content, pdfs: [{ filename: `${invoice.number} (draft).pdf`, content: await invoicePdfFor(invoice, settings) }], + }); + if (res.ok) await prisma.invoice.update({ where: { id: invoice.id }, data: { approvalEmailedAt: new Date() } }); +} + +/** Drafts every invoice that's due within billing_draft_days. Returns how many. */ +async function createDueDrafts(now, settings) { + const days = parseInt(settings.billing_draft_days, 10) || 14; + const horizon = addDays(today(now), days); + const licences = await prisma.licence.findMany({ + where: { status: 'active', billingCycle: { not: 'none' }, nextInvoiceDate: { lte: horizon } }, + select: { id: true }, + }); + let made = 0; + for (const { id } of licences) { + // Catch up a licence that's behind (at most a year's worth in one run). + for (let i = 0; i < 12; i++) { + const invoice = await draftForLicence(id, settings); + if (!invoice) break; + made += 1; + await emailApproval(invoice, settings).catch(e => console.warn('[billing] approval email failed:', e?.message || e)); + const lic = await prisma.licence.findUnique({ where: { id }, select: { nextInvoiceDate: true } }); + if (!lic?.nextInvoiceDate || ymd(lic.nextInvoiceDate) > ymd(horizon)) break; + } + } + return made; +} + +// ─── Sending ───────────────────────────────────────────────────────────────── + +async function sendInvoiceEmail(invoice, settings, { copy = false } = {}) { + const state = compute.invoiceState(invoice, ymd(today())); + const tax = String(settings.vat_registered) === 'true'; + const title = tax ? 'Tax invoice' : 'Invoice'; + const content = layout(settings, { + preheader: `${title} ${invoice.number}: ${money(state.balanceCents)} due ${longDate(invoice.dueDate)}`, + heading: `${title} ${invoice.number}${copy ? ' (copy)' : ''}`, + greeting: `Hi ${invoice.customer.contactName || invoice.customer.name},`, + paragraphs: [ + `Here is your ${title.toLowerCase()} for Cross Events${invoice.periodStart ? `, ${periodLabel(invoice.periodStart, invoice.periodEnd)}` : ''}. The PDF is attached.`, + 'Please pay by EFT using the invoice number as the reference. The banking details are on the invoice.', + ], + rows: [ + ['Invoice', invoice.number], + ...(invoice.periodStart ? [['Period', periodLabel(invoice.periodStart, invoice.periodEnd)]] : []), + ...(state.paidCents ? [['Total', money(invoice.totalCents)], ['Paid', money(state.paidCents)]] : []), + [state.paidCents ? 'Balance due' : 'Amount due', money(state.balanceCents), true], + ['Due by', longDate(invoice.dueDate), true], + ], + note: invoice.planChange ? { title: `Your tier is now ${invoice.planChange.to}`, text: `Your annual review moved your licence from the ${invoice.planChange.from} tier to the ${invoice.planChange.to} tier, based on your event income over the last 12 months.` } : null, + }); + return sendBillingEmail(settings, { + kind: 'invoice', customerId: invoice.customerId, refId: invoice.id, to: invoice.recipientEmail, + subject: `${title} ${invoice.number} from ${settings.business_name || 'Cross Code'}: ${money(state.balanceCents)} due ${longDate(invoice.dueDate)}`, + content, pdfs: [{ filename: `${invoice.number}.pdf`, content: await pdf.invoicePdf({ invoice: { ...invoice, status: 'sent' }, customer: invoice.customer, settings, state }) }], + }); +} + +/** Sends an approved invoice now. Marks it sent, or records why it couldn't go. */ +async function sendInvoice(id, settings, now = new Date()) { + const invoice = await loadInvoice(id); + if (invoice.status !== 'approved') throw bad('Only an approved invoice can be sent.', 409); + if (!invoice.recipientEmail) throw bad('There\'s no email address to send it to.', 409); + // Claim it so two runs never both send it. + const claimed = await prisma.invoice.updateMany({ where: { id, status: 'approved' }, data: { status: 'sent', sentAt: now, sendError: null } }); + if (claimed.count !== 1) throw bad('It was sent already.', 409); + const res = await sendInvoiceEmail({ ...invoice, status: 'sent' }, settings); + if (!res.ok) { + await prisma.invoice.update({ where: { id }, data: { status: 'approved', sentAt: null, sendError: res.error } }); + if (!invoice.sendError && settings.alert_email) { + await sendMail({ to: settings.alert_email, subject: `Invoice ${invoice.number} couldn't be sent`, text: `Invoice ${invoice.number} for ${invoice.customer.name} couldn't be emailed to ${invoice.recipientEmail}: ${res.error}\n\nIt will be tried again. ${hubLink(`/billing/invoices/${id}`)}` }).catch(() => {}); + } + throw bad(`It couldn't be emailed: ${res.error}`, 502); + } + await applyDueLicenceChanges(now); + return loadInvoice(id); +} + +async function sendDueInvoices(now, settings) { + if (saHour(now) < (parseInt(settings.billing_send_hour, 10) || 0)) return 0; + const due = await prisma.invoice.findMany({ where: { status: 'approved', issueDate: { lte: today(now) } }, select: { id: true } }); + let sent = 0; + for (const { id } of due) { + try { + await sendInvoice(id, settings, now); + sent += 1; + } catch (e) { + console.warn(`[billing] invoice ${id} not sent:`, e?.message || e); + } + } + return sent; +} + +/** Emails Joshua once about drafts whose date has come without approval. */ +async function remindUnapproved(now, settings) { + if (!settings.alert_email) return 0; + const late = await prisma.invoice.findMany({ where: { status: 'draft', issueDate: { lte: today(now) }, overdueEmailedAt: null }, include: { customer: true } }); + if (!late.length) return 0; + const content = layout(settings, { + heading: late.length === 1 ? 'An invoice is waiting for your approval' : `${late.length} invoices are waiting for your approval`, + paragraphs: ['Their date has come, but they won\'t go out until you approve them.'], + rows: late.map(i => [`${i.number}, ${i.customer.name}`, `${money(i.totalCents)}, ${longDate(i.issueDate)}`]), + button: { label: 'Open billing', url: hubLink('/billing') }, + signoff: false, + }); + const res = await sendBillingEmail(settings, { kind: 'reminder', to: settings.alert_email, subject: `${late.length} invoice${late.length === 1 ? '' : 's'} waiting for approval`, content }); + if (res.ok) await prisma.invoice.updateMany({ where: { id: { in: late.map(i => i.id) } }, data: { overdueEmailedAt: now } }); + return late.length; +} + +/** Puts tier changes into effect on their date (once the invoice that brings one has gone out). */ +async function applyDueLicenceChanges(now = new Date()) { + const due = await prisma.licenceChange.findMany({ + where: { appliedAt: null, effectiveDate: { lte: today(now) }, licenceId: { not: null }, OR: [{ invoiceId: null }, { invoice: { is: { status: 'sent' } } }] }, + }); + for (const c of due) { + const tier = tierNamed(c.toPlan); + const data = { plan: c.toPlan }; + // A custom price comes with a change to an unpriced plan; a priced tier uses its own price. + if (tier?.monthly != null) data.customMonthlyCents = null; + else if (c.toMonthlyCents != null) data.customMonthlyCents = c.toMonthlyCents; + await prisma.$transaction([ + prisma.licence.update({ where: { id: c.licenceId }, data }), + prisma.licenceChange.update({ where: { id: c.id }, data: { appliedAt: now } }), + ]); + } + return due.length; +} + +/** The background job. */ +async function runBilling(now = new Date()) { + const settings = await getSettings(); + if (String(settings.billing_enabled) !== 'true') return { drafted: 0, sent: 0, reminded: 0, applied: 0 }; + const drafted = await createDueDrafts(now, settings); + const sent = await sendDueInvoices(now, settings); + const reminded = await remindUnapproved(now, settings); + const applied = await applyDueLicenceChanges(now); + return { drafted, sent, reminded, applied }; +} + +// ─── Invoice actions ───────────────────────────────────────────────────────── + +/** Approves a draft. A tier change with it sends the church its notice now. */ +async function approveInvoice(id, actorId) { + const settings = await getSettings(); + const invoice = await loadInvoice(id); + if (invoice.status !== 'draft') throw bad('Only a draft can be approved.', 409); + if (!invoice.recipientEmail) throw bad('Add an email address to send it to first.', 409); + if (!invoice.lines?.length) throw bad('Add at least one line first.', 409); + await prisma.invoice.update({ where: { id }, data: { status: 'approved', approvedAt: new Date(), approvedById: actorId || null } }); + let notice = null; + if (invoice.planChange && invoice.licenceId) { + const existing = await prisma.licenceChange.findFirst({ where: { invoiceId: id } }); + if (!existing) { + const pc = invoice.planChange; + notice = await createLicenceChange({ + customerId: invoice.customerId, licenceId: invoice.licenceId, invoiceId: id, + fromPlan: pc.from, toPlan: pc.to, fromMonthlyCents: pc.fromMonthlyCents, toMonthlyCents: pc.toMonthlyCents, + effectiveDate: invoice.periodStart || invoice.issueDate, incomeBand: pc.band || invoice.incomeBand, + reason: 'Your annual tier review.', + }, settings); + } + } + return { invoice: await loadInvoice(id), notice }; +} + +async function unapproveInvoice(id) { + const res = await prisma.invoice.updateMany({ where: { id, status: 'approved' }, data: { status: 'draft', approvedAt: null, approvedById: null } }); + if (res.count !== 1) throw bad('Only an approved invoice that hasn\'t gone out can go back to draft.', 409); + return loadInvoice(id); +} + +/** Changes a draft or approved invoice: dates, lines, notes, recipient, the tier change. */ +async function updateInvoice(id, body) { + const settings = await getSettings(); + const invoice = await loadInvoice(id); + if (!EDITABLE.includes(invoice.status)) throw bad('A sent or void invoice can\'t be changed. Void it and make a new one instead.', 409); + const data = {}; + if (body.issueDate !== undefined) data.issueDate = day(body.issueDate); + if (body.dueDate !== undefined) data.dueDate = day(body.dueDate); + const issue = data.issueDate || invoice.issueDate; + const due = data.dueDate || invoice.dueDate; + if (ymd(due) < ymd(issue)) throw bad('The due date can\'t be before the invoice date.'); + if (body.lines !== undefined) { + const lines = compute.cleanLines(body.lines); + Object.assign(data, { lines }, compute.totals(lines, settings)); + } + if (body.notes !== undefined) data.notes = body.notes ? String(body.notes).slice(0, 2000) : null; + if (body.recipientEmail !== undefined) { + const e = String(body.recipientEmail || '').trim(); + if (e && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) throw bad('That email address doesn\'t look right.'); + data.recipientEmail = e || null; + } + if (body.dropPlanChange === true) { + if (await prisma.licenceChange.findFirst({ where: { invoiceId: id, sentAt: { not: null } } })) throw bad('The church has already been sent the notice of this tier change.', 409); + data.planChange = Prisma.DbNull; + data.plan = invoice.licence?.plan || invoice.plan; + } + // Editing an approved invoice keeps it approved; editing clears the "waiting" reminder. + data.overdueEmailedAt = null; + await prisma.invoice.update({ where: { id }, data }); + return loadInvoice(id); +} + +/** Rebuilds a draft's lines from the licence as it is now (e.g. after changing founding or the price). */ +async function recalculateInvoice(id) { + const settings = await getSettings(); + const invoice = await loadInvoice(id); + if (invoice.status !== 'draft') throw bad('Only a draft can be recalculated.', 409); + if (!invoice.licence || !invoice.periodStart) throw bad('Only an invoice for a licence\'s period can be recalculated.', 409); + const plan = invoice.planChange?.to || invoice.plan || invoice.licence.plan; + const { lines, warning } = compute.planLines({ + plan, customMonthlyCents: invoice.licence.customMonthlyCents, cycle: invoice.cycle, + periodStart: invoice.periodStart, periodEnd: invoice.periodEnd, founding: !!invoice.customer.founding, + }); + const recipientEmail = invoice.recipientEmail || compute.recipientFor(invoice.customer, invoice.licence.install); + await prisma.invoice.update({ + where: { id }, + data: { lines, ...compute.totals(lines, settings), founding: !!invoice.customer.founding, plan, recipientEmail, warnings: warning ? [warning] : [] }, + }); + return loadInvoice(id); +} + +async function voidInvoice(id, reason) { + const invoice = await loadInvoice(id); + if (invoice.status === 'void') throw bad('It\'s already void.', 409); + if (invoice.payments.length) throw bad('It has payments recorded against it. Remove them (or move them) first.', 409); + await prisma.invoice.update({ where: { id }, data: { status: 'void', voidedAt: new Date(), voidReason: reason ? String(reason).slice(0, 500) : null } }); + // A tier change that hasn't happened yet goes with it. + await prisma.licenceChange.deleteMany({ where: { invoiceId: id, appliedAt: null, sentAt: null } }); + return loadInvoice(id); +} + +async function resendInvoice(id) { + const settings = await getSettings(); + const invoice = await loadInvoice(id); + if (invoice.status !== 'sent') throw bad('Only a sent invoice can be sent again.', 409); + const res = await sendInvoiceEmail(invoice, settings, { copy: true }); + if (!res.ok) throw bad(`It couldn't be emailed: ${res.error}`, 502); + return invoice; +} + +/** A one-off invoice (e.g. standby support), as a draft. */ +async function createManualInvoice(body) { + const settings = await getSettings(); + const customer = await prisma.customer.findUnique({ where: { id: String(body.customerId || '') }, include: { installs: { where: { releasedAt: null }, take: 1 } } }); + if (!customer) throw bad('Customer not found', 404); + const lines = compute.cleanLines(body.lines || []); + if (!lines.length) throw bad('Add at least one line.'); + const issueDate = body.issueDate ? day(body.issueDate) : today(); + const terms = parseInt(settings.payment_terms_days, 10); + const dueDate = body.dueDate ? day(body.dueDate) : addDays(issueDate, Number.isFinite(terms) ? terms : 7); + const number = await nextNumber(settings.invoice_prefix || 'INV', issueDate); + return prisma.invoice.create({ + data: { + number, customerId: customer.id, licenceId: body.licenceId || null, status: 'draft', issueDate, dueDate, + cycle: 'none', lines, ...compute.totals(lines, settings), notes: body.notes || null, founding: customer.founding, + recipientEmail: body.recipientEmail || compute.recipientFor(customer, customer.installs[0]), + }, + include: invoiceInclude, + }); +} + +// ─── Payments and receipts ─────────────────────────────────────────────────── + +async function sendReceipt(paymentId, settings) { + const payment = await prisma.payment.findUnique({ where: { id: paymentId }, include: { customer: { include: { installs: { where: { releasedAt: null }, take: 1 } } }, invoice: { include: { payments: true } } } }); + if (!payment) throw bad('Payment not found', 404); + const to = payment.invoice?.recipientEmail || compute.recipientFor(payment.customer, payment.customer.installs[0]); + const state = payment.invoice ? compute.invoiceState(payment.invoice, ymd(today())) : null; + const content = layout(settings, { + preheader: `We received ${money(payment.amountCents)}. Thank you!`, + heading: 'Thank you for your payment', + greeting: `Hi ${payment.customer.contactName || payment.customer.name},`, + paragraphs: [`We received your payment of ${money(payment.amountCents)} on ${longDate(payment.paidOn)}. Your receipt is attached.`], + rows: [ + ['Receipt', payment.receiptNumber], + ['Amount', money(payment.amountCents), true], + ...(payment.invoice ? [['For invoice', payment.invoice.number], ['Still owing on it', money(Math.max(0, state.balanceCents)), true]] : []), + ], + note: state && state.balanceCents <= 0 ? { title: 'Paid in full', text: `Invoice ${payment.invoice.number} is now paid in full.` } : null, + }); + const res = await sendBillingEmail(settings, { + kind: 'receipt', customerId: payment.customerId, refId: payment.id, to, + subject: `Receipt ${payment.receiptNumber} from ${settings.business_name || 'Cross Code'}: ${money(payment.amountCents)} received`, + content, pdfs: [{ filename: `${payment.receiptNumber}.pdf`, content: await pdf.receiptPdf({ payment, customer: payment.customer, invoice: payment.invoice, state, settings, recipientEmail: to }) }], + }); + if (res.ok) await prisma.payment.update({ where: { id: paymentId }, data: { receiptSentAt: new Date() } }); + return res; +} + +/** Records a payment (and emails the receipt unless sendReceipt is false). */ +async function recordPayment(body, actorId) { + const settings = await getSettings(); + const customer = await prisma.customer.findUnique({ where: { id: String(body.customerId || '') } }); + if (!customer) throw bad('Customer not found', 404); + const amountCents = Math.round(Number(body.amountCents)); + if (!Number.isInteger(amountCents) || amountCents <= 0 || amountCents > 1e9) throw bad('Enter the amount received.'); + let invoiceId = null; + if (body.invoiceId) { + const inv = await prisma.invoice.findUnique({ where: { id: String(body.invoiceId) } }); + if (!inv || inv.customerId !== customer.id) throw bad('That invoice isn\'t this customer\'s.'); + if (inv.status === 'void') throw bad('That invoice is void.'); + invoiceId = inv.id; + } + const method = ['eft', 'card', 'cash', 'other'].includes(body.method) ? body.method : 'eft'; + const paidOn = body.paidOn ? day(body.paidOn) : today(); + const receiptNumber = await nextNumber(settings.receipt_prefix || 'REC', paidOn); + const payment = await prisma.payment.create({ + data: { receiptNumber, customerId: customer.id, invoiceId, amountCents, paidOn, method, reference: body.reference ? String(body.reference).slice(0, 200) : null, notes: body.notes ? String(body.notes).slice(0, 1000) : null, createdById: actorId || null }, + }); + let receipt = null; + if (body.sendReceipt !== false) receipt = await sendReceipt(payment.id, settings); + return { payment: await prisma.payment.findUnique({ where: { id: payment.id }, include: { invoice: true } }), receipt }; +} + +async function deletePayment(id) { + const payment = await prisma.payment.findUnique({ where: { id } }); + if (!payment) throw bad('Payment not found', 404); + await prisma.payment.delete({ where: { id } }); + return payment; +} + +async function resendReceipt(id) { + const res = await sendReceipt(id, await getSettings()); + if (!res.ok) throw bad(`It couldn't be emailed: ${res.error}`, 502); + return res; +} + +async function receiptPdfFor(id) { + const settings = await getSettings(); + const payment = await prisma.payment.findUnique({ where: { id }, include: { customer: { include: { installs: { where: { releasedAt: null }, take: 1 } } }, invoice: { include: { payments: true } } } }); + if (!payment) throw bad('Payment not found', 404); + const state = payment.invoice ? compute.invoiceState(payment.invoice, ymd(today())) : null; + return { filename: `${payment.receiptNumber}.pdf`, content: await pdf.receiptPdf({ payment, customer: payment.customer, invoice: payment.invoice, state, settings, recipientEmail: payment.invoice?.recipientEmail || compute.recipientFor(payment.customer, payment.customer.installs[0]) }) }; +} + +// ─── Statements ────────────────────────────────────────────────────────────── + +async function customerStatement(customerId, from, to) { + const customer = await prisma.customer.findUnique({ where: { id: customerId }, include: { installs: { where: { releasedAt: null }, take: 1 } } }); + if (!customer) throw bad('Customer not found', 404); + const t = to ? day(to) : today(); + const f = from ? day(from) : addDays(t, -365); + if (ymd(f) > ymd(t)) throw bad('The start date is after the end date.'); + const [invoices, payments] = await Promise.all([ + prisma.invoice.findMany({ where: { customerId, status: 'sent' }, include: { payments: true } }), + prisma.payment.findMany({ where: { customerId }, include: { invoice: { select: { number: true } } } }), + ]); + return { customer, recipientEmail: compute.recipientFor(customer, customer.installs[0]), statement: compute.statement({ invoices, payments, from: f, to: t }) }; +} + +async function statementPdfFor(customerId, from, to) { + const settings = await getSettings(); + const { customer, recipientEmail, statement } = await customerStatement(customerId, from, to); + return { filename: `Statement ${customer.name} ${statement.to}.pdf`, content: await pdf.statementPdf({ statement, customer, settings, recipientEmail }) }; +} + +async function emailStatement(customerId, { from, to, email } = {}) { + const settings = await getSettings(); + const { customer, recipientEmail, statement } = await customerStatement(customerId, from, to); + const sendTo = email || recipientEmail; + const content = layout(settings, { + preheader: `Balance ${money(statement.closingCents)} on ${longDate(statement.to)}`, + heading: 'Your Cross Events statement', + greeting: `Hi ${customer.contactName || customer.name},`, + paragraphs: [`Here is your statement from ${longDate(statement.from)} to ${longDate(statement.to)}. The PDF is attached.`], + rows: [ + ['Balance brought forward', money(statement.openingCents)], + [statement.closingCents > 0 ? 'Amount owing' : statement.closingCents < 0 ? 'In credit' : 'Balance', money(Math.abs(statement.closingCents)), true], + ...(statement.outstanding.length ? [['Invoices still owing', statement.outstanding.map(o => o.number).join(', ')]] : []), + ], + note: statement.closingCents <= 0 ? { title: 'All paid up', text: 'Nothing is owing. Thank you!' } : null, + }); + const res = await sendBillingEmail(settings, { + kind: 'statement', customerId, to: sendTo, + subject: `Statement from ${settings.business_name || 'Cross Code'}: ${statement.closingCents > 0 ? `${money(statement.closingCents)} owing` : 'all paid up'}`, + content, pdfs: [{ filename: `Statement ${statement.to}.pdf`, content: await pdf.statementPdf({ statement, customer, settings, recipientEmail: sendTo }) }], + }); + if (!res.ok) throw bad(`It couldn't be emailed: ${res.error}`, 502); + return { to: sendTo, statement }; +} + +// ─── Licence changes ───────────────────────────────────────────────────────── + +async function sendLicenceChange(change, settings) { + const customer = await prisma.customer.findUnique({ where: { id: change.customerId }, include: { installs: { where: { releasedAt: null }, take: 1 } } }); + const licence = change.licenceId ? await prisma.licence.findUnique({ where: { id: change.licenceId } }) : null; + const to = compute.recipientFor(customer, customer.installs[0]); + const up = change.fromMonthlyCents != null && change.toMonthlyCents != null ? change.toMonthlyCents > change.fromMonthlyCents : null; + const content = layout(settings, { + preheader: `From ${longDate(change.effectiveDate)}: the ${change.toPlan} tier`, + heading: up === false ? `Good news: your tier moves down to ${change.toPlan}` : `Your Cross Events tier changes to ${change.toPlan}`, + greeting: `Hi ${customer.contactName || customer.name},`, + paragraphs: [ + `From ${longDate(change.effectiveDate)}, your Cross Events licence moves from the ${change.fromPlan} tier to the ${change.toPlan} tier. The notice is attached.`, + ...(change.reason ? [change.reason] : []), + 'Every feature stays included, whatever your tier. If you have any questions, just reply to this email.', + ], + rows: [ + ['Now', `${change.fromPlan}${change.fromMonthlyCents != null ? `, ${money(change.fromMonthlyCents)} a month` : ''}`], + [`From ${longDate(change.effectiveDate)}`, `${change.toPlan}${change.toMonthlyCents != null ? `, ${money(change.toMonthlyCents)} a month` : ''}`, true], + ...(change.incomeBand ? [['12-month event income', change.incomeBand]] : []), + ], + note: customer.founding ? { title: 'Founding church', text: 'Your 35% founding discount carries on in your new tier.' } : null, + }); + const res = await sendBillingEmail(settings, { + kind: 'licence_change', customerId: customer.id, refId: change.id, to, + subject: `Notice of licence change: ${change.fromPlan} to ${change.toPlan} from ${longDate(change.effectiveDate)}`, + content, pdfs: [{ filename: `${change.number}.pdf`, content: await pdf.licenceChangePdf({ change, customer, settings, founding: customer.founding, recipientEmail: to, cycle: licence?.billingCycle }) }], + }); + if (res.ok) await prisma.licenceChange.update({ where: { id: change.id }, data: { sentAt: new Date() } }); + return res; +} + +/** + * A notice of licence change, sent now (`send` false to only record it). It takes effect on + * effectiveDate (the job changes the licence's plan then; at once if that's today or earlier). + */ +async function createLicenceChange(body, settings = null, { send = true } = {}) { + settings = settings || await getSettings(); + const licence = await prisma.licence.findUnique({ where: { id: String(body.licenceId || '') } }); + if (!licence) throw bad('Licence not found', 404); + const toPlan = String(body.toPlan || '').trim(); + if (!toPlan) throw bad('Choose the new tier.'); + const fromPlan = body.fromPlan || licence.plan; + if (toPlan === fromPlan && body.toMonthlyCents == null) throw bad('That\'s the tier it\'s on already.'); + const priceOf = (plan, custom) => (custom != null ? Math.round(Number(custom)) : planPrice(plan)?.monthlyCents ?? null); + const effectiveDate = body.effectiveDate ? day(body.effectiveDate) : licence.nextInvoiceDate ? day(licence.nextInvoiceDate) : today(); + const number = await nextNumber(settings.change_prefix || 'LC', new Date()); + const change = await prisma.licenceChange.create({ + data: { + number, customerId: licence.customerId, licenceId: licence.id, invoiceId: body.invoiceId || null, + fromPlan, toPlan, + fromMonthlyCents: body.fromMonthlyCents !== undefined ? body.fromMonthlyCents : priceOf(fromPlan, licence.customMonthlyCents), + toMonthlyCents: priceOf(toPlan, body.toMonthlyCents), + effectiveDate, reason: body.reason ? String(body.reason).slice(0, 1000) : null, incomeBand: body.incomeBand || null, + }, + }); + let email = null; + if (send) email = await sendLicenceChange(change, settings); + if (!body.invoiceId) await applyDueLicenceChanges(); + return { change: await prisma.licenceChange.findUnique({ where: { id: change.id } }), email }; +} + +async function resendLicenceChange(id) { + const change = await prisma.licenceChange.findUnique({ where: { id } }); + if (!change) throw bad('Notice not found', 404); + const res = await sendLicenceChange(change, await getSettings()); + if (!res.ok) throw bad(`It couldn't be emailed: ${res.error}`, 502); + return res; +} + +async function licenceChangePdfFor(id) { + const settings = await getSettings(); + const change = await prisma.licenceChange.findUnique({ where: { id }, include: { customer: { include: { installs: { where: { releasedAt: null }, take: 1 } } }, licence: true } }); + if (!change) throw bad('Notice not found', 404); + return { filename: `${change.number}.pdf`, content: await pdf.licenceChangePdf({ change, customer: change.customer, settings, founding: change.customer.founding, recipientEmail: compute.recipientFor(change.customer, change.customer.installs[0]), cycle: change.licence?.billingCycle }) }; +} + +module.exports = { + nextNumber, loadInvoice, invoicePdfFor, draftForLicence, createDueDrafts, sendInvoice, sendDueInvoices, remindUnapproved, + applyDueLicenceChanges, runBilling, approveInvoice, unapproveInvoice, updateInvoice, recalculateInvoice, voidInvoice, + resendInvoice, createManualInvoice, recordPayment, deletePayment, resendReceipt, receiptPdfFor, customerStatement, + statementPdfFor, emailStatement, createLicenceChange, resendLicenceChange, licenceChangePdfFor, emailApproval, +}; diff --git a/backend/src/utils/billing/pdf.js b/backend/src/utils/billing/pdf.js new file mode 100644 index 0000000..bbef3d2 --- /dev/null +++ b/backend/src/utils/billing/pdf.js @@ -0,0 +1,312 @@ +/** + * Billing PDFs (A4, pdfkit): invoices (tax invoices once VAT registered), receipts, statements + * and notices of licence change. One shared layout: the Cross Code logo and business details, + * the document's title and reference box, the church's details, a table, totals, how to pay, + * and a footer on every page. Drafts carry a DRAFT watermark. + */ + +const path = require('path'); +const PDFDocument = require('pdfkit'); +const { longDate, shortDate, money, periodLabel } = require('./dates'); +const { planPrice, FOUNDING_PERCENT } = require('./tiers'); + +const LOGO = path.join(__dirname, '..', '..', '..', 'assets', 'logo.png'); +const C = { navy: '#11111f', sky: '#62b5e5', skyDark: '#1d6a9c', skyPale: '#e8f4fb', text: '#2b3044', muted: '#6b7184', line: '#e1e5ee', surface: '#f5f7fb', green: '#12734a', red: '#b42318' }; +const M = 48; // page margin +const W = 595.28 - M * 2; // content width + +/** Renders `draw(doc)` into a PDF and resolves with its bytes. */ +function render(draw, { title, draft = false } = {}) { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margins: { top: M, bottom: 70, left: M, right: M }, bufferPages: true, info: { Title: title, Author: 'Cross Code', Creator: 'Cross Events Hub' } }); + const chunks = []; + doc.on('data', c => chunks.push(c)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + try { + draw(doc); + const range = doc.bufferedPageRange(); + for (let i = range.start; i < range.start + range.count; i++) { + doc.switchToPage(i); + if (draft) watermark(doc); + footer(doc, i - range.start + 1, range.count); + } + doc.end(); + } catch (e) { + reject(e); + } + }); +} + +function watermark(doc) { + doc.save(); + doc.rotate(-30, { origin: [297, 420] }); + doc.font('Helvetica-Bold').fontSize(110).fillColor(C.sky).fillOpacity(0.12).text('DRAFT', 60, 360, { width: 480, align: 'center', lineBreak: false }); + doc.restore(); + doc.fillOpacity(1); +} + +let footerText = ''; +function footer(doc, n, of) { + const y = 841.89 - 46; + const bottom = doc.page.margins.bottom; + doc.page.margins.bottom = 0; + doc.moveTo(M, y - 8).lineTo(M + W, y - 8).lineWidth(0.5).strokeColor(C.line).stroke(); + doc.font('Helvetica').fontSize(7.5).fillColor(C.muted).text(footerText, M, y, { width: W - 60, lineBreak: false }); + doc.text(`Page ${n} of ${of}`, M + W - 60, y, { width: 60, align: 'right', lineBreak: false }); + doc.page.margins.bottom = bottom; +} + +/** The logo and business details (left), the title and reference rows (right). Returns the y below. */ +function header(doc, s, title, refRows) { + footerText = [s.business_name, s.business_website, s.business_email, s.business_phone].filter(Boolean).join(' · '); + try { doc.image(LOGO, M, M - 4, { width: 46, height: 46 }); } catch { /* no logo file */ } + doc.font('Helvetica-Bold').fontSize(15).fillColor(C.navy).text(s.business_name || 'Cross Code', M + 56, M + 2); + const details = [ + s.business_owner && s.business_registration ? `${s.business_owner} · ${s.business_registration}` : s.business_owner || s.business_registration, + s.business_address, + [s.business_email, s.business_phone].filter(Boolean).join(' · '), + String(s.vat_registered) === 'true' && s.vat_number ? `VAT number ${s.vat_number}` : null, + ].filter(Boolean); + doc.font('Helvetica').fontSize(8).fillColor(C.muted).text(details.join('\n'), M + 56, M + 21, { width: 250, lineGap: 1.5 }); + + const rx = M + W - 210; + doc.font('Helvetica-Bold').fontSize(20).fillColor(C.navy).text(title, rx, M, { width: 210, align: 'right' }); + let y = M + 30; + doc.roundedRect(rx, y, 210, 14 + refRows.length * 15, 6).fillColor(C.surface).fill(); + y += 8; + for (const [k, v, bold] of refRows) { + doc.font('Helvetica').fontSize(8.5).fillColor(C.muted).text(k, rx + 10, y, { width: 90 }); + doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fillColor(C.text).text(v, rx + 95, y, { width: 105, align: 'right' }); + y += 15; + } + const bottom = Math.max(y + 8, M + 21 + details.length * 11 + 10); + doc.moveTo(M, bottom + 6).lineTo(M + W, bottom + 6).lineWidth(2).strokeColor(C.sky).stroke(); + return bottom + 22; +} + +/** "Billed to" (or another label) with the church's details. Returns the y below. */ +function party(doc, y, label, customer, email) { + doc.font('Helvetica-Bold').fontSize(8).fillColor(C.skyDark).text(label.toUpperCase(), M, y, { characterSpacing: 1 }); + const lines = [customer.contactName ? `Attention: ${customer.contactName}` : null, customer.billingAddress, email].filter(Boolean); + doc.font('Helvetica-Bold').fontSize(11).fillColor(C.navy).text(customer.name, M, y + 13, { width: 300 }); + doc.font('Helvetica').fontSize(9).fillColor(C.text).text(lines.join('\n'), M, doc.y + 2, { width: 300, lineGap: 1.5 }); + return doc.y + 16; +} + +/** + * A table. `cols`: [{ label, width, align }]; `rows`: arrays of cell text (or { text, bold, color }). + * Breaks onto new pages, repeating the heading. Returns the y below. + */ +function table(doc, y, cols, rows) { + const head = yy => { + doc.rect(M, yy, W, 20).fillColor(C.navy).fill(); + let x = M; + for (const c of cols) { + doc.font('Helvetica-Bold').fontSize(8).fillColor('#ffffff').text(c.label, x + 8, yy + 6.5, { width: c.width - 16, align: c.align || 'left', lineBreak: false }); + x += c.width; + } + return yy + 20; + }; + y = head(y); + rows.forEach((row, ri) => { + const cells = row.map(c => (typeof c === 'object' && c !== null ? c : { text: String(c ?? '') })); + doc.font('Helvetica').fontSize(9); + const h = Math.max(20, ...cells.map((c, i) => doc.heightOfString(c.text, { width: cols[i].width - 16 }) + 11)); + if (y + h > 841.89 - 90) { + doc.addPage(); + y = head(M); + } + if (ri % 2 === 1) doc.rect(M, y, W, h).fillColor(C.surface).fill(); + let x = M; + cells.forEach((c, i) => { + doc.font(c.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(9).fillColor(c.color || C.text).text(c.text, x + 8, y + 6, { width: cols[i].width - 16, align: cols[i].align || 'left' }); + x += cols[i].width; + }); + y += h; + }); + doc.moveTo(M, y).lineTo(M + W, y).lineWidth(0.5).strokeColor(C.line).stroke(); + return y + 10; +} + +/** Right-hand totals: [[label, value, { bold, big, color }]]. Returns the y below. */ +function totalsBlock(doc, y, rows) { + const x = M + W - 230; + for (const [k, v, o = {}] of rows) { + if (y > 841.89 - 110) { doc.addPage(); y = M; } + if (o.big) { + doc.roundedRect(x, y - 3, 230, 26, 5).fillColor(C.skyPale).fill(); + doc.font('Helvetica-Bold').fontSize(11).fillColor(C.navy).text(k, x + 10, y + 4, { width: 120 }); + doc.fillColor(o.color || C.navy).text(v, x + 120, y + 4, { width: 100, align: 'right' }); + y += 30; + } else { + doc.font(o.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(9).fillColor(C.muted).text(k, x + 10, y, { width: 130 }); + doc.fillColor(o.color || C.text).text(v, x + 120, y, { width: 100, align: 'right' }); + y += 15; + } + } + return y + 8; +} + +/** A shaded box with a heading and lines of text. Returns the y below. */ +function box(doc, y, heading, lines, { tone = 'surface' } = {}) { + doc.font('Helvetica').fontSize(9); + const textH = doc.heightOfString(lines.join('\n'), { width: W - 28, lineGap: 2 }); + const h = textH + 34; + if (y + h > 841.89 - 90) { doc.addPage(); y = M; } + doc.roundedRect(M, y, W, h, 6).fillColor(tone === 'sky' ? C.skyPale : C.surface).fill(); + doc.font('Helvetica-Bold').fontSize(9.5).fillColor(C.navy).text(heading, M + 14, y + 11); + doc.font('Helvetica').fontSize(9).fillColor(C.text).text(lines.join('\n'), M + 14, y + 26, { width: W - 28, lineGap: 2 }); + return y + h + 12; +} + +function paymentLines(s, reference) { + const bank = [ + s.bank_account_name && `Account name: ${s.bank_account_name}`, + s.bank_name && `Bank: ${s.bank_name}`, + s.bank_account_number && `Account number: ${s.bank_account_number}`, + s.bank_branch_code && `Branch code: ${s.bank_branch_code}`, + s.bank_account_type && `Account type: ${s.bank_account_type}`, + ].filter(Boolean); + if (!bank.length) return [`Please contact us at ${s.business_email || 'admin@crosscode.co.za'} for our banking details.`, `Reference: ${reference}`]; + return [...bank, `Reference: ${reference}`, 'Please use the reference so we can match your payment.']; +} + +const isTax = s => String(s.vat_registered) === 'true'; +const vatRow = (s, vatCents) => (isTax(s) ? [[`Includes VAT (${parseFloat(s.vat_rate) || 15}%)`, money(vatCents)]] : []); + +/** An invoice (a tax invoice once VAT registered). `state` gives paid and balance. */ +function invoicePdf({ invoice, customer, settings: s, state }) { + const title = isTax(s) ? 'Tax invoice' : 'Invoice'; + const draft = invoice.status === 'draft' || invoice.status === 'approved'; + return render(doc => { + let y = header(doc, s, invoice.status === 'void' ? `${title} (void)` : title, [ + ['Invoice number', invoice.number, true], + ['Date', longDate(invoice.issueDate)], + ['Due', longDate(invoice.dueDate)], + ...(invoice.periodStart ? [['Period', periodLabel(invoice.periodStart, invoice.periodEnd)]] : []), + ]); + y = party(doc, y, 'Billed to', customer, invoice.recipientEmail); + y = table(doc, y, [ + { label: 'Description', width: W - 250 }, + { label: 'Qty', width: 50, align: 'right' }, + { label: 'Unit price', width: 100, align: 'right' }, + { label: 'Amount', width: 100, align: 'right' }, + ], (invoice.lines || []).map(l => [l.description, String(l.quantity), money(l.unitCents), { text: money(l.amountCents), bold: true }])); + const paid = state?.paidCents || 0; + y = totalsBlock(doc, y, [ + ...vatRow(s, invoice.vatCents), + ...(paid ? [['Total', money(invoice.totalCents)], ['Paid', money(-paid), { color: C.green }]] : []), + [paid ? 'Balance due' : 'Total due', money(paid ? state.balanceCents : invoice.totalCents), { big: true }], + ]); + if (invoice.planChange) { + const pc = invoice.planChange; + y = box(doc, y, `Your tier changes to ${pc.to}`, [ + `From this period your licence moves from the ${pc.from} tier to the ${pc.to} tier${pc.band ? `, based on your event income over the last 12 months (${pc.band})` : ''}.`, + 'Tiers are reviewed once a year. Donations and sponsored places are never counted.', + ], { tone: 'sky' }); + } + if (invoice.status !== 'void') y = box(doc, y, 'How to pay', paymentLines(s, invoice.number)); + if (invoice.notes) y = box(doc, y, 'Notes', [invoice.notes]); + if (invoice.status === 'void' && invoice.voidReason) y = box(doc, y, 'This invoice is void', [invoice.voidReason]); + if (s.invoice_footer) doc.font('Helvetica-Oblique').fontSize(9).fillColor(C.muted).text(s.invoice_footer, M, y + 4, { width: W, align: 'center' }); + }, { title: `${title} ${invoice.number}`, draft }); +} + +/** A receipt for one payment. `invoice` (optional) with its state after the payment. */ +function receiptPdf({ payment, customer, invoice, state, settings: s, recipientEmail }) { + const METHOD = { eft: 'EFT', card: 'Card', cash: 'Cash', other: 'Other' }; + return render(doc => { + let y = header(doc, s, 'Receipt', [ + ['Receipt number', payment.receiptNumber, true], + ['Date received', longDate(payment.paidOn)], + ['Method', METHOD[payment.method] || payment.method], + ...(payment.reference ? [['Reference', payment.reference]] : []), + ]); + y = party(doc, y, 'Received from', customer, recipientEmail); + y = table(doc, y, [ + { label: 'Description', width: W - 120 }, + { label: 'Amount', width: 120, align: 'right' }, + ], [[invoice ? `Payment for invoice ${invoice.number}` : 'Payment on account', { text: money(payment.amountCents), bold: true }]]); + y = totalsBlock(doc, y, [ + ['Amount received', money(payment.amountCents), { big: true, color: C.green }], + ...(invoice && state ? [[`Invoice ${invoice.number} balance`, money(Math.max(0, state.balanceCents)), { bold: true }]] : []), + ]); + if (invoice && state && state.balanceCents <= 0) y = box(doc, y, 'Paid in full', [`Invoice ${invoice.number} is now paid in full. Thank you!`], { tone: 'sky' }); + if (payment.notes) y = box(doc, y, 'Notes', [payment.notes]); + doc.font('Helvetica-Oblique').fontSize(9).fillColor(C.muted).text('Thank you for your payment.', M, y + 4, { width: W, align: 'center' }); + }, { title: `Receipt ${payment.receiptNumber}` }); +} + +/** A statement (from compute.statement). */ +function statementPdf({ statement: st, customer, settings: s, recipientEmail }) { + return render(doc => { + let y = header(doc, s, 'Statement', [ + ['From', longDate(st.from)], + ['To', longDate(st.to)], + ['Balance', money(st.closingCents), true], + ]); + y = party(doc, y, 'Statement for', customer, recipientEmail); + const rows = [ + [shortDate(st.from), '', { text: 'Balance brought forward', bold: true }, '', '', { text: money(st.openingCents), bold: true }], + ...st.entries.map(e => [shortDate(e.date), e.reference, e.description, e.debitCents ? money(e.debitCents) : '', e.creditCents ? money(e.creditCents) : '', money(e.balanceCents)]), + ]; + y = table(doc, y, [ + { label: 'Date', width: 72 }, + { label: 'Reference', width: 82 }, + { label: 'Description', width: W - 72 - 82 - 70 - 70 - 75 }, + { label: 'Charged', width: 70, align: 'right' }, + { label: 'Paid', width: 70, align: 'right' }, + { label: 'Balance', width: 75, align: 'right' }, + ], rows); + y = totalsBlock(doc, y, [[st.closingCents > 0 ? 'Amount owing' : st.closingCents < 0 ? 'In credit' : 'Balance', money(Math.abs(st.closingCents)), { big: true, color: st.closingCents > 0 ? C.navy : C.green }]]); + if (st.outstanding.length) { + y = box(doc, y, 'Invoices still owing', st.outstanding.map(o => `${o.number}: ${money(o.balanceCents)} of ${money(o.totalCents)}, due ${longDate(o.dueDate)}${o.overdue ? ' (overdue)' : ''}`)); + y = box(doc, y, 'How to pay', paymentLines(s, st.outstanding.map(o => o.number).join(', '))); + } else { + y = box(doc, y, 'All paid up', ['Nothing is owing. Thank you!'], { tone: 'sky' }); + } + }, { title: `Statement ${customer.name}` }); +} + +/** A notice of licence change (tier up or down). */ +function licenceChangePdf({ change, customer, settings: s, founding, recipientEmail, cycle }) { + const up = change.fromMonthlyCents != null && change.toMonthlyCents != null ? change.toMonthlyCents > change.fromMonthlyCents : null; + // The monthly price, and for a founding church its founding price (the tier's, or 35% off). + const priceText = (plan, cents) => { + if (cents == null) return 'priced for your church'; + if (!founding) return `${money(cents)} a month`; + const p = planPrice(plan); + const f = p && p.monthlyCents === cents ? p.foundingCents : Math.round((cents * (100 - FOUNDING_PERCENT)) / 100); + return `${money(f)} a month (founding price; normally ${money(cents)})`; + }; + return render(doc => { + let y = header(doc, s, 'Licence change', [ + ['Notice number', change.number, true], + ['Date', longDate(change.createdAt || new Date())], + ['Takes effect', longDate(change.effectiveDate), true], + ]); + y = party(doc, y, 'Notice to', customer, recipientEmail); + doc.font('Helvetica-Bold').fontSize(14).fillColor(C.navy).text( + up == null ? `Your Cross Events tier changes to ${change.toPlan}` : up ? `Your Cross Events tier moves up to ${change.toPlan}` : `Good news: your Cross Events tier moves down to ${change.toPlan}`, M, y, { width: W }); + y = doc.y + 10; + y = table(doc, y, [ + { label: '', width: 150 }, + { label: 'Now', width: (W - 150) / 2 }, + { label: `From ${longDate(change.effectiveDate)}`, width: (W - 150) / 2 }, + ], [ + [{ text: 'Tier', bold: true }, change.fromPlan, { text: change.toPlan, bold: true }], + [{ text: 'Price', bold: true }, priceText(change.fromPlan, change.fromMonthlyCents), { text: priceText(change.toPlan, change.toMonthlyCents), bold: true }], + ]); + const why = []; + if (change.reason) why.push(change.reason); + if (change.incomeBand) why.push(`Your event income over the last 12 months was in the range ${change.incomeBand}. Tiers are based on ticket sales and other payments through Cross Events, less refunds; donations and sponsored places are never counted.`); + why.push('We review tiers once a year when your licence renews, never in the middle of the year.'); + if (founding) why.push('As a founding church, your 35% founding discount carries on in your new tier.'); + if (cycle === 'yearly') why.push('You pay yearly, so you still get two months free on the new price.'); + y = box(doc, y, 'Why it changes', why, { tone: 'sky' }); + y = box(doc, y, 'Questions?', [`Reply to this email or contact us at ${s.business_email || 'admin@crosscode.co.za'}${s.business_phone ? ` or ${s.business_phone}` : ''}. Every feature stays included, whatever your tier.`]); + }, { title: `Licence change ${change.number}` }); +} + +module.exports = { invoicePdf, receiptPdf, statementPdf, licenceChangePdf }; diff --git a/backend/src/utils/billing/tiers.js b/backend/src/utils/billing/tiers.js new file mode 100644 index 0000000..f72a811 --- /dev/null +++ b/backend/src/utils/billing/tiers.js @@ -0,0 +1,56 @@ +/** + * Cross Events licence tiers, billed on a site's event income over the last 12 months + * (`income.eventIncome12m`, Cross Events 2.0.2+). The same tiers are in + * frontend/src/lib/tiers.ts (a test checks they match) and the public prices in crosscode-site + * src/data/pricing.mjs. Prices are rand per month. + */ + +const TIERS = [ + { name: 'Seed', upTo: 50_000, monthly: 350, founding: 230 }, + { name: 'Sprout', upTo: 150_000, monthly: 550, founding: 360 }, + { name: 'Grow', upTo: 400_000, monthly: 850, founding: 550 }, + { name: 'Flourish', upTo: 1_000_000, monthly: 1350, founding: 880 }, + { name: 'Harvest', upTo: 2_500_000, monthly: 2100, founding: 1370 }, + { name: 'Abundance', upTo: 5_000_000, monthly: 3200, founding: 2080 }, + { name: 'Movement', upTo: 10_000_000, monthly: 4800, founding: 3120 }, + { name: 'Network', upTo: null, monthly: null, founding: null }, // priced per church, from R7 500 +]; + +/** Yearly billing: this many months' price for 12 months (two months free). */ +const YEARLY_MONTHS = 10; +/** Founding churches: this much off a price that has no published founding price. */ +const FOUNDING_PERCENT = 35; + +const tierNamed = name => TIERS.find(t => t.name.toLowerCase() === String(name || '').toLowerCase()) || null; + +/** The highest rand amount in a band label ("R100 001–R150 000" → 150000; "over R10 000 000" → Infinity). */ +function bandTop(band) { + if (/^over /i.test(band)) return Infinity; + const parts = String(band).split('–'); + if (parts.length !== 2 || !parts[1].trim()) return null; + const n = Number(parts[1].replace(/[^\d]/g, '')); + return Number.isFinite(n) ? n : null; +} + +/** The tier a band falls in, or null (no band, or an old install's "over R100 000"). */ +function tierForBand(band) { + if (!band || band === 'over R100 000') return null; + const top = bandTop(band); + if (top == null) return null; + return TIERS.find(t => t.upTo == null || top <= t.upTo) || null; +} + +/** + * The monthly price of a plan in cents, normal and founding: { monthlyCents, foundingCents }, + * or null when the plan has no price (Network, Trial, an old "standard") and no custom price. + */ +function planPrice(plan, customMonthlyCents = null) { + const tier = tierNamed(plan); + if (customMonthlyCents != null && customMonthlyCents >= 0) { + return { monthlyCents: customMonthlyCents, foundingCents: Math.round((customMonthlyCents * (100 - FOUNDING_PERCENT)) / 100), custom: true }; + } + if (!tier || tier.monthly == null) return null; + return { monthlyCents: tier.monthly * 100, foundingCents: tier.founding * 100, custom: false }; +} + +module.exports = { TIERS, YEARLY_MONTHS, FOUNDING_PERCENT, tierNamed, tierForBand, planPrice }; diff --git a/backend/src/utils/mail.js b/backend/src/utils/mail.js index 25a2de0..4d1a4cd 100644 --- a/backend/src/utils/mail.js +++ b/backend/src/utils/mail.js @@ -25,12 +25,17 @@ async function smtpConfigured() { return !!(await smtpSettings()).host; } -/** Send an email; without SMTP it's logged instead (development). */ -async function sendMail({ to, subject, text }) { +/** + * Send an email; without SMTP it's logged instead (development). Billing emails (1.7.0) add + * `html`, `attachments` (nodemailer's: { filename, content, contentType, cid? }), `replyTo` and + * `fromName` (shown as the sender's name). + */ +async function sendMail({ to, subject, text, html, attachments, replyTo, fromName }) { if (!to) return; const smtp = await smtpSettings(); if (!smtp.host) { - console.info(`[mail] (SMTP not set up) to=${to} subject="${subject}"\n${text}`); + const files = (attachments || []).filter(a => !a.cid).map(a => a.filename).join(', '); + console.info(`[mail] (SMTP not set up) to=${to} subject="${subject}"${files ? ` attachments=${files}` : ''}\n${text}`); return; } const key = JSON.stringify(smtp); @@ -38,7 +43,9 @@ async function sendMail({ to, subject, text }) { transporter = createTransport(smtp); transporterFor = key; } - await transporter.sendMail({ from: fromAddress(smtp), to, subject, text }); + const address = fromAddress(smtp); + const from = fromName ? { name: fromName, address: address.replace(/^.*<([^>]+)>.*$/, '$1') } : address; + await transporter.sendMail({ from, to, subject, text, html, attachments, replyTo }); } /** diff --git a/backend/src/utils/settings.js b/backend/src/utils/settings.js index 659c47b..b9cada7 100644 --- a/backend/src/utils/settings.js +++ b/backend/src/utils/settings.js @@ -37,6 +37,37 @@ const DEFAULTS = { ai_usd_zar: '', // Default markup on the real cost, in %; a licence can override it. ai_markup_percent: '40', + // Billing (1.7.0, utils/billing/). Drafts are made billing_draft_days before an invoice's date + // and emailed to alert_email for approval; approved invoices go out on their date at + // billing_send_hour (South African time). + billing_enabled: 'true', + billing_draft_days: '14', + billing_send_hour: '8', + payment_terms_days: '7', + invoice_prefix: 'INV', + receipt_prefix: 'REC', + change_prefix: 'LC', + // Printed on every document. + business_name: 'Cross Code', + business_owner: 'Joshua Oosthuizen', + business_address: '44 Gordon Road, Henley on Klip, Gauteng, South Africa', + business_email: 'admin@crosscode.co.za', + business_phone: '+27 68 903 8075', + business_website: 'crosscode.co.za', + business_registration: '', + // Not VAT registered: no VAT on documents. Once registered, prices are VAT-inclusive: the + // totals stay the same, documents become tax invoices and show the VAT included. + vat_registered: '', + vat_number: '', + vat_rate: '15', + bank_name: '', + bank_account_name: '', + bank_account_number: '', + bank_branch_code: '', + bank_account_type: '', + invoice_footer: 'Thank you for using Cross Events.', + // Replies to billing emails go here (blank: business_email). + billing_reply_to: '', }; // Stored encrypted and never sent to the browser: only "is one saved" (_set). diff --git a/backend/tests/billing.test.js b/backend/tests/billing.test.js new file mode 100644 index 0000000..61739e6 --- /dev/null +++ b/backend/tests/billing.test.js @@ -0,0 +1,201 @@ +jest.mock('../src/config/db', () => ({})); + +const fs = require('fs'); +const path = require('path'); +const { TIERS, tierForBand, planPrice } = require('../src/utils/billing/tiers'); +const { day, ymd, addMonths, addDays, today, saHour, periodLabel, money } = require('../src/utils/billing/dates'); +const { draftInvoice, cleanLines, totals, invoiceState, statement, recipientFor } = require('../src/utils/billing/compute'); + +const customer = (extra = {}) => ({ id: 'c1', name: 'Hope Church', email: 'office@hope.example', founding: false, ...extra }); +const install = (band, email = 'admin@hope.example') => ({ adminContact: { email }, stats: band ? { income: { eventIncome12m: band } } : {} }); +const licence = (extra = {}) => ({ + id: 'l1', plan: 'Sprout', billingCycle: 'monthly', billingStart: day('2026-01-01'), nextInvoiceDate: day('2026-12-01'), + reviewDate: day('2027-01-01'), customMonthlyCents: null, ...extra, +}); +const settings = { payment_terms_days: '7' }; + +describe('tiers', () => { + test('the server and the Hub screens have the same tiers', () => { + const ts = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'tiers.ts'), 'utf8'); + const front = [...ts.matchAll(/\{ name: "(\w+)", upTo: ([\d_]+|null), monthly: (\d+|null), founding: (\d+|null) \}/g)] + .map(m => [m[1], m[2] === 'null' ? null : Number(m[2].replace(/_/g, '')), m[3] === 'null' ? null : Number(m[3]), m[4] === 'null' ? null : Number(m[4])]); + expect(front).toEqual(TIERS.map(t => [t.name, t.upTo, t.monthly, t.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('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(); + }); +}); + +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', () => { + test('monthly, in advance: the period starting on its date, due after the payment terms', () => { + const { invoice, licenceUpdate } = draftInvoice({ licence: licence(), customer: customer(), install: install('R100 001–R150 000'), settings }); + 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 licence: Sprout tier, December 2026', quantity: 1, unitCents: 55000, amountCents: 55000 }]); + expect(invoice).toMatchObject({ totalCents: 55000, vatCents: 0, reviewed: false, planChange: null, recipientEmail: 'admin@hope.example', warnings: [] }); + expect(ymd(licenceUpdate.nextInvoiceDate)).toBe('2027-01-01'); + expect(ymd(licenceUpdate.reviewDate)).toBe('2027-01-01'); // not reviewed yet + }); + + test('between reviews the tier stays, whatever the income', () => { + const { invoice } = draftInvoice({ licence: licence(), customer: customer(), install: install('R1 000 001–R2 500 000'), settings }); + 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, licenceUpdate } = draftInvoice({ licence: licence({ nextInvoiceDate: day('2027-01-01') }), customer: customer(), install: install('R250 001–R400 000'), settings }); + expect(invoice).toMatchObject({ reviewed: true, plan: 'Grow', incomeBand: 'R250 001–R400 000', totalCents: 85000 }); + expect(invoice.planChange).toEqual({ from: 'Sprout', to: 'Grow', fromMonthlyCents: 55000, toMonthlyCents: 85000, band: 'R250 001–R400 000' }); + expect(ymd(licenceUpdate.reviewDate)).toBe('2028-01-01'); + }); + + test('...or down', () => { + const { invoice } = draftInvoice({ licence: licence({ nextInvoiceDate: day('2027-01-01') }), customer: customer(), install: install('R5 001–R20 000'), settings }); + expect(invoice.planChange).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 } = draftInvoice({ licence: licence({ nextInvoiceDate: day('2027-01-01') }), customer: customer(), install: install(null), settings }); + 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 } = draftInvoice({ licence: licence(), customer: customer({ founding: true }), install: install(null), settings }); + 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, licenceUpdate } = draftInvoice({ + licence: licence({ plan: 'Grow', billingCycle: 'yearly', nextInvoiceDate: day('2026-12-01'), reviewDate: day('2027-12-01') }), + customer: customer({ founding: true }), install: install(null), settings, + }); + 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 licence: Grow tier, 12 months (1 December 2026 – 30 November 2027), paid yearly: 2 months free'); + expect(invoice.totalCents).toBe(550000); + expect(ymd(licenceUpdate.nextInvoiceDate)).toBe('2027-12-01'); + }); + + test('a custom price is kept at the review, with a note if the income points elsewhere', () => { + const { invoice } = draftInvoice({ + licence: licence({ plan: 'Network', customMonthlyCents: 750000, nextInvoiceDate: day('2027-01-01') }), + customer: customer(), install: install('R5 000 001–R10 000 000'), settings, + }); + 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', () => { + const { invoice } = draftInvoice({ licence: licence({ plan: 'standard' }), customer: customer(), install: install(null), settings }); + expect(invoice.lines).toEqual([]); + expect(invoice.warnings[0]).toMatch(/no price for the "standard" plan/); + }); + + test('month-end billing day stays on the last day', () => { + const { invoice, licenceUpdate } = draftInvoice({ licence: licence({ billingStart: day('2026-01-31'), nextInvoiceDate: day('2027-01-31'), reviewDate: day('2028-01-31') }), customer: customer(), install: install(null), settings }); + expect(ymd(invoice.periodEnd)).toBe('2027-02-27'); + expect(ymd(licenceUpdate.nextInvoiceDate)).toBe('2027-02-28'); + }); +}); + +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); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3c30b8c..7a4dee0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "events-hub-frontend", - "version": "1.6.2", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "events-hub-frontend", - "version": "1.6.2", + "version": "1.7.0", "dependencies": { "lucide-react": "^0.536.0", "next": "^15.5.24", diff --git a/frontend/package.json b/frontend/package.json index fbf1d57..6c76bb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "events-hub-frontend", - "version": "1.6.2", + "version": "1.7.0", "private": true, "scripts": { "dev": "next dev -p 3200", diff --git a/frontend/src/app/(hub)/billing/invoices/[id]/page.tsx b/frontend/src/app/(hub)/billing/invoices/[id]/page.tsx new file mode 100644 index 0000000..15225bf --- /dev/null +++ b/frontend/src/app/(hub)/billing/invoices/[id]/page.tsx @@ -0,0 +1,264 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { AlertTriangle, ArrowLeft, Check, FileText, RefreshCw, Send, Star, Undo2, XCircle } from "lucide-react"; +import { api, errorText, openPdf } from "@/lib/api"; +import { fmtDate } from "@/lib/format"; +import { Loading, Message, PageTitle } from "@/components/ui"; +import { EMAIL_KIND, METHOD, StateBadge, day, money, todayYmd, type BillingEmail, type Invoice, type LicenceChange } from "@/lib/billing"; +import { LinesEditor, editTotal, fromEdit, toEdit, type EditLine } from "@/components/billing/LinesEditor"; +import { RecordPayment } from "@/components/billing/RecordPayment"; + +type Msg = { type: "ok" | "err"; text: string } | null; +type Detail = Invoice & { licenceChanges: LicenceChange[]; emails: BillingEmail[] }; + +/** A one-off invoice (standby support, set-up…), made as a draft. */ +function NewInvoice() { + const router = useRouter(); + const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]); + const [form, setForm] = useState({ customerId: "", issueDate: todayYmd(), notes: "" }); + const [lines, setLines] = useState([{ description: "", quantity: "1", unit: "" }]); + const [msg, setMsg] = useState(null); + + useEffect(() => { + api<{ id: string; name: string }[]>("/api/admin/billing/customers").then(setCustomers).catch(e => setMsg({ type: "err", text: errorText(e) })); + const c = new URLSearchParams(window.location.search).get("customer"); + if (c) setForm(f => ({ ...f, customerId: c })); + }, []); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const inv = await api("/api/admin/billing/invoices", { method: "POST", body: { ...form, lines: fromEdit(lines) } }); + router.replace(`/billing/invoices/${inv.id}`); + } catch (err) { setMsg({ type: "err", text: errorText(err) }); } + }; + + return ( + <> + Billing + +
+
+
+
+ + +
+
setForm({ ...form, issueDate: e.target.value })} required />
+
+ +