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:
parent
bf5a333117
commit
be7145f96c
3 changed files with 238 additions and 0 deletions
16
server/emails/paymentConfirmation.js
Normal file
16
server/emails/paymentConfirmation.js
Normal 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
63
server/utils/payments.js
Normal 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 }
|
||||||
|
}
|
||||||
159
tests/server/utils/payments.test.js
Normal file
159
tests/server/utils/payments.test.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import Payment from '../../../server/models/payment.js'
|
||||||
|
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/payment.js', () => ({
|
||||||
|
default: {
|
||||||
|
findOne: vi.fn(),
|
||||||
|
create: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sendMock = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||||
|
vi.mock('resend', () => ({
|
||||||
|
Resend: function MockResend() {
|
||||||
|
this.emails = { send: (...args) => sendMock(...args) }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const memberDoc = {
|
||||||
|
_id: 'member-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test Member',
|
||||||
|
helcimCustomerId: 'CST123',
|
||||||
|
helcimSubscriptionId: 'sub-1',
|
||||||
|
billingCadence: 'monthly'
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidTx = {
|
||||||
|
id: 'tx-100',
|
||||||
|
date: '2026-04-20T10:00:00Z',
|
||||||
|
amount: 15,
|
||||||
|
status: 'paid',
|
||||||
|
currency: 'CAD'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('upsertPaymentFromHelcim', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts a new Payment when helcimTransactionId is unseen', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p1', helcimTransactionId: 'tx-100' })
|
||||||
|
|
||||||
|
const result = await upsertPaymentFromHelcim(memberDoc, paidTx)
|
||||||
|
|
||||||
|
expect(result.created).toBe(true)
|
||||||
|
expect(result.payment._id).toBe('p1')
|
||||||
|
expect(Payment.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
memberId: 'member-1',
|
||||||
|
helcimTransactionId: 'tx-100',
|
||||||
|
helcimCustomerId: 'CST123',
|
||||||
|
helcimSubscriptionId: 'sub-1',
|
||||||
|
amount: 15,
|
||||||
|
currency: 'CAD',
|
||||||
|
paymentType: 'monthly',
|
||||||
|
status: 'success',
|
||||||
|
rawHelcim: paidTx
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT overwrite an existing Payment', async () => {
|
||||||
|
const existing = { _id: 'p1', helcimTransactionId: 'tx-100', receiptIssued: true, receiptId: 'R-2026-001' }
|
||||||
|
Payment.findOne.mockResolvedValue(existing)
|
||||||
|
|
||||||
|
const result = await upsertPaymentFromHelcim(memberDoc, paidTx)
|
||||||
|
|
||||||
|
expect(result.created).toBe(false)
|
||||||
|
expect(result.payment).toBe(existing)
|
||||||
|
expect(Payment.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps helcim status 'refunded' to 'refunded'", async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p2' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, { ...paidTx, id: 'tx-101', status: 'refunded' })
|
||||||
|
|
||||||
|
expect(Payment.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'refunded' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps helcim status 'failed' to 'failed'", async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p3' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, { ...paidTx, id: 'tx-102', status: 'failed' })
|
||||||
|
|
||||||
|
expect(Payment.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips helcim status 'other' and returns created:false, payment:null", async () => {
|
||||||
|
const result = await upsertPaymentFromHelcim(memberDoc, { ...paidTx, id: 'tx-103', status: 'other' })
|
||||||
|
|
||||||
|
expect(result).toEqual({ created: false, payment: null })
|
||||||
|
expect(Payment.findOne).not.toHaveBeenCalled()
|
||||||
|
expect(Payment.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses opts.paymentType when provided, overriding memberDoc.billingCadence', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p4' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, paidTx, { paymentType: 'annual' })
|
||||||
|
|
||||||
|
expect(Payment.create).toHaveBeenCalledWith(expect.objectContaining({ paymentType: 'annual' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to memberDoc.billingCadence when opts.paymentType is absent', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p5' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim({ ...memberDoc, billingCadence: 'annual' }, paidTx)
|
||||||
|
|
||||||
|
expect(Payment.create).toHaveBeenCalledWith(expect.objectContaining({ paymentType: 'annual' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends Resend confirmation when sendConfirmation:true AND created:true', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p6' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, paidTx, { sendConfirmation: true })
|
||||||
|
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1)
|
||||||
|
const emailArg = sendMock.mock.calls[0][0]
|
||||||
|
expect(emailArg.to).toContain('test@example.com')
|
||||||
|
expect(emailArg.text).toContain('Baby Ghosts Studio Development Fund')
|
||||||
|
expect(emailArg.text).toContain('not an official donation receipt')
|
||||||
|
expect(emailArg.text).toContain('available starting later in 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT send confirmation when sendConfirmation:true but created:false', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue({ _id: 'existing' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, paidTx, { sendConfirmation: true })
|
||||||
|
|
||||||
|
expect(sendMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT send confirmation when sendConfirmation is absent/false', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p7' })
|
||||||
|
|
||||||
|
await upsertPaymentFromHelcim(memberDoc, paidTx)
|
||||||
|
|
||||||
|
expect(sendMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swallows Resend send errors (logging must not break payment flow)', async () => {
|
||||||
|
Payment.findOne.mockResolvedValue(null)
|
||||||
|
Payment.create.mockResolvedValue({ _id: 'p8' })
|
||||||
|
sendMock.mockRejectedValueOnce(new Error('SMTP blew up'))
|
||||||
|
|
||||||
|
const result = await upsertPaymentFromHelcim(memberDoc, paidTx, { sendConfirmation: true })
|
||||||
|
|
||||||
|
expect(result.created).toBe(true)
|
||||||
|
expect(result.payment._id).toBe('p8')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue