feat(payments): add upsertPaymentFromHelcim helper with idempotent insert

Takes a Member doc + a normalized Helcim transaction and inserts a
Payment row if helcimTransactionId is unseen. Maps helcim status
paid→success, refunded→refunded, failed→failed; skips 'other'.

opts.paymentType overrides the cadence fallback for mid-flight cadence
changes. opts.sendConfirmation triggers a Resend payment-confirmation
email ONLY on new inserts — swallows send failures so email trouble
cannot break the upstream payment flow.

The Resend template lives in server/emails/paymentConfirmation.js. It
is CRA-safe (charity name + 'not an official donation receipt / tax
receipts available later in 2026' disclaimer) so it can be used in
either Task 8 branch without copy changes.
This commit is contained in:
Jennie Robinson Faber 2026-04-20 13:15:38 +01:00
parent bf5a333117
commit be7145f96c
3 changed files with 238 additions and 0 deletions

View file

@ -0,0 +1,16 @@
export const paymentConfirmationEmail = ({ member, amount, paymentDate, transactionId }) => ({
from: 'Ghost Guild <ghostguild@babyghosts.org>',
to: member.email,
subject: 'Payment confirmation — Ghost Guild',
text: `Hi ${member.name || 'there'},
We received your payment of $${Number(amount).toFixed(2)} CAD on ${new Date(paymentDate).toLocaleDateString('en-CA')}.
Baby Ghosts Studio Development Fund (BN 788709350 RR 0001)
Helcim transaction ID: ${transactionId}
This is a payment confirmation, not an official donation receipt for Canadian tax purposes. Tax receipts for eligible members will be available starting later in 2026.
Thanks for being part of Ghost Guild.
`
})

63
server/utils/payments.js Normal file
View file

@ -0,0 +1,63 @@
import { Resend } from 'resend'
import Payment from '../models/payment.js'
import { paymentConfirmationEmail } from '../emails/paymentConfirmation.js'
const resend = new Resend(process.env.RESEND_API_KEY)
function mapStatus(helcimStatus) {
if (helcimStatus === 'paid') return 'success'
if (helcimStatus === 'refunded') return 'refunded'
if (helcimStatus === 'failed') return 'failed'
return null
}
export async function upsertPaymentFromHelcim(memberDoc, helcimTx, opts = {}) {
const status = mapStatus(helcimTx?.status)
if (!status) return { created: false, payment: null }
const existing = await Payment.findOne({ helcimTransactionId: helcimTx.id })
if (existing) return { created: false, payment: existing }
const paymentType = opts.paymentType || memberDoc.billingCadence
const fields = {
memberId: memberDoc._id,
helcimTransactionId: helcimTx.id,
helcimCustomerId: memberDoc.helcimCustomerId || null,
helcimSubscriptionId: memberDoc.helcimSubscriptionId || null,
amount: helcimTx.amount,
currency: helcimTx.currency || 'CAD',
paymentDate: helcimTx.date ? new Date(helcimTx.date) : new Date(),
paymentType,
status,
rawHelcim: helcimTx
}
let payment
try {
payment = await Payment.create(fields)
} catch (err) {
if (err?.code === 11000) {
const racer = await Payment.findOne({ helcimTransactionId: helcimTx.id })
return { created: false, payment: racer }
}
throw err
}
if (opts.sendConfirmation) {
try {
await resend.emails.send(
paymentConfirmationEmail({
member: memberDoc,
amount: fields.amount,
paymentDate: fields.paymentDate,
transactionId: fields.helcimTransactionId
})
)
} catch (err) {
console.error('[payments] failed to send payment confirmation email:', err?.message || err)
}
}
return { created: true, payment }
}