Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown, early-bird discount, balance due, Yoco pay-now link/QR) whenever a balance is outstanding; payment/donation confirmations attach a payment receipt PDF. Sent as an email attachment and, over WhatsApp, as the PDF itself with the existing message as its caption. - Users can also (re)send either document on demand: an "Invoice" button on the registration detail popup, and a "Receipt" button next to each payment there and on the Payment history page, each opening an Email/WhatsApp choice popup, via two new endpoints restricted to the registration/payment's own owner. - Fix: editing an event option's early-bird tiers deleted and recreated every tier for that option with brand-new ids, silently severing the appliedTierId link on all historical purchases (losing early-bird attribution and undercounting stock-limit usage) even for tiers the admin didn't touch. Tiers are now upserted by id. - Update the "My Events" help content and the API docs index for the new endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
### Added
|
||||
|
||||
- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step.
|
||||
- Registration and payment notifications now include a branded PDF: registration confirmations attach an **invoice** (itemized breakdown, early-bird discount, balance due, and — when there's an outstanding balance — a clickable "pay now" link and QR code pointing at a Yoco checkout) whenever the registration isn't fully paid, and payment/donation confirmations attach a **payment receipt** (itemized breakdown plus the amount paid on that transaction). Sent as an email attachment and, over WhatsApp, as the PDF itself (with the existing message text as its caption) in place of the previous text-only send. Both documents pick up the org's configured name/logo/brand color from Site Settings → Branding.
|
||||
- The user dashboard's registration detail popup now has an "Invoice" button and, next to each payment, a "Receipt" button — both pop up a small Email/WhatsApp choice and (re)send that document on demand, via two new endpoints (`POST /api/registrations/:id/send-invoice`, `POST /api/payments/:id/send-receipt`), restricted to the registration/payment's own owner. The invoice works regardless of balance, showing "Paid in full" when nothing is owed. The Payment history page (`/dashboard/user/payments`) got the same per-payment "Receipt" button too.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,6 +20,7 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
- An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did.
|
||||
- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines.
|
||||
- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too.
|
||||
- Editing an event option's early-bird tiers (even just tweaking one tier's deadline or price) deleted and recreated *every* tier for that option with brand-new ids, including tiers the admin didn't touch. Since past purchases point at a specific tier by id, this silently severed that link on every edit — wiping the "early bird" attribution (and locked-in stock-limit counts, risking oversold tiers) off historical registrations that were never meant to change. Tiers are now upserted by id, so their identity — and everything referencing them — survives an edit; a tier is only ever removed if it's no longer in the saved list *and* has no purchase history against it.
|
||||
|
||||
## [1.7.0] - 2026-08-20
|
||||
|
||||
|
||||
@@ -691,26 +691,49 @@ const updateEventOption = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
|
||||
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
||||
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
||||
// every tier for the option on every save, which silently reassigned each one a brand-new
|
||||
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
||||
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
||||
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
||||
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
||||
// ids stable across edits so that history stays linked.
|
||||
if (Array.isArray(earlyBirdTiers)) {
|
||||
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
|
||||
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const et of existingTiers) {
|
||||
if (incomingIds.includes(et.id)) continue;
|
||||
// Never delete a tier that's still attributed on past purchases — just leave it
|
||||
// orphaned from the option's active tier list rather than nulling out history.
|
||||
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
||||
if (usageCount === 0) {
|
||||
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
||||
const t = earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
await prisma.earlyBirdTier.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
}
|
||||
});
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
};
|
||||
if (t.id) {
|
||||
await prisma.earlyBirdTier.upsert({
|
||||
where: { id: t.id },
|
||||
update: data,
|
||||
create: { id: t.id, ...data },
|
||||
});
|
||||
} else {
|
||||
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1420,6 +1420,20 @@ const getPaymentStats = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// @desc Re-send the receipt PDF for one of the caller's own payments
|
||||
// @route POST /api/payments/:id/send-receipt
|
||||
// @access Private
|
||||
const sendReceipt = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.body || {};
|
||||
const { sendReceiptToUser } = require('../utils/notifications');
|
||||
await sendReceiptToUser(req.params.id, req.user.id, channel);
|
||||
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -1433,5 +1447,6 @@ module.exports = {
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
};
|
||||
@@ -1747,6 +1747,20 @@ const saveFormDraft = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send the current invoice PDF for one of the caller's own registrations
|
||||
// @route POST /api/registrations/:id/send-invoice
|
||||
// @access Private
|
||||
const sendInvoice = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.body || {};
|
||||
const { sendInvoiceToUser } = require('../utils/notifications');
|
||||
await sendInvoiceToUser(req.params.id, req.user.id, channel);
|
||||
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
@@ -1761,4 +1775,5 @@ module.exports = {
|
||||
replaceFormResponses,
|
||||
getFormDraft,
|
||||
saveFormDraft,
|
||||
sendInvoice,
|
||||
};
|
||||
@@ -529,6 +529,15 @@ app.get('/docs', async (req, res) => {
|
||||
pathParams:{ ':id':'Registration UUID' },
|
||||
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
|
||||
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
|
||||
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
|
||||
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
|
||||
pathParams:{ ':id':'Registration UUID' },
|
||||
request:{ body:{ channel:'email' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
|
||||
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
|
||||
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
@@ -546,6 +555,14 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||||
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
|
||||
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
|
||||
pathParams:{ ':id':'Payment UUID' },
|
||||
request:{ body:{ channel:'whatsapp' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
|
||||
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -21,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
@@ -72,7 +73,7 @@ async function loadRegistrationFull(registrationId) {
|
||||
return prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
registration: {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
@@ -898,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
|
||||
// ─── Send functions ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an invoice PDF for a registration when it still has an outstanding balance,
|
||||
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
|
||||
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
|
||||
* PDF generation fails — callers fall back to the existing text-only notification either way.
|
||||
*/
|
||||
/**
|
||||
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
|
||||
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
|
||||
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
|
||||
*/
|
||||
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
|
||||
if (paymentUrlHint) return paymentUrlHint;
|
||||
if (totalDue - totalPaid <= 0.01) return null;
|
||||
try {
|
||||
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return checkout.redirectUrl || null;
|
||||
} catch (e) {
|
||||
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by the automatic registration-confirmation sends, which should stay silent (no
|
||||
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
|
||||
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
|
||||
if (totalDue - totalPaid <= 0.01) return null;
|
||||
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
|
||||
try {
|
||||
const { generateInvoicePdf } = require('./pdfDocs');
|
||||
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
|
||||
} catch (e) {
|
||||
console.error('Failed to generate invoice PDF:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationEmails(registrationId) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
|
||||
|
||||
// Email: only for real addresses (skip guest.local placeholders)
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: true });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
|
||||
const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
@@ -925,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
|
||||
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: false });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
@@ -951,25 +1010,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration updated emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
|
||||
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
@@ -977,28 +1045,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send self-service registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPaymentEmails(paymentId) {
|
||||
let receiptPdf = null;
|
||||
try {
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) return;
|
||||
const user = payment.registration?.user || payment.user;
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
|
||||
const { buildWAPayment } = require('./waMessages');
|
||||
const { generateReceiptPdf } = require('./pdfDocs');
|
||||
|
||||
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
const msg = buildPaymentReceipt(payment);
|
||||
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(user)) sends.push(sendMail({
|
||||
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
|
||||
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
|
||||
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
|
||||
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
|
||||
const waCaption = buildWAPayment(payment);
|
||||
if (hasValidEmail) {
|
||||
sends.push(waText(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
|
||||
}
|
||||
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
|
||||
if (hasEvent) {
|
||||
@@ -1010,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails:', e);
|
||||
} finally {
|
||||
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,6 +1234,137 @@ async function sendDonationUnassignmentEmails(leg) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual "send to me" requests ──────────────────────────────────────────────
|
||||
// Unlike the fire-and-forget sends above, these are awaited directly from a
|
||||
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
|
||||
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
|
||||
// controller can surface a real error to the user rather than failing silently.
|
||||
|
||||
function userFacingError(message, statusCode) {
|
||||
const e = new Error(message);
|
||||
e.statusCode = statusCode;
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
|
||||
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
|
||||
* @param {string} registrationId
|
||||
* @param {string} requesterId - req.user.id of the caller
|
||||
* @param {'email'|'whatsapp'} channel
|
||||
*/
|
||||
async function sendInvoiceToUser(registrationId, requesterId, channel) {
|
||||
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
|
||||
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) throw userFacingError('Registration not found', 404);
|
||||
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
|
||||
|
||||
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
|
||||
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
|
||||
}
|
||||
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
|
||||
throw userFacingError('No phone number on file. Add one in your profile.', 400);
|
||||
}
|
||||
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
// Unlike the automatic post-registration send, a manually-requested invoice is generated
|
||||
// regardless of balance — someone may want it as a paid-in-full record too.
|
||||
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
|
||||
let invoicePdf;
|
||||
try {
|
||||
const { generateInvoicePdf } = require('./pdfDocs');
|
||||
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
|
||||
} catch (e) {
|
||||
console.error('Failed to generate invoice PDF:', e);
|
||||
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventTitle = reg.event?.title || 'your registration';
|
||||
if (channel === 'email') {
|
||||
await sendMail({
|
||||
to: reg.user.email,
|
||||
subject: `Your invoice for ${eventTitle}`,
|
||||
html: emailWrapper(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
|
||||
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
} else {
|
||||
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
|
||||
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
|
||||
// preference is email-only.
|
||||
const { waPdfAny } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
|
||||
* channel. Only the person the payment belongs to (via the payment itself or its registration)
|
||||
* may request their own receipt.
|
||||
* @param {string} paymentId
|
||||
* @param {string} requesterId - req.user.id of the caller
|
||||
* @param {'email'|'whatsapp'} channel
|
||||
*/
|
||||
async function sendReceiptToUser(paymentId, requesterId, channel) {
|
||||
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
|
||||
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) throw userFacingError('Payment not found', 404);
|
||||
const owner = payment.registration?.user || payment.user;
|
||||
const ownerId = payment.registration?.userId || payment.userId;
|
||||
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
|
||||
|
||||
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
|
||||
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
|
||||
}
|
||||
if (channel === 'whatsapp' && !owner?.phoneNumber) {
|
||||
throw userFacingError('No phone number on file. Add one in your profile.', 400);
|
||||
}
|
||||
|
||||
const { generateReceiptPdf } = require('./pdfDocs');
|
||||
let receiptPdf;
|
||||
try {
|
||||
receiptPdf = await generateReceiptPdf(payment);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate receipt PDF:', e);
|
||||
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
|
||||
if (channel === 'email') {
|
||||
await sendMail({
|
||||
to: owner.email,
|
||||
subject: `Your payment receipt for ${eventTitle}`,
|
||||
html: emailWrapper(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
|
||||
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
} else {
|
||||
const { waPdfAny } = require('./notify');
|
||||
const { buildWAPayment } = require('./waMessages');
|
||||
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDailyEventSummaries(now = new Date()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
@@ -1210,4 +1423,6 @@ module.exports = {
|
||||
sendDonationUnassignmentEmails,
|
||||
sendCheckInEmails,
|
||||
buildCheckInConfirmation,
|
||||
sendInvoiceToUser,
|
||||
sendReceiptToUser,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Branded PDF documents: payment receipts and registration invoices.
|
||||
*
|
||||
* Both are generated with pdfkit into backend/temp and returned as
|
||||
* { filePath, filename } for callers to attach to an email/WhatsApp send and
|
||||
* clean up afterwards (see notifications.js).
|
||||
*
|
||||
* Line items are built the same tranche-aware way the user dashboard renders
|
||||
* them (backend/src/utils/pricing.js) — merging tranches that share a
|
||||
* name/price/tier and separating early-bird lines from standard-price ones —
|
||||
* so the PDF total always matches computeRegistrationTotalDue().
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
return `R${Number(amt || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
|
||||
}
|
||||
|
||||
function formatMethod(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
if (!m) return '—';
|
||||
if (m === 'eft') return 'EFT';
|
||||
return m.replace(/^./, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function tempDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function docNumber(prefix, id, date) {
|
||||
const year = new Date(date || Date.now()).getFullYear();
|
||||
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
|
||||
return `${prefix}-${year}-${short}`;
|
||||
}
|
||||
|
||||
async function getBranding() {
|
||||
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_address', ''),
|
||||
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
getSetting('org_phone', ''),
|
||||
getSetting('primary_color', ''),
|
||||
getSetting('accent_color', ''),
|
||||
getSetting('logo_url', ''),
|
||||
]);
|
||||
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
let logoPath = null;
|
||||
if (logoUrl) {
|
||||
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(p)) logoPath = p;
|
||||
}
|
||||
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a registration's tranches into display rows, same grouping logic as
|
||||
* the user dashboard: one row per (name, unit price, early-bird flag), with
|
||||
* `basePrice` carried along so callers can work out the early-bird discount.
|
||||
*/
|
||||
function buildLineItems(registrationOptions) {
|
||||
const rows = [];
|
||||
for (const ro of (registrationOptions || [])) {
|
||||
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
|
||||
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
|
||||
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
|
||||
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
|
||||
? ro.tranches
|
||||
: [{
|
||||
quantity: ro.quantity,
|
||||
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
|
||||
appliedTierId: ro.appliedTierId,
|
||||
}];
|
||||
for (const t of tranches) {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
const isEarlyBird = !!t.appliedTierId;
|
||||
const key = `${label}__${isEarlyBird}__${unitPrice}`;
|
||||
let row = rows.find(r => r.key === key);
|
||||
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
|
||||
row.quantity += (t.quantity || 0);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
|
||||
const colDesc = x;
|
||||
// Fixed-width columns anchored to the right edge so amounts never wrap,
|
||||
// regardless of the overall table width (receipt vs. narrower invoice table).
|
||||
const totalColW = 75, priceColW = 65, qtyColW = 35;
|
||||
const colTotal = x + width - totalColW;
|
||||
const colPrice = colTotal - priceColW;
|
||||
const colQty = colPrice - qtyColW;
|
||||
const rowH = 22;
|
||||
|
||||
if (headerLight) {
|
||||
doc.rect(x, y, width, rowH).fill('#f8fafc');
|
||||
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
|
||||
} else {
|
||||
doc.rect(x, y, width, rowH).fill(brandColor);
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
|
||||
}
|
||||
doc.text('DESCRIPTION', colDesc + 8, y + 7);
|
||||
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
|
||||
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
|
||||
y += rowH;
|
||||
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#374151');
|
||||
for (const row of rows) {
|
||||
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
|
||||
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
|
||||
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
|
||||
y += rowH;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
|
||||
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateReceiptPdf(payment) {
|
||||
const org = await getBranding();
|
||||
const reg = payment.registration;
|
||||
const user = reg?.user || payment.user;
|
||||
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
|
||||
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
|
||||
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
|
||||
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
|
||||
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
|
||||
|
||||
const filename = `receipt-${receiptNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const marginX = 40;
|
||||
|
||||
// Header banner
|
||||
const bannerH = 150;
|
||||
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
|
||||
}
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
|
||||
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
|
||||
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
|
||||
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
|
||||
|
||||
const metaX = pageWidth - 250;
|
||||
const meta = [
|
||||
['Receipt No.', receiptNo],
|
||||
['Date', fmtDate(payment.createdAt)],
|
||||
['Payment Method', formatMethod(payment.method)],
|
||||
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
|
||||
];
|
||||
let metaY = 34;
|
||||
for (const [label, value] of meta) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
|
||||
metaY += 24;
|
||||
}
|
||||
|
||||
// Body
|
||||
let y = bannerH + 30;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
|
||||
y += 16;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
|
||||
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
|
||||
y += 14;
|
||||
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
|
||||
y += 30;
|
||||
|
||||
if (rows.length > 0) {
|
||||
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
|
||||
}
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
|
||||
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
|
||||
y += boxH + 30;
|
||||
|
||||
// Footer
|
||||
const footerH = 60;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
|
||||
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
// ─── Invoice ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
|
||||
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
|
||||
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
|
||||
const org = await getBranding();
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
|
||||
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(due - paid, 0);
|
||||
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
|
||||
const rows = buildLineItems(registration.registrationOptions);
|
||||
|
||||
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
|
||||
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
|
||||
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
|
||||
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
|
||||
const discount = Math.max(0, fullPriceTotal - chargedTotal);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const sidebarW = 190;
|
||||
const mainX = sidebarW + 30;
|
||||
const mainW = pageWidth - mainX - 40;
|
||||
|
||||
// Sidebar
|
||||
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
|
||||
let sy = 40;
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
|
||||
}
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
|
||||
sy += 60;
|
||||
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 28;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
|
||||
sy += 34;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
|
||||
sy += 40;
|
||||
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
|
||||
sy += 13;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 15;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 12;
|
||||
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
|
||||
|
||||
let by = pageHeight - 140;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
|
||||
by += 13;
|
||||
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
|
||||
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
|
||||
|
||||
// Main content
|
||||
let y = 40;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
|
||||
y += 14;
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
|
||||
y += 18;
|
||||
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
|
||||
y += 16;
|
||||
|
||||
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
|
||||
y += 16;
|
||||
|
||||
const totalsX = mainX + mainW - 220;
|
||||
const totalLine = (label, value, opts = {}) => {
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(label, totalsX, y, { width: 120 });
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
|
||||
y += (opts.size || 10) + 10;
|
||||
};
|
||||
if (discount > 0.01) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
|
||||
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
|
||||
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
|
||||
y += 20;
|
||||
|
||||
// Payment section
|
||||
if (balance > 0.01) {
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
|
||||
y += 16;
|
||||
if (paymentUrl) {
|
||||
const qrSize = 100;
|
||||
try {
|
||||
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
|
||||
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
|
||||
} catch {}
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
|
||||
y += 60;
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
|
||||
y += 24;
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
|
||||
y += 24;
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerH = 40;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
|
||||
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
|
||||
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
try {
|
||||
const tiers = rows
|
||||
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
|
||||
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
|
||||
.map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
|
||||
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
|
||||
onSave(tiers);
|
||||
} finally {
|
||||
|
||||
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
|
||||
|
||||
// Registration details modal
|
||||
const [activeRegId, setActiveRegId] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
|
||||
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
|
||||
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
|
||||
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
|
||||
|
||||
// Track whether the active registration's event has attendee forms
|
||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
|
||||
@@ -253,26 +255,6 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const emailRegistration = async (registrationId: string) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
// Show loading dialog while sending
|
||||
setDialog({ open: true, message: "Sending tickets…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { registrationId },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to email registration tickets");
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappTickets = async (ticketIds: string[]) => {
|
||||
if (!token || ticketIds.length === 0) return;
|
||||
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
|
||||
@@ -293,26 +275,60 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappRegistration = async (registrationId: string) => {
|
||||
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
|
||||
setDialog({
|
||||
open: true, loading: true, message: "",
|
||||
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
body: { registrationId, channel: "whatsapp" },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send tickets to WhatsApp");
|
||||
setError(e?.message || "Failed to send invoice");
|
||||
}
|
||||
};
|
||||
|
||||
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setDialog({
|
||||
open: true, loading: true, message: "",
|
||||
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e: any) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
}
|
||||
};
|
||||
|
||||
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
|
||||
// whichever id it was opened with, then closes the popup.
|
||||
const chooseChannel = (channel: 'email' | 'whatsapp') => {
|
||||
if (!channelPicker) return;
|
||||
const { kind, id } = channelPicker;
|
||||
setChannelPicker(null);
|
||||
if (kind === 'invoice') sendInvoice(id, channel);
|
||||
else sendReceipt(id, channel);
|
||||
};
|
||||
|
||||
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
|
||||
// choosing a partial amount is only available from the supervisor payments dashboard.
|
||||
const payNow = async (registrationId: string) => {
|
||||
@@ -1129,9 +1145,12 @@ export default function UserDashboardPage() {
|
||||
{activeBill && activeBill.payments.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium mb-1">Payments</div>
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
<ul className="text-sm space-y-1">
|
||||
{activeBill.payments.map((p: any) => (
|
||||
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
|
||||
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1143,24 +1162,21 @@ export default function UserDashboardPage() {
|
||||
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
|
||||
>Attendee forms</button>
|
||||
)}
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
|
||||
>Invoice</button>
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => payNow(activeReg.id)}
|
||||
>Make payment</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
|
||||
{user?.phoneNumber && (
|
||||
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
|
||||
@@ -1194,6 +1210,31 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelPicker && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
|
||||
onClick={() => setChannelPicker(null)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
|
||||
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => chooseChannel('email')}
|
||||
>Email</button>
|
||||
{user?.phoneNumber && (
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => chooseChannel('whatsapp')}
|
||||
>WhatsApp</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog.open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
@@ -1208,7 +1249,7 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-semibold mb-2">Tickets sent</div>
|
||||
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
|
||||
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
|
||||
const [payments, setPayments] = useState<PaymentItem[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
|
||||
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
|
||||
const [sendingReceipt, setSendingReceipt] = useState(false);
|
||||
|
||||
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
|
||||
if (!token) return;
|
||||
setReceiptPickerId(null);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
} finally {
|
||||
setSendingReceipt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filters
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt…</p>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
||||
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
|
||||
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
|
||||
</div>
|
||||
{!isRefund && (
|
||||
<button
|
||||
className="text-xs text-brand-700 hover:underline shrink-0"
|
||||
disabled={sendingReceipt}
|
||||
onClick={() => setReceiptPickerId(p.id)}
|
||||
>Receipt</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{receiptPickerId && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => setReceiptPickerId(null)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-base font-semibold mb-1">Send receipt</div>
|
||||
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => sendReceipt(receiptPickerId, 'email')}
|
||||
>Email</button>
|
||||
{user?.phoneNumber && (
|
||||
<button
|
||||
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
|
||||
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
|
||||
>WhatsApp</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
|
||||
Tap a registration to change quantities or options, pay what's outstanding, or cancel it entirely.
|
||||
</GuideItem>
|
||||
<GuideItem icon={FileText} title="Invoice" tone="rose">
|
||||
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what's still owing (or "Paid in full") — to your email or WhatsApp, whichever you pick.
|
||||
</GuideItem>
|
||||
<GuideItem icon={History} title="Show past events" tone="gray">
|
||||
Registrations page defaults to upcoming events only — tick "Show past events" at the top to bring back ones that have already happened.
|
||||
</GuideItem>
|
||||
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={Receipt} title="Payment history" tone="green">
|
||||
The Payment history page lists every payment you've made across all your registrations, past and present.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Receipt} title="Receipts" tone="amber">
|
||||
Every payment — in a registration's Payments list, or on the Payment history page — has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Donations" tone="rose">
|
||||
A donation isn't tied to any one registration — make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
|
||||
</GuideItem>
|
||||
|
||||
Reference in New Issue
Block a user