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:
2026-08-21 10:26:49 +02:00
co-authored by Claude Sonnet 5
parent e9cb238ce1
commit bc64069021
13 changed files with 835 additions and 82 deletions
+36 -13
View File
@@ -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 } });
}
}
}
+16 -1
View File
@@ -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,
};