From be7145f96c3c3281784e82292ef602e196add856 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 13:15:38 +0100 Subject: [PATCH] feat(payments): add upsertPaymentFromHelcim helper with idempotent insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/emails/paymentConfirmation.js | 16 +++ server/utils/payments.js | 63 +++++++++++ tests/server/utils/payments.test.js | 159 +++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 server/emails/paymentConfirmation.js create mode 100644 server/utils/payments.js create mode 100644 tests/server/utils/payments.test.js diff --git a/server/emails/paymentConfirmation.js b/server/emails/paymentConfirmation.js new file mode 100644 index 0000000..825ae50 --- /dev/null +++ b/server/emails/paymentConfirmation.js @@ -0,0 +1,16 @@ +export const paymentConfirmationEmail = ({ member, amount, paymentDate, transactionId }) => ({ + from: 'Ghost Guild ', + 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. +` +}) diff --git a/server/utils/payments.js b/server/utils/payments.js new file mode 100644 index 0000000..bd0e984 --- /dev/null +++ b/server/utils/payments.js @@ -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 } +} diff --git a/tests/server/utils/payments.test.js b/tests/server/utils/payments.test.js new file mode 100644 index 0000000..d0dd5c0 --- /dev/null +++ b/tests/server/utils/payments.test.js @@ -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') + }) +})