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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <dir>`
|
||||
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**;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Generated
+181
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "events-hub-backend",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "events-hub-backend",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^1.3.0",
|
||||
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.2",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1972,6 +1973,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
@@ -2945,6 +2955,26 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
+198
-34
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
})();
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 => `<p style="margin:0 0 14px 0;font-family:${FONT};font-size:15px;line-height:1.6;color:#2b3044">${esc(t)}</p>`;
|
||||
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(heading)}</title></head>
|
||||
<body style="margin:0;padding:0;background:#f5f7fb">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0">${esc(preheader)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb;padding:24px 12px"><tr><td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e1e5ee">
|
||||
<tr><td style="background:#11111f;padding:22px 28px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
${logo() ? '<td style="padding-right:12px"><img src="cid:logo" width="40" height="40" alt="" style="display:block;border:0"></td>' : ''}
|
||||
<td style="font-family:${FONT};font-size:15px;letter-spacing:4px;text-transform:uppercase;color:#ffffff"><span style="color:#62b5e5">${esc(business.split(' ')[0])}</span> ${esc(business.split(' ').slice(1).join(' '))}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="height:4px;background:#62b5e5;line-height:4px;font-size:0"> </td></tr>
|
||||
<tr><td style="padding:30px 28px 10px 28px">
|
||||
<h1 style="margin:0 0 18px 0;font-family:${FONT};font-size:22px;line-height:1.3;color:#11111f">${esc(heading)}</h1>
|
||||
${greeting ? p(greeting) : ''}
|
||||
${paragraphs.map(p).join('')}
|
||||
${rows.length ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:6px 0 20px 0;background:#f5f7fb;border-radius:10px">
|
||||
${rows.map(([k, v, bold], i) => `<tr><td style="padding:${i ? 8 : 14}px 16px ${i === rows.length - 1 ? 14 : 8}px 16px;font-family:${FONT};font-size:14px;color:#6b7184">${esc(k)}</td><td align="right" style="padding:${i ? 8 : 14}px 16px ${i === rows.length - 1 ? 14 : 8}px 16px;font-family:${FONT};font-size:14px;color:#11111f;${bold ? 'font-weight:700' : ''}">${esc(v)}</td></tr>`).join('')}
|
||||
</table>` : ''}
|
||||
${note ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px 0;background:#e8f4fb;border-left:4px solid #62b5e5;border-radius:6px"><tr><td style="padding:14px 16px;font-family:${FONT};font-size:14px;line-height:1.55;color:#2b3044">${note.title ? `<strong style="color:#11111f">${esc(note.title)}</strong><br>` : ''}${esc(note.text).replace(/\n/g, '<br>')}</td></tr></table>` : ''}
|
||||
${button ? `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 22px 0"><tr><td style="border-radius:8px;background:#1d6a9c"><a href="${esc(button.url)}" style="display:inline-block;padding:12px 22px;font-family:${FONT};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px">${esc(button.label)}</a></td></tr></table>` : ''}
|
||||
${signoff ? p(`Kind regards,\n${s.business_owner || ''}${s.business_owner ? ', ' : ''}${business}`).replace('\n', '<br>') : ''}
|
||||
</td></tr>
|
||||
<tr><td style="padding:18px 28px 24px 28px;border-top:1px solid #e1e5ee;font-family:${FONT};font-size:12px;line-height:1.5;color:#6b7184">${footer}</td></tr>
|
||||
</table>
|
||||
</td></tr></table>
|
||||
</body></html>`;
|
||||
|
||||
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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" (<key>_set).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "events-hub-frontend",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3200",
|
||||
|
||||
@@ -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<EditLine[]>([{ description: "", quantity: "1", unit: "" }]);
|
||||
const [msg, setMsg] = useState<Msg>(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<Invoice>("/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 (
|
||||
<>
|
||||
<Link href="/billing" className="mb-2 inline-flex items-center gap-1 text-sm text-slate-500 hover:underline"><ArrowLeft className="h-4 w-4" /> Billing</Link>
|
||||
<PageTitle title="One-off invoice" subtitle="For anything outside the licence, like standby support for a big event. It's made as a draft for you to approve." />
|
||||
<div className="mb-4"><Message msg={msg} /></div>
|
||||
<form onSubmit={save} className="card max-w-3xl space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor="new-customer">Church</label>
|
||||
<select id="new-customer" className="input" value={form.customerId} onChange={e => setForm({ ...form, customerId: e.target.value })} required>
|
||||
<option value="">Choose…</option>
|
||||
{customers.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label" htmlFor="new-date">Invoice date (it goes out on this day once approved)</label><input id="new-date" type="date" className="input" value={form.issueDate} onChange={e => setForm({ ...form, issueDate: e.target.value })} required /></div>
|
||||
</div>
|
||||
<LinesEditor lines={lines} onChange={setLines} />
|
||||
<div><label className="label" htmlFor="new-notes">Notes (printed on the invoice)</label><textarea id="new-notes" className="input" rows={2} value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} /></div>
|
||||
<button className="btn-primary" disabled={editTotal(lines) === 0 && !fromEdit(lines).length}>Create draft</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceDetail({ id }: { id: string }) {
|
||||
const [inv, setInv] = useState<Detail | null>(null);
|
||||
const [form, setForm] = useState({ issueDate: "", dueDate: "", recipientEmail: "", notes: "" });
|
||||
const [lines, setLines] = useState<EditLine[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const load = useCallback(() => api<Detail>(`/api/admin/billing/invoices/${id}`).then(x => {
|
||||
setInv(x);
|
||||
setForm({ issueDate: x.issueDate, dueDate: x.dueDate, recipientEmail: x.recipientEmail || "", notes: x.notes || "" });
|
||||
setLines(toEdit(x.lines));
|
||||
setDirty(false);
|
||||
}).catch(e => setMsg({ type: "err", text: errorText(e) })), [id]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!inv) return msg ? <Message msg={msg} /> : <Loading />;
|
||||
const editable = inv.status === "draft" || inv.status === "approved";
|
||||
|
||||
const act = async (name: string, path: string, body?: unknown, ok?: (r: { invoice?: Invoice; notice?: { change: LicenceChange; email: { ok: boolean; error: string | null } | null } | null; message?: string }) => string) => {
|
||||
setBusy(name);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await api<{ invoice?: Invoice; notice?: { change: LicenceChange; email: { ok: boolean; error: string | null } | null } | null; message?: string }>(`/api/admin/billing/invoices/${id}/${path}`, { method: "POST", body: body ?? {} });
|
||||
setMsg({ type: "ok", text: ok ? ok(r) : r.message || "Done." });
|
||||
await load();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy("save");
|
||||
try {
|
||||
await api(`/api/admin/billing/invoices/${id}`, { method: "PUT", body: { ...form, lines: fromEdit(lines) } });
|
||||
setMsg({ type: "ok", text: inv.status === "approved" ? "Saved. It's still approved and goes out on its date." : "Saved." });
|
||||
await load();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const approve = () => {
|
||||
if (dirty) { setMsg({ type: "err", text: "Save your changes first." }); return; }
|
||||
const when = inv.issueDate <= todayYmd() ? "within the next 15 minutes (its date has come)" : `on ${day(inv.issueDate)}`;
|
||||
if (!confirm(`Approve ${inv.number}? It goes to ${inv.recipientEmail} ${when}.${inv.planChange ? `\n\nThe church is also sent a notice of licence change (${inv.planChange.from} → ${inv.planChange.to}) now.` : ""}`)) return;
|
||||
act("approve", "approve", {}, r => `Approved. It goes out ${when}.${r.notice ? (r.notice.email?.ok ? ` The notice of licence change ${r.notice.change.number} was emailed.` : ` The notice ${r.notice.change.number} couldn't be emailed: ${r.notice.email?.error}`) : ""}`);
|
||||
};
|
||||
const voidIt = () => {
|
||||
const reason = prompt(`Void ${inv.number}? It stays on record, marked void. Why (printed on it)?`, "");
|
||||
if (reason === null) return;
|
||||
act("void", "void", { reason }, () => `${inv.number} is void.`);
|
||||
};
|
||||
const dropChange = async () => {
|
||||
if (!confirm("Remove the tier change from this invoice? The tier stays as it is; then press Recalculate to price it at that tier.")) return;
|
||||
setBusy("drop");
|
||||
try {
|
||||
await api(`/api/admin/billing/invoices/${id}`, { method: "PUT", body: { dropPlanChange: true } });
|
||||
setMsg({ type: "ok", text: "Tier change removed. Press Recalculate to price it at the current tier." });
|
||||
await load();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); } finally { setBusy(null); }
|
||||
};
|
||||
const pdf = (path: string) => openPdf(path).catch(e => setMsg({ type: "err", text: errorText(e) }));
|
||||
const paymentAction = async (pid: string, what: "resend" | "delete") => {
|
||||
if (what === "delete" && !confirm("Remove this payment? Its receipt number won't be used again.")) return;
|
||||
try {
|
||||
const r = await api<{ message: string }>(`/api/admin/billing/payments/${pid}${what === "resend" ? "/resend" : ""}`, { method: what === "resend" ? "POST" : "DELETE" });
|
||||
setMsg({ type: "ok", text: r.message });
|
||||
load();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href="/billing" className="mb-2 inline-flex items-center gap-1 text-sm text-slate-500 hover:underline"><ArrowLeft className="h-4 w-4" /> Billing</Link>
|
||||
<PageTitle
|
||||
title={`Invoice ${inv.number}`}
|
||||
subtitle={<><Link className="text-indigo-600 hover:underline" href={`/customers/${inv.customerId}#billing`}>{inv.customer?.name}</Link>{inv.customer?.founding && <span className="ml-2 inline-flex items-center gap-1 text-amber-700"><Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" /> founding church</span>}</>}
|
||||
actions={<StateBadge state={inv.state} />}
|
||||
/>
|
||||
<div className="mb-4"><Message msg={msg} /></div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{inv.status === "draft" && <button className="btn-primary" onClick={approve} disabled={!!busy}><Check className="h-4 w-4" /> {busy === "approve" ? "Approving…" : "Approve"}</button>}
|
||||
{inv.status === "draft" && inv.licenceId && inv.periodStart && <button className="btn-secondary" onClick={() => act("recalc", "recalculate", {}, () => "Recalculated from the licence and founding status as they are now.")} disabled={!!busy}><RefreshCw className="h-4 w-4" /> Recalculate</button>}
|
||||
{inv.status === "approved" && <button className="btn-primary" onClick={() => confirm(`Send ${inv.number} to ${inv.recipientEmail} now?`) && act("send", "send", {}, () => `Sent to ${inv.recipientEmail}.`)} disabled={!!busy}><Send className="h-4 w-4" /> {busy === "send" ? "Sending…" : "Send now"}</button>}
|
||||
{inv.status === "approved" && <button className="btn-secondary" onClick={() => act("unapprove", "unapprove", {}, () => "Back to draft: it won't go out until you approve it again.")} disabled={!!busy}><Undo2 className="h-4 w-4" /> Back to draft</button>}
|
||||
{inv.status === "sent" && inv.balanceCents > 0 && <button className="btn-primary" onClick={() => setPaying(true)}>Record payment</button>}
|
||||
{inv.status === "sent" && <button className="btn-secondary" onClick={() => act("resend", "resend")} disabled={!!busy}><Send className="h-4 w-4" /> Send again</button>}
|
||||
<button className="btn-secondary" onClick={() => pdf(`/api/admin/billing/invoices/${id}/pdf`)}><FileText className="h-4 w-4" /> {editable ? "Preview PDF" : "PDF"}</button>
|
||||
{inv.status !== "void" && inv.payments.length === 0 && <button className="btn-danger" onClick={voidIt} disabled={!!busy}><XCircle className="h-4 w-4" /> Void</button>}
|
||||
</div>
|
||||
|
||||
{inv.sendError && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">The last attempt to send it failed: {inv.sendError}. It's tried again every 15 minutes.</div>}
|
||||
{inv.warnings.length > 0 && editable && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium"><AlertTriangle className="h-4 w-4" /> Check before approving</div>
|
||||
<ul className="list-disc space-y-0.5 pl-5">{inv.warnings.map(w => <li key={w}>{w}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{inv.planChange && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-900">
|
||||
<div><strong>Annual review: {inv.planChange.from} → {inv.planChange.to}</strong>{inv.planChange.band ? ` (12-month event income ${inv.planChange.band})` : ""}. {inv.status === "draft" ? "Approving sends the church a notice of licence change straight away; the tier changes when this invoice goes out." : inv.status === "approved" ? "The notice was sent; the tier changes when this invoice goes out." : ""}</div>
|
||||
{inv.status === "draft" && <button className="btn-secondary" onClick={dropChange} disabled={!!busy}>Keep the tier</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paying && <div className="mb-4"><RecordPayment invoice={inv} onClose={() => setPaying(false)} onSaved={m => { setMsg(m); setPaying(false); load(); }} /></div>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="card space-y-4">
|
||||
{editable ? (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div><label className="label" htmlFor="inv-date">Date (goes out)</label><input id="inv-date" type="date" className="input" value={form.issueDate} onChange={e => { setForm({ ...form, issueDate: e.target.value }); setDirty(true); }} /></div>
|
||||
<div><label className="label" htmlFor="inv-due">Due</label><input id="inv-due" type="date" className="input" value={form.dueDate} onChange={e => { setForm({ ...form, dueDate: e.target.value }); setDirty(true); }} /></div>
|
||||
<div><label className="label" htmlFor="inv-to">Send to</label><input id="inv-to" type="email" className="input" value={form.recipientEmail} onChange={e => { setForm({ ...form, recipientEmail: e.target.value }); setDirty(true); }} /></div>
|
||||
</div>
|
||||
<LinesEditor lines={lines} onChange={l => { setLines(l); setDirty(true); }} />
|
||||
<div><label className="label" htmlFor="inv-notes">Notes (printed on the invoice)</label><textarea id="inv-notes" className="input" rows={2} value={form.notes} onChange={e => { setForm({ ...form, notes: e.target.value }); setDirty(true); }} /></div>
|
||||
<button className="btn-primary" onClick={save} disabled={!dirty || !!busy}>{busy === "save" ? "Saving…" : "Save changes"}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs text-slate-500"><tr><th className="pb-2">Description</th><th className="pb-2 text-right">Qty</th><th className="pb-2 text-right">Unit</th><th className="pb-2 text-right">Amount</th></tr></thead>
|
||||
<tbody>{inv.lines.map((l, i) => <tr key={i} className="border-t border-slate-100"><td className="py-2">{l.description}</td><td className="py-2 text-right">{l.quantity}</td><td className="py-2 text-right tabular-nums">{money(l.unitCents)}</td><td className="py-2 text-right tabular-nums">{money(l.amountCents)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
<div className="ml-auto max-w-xs space-y-1 text-sm">
|
||||
{inv.vatCents > 0 && <div className="flex justify-between text-slate-500"><span>Includes VAT</span><span className="tabular-nums">{money(inv.vatCents)}</span></div>}
|
||||
<div className="flex justify-between font-semibold"><span>Total</span><span className="tabular-nums">{money(inv.totalCents)}</span></div>
|
||||
{inv.status === "sent" && <><div className="flex justify-between text-green-700"><span>Paid</span><span className="tabular-nums">{money(inv.paidCents)}</span></div><div className="flex justify-between font-semibold"><span>Balance</span><span className="tabular-nums">{money(inv.balanceCents)}</span></div></>}
|
||||
</div>
|
||||
{inv.notes && <p className="text-sm text-slate-600">Notes: {inv.notes}</p>}
|
||||
{inv.voidReason && <p className="text-sm text-slate-600">Void: {inv.voidReason}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="card space-y-1.5 text-sm">
|
||||
<h2 className="mb-1 font-semibold">About it</h2>
|
||||
<Row k="Date" v={day(inv.issueDate)} />
|
||||
<Row k="Due" v={day(inv.dueDate)} />
|
||||
{inv.periodStart && <Row k="Period" v={`${day(inv.periodStart)} – ${day(inv.periodEnd)}`} />}
|
||||
<Row k="Tier" v={inv.plan || "One-off"} />
|
||||
{inv.reviewed && <Row k="12-month income" v={inv.incomeBand || "not reported"} />}
|
||||
<Row k="Sends to" v={inv.recipientEmail || "—"} />
|
||||
{inv.licence?.installUrl && <Row k="Site" v={inv.licence.installUrl.replace(/^https?:\/\//, "")} />}
|
||||
{inv.approvalEmailedAt && <Row k="Approval emailed" v={fmtDate(inv.approvalEmailedAt)} />}
|
||||
{inv.approvedAt && <Row k="Approved" v={fmtDate(inv.approvedAt)} />}
|
||||
{inv.sentAt && <Row k="Sent" v={fmtDate(inv.sentAt)} />}
|
||||
</div>
|
||||
|
||||
{inv.payments.length > 0 && (
|
||||
<div className="card text-sm">
|
||||
<h2 className="mb-2 font-semibold">Payments</h2>
|
||||
{inv.payments.map(p => (
|
||||
<div key={p.id} className="border-t border-slate-100 py-2 first:border-0">
|
||||
<div className="flex justify-between"><span>{p.receiptNumber} · {day(p.paidOn)}</span><strong className="tabular-nums">{money(p.amountCents)}</strong></div>
|
||||
<div className="text-xs text-slate-500">{METHOD[p.method] || p.method}{p.reference && p.reference !== inv.number ? ` · ${p.reference}` : ""}{p.receiptSentAt ? " · receipt sent" : ""}</div>
|
||||
<div className="mt-1 flex gap-3 text-xs">
|
||||
<button className="text-indigo-700 hover:underline" onClick={() => pdf(`/api/admin/billing/payments/${p.id}/pdf`)}>Receipt</button>
|
||||
<button className="text-indigo-700 hover:underline" onClick={() => paymentAction(p.id, "resend")}>Email receipt</button>
|
||||
<button className="text-red-700 hover:underline" onClick={() => paymentAction(p.id, "delete")}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inv.licenceChanges.length > 0 && (
|
||||
<div className="card text-sm">
|
||||
<h2 className="mb-2 font-semibold">Notice of licence change</h2>
|
||||
{inv.licenceChanges.map(c => (
|
||||
<div key={c.id}>
|
||||
{c.number}: {c.fromPlan} → {c.toPlan} from {day(c.effectiveDate)}
|
||||
<div className="text-xs text-slate-500">{c.sentAt ? `Sent ${fmtDate(c.sentAt)}` : "Not sent"}{c.appliedAt ? " · in effect" : ""}</div>
|
||||
<button className="text-xs text-indigo-700 hover:underline" onClick={() => pdf(`/api/admin/billing/licence-changes/${c.id}/pdf`)}>Open the notice</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card text-sm">
|
||||
<h2 className="mb-2 font-semibold">Emails</h2>
|
||||
{inv.emails.length === 0 ? <p className="text-slate-500">None yet.</p> : inv.emails.map(e => (
|
||||
<div key={e.id} className="border-t border-slate-100 py-1.5 first:border-0">
|
||||
<div>{EMAIL_KIND[e.kind] || e.kind} → {e.to}</div>
|
||||
<div className={`text-xs ${e.error ? "text-red-700" : "text-slate-500"}`}>{fmtDate(e.sentAt)}{e.error ? ` · failed: ${e.error}` : ""}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ k, v }: { k: string; v: React.ReactNode }) => <div className="flex justify-between gap-3"><span className="text-slate-500">{k}</span><span className="text-right">{v}</span></div>;
|
||||
|
||||
export default function InvoicePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return id === "new" ? <NewInvoice /> : <InvoiceDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText, Play, Plus, Star } from "lucide-react";
|
||||
import { api, errorText, openPdf } from "@/lib/api";
|
||||
import { matches, usePaged } from "@/lib/paging";
|
||||
import { Loading, Message, PageTitle, Pager, SearchInput, Stat, Tabs } from "@/components/ui";
|
||||
import { CYCLE, METHOD, StateBadge, day, money, type BillingCycle, type Invoice, type Payment } from "@/lib/billing";
|
||||
import { RecordPayment } from "@/components/billing/RecordPayment";
|
||||
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
type Overview = { drafts: number; approved: number; owingCents: number; overdueCents: number; overdue: number; receivedThisMonthCents: number };
|
||||
type Account = {
|
||||
id: string; name: string; founding: boolean; foundingSince: string | null; foundingNote: string | null; billingEmail: string | null;
|
||||
balanceCents: number; overdue: number;
|
||||
licences: { id: string; key: string; plan: string; status: string; billingCycle: BillingCycle; nextInvoiceDate: string | null; reviewDate: string | null; customMonthlyCents: number | null; url: string | null }[];
|
||||
};
|
||||
type Tab = "invoices" | "payments" | "churches";
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "invoices", label: "Invoices" },
|
||||
{ key: "payments", label: "Payments" },
|
||||
{ key: "churches", label: "Churches and founding" },
|
||||
];
|
||||
const FILTERS = [
|
||||
["", "All invoices"], ["draft", "Waiting for approval"], ["approved", "Approved, not sent yet"], ["unpaid", "Owing"],
|
||||
["overdue", "Overdue"], ["paid", "Paid"], ["void", "Void"],
|
||||
] as const;
|
||||
|
||||
/** Billing: invoices (drafted automatically, approved here), payments and receipts, churches and founding status. */
|
||||
export default function BillingPage() {
|
||||
const [tab, setTab] = useState<Tab>("invoices");
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [invoices, setInvoices] = useState<Invoice[] | null>(null);
|
||||
const [payments, setPayments] = useState<Payment[] | null>(null);
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const loadOverview = useCallback(() => api<Overview>("/api/admin/billing/overview").then(setOverview).catch(e => setMsg({ type: "err", text: errorText(e) })), []);
|
||||
const loadInvoices = useCallback(() => api<Invoice[]>(`/api/admin/billing/invoices${filter ? `?status=${filter}` : ""}`).then(setInvoices).catch(e => setMsg({ type: "err", text: errorText(e) })), [filter]);
|
||||
const loadPayments = useCallback(() => api<Payment[]>("/api/admin/billing/payments").then(setPayments).catch(e => setMsg({ type: "err", text: errorText(e) })), []);
|
||||
const loadAccounts = useCallback(() => api<Account[]>("/api/admin/billing/customers").then(setAccounts).catch(e => setMsg({ type: "err", text: errorText(e) })), []);
|
||||
|
||||
useEffect(() => {
|
||||
const fromHash = window.location.hash.slice(1);
|
||||
if (TABS.some(t => t.key === fromHash)) setTab(fromHash as Tab);
|
||||
const f = new URLSearchParams(window.location.search).get("status");
|
||||
if (f && FILTERS.some(([k]) => k === f)) setFilter(f);
|
||||
loadOverview();
|
||||
}, [loadOverview]);
|
||||
useEffect(() => { if (tab === "invoices") loadInvoices(); }, [tab, loadInvoices]);
|
||||
useEffect(() => { if (tab === "payments" || paying) { loadPayments(); loadAccounts(); } }, [tab, paying, loadPayments, loadAccounts]);
|
||||
useEffect(() => { if (tab === "churches") loadAccounts(); }, [tab, loadAccounts]);
|
||||
|
||||
const openTab = (t: Tab) => { setTab(t); setQuery(""); window.history.replaceState(null, "", `#${t}`); };
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
const r = await api<{ drafted: number; sent: number; reminded: number; applied: number }>("/api/admin/billing/run", { method: "POST" });
|
||||
setMsg({ type: "ok", text: `Billing run: ${r.drafted} drafted, ${r.sent} sent, ${r.applied} tier change${r.applied === 1 ? "" : "s"} applied.` });
|
||||
loadOverview(); loadInvoices();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); } finally { setRunning(false); }
|
||||
};
|
||||
|
||||
const toggleFounding = async (a: Account) => {
|
||||
try {
|
||||
await api(`/api/admin/billing/customers/${a.id}`, { method: "PUT", body: { founding: !a.founding } });
|
||||
setMsg({ type: "ok", text: a.founding ? `${a.name} is no longer a founding church. Drafts already made keep their price; recalculate them if needed.` : `${a.name} is now a founding church: 35% off from the next draft.` });
|
||||
loadAccounts();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); }
|
||||
};
|
||||
|
||||
const shownInvoices = useMemo(() => (invoices || []).filter(i => matches(query, i.number, i.customer?.name, i.plan, i.recipientEmail)), [invoices, query]);
|
||||
const shownPayments = useMemo(() => (payments || []).filter(p => matches(query, p.receiptNumber, p.customer?.name, p.reference, p.invoice?.number)), [payments, query]);
|
||||
const shownAccounts = useMemo(() => (accounts || []).filter(a => matches(query, a.name, a.billingEmail, ...a.licences.map(l => l.plan))), [accounts, query]);
|
||||
const invPaged = usePaged(shownInvoices, `${query}|${filter}`, 25);
|
||||
const payPaged = usePaged(shownPayments, query, 25);
|
||||
const founding = (accounts || []).filter(a => a.founding).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="Billing"
|
||||
subtitle="Invoices are drafted 14 days before their date and emailed to you to approve; approved ones go out on their date."
|
||||
actions={<>
|
||||
<button className="btn-secondary" onClick={run} disabled={running}><Play className="h-4 w-4" /> {running ? "Running…" : "Run billing now"}</button>
|
||||
<Link className="btn-secondary" href="/billing/invoices/new"><Plus className="h-4 w-4" /> One-off invoice</Link>
|
||||
<button className="btn-primary" onClick={() => setPaying(true)}><Plus className="h-4 w-4" /> Record payment</button>
|
||||
</>}
|
||||
/>
|
||||
<div className="mb-4"><Message msg={msg} /></div>
|
||||
|
||||
{overview && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<button type="button" className="text-left" onClick={() => { setFilter("draft"); openTab("invoices"); }}><Stat label="Waiting for approval" value={overview.drafts} tone={overview.drafts ? "amber" : undefined} /></button>
|
||||
<button type="button" className="text-left" onClick={() => { setFilter("approved"); openTab("invoices"); }}><Stat label="Approved, to go out" value={overview.approved} /></button>
|
||||
<button type="button" className="text-left" onClick={() => { setFilter("unpaid"); openTab("invoices"); }}><Stat label="Owing" value={money(overview.owingCents)} /></button>
|
||||
<button type="button" className="text-left" onClick={() => { setFilter("overdue"); openTab("invoices"); }}><Stat label={`Overdue (${overview.overdue})`} value={money(overview.overdueCents)} tone={overview.overdue ? "red" : undefined} /></button>
|
||||
<Stat label="Received this month" value={money(overview.receivedThisMonthCents)} tone="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paying && accounts && (
|
||||
<div className="mb-6"><RecordPayment customers={accounts.map(a => ({ id: a.id, name: a.name }))} onClose={() => setPaying(false)} onSaved={(m) => { setMsg(m); setPaying(false); loadOverview(); loadPayments(); loadInvoices(); }} /></div>
|
||||
)}
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onChange={openTab} />
|
||||
<div className="mt-4 mb-3 flex flex-wrap items-center gap-3">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder={tab === "payments" ? "Search receipts, churches, references" : tab === "churches" ? "Search churches" : "Search invoices, churches"} className="w-72 max-w-full" />
|
||||
{tab === "invoices" && (
|
||||
<select className="input w-auto" value={filter} onChange={e => { setFilter(e.target.value); window.history.replaceState(null, "", `${e.target.value ? `?status=${e.target.value}` : "?"}#invoices`); }} aria-label="Show">
|
||||
{FILTERS.map(([k, label]) => <option key={k} value={k}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "invoices" && (!invoices ? <Loading /> : (
|
||||
<div className="card overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs text-slate-500"><tr><th className="p-3">Invoice</th><th className="p-3">Church</th><th className="p-3">Date</th><th className="p-3 text-right">Total</th><th className="p-3 text-right">Balance</th><th className="p-3">State</th><th className="p-3" /></tr></thead>
|
||||
<tbody>
|
||||
{invPaged.rows.map(i => (
|
||||
<tr key={i.id} className="border-t border-slate-100">
|
||||
<td className="p-3"><Link className="font-medium text-indigo-700 hover:underline" href={`/billing/invoices/${i.id}`}>{i.number}</Link>{i.planChange && <div className="text-xs text-amber-700">{i.planChange.from} → {i.planChange.to}</div>}{i.warnings.length > 0 && i.status === "draft" && <div className="text-xs text-amber-700">Check {i.warnings.length === 1 ? "1 note" : `${i.warnings.length} notes`}</div>}</td>
|
||||
<td className="p-3">{i.customer?.name}{i.customer?.founding && <Star className="ml-1 inline h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-label="Founding church" />}<div className="text-xs text-slate-500">{i.plan || "One-off"}</div></td>
|
||||
<td className="p-3 whitespace-nowrap">{day(i.issueDate)}<div className="text-xs text-slate-500">due {day(i.dueDate)}</div></td>
|
||||
<td className="p-3 text-right tabular-nums">{money(i.totalCents)}</td>
|
||||
<td className="p-3 text-right tabular-nums">{i.status === "sent" ? money(i.balanceCents) : "—"}</td>
|
||||
<td className="p-3"><StateBadge state={i.state} short /></td>
|
||||
<td className="p-3 text-right"><button className="text-slate-500 hover:text-indigo-700" title="Open the PDF" onClick={() => openPdf(`/api/admin/billing/invoices/${i.id}/pdf`).catch(e => setMsg({ type: "err", text: errorText(e) }))}><FileText className="h-4 w-4" /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{shownInvoices.length === 0 && <tr><td colSpan={7} className="p-6 text-center text-slate-500">No invoices{filter || query ? " match" : " yet. Set up billing on a customer's licence and the drafts appear here 14 days before each invoice date"}.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-3"><Pager {...invPaged} /></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "payments" && (!payments ? <Loading /> : (
|
||||
<div className="card overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs text-slate-500"><tr><th className="p-3">Receipt</th><th className="p-3">Church</th><th className="p-3">Received</th><th className="p-3">For</th><th className="p-3 text-right">Amount</th><th className="p-3">Receipt sent</th><th className="p-3" /></tr></thead>
|
||||
<tbody>
|
||||
{payPaged.rows.map(p => (
|
||||
<tr key={p.id} className="border-t border-slate-100">
|
||||
<td className="p-3 font-medium">{p.receiptNumber}<div className="text-xs text-slate-500">{METHOD[p.method] || p.method}{p.reference ? ` · ${p.reference}` : ""}</div></td>
|
||||
<td className="p-3"><Link className="hover:underline" href={`/customers/${p.customerId}#billing`}>{p.customer?.name}</Link></td>
|
||||
<td className="p-3 whitespace-nowrap">{day(p.paidOn)}</td>
|
||||
<td className="p-3">{p.invoice ? <Link className="text-indigo-700 hover:underline" href={`/billing/invoices/${p.invoice.id}`}>{p.invoice.number}</Link> : "On account"}</td>
|
||||
<td className="p-3 text-right tabular-nums">{money(p.amountCents)}</td>
|
||||
<td className="p-3 text-slate-600">{p.receiptSentAt ? day(p.receiptSentAt) : "Not sent"}</td>
|
||||
<td className="p-3 text-right"><button className="text-slate-500 hover:text-indigo-700" title="Open the receipt" onClick={() => openPdf(`/api/admin/billing/payments/${p.id}/pdf`).catch(e => setMsg({ type: "err", text: errorText(e) }))}><FileText className="h-4 w-4" /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{shownPayments.length === 0 && <tr><td colSpan={7} className="p-6 text-center text-slate-500">No payments{query ? " match" : " recorded yet"}.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-3"><Pager {...payPaged} /></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "churches" && (!accounts ? <Loading /> : (
|
||||
<>
|
||||
<p className="mb-3 text-sm text-slate-600">{founding} founding church{founding === 1 ? "" : "es"}. Founding churches pay the founding price (35% less) on every tier for as long as they stay. Set billing up for each licence on the church's page.</p>
|
||||
<div className="card overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs text-slate-500"><tr><th className="p-3">Church</th><th className="p-3">Founding</th><th className="p-3">Tier and billing</th><th className="p-3">Next invoice</th><th className="p-3 text-right">Owing</th></tr></thead>
|
||||
<tbody>
|
||||
{shownAccounts.map(a => (
|
||||
<tr key={a.id} className="border-t border-slate-100 align-top">
|
||||
<td className="p-3"><Link className="font-medium text-indigo-700 hover:underline" href={`/customers/${a.id}#billing`}>{a.name}</Link><div className="text-xs text-slate-500">{a.billingEmail || ""}</div></td>
|
||||
<td className="p-3">
|
||||
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={a.founding} onChange={() => toggleFounding(a)} /> {a.founding ? "Founding" : "No"}</label>
|
||||
{a.founding && <div className="text-xs text-slate-500">{a.foundingSince ? `since ${day(a.foundingSince)}` : ""}{a.foundingNote ? ` · ${a.foundingNote}` : ""}</div>}
|
||||
</td>
|
||||
<td className="p-3">{a.licences.length === 0 ? <span className="text-slate-400">No licence</span> : a.licences.map(l => (
|
||||
<div key={l.id}>{l.plan}{l.customMonthlyCents != null ? ` (${money(l.customMonthlyCents)}/month)` : ""} · <span className={l.billingCycle === "none" ? "text-slate-400" : ""}>{CYCLE[l.billingCycle]}</span>{l.status !== "active" && <span className="text-red-600"> · {l.status}</span>}</div>
|
||||
))}</td>
|
||||
<td className="p-3">{a.licences.filter(l => l.billingCycle !== "none").map(l => <div key={l.id}>{day(l.nextInvoiceDate)}{l.reviewDate && <span className="text-xs text-slate-500"> · review {day(l.reviewDate)}</span>}</div>)}</td>
|
||||
<td className={`p-3 text-right tabular-nums ${a.overdue ? "text-red-700" : ""}`}>{a.balanceCents ? money(a.balanceCents) : "—"}{a.overdue > 0 && <div className="text-xs">{a.overdue} overdue</div>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{shownAccounts.length === 0 && <tr><td colSpan={5} className="p-6 text-center text-slate-500">No churches{query ? " match" : " yet"}.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ago, fmtDate } from "@/lib/format";
|
||||
import { PLANS } from "@/lib/tiers";
|
||||
import type { Customer, Licence, LicenceStatus } from "@/lib/types";
|
||||
import { Badge, Loading, Message, PageTitle } from "@/components/ui";
|
||||
import { CustomerBilling } from "@/components/billing/CustomerBilling";
|
||||
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
const dateInput = (d: string | null) => (d ? d.slice(0, 10) : "");
|
||||
@@ -77,7 +78,7 @@ function LicenceCard({ licence, onSaved, setMsg }: { licence: Licence; onSaved:
|
||||
{!PLANS.includes(form.plan) && <option value={form.plan}>{form.plan}</option>}
|
||||
{PLANS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">Shown on the church's Licence page. Founding discount: note it below.</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Shown on the church's Licence page. Billing and founding status are under Billing below.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
@@ -109,7 +110,7 @@ function LicenceCard({ licence, onSaved, setMsg }: { licence: Licence; onSaved:
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Notes</label>
|
||||
<input className="input" value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} placeholder="e.g. founding church, paid annually, invoice #123" />
|
||||
<input className="input" value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} placeholder="Anything worth remembering about this licence" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn-primary" onClick={save} disabled={busy}>{busy ? "Saving…" : "Save licence"}</button>
|
||||
@@ -183,6 +184,8 @@ export default function CustomerPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomerBilling key={c.licences.map(l => `${l.id}-${l.status}`).join()} customerId={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { LayoutDashboard, Building2, BellRing, Settings, LogOut, Rocket, Megaphone } from "lucide-react";
|
||||
import { LayoutDashboard, Building2, BellRing, Settings, LogOut, Rocket, Megaphone, Receipt } from "lucide-react";
|
||||
import { api, getToken, setToken } from "@/lib/api";
|
||||
import { Logo } from "@/components/ui";
|
||||
|
||||
@@ -12,6 +12,7 @@ type Me = { id: string; name: string; email: string };
|
||||
const NAV = [
|
||||
{ href: "/", label: "Installs", icon: LayoutDashboard },
|
||||
{ href: "/customers", label: "Customers", icon: Building2 },
|
||||
{ href: "/billing", label: "Billing", icon: Receipt },
|
||||
{ href: "/alerts", label: "Alerts", icon: BellRing },
|
||||
{ href: "/releases", label: "Releases", icon: Rocket },
|
||||
{ href: "/notices", label: "Notices", icon: Megaphone },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fmtDate, rand, randFine } from "@/lib/format";
|
||||
import { matches, usePaged } from "@/lib/paging";
|
||||
import type { AiInstallUse } from "@/lib/types";
|
||||
import { Loading, Message, PageTitle, Pager, SearchInput, Tabs } from "@/components/ui";
|
||||
import { BillingSettings } from "@/components/billing/BillingSettings";
|
||||
|
||||
type Settings = Record<string, string>;
|
||||
type AuditRow = { id: string; action: string; target: string | null; metadata: unknown; createdAt: string; actor: { name: string } | null };
|
||||
@@ -21,10 +22,11 @@ const FIELDS: { key: string; label: string; hint: string; type?: string }[] = [
|
||||
{ key: "licence_expiry_warning_days", label: "Warn about licence expiry (days before)", hint: "Default 14.", type: "number" },
|
||||
];
|
||||
|
||||
type Tab = "alerts" | "email" | "ai" | "security" | "audit";
|
||||
type Tab = "alerts" | "email" | "billing" | "ai" | "security" | "audit";
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "alerts", label: "Alerts" },
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "billing", label: "Billing" },
|
||||
{ key: "ai", label: "AI" },
|
||||
{ key: "security", label: "Login and security" },
|
||||
{ key: "audit", label: "Audit log" },
|
||||
@@ -301,6 +303,7 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === "billing" && <BillingSettings settings={settings} onSaved={setSettings} setMsg={setMsg} />}
|
||||
{tab === "ai" && <AiTab settings={settings} models={aiModels} onSaved={setSettings} setMsg={setMsg} />}
|
||||
|
||||
{tab === "security" && (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
|
||||
type Settings = Record<string, string>;
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
type Field = { key: string; label: string; hint?: string; type?: string; wide?: boolean };
|
||||
|
||||
const GROUPS: { title: string; intro?: string; fields: Field[] }[] = [
|
||||
{
|
||||
title: "Your business (printed on every document)",
|
||||
fields: [
|
||||
{ key: "business_name", label: "Business name" },
|
||||
{ key: "business_owner", label: "Owner" },
|
||||
{ key: "business_registration", label: "Registration (optional)", hint: "e.g. a CIPC number, if you have one." },
|
||||
{ key: "business_address", label: "Address", wide: true },
|
||||
{ key: "business_email", label: "Email", type: "email" },
|
||||
{ key: "business_phone", label: "Phone" },
|
||||
{ key: "business_website", label: "Website" },
|
||||
{ key: "billing_reply_to", label: "Replies go to", type: "email", hint: "Blank: the business email." },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Banking details (how churches pay)",
|
||||
intro: "Shown under \"How to pay\" on invoices and statements. Until they're filled in, documents ask the church to contact you for them.",
|
||||
fields: [
|
||||
{ key: "bank_account_name", label: "Account name" },
|
||||
{ key: "bank_name", label: "Bank" },
|
||||
{ key: "bank_account_number", label: "Account number" },
|
||||
{ key: "bank_branch_code", label: "Branch code" },
|
||||
{ key: "bank_account_type", label: "Account type", hint: "e.g. Cheque, Business current" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Timing and numbering",
|
||||
fields: [
|
||||
{ key: "billing_draft_days", label: "Draft invoices this many days before", type: "number", hint: "Default 14. The draft is emailed to the alert email for approval." },
|
||||
{ key: "billing_send_hour", label: "Send approved invoices from (hour, SA time)", type: "number", hint: "Default 8 (08:00) on the invoice date." },
|
||||
{ key: "payment_terms_days", label: "Payment due after (days)", type: "number", hint: "Default 7." },
|
||||
{ key: "invoice_prefix", label: "Invoice numbers start with", hint: "INV → INV-2026-0001" },
|
||||
{ key: "receipt_prefix", label: "Receipt numbers start with", hint: "REC → REC-2026-0001" },
|
||||
{ key: "change_prefix", label: "Licence change notices start with", hint: "LC → LC-2026-0001" },
|
||||
{ key: "invoice_footer", label: "Line at the bottom of invoices", wide: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const KEYS = ["billing_enabled", "vat_registered", "vat_number", "vat_rate", ...GROUPS.flatMap(g => g.fields.map(f => f.key))];
|
||||
|
||||
/** Settings → Billing. */
|
||||
export function BillingSettings({ settings, onSaved, setMsg }: { settings: Settings; onSaved: (s: Settings) => void; setMsg: (m: Msg) => void }) {
|
||||
const [form, setForm] = useState<Settings>(() => Object.fromEntries(KEYS.map(k => [k, settings[k] ?? ""])));
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
const vat = form.vat_registered === "true";
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (vat && !form.vat_number.trim()) { setMsg({ type: "err", text: "Enter your VAT number before switching VAT on." }); return; }
|
||||
try {
|
||||
const r = await api<{ settings: Settings }>("/api/admin/settings", { method: "PUT", body: { settings: form } });
|
||||
onSaved(r.settings);
|
||||
setMsg({ type: "ok", text: "Billing settings saved." });
|
||||
} catch (err) { setMsg({ type: "err", text: errorText(err) }); }
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={save} className="space-y-6">
|
||||
<div className="card space-y-3">
|
||||
<label className="flex items-center gap-2 font-medium"><input type="checkbox" checked={form.billing_enabled === "true"} onChange={e => set("billing_enabled", e.target.checked ? "true" : "")} /> Automatic billing on</label>
|
||||
<p className="text-sm text-slate-600">When it's on, invoices are drafted for billed licences, emailed to you for approval, and approved ones go out on their date. Off: nothing is drafted or sent automatically (you can still do it all by hand).</p>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<h2 className="font-semibold">VAT</h2>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={vat} onChange={e => set("vat_registered", e.target.checked ? "true" : "")} /> I'm registered for VAT</label>
|
||||
<p className="text-sm text-slate-600">
|
||||
{vat
|
||||
? "Prices are VAT-inclusive: totals stay the same, documents become tax invoices with your VAT number and show the VAT included. Only invoices made from now on change."
|
||||
: "Not registered: documents are plain invoices with no VAT on them, as the law requires. When you register, tick this: your prices then include VAT, so churches pay the same."}
|
||||
</p>
|
||||
{vat && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className="label" htmlFor="vat_number">VAT number</label><input id="vat_number" className="input" value={form.vat_number} onChange={e => set("vat_number", e.target.value)} /></div>
|
||||
<div><label className="label" htmlFor="vat_rate">VAT rate (%)</label><input id="vat_rate" type="number" className="input" value={form.vat_rate} onChange={e => set("vat_rate", e.target.value)} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{GROUPS.map(g => (
|
||||
<div key={g.title} className="card space-y-3">
|
||||
<h2 className="font-semibold">{g.title}</h2>
|
||||
{g.intro && <p className="text-sm text-slate-600">{g.intro}</p>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{g.fields.map(f => (
|
||||
<div key={f.key} className={f.wide ? "sm:col-span-2" : ""}>
|
||||
<label className="label" htmlFor={f.key}>{f.label}</label>
|
||||
<input id={f.key} className="input" type={f.type || "text"} value={form[f.key] ?? ""} onChange={e => set(f.key, e.target.value)} />
|
||||
{f.hint && <p className="mt-1 text-xs text-slate-500">{f.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn-primary">Save billing settings</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText, Mail, Plus, Star } from "lucide-react";
|
||||
import { api, errorText, openPdf } from "@/lib/api";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { CYCLE, EMAIL_KIND, METHOD, StateBadge, centsFrom, day, money, randInput, todayYmd, type BillingCycle, type BillingEmail, type BillingTier, type Invoice, type LicenceChange, type Payment } from "@/lib/billing";
|
||||
import { RecordPayment } from "./RecordPayment";
|
||||
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
type BillingLicence = { id: string; key: string; plan: string; status: string; billingCycle: BillingCycle; billingStart: string | null; nextInvoiceDate: string | null; reviewDate: string | null; customMonthlyCents: number | null; url: string | null; incomeBand: string | null };
|
||||
type CustomerBillingData = {
|
||||
id: string; name: string; billingEmail: string | null; billingAddress: string | null; founding: boolean; foundingSince: string | null; foundingNote: string | null;
|
||||
sendsTo: string | null; balanceCents: number; licences: BillingLicence[]; invoices: Invoice[]; payments: Payment[]; licenceChanges: LicenceChange[]; emails: BillingEmail[]; tiers: BillingTier[];
|
||||
};
|
||||
|
||||
const yearAgo = () => { const d = new Date(`${todayYmd()}T00:00:00Z`); d.setUTCFullYear(d.getUTCFullYear() - 1); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
|
||||
|
||||
/** The tier a band ("R100 001–R150 000") falls in. */
|
||||
function tierFor(tiers: BillingTier[], band: string | null) {
|
||||
if (!band || band === "over R100 000") return null;
|
||||
if (/^over /.test(band)) return tiers.find(t => t.upTo == null) || null;
|
||||
const top = Number((band.split("–")[1] || "").replace(/[^\d]/g, ""));
|
||||
return tiers.find(t => t.upTo == null || top <= t.upTo) || null;
|
||||
}
|
||||
|
||||
/** One licence's billing set-up, and a notice of licence change for it. */
|
||||
function LicenceBilling({ licence, tiers, onSaved, setMsg }: { licence: BillingLicence; tiers: BillingTier[]; onSaved: () => void; setMsg: (m: Msg) => void }) {
|
||||
const [form, setForm] = useState({
|
||||
billingCycle: licence.billingCycle, billingStart: licence.billingStart || "", nextInvoiceDate: licence.nextInvoiceDate || "",
|
||||
reviewDate: licence.reviewDate || "", custom: randInput(licence.customMonthlyCents),
|
||||
});
|
||||
const [change, setChange] = useState<{ toPlan: string; effectiveDate: string; price: string; reason: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const suggested = tierFor(tiers, licence.incomeBand);
|
||||
const tier = tiers.find(t => t.name === licence.plan);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/admin/billing/licences/${licence.id}`, {
|
||||
method: "PUT",
|
||||
body: { billingCycle: form.billingCycle, billingStart: form.billingStart || null, nextInvoiceDate: form.nextInvoiceDate || null, reviewDate: form.reviewDate || null, customMonthlyCents: form.custom.trim() ? centsFrom(form.custom) : null },
|
||||
});
|
||||
setMsg({ type: "ok", text: form.billingCycle === "none" ? "Saved: this licence isn't billed." : "Billing saved. The next draft is made 14 days before the next invoice date." });
|
||||
onSaved();
|
||||
} catch (e) { setMsg({ type: "err", text: errorText(e) }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const sendChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!change) return;
|
||||
if (!confirm(`Email ${licence.plan} → ${change.toPlan} from ${day(change.effectiveDate)} to the church now?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{ change: LicenceChange; email: { ok: boolean; error: string | null } | null }>("/api/admin/billing/licence-changes", {
|
||||
method: "POST", body: { licenceId: licence.id, toPlan: change.toPlan, effectiveDate: change.effectiveDate, reason: change.reason, toMonthlyCents: change.price.trim() ? centsFrom(change.price) : null },
|
||||
});
|
||||
setMsg({ type: r.email?.ok ? "ok" : "err", text: r.email?.ok ? `Notice ${r.change.number} sent. The tier changes on ${day(r.change.effectiveDate)}.` : `Notice ${r.change.number} recorded but not emailed: ${r.email?.error}` });
|
||||
setChange(null);
|
||||
onSaved();
|
||||
} catch (err) { setMsg({ type: "err", text: errorText(err) }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><code className="text-xs">{licence.key}</code>{licence.url && <span className="ml-2 text-xs text-slate-500">{licence.url.replace(/^https?:\/\//, "")}</span>}</div>
|
||||
<div className="text-sm">
|
||||
Tier <strong>{licence.plan}</strong>{licence.customMonthlyCents != null ? ` at ${money(licence.customMonthlyCents)}/month` : tier?.monthly != null ? ` (R${tier.monthly}/month)` : ""}
|
||||
{licence.incomeBand && <span className="ml-2 text-xs text-slate-500">12-month income {licence.incomeBand}{suggested && suggested.name !== licence.plan ? ` → ${suggested.name} at the next review` : ""}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label" htmlFor={`cycle-${licence.id}`}>Billing</label>
|
||||
<select id={`cycle-${licence.id}`} className="input" value={form.billingCycle} onChange={e => setForm({ ...form, billingCycle: e.target.value as BillingCycle })}>
|
||||
{(Object.keys(CYCLE) as BillingCycle[]).map(c => <option key={c} value={c}>{CYCLE[c]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label" htmlFor={`start-${licence.id}`}>Billing started</label><input id={`start-${licence.id}`} type="date" className="input" value={form.billingStart} onChange={e => setForm({ ...form, billingStart: e.target.value })} disabled={form.billingCycle === "none"} /></div>
|
||||
<div><label className="label" htmlFor={`next-${licence.id}`}>Next invoice</label><input id={`next-${licence.id}`} type="date" className="input" value={form.nextInvoiceDate} onChange={e => setForm({ ...form, nextInvoiceDate: e.target.value })} disabled={form.billingCycle === "none"} /></div>
|
||||
<div><label className="label" htmlFor={`review-${licence.id}`}>Next tier review</label><input id={`review-${licence.id}`} type="date" className="input" value={form.reviewDate} onChange={e => setForm({ ...form, reviewDate: e.target.value })} disabled={form.billingCycle === "none"} /></div>
|
||||
<div><label className="label" htmlFor={`custom-${licence.id}`}>Custom price (R/month)</label><input id={`custom-${licence.id}`} className="input" inputMode="decimal" placeholder="Tier price" value={form.custom} onChange={e => setForm({ ...form, custom: e.target.value })} /></div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">Invoices are in advance: each covers the month (or year) starting on its date, on the same day of the month as billing started. Leave the next invoice and review blank to start from the billing date and review a year later. A custom price replaces the tier's (e.g. Network); founding churches get 35% off it.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button className="btn-primary" onClick={save} disabled={busy}>Save billing</button>
|
||||
{!change && <button className="btn-secondary" onClick={() => setChange({ toPlan: suggested && suggested.name !== licence.plan ? suggested.name : "", effectiveDate: licence.nextInvoiceDate || todayYmd(), price: "", reason: "" })}>Notice of licence change…</button>}
|
||||
</div>
|
||||
{change && (
|
||||
<form onSubmit={sendChange} className="mt-4 space-y-3 rounded-lg bg-slate-50 p-3">
|
||||
<div className="text-sm font-medium">Notice of licence change (emailed with a PDF)</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="label" htmlFor={`to-${licence.id}`}>New tier</label>
|
||||
<select id={`to-${licence.id}`} className="input" value={change.toPlan} onChange={e => setChange({ ...change, toPlan: e.target.value })} required>
|
||||
<option value="">Choose…</option>
|
||||
{tiers.map(t => <option key={t.name} value={t.name} disabled={t.name === licence.plan}>{t.name}{t.monthly != null ? ` (R${t.monthly}/month)` : " (custom price)"}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label" htmlFor={`eff-${licence.id}`}>Takes effect</label><input id={`eff-${licence.id}`} type="date" className="input" value={change.effectiveDate} onChange={e => setChange({ ...change, effectiveDate: e.target.value })} required /></div>
|
||||
<div><label className="label" htmlFor={`price-${licence.id}`}>New price (R/month, for a custom price)</label><input id={`price-${licence.id}`} className="input" inputMode="decimal" placeholder="Tier price" value={change.price} onChange={e => setChange({ ...change, price: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><label className="label" htmlFor={`why-${licence.id}`}>Why (printed on the notice)</label><input id={`why-${licence.id}`} className="input" value={change.reason} onChange={e => setChange({ ...change, reason: e.target.value })} placeholder="e.g. As agreed on our call." /></div>
|
||||
<div className="flex gap-2"><button className="btn-primary" disabled={busy}>Send notice</button><button type="button" className="btn-secondary" onClick={() => setChange(null)}>Cancel</button></div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer page → Billing: founding, where invoices go, each licence's billing, statements, invoices, payments. */
|
||||
export function CustomerBilling({ customerId }: { customerId: string }) {
|
||||
const [c, setC] = useState<CustomerBillingData | null>(null);
|
||||
const [form, setForm] = useState({ founding: false, foundingSince: "", foundingNote: "", billingEmail: "", billingAddress: "" });
|
||||
const [stmt, setStmt] = useState({ from: yearAgo(), to: todayYmd(), email: "" });
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const load = useCallback(() => api<CustomerBillingData>(`/api/admin/billing/customers/${customerId}`).then(x => {
|
||||
setC(x);
|
||||
setForm({ founding: x.founding, foundingSince: x.foundingSince || "", foundingNote: x.foundingNote || "", billingEmail: x.billingEmail || "", billingAddress: x.billingAddress || "" });
|
||||
}).catch(e => setMsg({ type: "err", text: errorText(e) })), [customerId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!c) return msg ? <div className="card text-sm text-red-700">{msg.text}</div> : null;
|
||||
|
||||
const saveDetails = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api(`/api/admin/billing/customers/${customerId}`, { method: "PUT", body: { ...form, foundingSince: form.foundingSince || null } });
|
||||
setMsg({ type: "ok", text: "Billing details saved. Drafts already made keep their price; recalculate them if needed." });
|
||||
load();
|
||||
} catch (err) { setMsg({ type: "err", text: errorText(err) }); }
|
||||
};
|
||||
const emailStatement = async () => {
|
||||
const to = stmt.email || c.sendsTo;
|
||||
if (!confirm(`Email the statement (${day(stmt.from)} – ${day(stmt.to)}) to ${to}?`)) return;
|
||||
try {
|
||||
const r = await api<{ message: string }>(`/api/admin/billing/customers/${customerId}/statement/email`, { method: "POST", body: { from: stmt.from, to: stmt.to, email: stmt.email || undefined } });
|
||||
setMsg({ type: "ok", text: r.message });
|
||||
load();
|
||||
} catch (err) { setMsg({ type: "err", text: errorText(err) }); }
|
||||
};
|
||||
const pdf = (path: string) => openPdf(path).catch(e => setMsg({ type: "err", text: errorText(e) }));
|
||||
|
||||
return (
|
||||
<section id="billing" className="mt-8 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Billing</h2>
|
||||
<div className="text-sm">{c.balanceCents > 0 ? <>Owing <strong>{money(c.balanceCents)}</strong></> : "Nothing owing"}</div>
|
||||
</div>
|
||||
{msg && <div role="status" className={`rounded-lg border p-3 text-sm ${msg.type === "ok" ? "border-green-200 bg-green-50 text-green-800" : "border-red-200 bg-red-50 text-red-800"}`}>{msg.text}</div>}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
|
||||
<form onSubmit={saveDetails} className="card space-y-3 self-start">
|
||||
<label className="flex items-center gap-2 font-medium"><input type="checkbox" checked={form.founding} onChange={e => setForm({ ...form, founding: e.target.checked, foundingSince: e.target.checked && !form.foundingSince ? todayYmd() : form.foundingSince })} /> <Star className="h-4 w-4 fill-amber-400 text-amber-400" /> Founding church (35% off)</label>
|
||||
{form.founding && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className="label" htmlFor="f-since">Founding since</label><input id="f-since" type="date" className="input" value={form.foundingSince} onChange={e => setForm({ ...form, foundingSince: e.target.value })} /></div>
|
||||
<div><label className="label" htmlFor="f-note">Note</label><input id="f-note" className="input" value={form.foundingNote} onChange={e => setForm({ ...form, foundingNote: e.target.value })} placeholder="e.g. Founding church 3 of 10" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div><label className="label" htmlFor="b-email">Billing email</label><input id="b-email" type="email" className="input" value={form.billingEmail} onChange={e => setForm({ ...form, billingEmail: e.target.value })} placeholder={c.sendsTo || ""} /><p className="mt-1 text-xs text-slate-500">Blank: the site's admin contact, then the customer's email. Now going to {c.sendsTo || "nobody (add one)"}.</p></div>
|
||||
<div><label className="label" htmlFor="b-addr">Postal address (on invoices)</label><textarea id="b-addr" className="input" rows={2} value={form.billingAddress} onChange={e => setForm({ ...form, billingAddress: e.target.value })} /></div>
|
||||
<button className="btn-secondary">Save billing details</button>
|
||||
</form>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<h3 className="font-semibold">Licences</h3>
|
||||
{c.licences.length === 0 ? <p className="text-sm text-slate-500">Generate a licence first.</p> : c.licences.map(l => <LicenceBilling key={`${l.id}-${l.plan}-${l.billingCycle}-${l.nextInvoiceDate}-${l.reviewDate}-${l.customMonthlyCents}`} licence={l} tiers={c.tiers} onSaved={load} setMsg={setMsg} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<h3 className="font-semibold">Statement</h3>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div><label className="label" htmlFor="s-from">From</label><input id="s-from" type="date" className="input" value={stmt.from} onChange={e => setStmt({ ...stmt, from: e.target.value })} /></div>
|
||||
<div><label className="label" htmlFor="s-to">To</label><input id="s-to" type="date" className="input" value={stmt.to} onChange={e => setStmt({ ...stmt, to: e.target.value })} /></div>
|
||||
<div className="min-w-56 flex-1"><label className="label" htmlFor="s-email">Send to</label><input id="s-email" type="email" className="input" value={stmt.email} onChange={e => setStmt({ ...stmt, email: e.target.value })} placeholder={c.sendsTo || ""} /></div>
|
||||
<button className="btn-secondary" onClick={() => pdf(`/api/admin/billing/customers/${customerId}/statement.pdf?from=${stmt.from}&to=${stmt.to}`)}><FileText className="h-4 w-4" /> Open PDF</button>
|
||||
<button className="btn-primary" onClick={emailStatement}><Mail className="h-4 w-4" /> Email statement</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paying && <RecordPayment customerId={customerId} onClose={() => setPaying(false)} onSaved={m => { setMsg(m); setPaying(false); load(); }} />}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-semibold">Invoices</h3>
|
||||
<div className="flex gap-2"><Link className="btn-secondary" href={`/billing/invoices/new?customer=${customerId}`}><Plus className="h-4 w-4" /> One-off invoice</Link><button className="btn-primary" onClick={() => setPaying(true)}><Plus className="h-4 w-4" /> Record payment</button></div>
|
||||
</div>
|
||||
{c.invoices.length === 0 ? <p className="text-sm text-slate-500">No invoices yet.</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs text-slate-500"><tr><th className="py-2">Invoice</th><th className="py-2">Date</th><th className="py-2">Tier</th><th className="py-2 text-right">Total</th><th className="py-2 text-right">Balance</th><th className="py-2">State</th></tr></thead>
|
||||
<tbody>{c.invoices.map(i => (
|
||||
<tr key={i.id} className="border-t border-slate-100">
|
||||
<td className="py-2"><Link className="font-medium text-indigo-700 hover:underline" href={`/billing/invoices/${i.id}`}>{i.number}</Link></td>
|
||||
<td className="py-2">{day(i.issueDate)}</td>
|
||||
<td className="py-2">{i.plan || "One-off"}{i.planChange ? ` (from ${i.planChange.from})` : ""}</td>
|
||||
<td className="py-2 text-right tabular-nums">{money(i.totalCents)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{i.status === "sent" ? money(i.balanceCents) : "—"}</td>
|
||||
<td className="py-2"><StateBadge state={i.state} short /></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="card text-sm">
|
||||
<h3 className="mb-2 font-semibold">Payments</h3>
|
||||
{c.payments.length === 0 ? <p className="text-slate-500">None yet.</p> : c.payments.map(p => (
|
||||
<div key={p.id} className="flex justify-between gap-3 border-t border-slate-100 py-2 first:border-0">
|
||||
<div>{p.receiptNumber} · {day(p.paidOn)}<div className="text-xs text-slate-500">{METHOD[p.method] || p.method}{p.invoice ? ` · ${p.invoice.number}` : " · on account"}{p.reference && p.reference !== p.invoice?.number ? ` · ${p.reference}` : ""}</div></div>
|
||||
<div className="text-right"><strong className="tabular-nums">{money(p.amountCents)}</strong><div><button className="text-xs text-indigo-700 hover:underline" onClick={() => pdf(`/api/admin/billing/payments/${p.id}/pdf`)}>Receipt</button></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card text-sm">
|
||||
<h3 className="mb-2 font-semibold">Notices of licence change</h3>
|
||||
{c.licenceChanges.length === 0 ? <p className="text-slate-500">None yet.</p> : c.licenceChanges.map(ch => (
|
||||
<div key={ch.id} className="flex justify-between gap-3 border-t border-slate-100 py-2 first:border-0">
|
||||
<div>{ch.number}: {ch.fromPlan} → {ch.toPlan}<div className="text-xs text-slate-500">from {day(ch.effectiveDate)} · {ch.sentAt ? `sent ${fmtDate(ch.sentAt, false)}` : "not sent"}{ch.appliedAt ? " · in effect" : " · not yet in effect"}</div></div>
|
||||
<div className="flex shrink-0 gap-3 text-xs">
|
||||
<button className="text-indigo-700 hover:underline" onClick={() => pdf(`/api/admin/billing/licence-changes/${ch.id}/pdf`)}>PDF</button>
|
||||
<button className="text-indigo-700 hover:underline" onClick={() => api<{ message: string }>(`/api/admin/billing/licence-changes/${ch.id}/resend`, { method: "POST" }).then(r => { setMsg({ type: "ok", text: r.message }); load(); }).catch(e => setMsg({ type: "err", text: errorText(e) }))}>Send again</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card text-sm">
|
||||
<h3 className="mb-2 font-semibold">Billing emails</h3>
|
||||
{c.emails.length === 0 ? <p className="text-slate-500">None yet.</p> : (
|
||||
<table className="w-full">
|
||||
<tbody>{c.emails.map(e => (
|
||||
<tr key={e.id} className="border-t border-slate-100 first:border-0">
|
||||
<td className="py-1.5 pr-3 whitespace-nowrap text-slate-500">{fmtDate(e.sentAt)}</td>
|
||||
<td className="py-1.5 pr-3">{EMAIL_KIND[e.kind] || e.kind}</td>
|
||||
<td className="py-1.5 pr-3">{e.subject}<div className="text-xs text-slate-500">to {e.to}</div></td>
|
||||
<td className={`py-1.5 ${e.error ? "text-red-700" : "text-green-700"}`}>{e.error ? `Failed: ${e.error}` : "Sent"}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { centsFrom, money, randInput, type Line } from "@/lib/billing";
|
||||
|
||||
/** A line as it's typed: the unit price as text in rand. */
|
||||
export type EditLine = { description: string; quantity: string; unit: string };
|
||||
|
||||
export const toEdit = (lines: Line[]): EditLine[] => lines.map(l => ({ description: l.description, quantity: String(l.quantity), unit: randInput(l.unitCents) }));
|
||||
export const fromEdit = (lines: EditLine[]) => lines
|
||||
.filter(l => l.description.trim())
|
||||
.map(l => ({ description: l.description.trim(), quantity: Number(l.quantity) || 1, unitCents: centsFrom(l.unit) ?? 0 }));
|
||||
export const editTotal = (lines: EditLine[]) => fromEdit(lines).reduce((s, l) => s + Math.round(l.quantity * l.unitCents), 0);
|
||||
|
||||
/** Invoice lines: description, quantity and unit price (negative for a discount). */
|
||||
export function LinesEditor({ lines, onChange }: { lines: EditLine[]; onChange: (lines: EditLine[]) => void }) {
|
||||
const set = (i: number, patch: Partial<EditLine>) => onChange(lines.map((l, j) => (j === i ? { ...l, ...patch } : l)));
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs text-slate-500"><tr><th className="pb-1">Description</th><th className="w-20 pb-1">Qty</th><th className="w-32 pb-1">Unit price (R)</th><th className="w-28 pb-1 text-right">Amount</th><th className="w-8" /></tr></thead>
|
||||
<tbody>
|
||||
{lines.map((l, i) => {
|
||||
const amount = Math.round((Number(l.quantity) || 0) * (centsFrom(l.unit) ?? 0));
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-1 pr-2"><input aria-label={`Line ${i + 1} description`} className="input" value={l.description} onChange={e => set(i, { description: e.target.value })} /></td>
|
||||
<td className="py-1 pr-2"><input aria-label={`Line ${i + 1} quantity`} className="input" inputMode="decimal" value={l.quantity} onChange={e => set(i, { quantity: e.target.value })} /></td>
|
||||
<td className="py-1 pr-2"><input aria-label={`Line ${i + 1} unit price`} className="input" inputMode="decimal" value={l.unit} onChange={e => set(i, { unit: e.target.value })} /></td>
|
||||
<td className="py-1 text-right tabular-nums">{money(amount)}</td>
|
||||
<td className="py-1 pl-2"><button type="button" className="text-slate-400 hover:text-red-600" aria-label={`Remove line ${i + 1}`} onClick={() => onChange(lines.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></button></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<button type="button" className="btn-secondary" onClick={() => onChange([...lines, { description: "", quantity: "1", unit: "" }])}><Plus className="h-4 w-4" /> Add a line</button>
|
||||
<div className="text-sm">Total <strong className="ml-2 tabular-nums">{money(editTotal(lines))}</strong></div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">Use a negative unit price for a discount.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { centsFrom, day, money, randInput, todayYmd, type Invoice } from "@/lib/billing";
|
||||
|
||||
type Msg = { type: "ok" | "err"; text: string };
|
||||
|
||||
/**
|
||||
* Record money received: from which church, against which invoice (or on account), and the
|
||||
* receipt emailed unless unticked. `customerId` / `invoice` fix the choice (customer and invoice pages).
|
||||
*/
|
||||
export function RecordPayment({ customers, customerId, invoice, onClose, onSaved }: {
|
||||
customers?: { id: string; name: string }[];
|
||||
customerId?: string;
|
||||
invoice?: Invoice;
|
||||
onClose: () => void;
|
||||
onSaved: (msg: Msg) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
customerId: invoice?.customerId || customerId || "",
|
||||
invoiceId: invoice?.id || "",
|
||||
amount: invoice ? randInput(Math.max(0, invoice.balanceCents)) : "",
|
||||
paidOn: todayYmd(),
|
||||
method: "eft",
|
||||
reference: invoice?.number || "",
|
||||
notes: "",
|
||||
sendReceipt: true,
|
||||
});
|
||||
const [open, setOpen] = useState<Invoice[]>(invoice ? [invoice] : []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The church's invoices that still owe something.
|
||||
useEffect(() => {
|
||||
if (invoice || !form.customerId) return;
|
||||
api<Invoice[]>(`/api/admin/billing/invoices?customerId=${form.customerId}&status=unpaid`).then(setOpen).catch(() => setOpen([]));
|
||||
}, [form.customerId, invoice]);
|
||||
|
||||
const pickInvoice = (id: string) => {
|
||||
const inv = open.find(i => i.id === id);
|
||||
setForm(f => ({ ...f, invoiceId: id, amount: inv ? randInput(Math.max(0, inv.balanceCents)) : f.amount, reference: inv ? inv.number : f.reference }));
|
||||
};
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const amountCents = centsFrom(form.amount);
|
||||
if (!form.customerId) { setError("Choose the church."); return; }
|
||||
if (amountCents == null || amountCents <= 0) { setError("Enter the amount received."); return; }
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api<{ payment: { receiptNumber: string }; receipt: { ok: boolean; error: string | null } | null }>("/api/admin/billing/payments", {
|
||||
method: "POST",
|
||||
body: { customerId: form.customerId, invoiceId: form.invoiceId || null, amountCents, paidOn: form.paidOn, method: form.method, reference: form.reference, notes: form.notes, sendReceipt: form.sendReceipt },
|
||||
});
|
||||
const receipt = r.receipt ? (r.receipt.ok ? " The receipt was emailed." : ` The receipt couldn't be emailed: ${r.receipt.error}`) : "";
|
||||
onSaved({ type: r.receipt && !r.receipt.ok ? "err" : "ok", text: `Payment ${r.payment.receiptNumber} of ${money(amountCents)} recorded.${receipt}` });
|
||||
} catch (err) { setError(errorText(err)); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={save} className="card space-y-3 border-indigo-200">
|
||||
<div className="flex items-center justify-between"><h2 className="font-semibold">Record a payment</h2><button type="button" onClick={onClose} aria-label="Close" className="text-slate-400 hover:text-slate-700"><X className="h-4 w-4" /></button></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{!invoice && !customerId && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label" htmlFor="pay-customer">Church</label>
|
||||
<select id="pay-customer" className="input" value={form.customerId} onChange={e => setForm({ ...form, customerId: e.target.value, invoiceId: "" })} required>
|
||||
<option value="">Choose…</option>
|
||||
{(customers || []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!invoice && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label" htmlFor="pay-invoice">For invoice</label>
|
||||
<select id="pay-invoice" className="input" value={form.invoiceId} onChange={e => pickInvoice(e.target.value)} disabled={!form.customerId}>
|
||||
<option value="">On account (no invoice)</option>
|
||||
{open.map(i => <option key={i.id} value={i.id}>{i.number}: {money(i.balanceCents)} owing, due {day(i.dueDate)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div><label className="label" htmlFor="pay-amount">Amount (R)</label><input id="pay-amount" className="input" inputMode="decimal" value={form.amount} onChange={e => setForm({ ...form, amount: e.target.value })} required /></div>
|
||||
<div><label className="label" htmlFor="pay-date">Received on</label><input id="pay-date" type="date" className="input" value={form.paidOn} max={todayYmd()} onChange={e => setForm({ ...form, paidOn: e.target.value })} required /></div>
|
||||
<div>
|
||||
<label className="label" htmlFor="pay-method">Method</label>
|
||||
<select id="pay-method" className="input" value={form.method} onChange={e => setForm({ ...form, method: e.target.value })}>
|
||||
<option value="eft">EFT</option><option value="card">Card</option><option value="cash">Cash</option><option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label" htmlFor="pay-ref">Their reference</label><input id="pay-ref" className="input" value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} /></div>
|
||||
<div className="sm:col-span-2 lg:col-span-4"><label className="label" htmlFor="pay-notes">Notes (printed on the receipt)</label><input id="pay-notes" className="input" value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} /></div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.sendReceipt} onChange={e => setForm({ ...form, sendReceipt: e.target.checked })} /> Email the receipt (PDF) to the church</label>
|
||||
{error && <p className="text-sm text-red-700">{error}</p>}
|
||||
<div className="flex gap-2"><button className="btn-primary" disabled={busy}>{busy ? "Saving…" : "Record payment"}</button><button type="button" className="btn-secondary" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -40,3 +40,24 @@ export async function api<T = unknown>(path: string, options: { method?: string;
|
||||
}
|
||||
|
||||
export const errorText = (e: unknown, fallback = "Something went wrong.") => (e instanceof Error && e.message) || fallback;
|
||||
|
||||
/** Opens a PDF from the API (which needs the session token) in a new tab. */
|
||||
export async function openPdf(path: string) {
|
||||
// Open the tab first, while the click still counts as the user's: browsers block it after an await.
|
||||
const tab = window.open("", "_blank");
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_URL}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new ApiError(data?.message || `Request failed (${res.status})`, res.status, data?.code);
|
||||
}
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
if (tab) tab.location.href = url;
|
||||
else window.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e) {
|
||||
tab?.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
|
||||
// Billing (1.7.0): the shapes the billing API returns, and how they're shown.
|
||||
|
||||
export type InvoiceStatus = "draft" | "approved" | "sent" | "void";
|
||||
export type InvoiceState = InvoiceStatus | "unpaid" | "part_paid" | "overdue" | "paid";
|
||||
export type BillingCycle = "none" | "monthly" | "yearly";
|
||||
export type Line = { description: string; quantity: number; unitCents: number; amountCents: number };
|
||||
export type PlanChange = { from: string; to: string; fromMonthlyCents: number | null; toMonthlyCents: number | null; band?: string | null };
|
||||
export type BillingTier = { name: string; upTo: number | null; monthly: number | null; founding: number | null };
|
||||
|
||||
export type Payment = {
|
||||
id: string; receiptNumber: string; customerId: string; invoiceId: string | null; amountCents: number; paidOn: string;
|
||||
method: string; reference: string | null; notes: string | null; receiptSentAt: string | null; createdAt: string;
|
||||
invoice?: { id: string; number: string } | null; customer?: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type Invoice = {
|
||||
id: string; number: string; customerId: string; licenceId: string | null; status: InvoiceStatus; state: InvoiceState;
|
||||
issueDate: string; dueDate: string; periodStart: string | null; periodEnd: string | null; cycle: BillingCycle;
|
||||
plan: string | null; incomeBand: string | null; reviewed: boolean; founding: boolean; planChange: PlanChange | null;
|
||||
lines: Line[]; totalCents: number; vatCents: number; paidCents: number; balanceCents: number;
|
||||
notes: string | null; warnings: string[]; recipientEmail: string | null;
|
||||
approvedAt: string | null; approvalEmailedAt: string | null; sentAt: string | null; sendError: string | null; voidedAt: string | null; voidReason: string | null;
|
||||
createdAt: string; payments: Payment[];
|
||||
customer?: { id: string; name: string; founding: boolean };
|
||||
licence?: { id: string; key: string; plan: string; billingCycle: BillingCycle; installUrl: string | null } | null;
|
||||
};
|
||||
|
||||
export type LicenceChange = {
|
||||
id: string; number: string; fromPlan: string; toPlan: string; fromMonthlyCents: number | null; toMonthlyCents: number | null;
|
||||
effectiveDate: string; reason: string | null; incomeBand: string | null; appliedAt: string | null; sentAt: string | null; invoiceId: string | null; createdAt: string;
|
||||
};
|
||||
|
||||
export type BillingEmail = { id: string; kind: string; to: string; subject: string; error: string | null; sentAt: string };
|
||||
|
||||
/** R1 350.00 */
|
||||
export function money(cents: number | null | undefined) {
|
||||
if (cents == null) return "—";
|
||||
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}`;
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
/** "1 Dec 2026" from "2026-12-01". */
|
||||
export function day(d: string | null | undefined) {
|
||||
if (!d) return "—";
|
||||
const [y, m, dd] = d.slice(0, 10).split("-").map(Number);
|
||||
return `${dd} ${MONTHS[m - 1]} ${y}`;
|
||||
}
|
||||
/** Today in South Africa, "YYYY-MM-DD". */
|
||||
export const todayYmd = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Johannesburg", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
|
||||
|
||||
export const STATE: Record<InvoiceState, { label: string; cls: string }> = {
|
||||
draft: { label: "Draft: needs approval", cls: "bg-amber-50 text-amber-800 border-amber-200" },
|
||||
approved: { label: "Approved: goes out on its date", cls: "bg-indigo-50 text-indigo-700 border-indigo-200" },
|
||||
sent: { label: "Sent", cls: "bg-slate-50 text-slate-700 border-slate-200" },
|
||||
unpaid: { label: "Unpaid", cls: "bg-sky-50 text-sky-800 border-sky-200" },
|
||||
part_paid: { label: "Part paid", cls: "bg-sky-50 text-sky-800 border-sky-200" },
|
||||
overdue: { label: "Overdue", cls: "bg-red-50 text-red-700 border-red-200" },
|
||||
paid: { label: "Paid", cls: "bg-green-50 text-green-700 border-green-200" },
|
||||
void: { label: "Void", cls: "bg-slate-100 text-slate-500 border-slate-200" },
|
||||
};
|
||||
|
||||
export function StateBadge({ state, short = false }: { state: InvoiceState; short?: boolean }) {
|
||||
const s = STATE[state];
|
||||
return <span className={`inline-block whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium ${s.cls}`}>{short ? s.label.split(":")[0] : s.label}</span>;
|
||||
}
|
||||
|
||||
export const CYCLE: Record<BillingCycle, string> = { none: "Not billed", monthly: "Monthly", yearly: "Yearly (2 months free)" };
|
||||
export const METHOD: Record<string, string> = { eft: "EFT", card: "Card", cash: "Cash", other: "Other" };
|
||||
export const EMAIL_KIND: Record<string, string> = { invoice: "Invoice", receipt: "Receipt", statement: "Statement", licence_change: "Licence change", approval: "Approval request (to you)", reminder: "Reminder (to you)" };
|
||||
|
||||
/** Rand typed into a form ("1350" or "1 350,50") → cents, or null. */
|
||||
export function centsFrom(v: string): number | null {
|
||||
const n = Number(String(v).replace(/[\sR]/g, "").replace(",", "."));
|
||||
return Number.isFinite(n) && String(v).trim() !== "" ? Math.round(n * 100) : null;
|
||||
}
|
||||
export const randInput = (cents: number | null | undefined) => (cents == null ? "" : (cents / 100).toFixed(2));
|
||||
Reference in New Issue
Block a user