From e8e1b8ea2f845c69aa97f9d6dda12bb5b0a6ad52 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Wed, 28 Aug 2024 21:08:42 +0900 Subject: [PATCH] Added donation message to Stripe and Email (#20828) ref PLG-160 - Refactored donation handling logic to be processed within the `checkout.session.completed` webhook event. - Added support for capturing and storing donation messages from Stripe sessions. - Integrated donation messages into the email notifications sent to staff. - Added database integration. - Removed redundant donation logic from the invoice.payment_succeeded webhook, since custom fields isn't supported. - Updated and added new tests --------- Co-authored-by: Sanne de Vries --- .../donation-checkout-session.test.js.snap | 6 + .../members/donation-checkout-session.test.js | 126 +++++++++++-- .../src/DonationBookshelfRepository.ts | 2 + ghost/donations/src/DonationPaymentEvent.ts | 2 + ghost/staff-service/lib/StaffServiceEmails.js | 3 +- .../lib/email-templates/donation.hbs | 76 ++++++-- .../lib/email-templates/donation.txt.js | 2 + .../staff-service/test/staff-service.test.js | 109 ++++++++++- ghost/stripe/lib/StripeAPI.js | 21 ++- ghost/stripe/lib/WebhookController.js | 65 ++++--- ghost/stripe/test/unit/lib/StripeAPI.test.js | 53 +++++- .../test/unit/lib/WebhookController.test.js | 174 ++++++++++++++++++ 12 files changed, 572 insertions(+), 67 deletions(-) create mode 100644 ghost/stripe/test/unit/lib/WebhookController.test.js diff --git a/ghost/core/test/e2e-api/members/__snapshots__/donation-checkout-session.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/donation-checkout-session.test.js.snap index 67a223dba8..b6feb03d6b 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/donation-checkout-session.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/donation-checkout-session.test.js.snap @@ -11,3 +11,9 @@ Object { "url": "https://checkout.stripe.com/c/pay/fake-data", } `; + +exports[`Create Stripe Checkout Session for Donations check if donation message is in email 1: [body] 1`] = ` +Object { + "url": "https://checkout.stripe.com/c/pay/fake-data", +} +`; diff --git a/ghost/core/test/e2e-api/members/donation-checkout-session.test.js b/ghost/core/test/e2e-api/members/donation-checkout-session.test.js index 914d8c488b..30394914e5 100644 --- a/ghost/core/test/e2e-api/members/donation-checkout-session.test.js +++ b/ghost/core/test/e2e-api/members/donation-checkout-session.test.js @@ -58,21 +58,29 @@ describe('Create Stripe Checkout Session for Donations', function () { .expectStatus(200) .matchBodySnapshot(); - // Send a webhook of a paid invoice for this session + // Send a webhook of a completed checkout session for this donation await stripeMocker.sendWebhook({ - type: 'invoice.payment_succeeded', + type: 'checkout.session.completed', data: { object: { - type: 'invoice', - paid: true, - amount_paid: 1200, + mode: 'payment', + amount_total: 1200, currency: 'usd', customer: (stripeMocker.checkoutSessions[0].customer), - customer_name: 'Paid Test', - customer_email: 'exampledonation@example.com', + customer_details: { + name: 'Paid Test', + email: 'exampledonation@example.com' + }, metadata: { - ...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {}) - } + ...(stripeMocker.checkoutSessions[0].metadata ?? {}), + ghost_donation: true + }, + custom_fields: [{ + key: 'donation_message', + text: { + value: 'You are the best! Have a lovely day!' + } + }] } } }); @@ -87,11 +95,13 @@ describe('Create Stripe Checkout Session for Donations', function () { const lastDonation = await models.DonationPaymentEvent.findOne({ email: 'exampledonation@example.com' }, {require: true}); + assert.equal(lastDonation.get('amount'), 1200); assert.equal(lastDonation.get('currency'), 'usd'); assert.equal(lastDonation.get('email'), 'exampledonation@example.com'); assert.equal(lastDonation.get('name'), 'Paid Test'); assert.equal(lastDonation.get('member_id'), null); + assert.equal(lastDonation.get('donation_message'), 'You are the best! Have a lovely day!'); // Check referrer assert.equal(lastDonation.get('referrer_url'), 'example.com'); @@ -125,6 +135,7 @@ describe('Create Stripe Checkout Session for Donations', function () { await membersAgent.post('/api/create-stripe-checkout-session/') .body({ + mode: 'payment', customerEmail: email, identity: token, type: 'donation', @@ -146,21 +157,29 @@ describe('Create Stripe Checkout Session for Donations', function () { .expectStatus(200) .matchBodySnapshot(); - // Send a webhook of a paid invoice for this session + // Send a webhook of a completed checkout session for this donation await stripeMocker.sendWebhook({ - type: 'invoice.payment_succeeded', + type: 'checkout.session.completed', data: { object: { - type: 'invoice', - paid: true, - amount_paid: 1220, + mode: 'payment', + amount_total: 1220, currency: 'eur', customer: (stripeMocker.checkoutSessions[0].customer), - customer_name: 'Member Test', - customer_email: email, + customer_details: { + name: 'Member Test', + email: email + }, metadata: { - ...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {}) - } + ...(stripeMocker.checkoutSessions[0].metadata ?? {}), + ghost_donation: true + }, + custom_fields: [{ + key: 'donation_message', + text: { + value: 'You are the best! Have a lovely day!' + } + }] } } }); @@ -180,6 +199,7 @@ describe('Create Stripe Checkout Session for Donations', function () { assert.equal(lastDonation.get('email'), email); assert.equal(lastDonation.get('name'), 'Member Test'); assert.equal(lastDonation.get('member_id'), member.id); + assert.equal(lastDonation.get('donation_message'), 'You are the best! Have a lovely day!'); // Check referrer assert.equal(lastDonation.get('referrer_url'), 'example.com'); @@ -191,4 +211,74 @@ describe('Create Stripe Checkout Session for Donations', function () { assert.equal(lastDonation.get('attribution_type'), 'post'); assert.equal(lastDonation.get('attribution_url'), url); }); + it('check if donation message is in email', async function () { + const post = await getPost(fixtureManager.get('posts', 0).id); + const url = urlService.getUrlByResourceId(post.id, {absolute: false}); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + mode: 'payment', + type: 'donation', + customerEmail: 'paid@test.com', + successUrl: 'https://example.com/?type=success', + cancelUrl: 'https://example.com/?type=cancel', + metadata: { + urlHistory: [ + { + path: url, + time: Date.now(), + referrerMedium: null, + referrerSource: 'ghost-explore', + referrerUrl: 'https://example.com/blog/' + } + ], + ghost_donation: true + }, + custom_fields: [{ + key: 'donation_message', + label: { + type: 'custom', + custom: 'Add a personal note' + }, + type: 'text', + optional: true + }] + }) + .expectStatus(200) + .matchBodySnapshot(); + + // Send a webhook of a completed checkout session for this donation + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: { + object: { + mode: 'payment', + amount_total: 1200, + currency: 'usd', + customer: (stripeMocker.checkoutSessions[0].customer), + customer_details: { + name: 'Paid Test', + email: 'exampledonation@example.com' + }, + metadata: { + ...(stripeMocker.checkoutSessions[0].metadata ?? {}), + ghost_donation: true + }, + custom_fields: [{ + key: 'donation_message', + text: { + value: 'You are the best! Have a lovely day!' + } + }] + } + } + }); + + // check if donation message is in email + mockManager.assert.sentEmail({ + subject: '💰 One-time payment received: $12.00 from Paid Test', + to: 'jbloggs@example.com', + text: /You are the best! Have a lovely day!/ + }); + }); }); diff --git a/ghost/donations/src/DonationBookshelfRepository.ts b/ghost/donations/src/DonationBookshelfRepository.ts index d56c32e04a..3d1fddab0b 100644 --- a/ghost/donations/src/DonationBookshelfRepository.ts +++ b/ghost/donations/src/DonationBookshelfRepository.ts @@ -12,6 +12,7 @@ type DonationEventModelInstance = BookshelfModelInstance & { member_id: string | null; amount: number; currency: string; + donation_message: string | null; attribution_id: string | null; attribution_url: string | null; @@ -36,6 +37,7 @@ export class DonationBookshelfRepository implements DonationRepository { member_id: event.memberId, amount: event.amount, currency: event.currency, + donation_message: event.donationMessage, attribution_id: event.attributionId, attribution_url: event.attributionUrl, diff --git a/ghost/donations/src/DonationPaymentEvent.ts b/ghost/donations/src/DonationPaymentEvent.ts index 1202744ccd..0a3523b2e1 100644 --- a/ghost/donations/src/DonationPaymentEvent.ts +++ b/ghost/donations/src/DonationPaymentEvent.ts @@ -5,6 +5,7 @@ export class DonationPaymentEvent { memberId: string | null; amount: number; currency: string; + donationMessage: string | null; attributionId: string | null; attributionUrl: string | null; @@ -21,6 +22,7 @@ export class DonationPaymentEvent { this.memberId = data.memberId; this.amount = data.amount; this.currency = data.currency; + this.donationMessage = data.donationMessage; this.attributionId = data.attributionId; this.attributionUrl = data.attributionUrl; diff --git a/ghost/staff-service/lib/StaffServiceEmails.js b/ghost/staff-service/lib/StaffServiceEmails.js index 4022de8ae6..a4d5206176 100644 --- a/ghost/staff-service/lib/StaffServiceEmails.js +++ b/ghost/staff-service/lib/StaffServiceEmails.js @@ -296,7 +296,8 @@ class StaffServiceEmails { donation: { name: donationPaymentEvent.name ?? donationPaymentEvent.email, email: donationPaymentEvent.email, - amount: formattedAmount + amount: formattedAmount, + donationMessage: donationPaymentEvent.donationMessage }, memberData, accentColor: this.settingsCache.get('accent_color') diff --git a/ghost/staff-service/lib/email-templates/donation.hbs b/ghost/staff-service/lib/email-templates/donation.hbs index 73fcf6b827..d4652c1dc8 100644 --- a/ghost/staff-service/lib/email-templates/donation.hbs +++ b/ghost/staff-service/lib/email-templates/donation.hbs @@ -28,24 +28,70 @@ {{/if}} -

Cha-ching! You received a {{donation.amount}} tip from {{donation.name}}.

+

Cha-ching! You received a tip.

- - - - diff --git a/ghost/staff-service/lib/email-templates/donation.txt.js b/ghost/staff-service/lib/email-templates/donation.txt.js index 39dc293b2d..95a3ec35d0 100644 --- a/ghost/staff-service/lib/email-templates/donation.txt.js +++ b/ghost/staff-service/lib/email-templates/donation.txt.js @@ -5,6 +5,8 @@ Cha-ching! You received a one-time payment from of ${data.donation.amount} from "${data.donation.name}". +Message: ${data.donation.donationMessage ? data.donation.donationMessage : 'No message provided'} + --- Sent to ${data.toEmail} from ${data.siteDomain}. diff --git a/ghost/staff-service/test/staff-service.test.js b/ghost/staff-service/test/staff-service.test.js index 7570986cd2..a2e167fb17 100644 --- a/ghost/staff-service/test/staff-service.test.js +++ b/ghost/staff-service/test/staff-service.test.js @@ -930,7 +930,8 @@ describe('StaffService', function () { amount: 1500, currency: 'eur', name: 'Simon', - email: 'simon@example.com' + email: 'simon@example.com', + donationMessage: 'Thank you for the awesome newsletter!' }; await service.emails.notifyDonationReceived({donationPaymentEvent}); @@ -943,6 +944,112 @@ describe('StaffService', function () { sinon.match.has('html', sinon.match('One-time payment received: €15.00 from Simon')) ).should.be.true(); }); + + it('has donation message in text', async function () { + const donationPaymentEvent = { + amount: 1500, + currency: 'eur', + name: 'Jamie', + email: 'jamie@example.com', + donationMessage: 'Thank you for the awesome newsletter!' + }; + + await service.emails.notifyDonationReceived({donationPaymentEvent}); + + getEmailAlertUsersStub.calledWith('donation').should.be.true(); + + mailStub.calledOnce.should.be.true(); + + mailStub.calledWith( + sinon.match.has('text', sinon.match('Thank you for the awesome newsletter!')) + ).should.be.true(); + }); + + it('has donation message in html', async function () { + const donationPaymentEvent = { + amount: 1500, + currency: 'eur', + name: 'Jamie', + email: 'jamie@example.com', + donationMessage: 'Thank you for the awesome newsletter!' + }; + + await service.emails.notifyDonationReceived({donationPaymentEvent}); + + getEmailAlertUsersStub.calledWith('donation').should.be.true(); + + mailStub.calledOnce.should.be.true(); + + mailStub.calledWith( + sinon.match.has('html', sinon.match('Thank you for the awesome newsletter!')) + ).should.be.true(); + }); + + it('does not contain donation message in HTML if not provided', async function () { + const donationPaymentEvent = { + amount: 1500, + currency: 'eur', + name: 'Jamie', + email: 'jamie@example.com', + donationMessage: null // No donation message provided + }; + + await service.emails.notifyDonationReceived({donationPaymentEvent}); + + getEmailAlertUsersStub.calledWith('donation').should.be.true(); + mailStub.calledOnce.should.be.true(); + + // Check that the specific HTML block for the donation message is NOT present + mailStub.calledWith( + sinon.match.has('html', sinon.match(function (html) { + // Ensure that the block with `{{donation.donationMessage}}` does not exist in the rendered HTML + return !html.includes('“') && !html.includes('”'); + })) + ).should.be.true(); + }); + + // Not really a relevant test, but it's here to show that the donation message is wrapped in quotation marks + // and that the above test is actually working, since only the donation message is wrapped in quotation marks + it('The donation message is wrapped in quotation marks', async function () { + const donationPaymentEvent = { + amount: 1500, + currency: 'eur', + name: 'Jamie', + email: 'jamie@example.com', + donationMessage: 'Thank you for the great newsletter!' + }; + + await service.emails.notifyDonationReceived({donationPaymentEvent}); + + getEmailAlertUsersStub.calledWith('donation').should.be.true(); + mailStub.calledOnce.should.be.true(); + + mailStub.calledWith( + sinon.match.has('html', sinon.match(function (html) { + return html.includes('“') && html.includes('”'); + })) + ).should.be.true(); + }); + + it('send donation email without message', async function () { + const donationPaymentEvent = { + amount: 1500, + currency: 'eur', + name: 'Ronald', + email: 'ronald@example.com', + donationMessage: null + }; + + await service.emails.notifyDonationReceived({donationPaymentEvent}); + + getEmailAlertUsersStub.calledWith('donation').should.be.true(); + + mailStub.calledOnce.should.be.true(); + + mailStub.calledWith( + sinon.match.has('text', sinon.match('No message provided')) + ).should.be.true(); + }); }); describe('renderText for webmentions', function () { diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js index 999e5a0155..9dc29725e4 100644 --- a/ghost/stripe/lib/StripeAPI.js +++ b/ghost/stripe/lib/StripeAPI.js @@ -523,6 +523,14 @@ module.exports = class StripeAPI { /** * @type {Stripe.Checkout.SessionCreateParams} */ + + // TODO - add it higher up the stack to the metadata object. + // add ghost_donation key to metadata object + metadata = { + ghost_donation: true, + ...metadata + }; + const stripeSessionOptions = { mode: 'payment', success_url: successUrl || this._config.checkoutSessionSuccessUrl, @@ -547,7 +555,18 @@ module.exports = class StripeAPI { line_items: [{ price: priceId, quantity: 1 - }] + }], + custom_fields: [ + { + key: 'donation_message', + label: { + type: 'custom', + custom: 'Add a personal note' + }, + type: 'text', + optional: true + } + ] }; if (customer && this._config.enableAutomaticTax) { diff --git a/ghost/stripe/lib/WebhookController.js b/ghost/stripe/lib/WebhookController.js index d72fcb29f9..1877bc2698 100644 --- a/ghost/stripe/lib/WebhookController.js +++ b/ghost/stripe/lib/WebhookController.js @@ -111,35 +111,8 @@ module.exports = class WebhookController { async invoiceEvent(invoice) { if (!invoice.subscription) { // Check if this is a one time payment, related to a donation - if (invoice.metadata.ghost_donation && invoice.paid) { - // Track a one time payment event - const amount = invoice.amount_paid; - - const member = invoice.customer ? (await this.deps.memberRepository.get({ - customer_id: invoice.customer - })) : null; - - const data = DonationPaymentEvent.create({ - name: member?.get('name') ?? invoice.customer_name, - email: member?.get('email') ?? invoice.customer_email, - memberId: member?.id ?? null, - amount, - currency: invoice.currency, - - // Attribution data - attributionId: invoice.metadata.attribution_id ?? null, - attributionUrl: invoice.metadata.attribution_url ?? null, - attributionType: invoice.metadata.attribution_type ?? null, - referrerSource: invoice.metadata.referrer_source ?? null, - referrerMedium: invoice.metadata.referrer_medium ?? null, - referrerUrl: invoice.metadata.referrer_url ?? null - }); - - await this.deps.donationRepository.save(data); - await this.deps.staffServiceEmails.notifyDonationReceived({ - donationPaymentEvent: data - }); - } + // this is being handled in checkoutSessionEvent because we need to handle the custom donation message + // which is not available in the invoice object return; } const subscription = await this.api.getSubscription(invoice.subscription, { @@ -182,6 +155,40 @@ module.exports = class WebhookController { * @private */ async checkoutSessionEvent(session) { + if (session.mode === 'payment' && session.metadata?.ghost_donation) { + const donationField = session.custom_fields?.find(obj => obj?.key === 'donation_message'); + // const customMessage = donationField?.text?.value ?? ''; + + // custom message should be null if it's empty + + const donationMessage = donationField?.text?.value ? donationField.text.value : null; + + const amount = session.amount_total; + const currency = session.currency; + const member = session.customer ? (await this.deps.memberRepository.get({ + customer_id: session.customer + })) : null; + + const data = DonationPaymentEvent.create({ + name: member?.get('name') ?? session.customer_details.name, + email: member?.get('email') ?? session.customer_details.email, + memberId: member?.id ?? null, + amount, + currency, + donationMessage, + attributionId: session.metadata.attribution_id ?? null, + attributionUrl: session.metadata.attribution_url ?? null, + attributionType: session.metadata.attribution_type ?? null, + referrerSource: session.metadata.referrer_source ?? null, + referrerMedium: session.metadata.referrer_medium ?? null, + referrerUrl: session.metadata.referrer_url ?? null + }); + + await this.deps.donationRepository.save(data); + await this.deps.staffServiceEmails.notifyDonationReceived({ + donationPaymentEvent: data + }); + } if (session.mode === 'setup') { const setupIntent = await this.api.getSetupIntent(session.setup_intent); const member = await this.deps.memberRepository.get({ diff --git a/ghost/stripe/test/unit/lib/StripeAPI.test.js b/ghost/stripe/test/unit/lib/StripeAPI.test.js index 3747aa1556..a403138496 100644 --- a/ghost/stripe/test/unit/lib/StripeAPI.test.js +++ b/ghost/stripe/test/unit/lib/StripeAPI.test.js @@ -508,8 +508,7 @@ describe('StripeAPI', function () { it('passes metadata correctly', async function () { const metadata = { - key1: 'value1', - key2: 'value2' + ghost_donation: true }; await api.createDonationCheckoutSession({ @@ -524,6 +523,56 @@ describe('StripeAPI', function () { should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.metadata); should.deepEqual(mockStripe.checkout.sessions.create.firstCall.firstArg.metadata, metadata); }); + + it('passes custom fields correctly', async function () { + await api.createDonationCheckoutSession({ + priceId: 'priceId', + successUrl: '/success', + cancelUrl: '/cancel', + metadata: {}, + customer: null, + customerEmail: mockCustomerEmail + }); + + should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.custom_fields); + const customFields = mockStripe.checkout.sessions.create.firstCall.firstArg.custom_fields; + should.equal(customFields.length, 1); + }); + + it('has correct data for custom field message', async function () { + await api.createDonationCheckoutSession({ + priceId: 'priceId', + successUrl: '/success', + cancelUrl: '/cancel', + metadata: {}, + customer: null, + customerEmail: mockCustomerEmail + }); + + const customFields = mockStripe.checkout.sessions.create.firstCall.firstArg.custom_fields; + should.deepEqual(customFields[0], { + key: 'donation_message', + label: { + type: 'custom', + custom: 'Add a personal note' + }, + type: 'text', + optional: true + }); + }); + + it('does not have more than 3 custom fields (stripe limitation)', async function () { + await api.createDonationCheckoutSession({ + priceId: 'priceId', + successUrl: '/success', + cancelUrl: '/cancel', + metadata: {}, + customer: null, + customerEmail: mockCustomerEmail + }); + + should.ok(mockStripe.checkout.sessions.create.firstCall.firstArg.custom_fields.length <= 3); + }); }); }); }); diff --git a/ghost/stripe/test/unit/lib/WebhookController.test.js b/ghost/stripe/test/unit/lib/WebhookController.test.js new file mode 100644 index 0000000000..7cb407d97c --- /dev/null +++ b/ghost/stripe/test/unit/lib/WebhookController.test.js @@ -0,0 +1,174 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const {expect} = chai; +const WebhookController = require('../../../lib/WebhookController'); +// const {DonationPaymentEvent} = require('@tryghost/donations'); + +describe('WebhookController', function () { + let controller; + let deps; + let req; + let res; + + beforeEach(function () { + deps = { + api: {getSubscription: sinon.stub(), getCustomer: sinon.stub(), getSetupIntent: sinon.stub(), attachPaymentMethodToCustomer: sinon.stub(), updateSubscriptionDefaultPaymentMethod: sinon.stub()}, + webhookManager: {parseWebhook: sinon.stub()}, + eventRepository: {registerPayment: sinon.stub()}, + memberRepository: {get: sinon.stub(), create: sinon.stub(), update: sinon.stub(), linkSubscription: sinon.stub(), upsertCustomer: sinon.stub()}, + donationRepository: {save: sinon.stub()}, + productRepository: {get: sinon.stub()}, + staffServiceEmails: {notifyDonationReceived: sinon.stub()}, + sendSignupEmail: sinon.stub() + }; + + controller = new WebhookController(deps); + + req = { + body: {}, + headers: { + 'stripe-signature': 'valid-signature' + } + }; + + res = { + writeHead: sinon.stub(), + end: sinon.stub() + }; + }); + + it('should return 400 if request body or signature is missing', async function () { + req.body = null; + await controller.handle(req, res); + expect(res.writeHead.calledWith(400)).to.be.true; + expect(res.end.called).to.be.true; + }); + + it('should return 401 if webhook signature is invalid', async function () { + deps.webhookManager.parseWebhook.throws(new Error('Invalid signature')); + await controller.handle(req, res); + expect(res.writeHead.calledWith(401)).to.be.true; + expect(res.end.called).to.be.true; + }); + + it('should handle customer.subscription.created event', async function () { + const event = { + type: 'customer.subscription.created', + data: { + object: {customer: 'cust_123', items: {data: [{price: {id: 'price_123'}}]}} + } + }; + deps.webhookManager.parseWebhook.returns(event); + deps.memberRepository.get.resolves({id: 'member_123'}); + + await controller.handle(req, res); + + expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true; + expect(deps.memberRepository.linkSubscription.calledOnce).to.be.true; + expect(res.writeHead.calledWith(200)).to.be.true; + expect(res.end.called).to.be.true; + }); + + it('should handle a donation in checkoutSessionEvent', async function () { + const session = { + mode: 'payment', + metadata: { + ghost_donation: true, + attribution_id: 'attr_123', + attribution_url: 'https://example.com', + attribution_type: 'referral', + referrer_source: 'google', + referrer_medium: 'cpc', + referrer_url: 'https://referrer.com' + }, + amount_total: 5000, + currency: 'usd', + customer: 'cust_123', + customer_details: { + name: 'John Doe', + email: 'john@example.com' + }, + custom_fields: [{ + key: 'donation_message', + text: { + value: 'Thank you for the awesome newsletter!' + } + }] + }; + + const member = { + id: 'member_123', + get: sinon.stub() + }; + + member.get.withArgs('name').returns('John Doe'); + member.get.withArgs('email').returns('john@example.com'); + + deps.memberRepository.get.resolves(member); + + await controller.checkoutSessionEvent(session); + + expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true; + expect(deps.donationRepository.save.calledOnce).to.be.true; + expect(deps.staffServiceEmails.notifyDonationReceived.calledOnce).to.be.true; + + const savedDonationEvent = deps.donationRepository.save.getCall(0).args[0]; + expect(savedDonationEvent.amount).to.equal(5000); + expect(savedDonationEvent.currency).to.equal('usd'); + expect(savedDonationEvent.name).to.equal('John Doe'); + expect(savedDonationEvent.email).to.equal('john@example.com'); + expect(savedDonationEvent.donationMessage).to.equal('Thank you for the awesome newsletter!'); + expect(savedDonationEvent.attributionId).to.equal('attr_123'); + expect(savedDonationEvent.attributionUrl).to.equal('https://example.com'); + expect(savedDonationEvent.attributionType).to.equal('referral'); + expect(savedDonationEvent.referrerSource).to.equal('google'); + expect(savedDonationEvent.referrerMedium).to.equal('cpc'); + expect(savedDonationEvent.referrerUrl).to.equal('https://referrer.com'); + }); + + it('donation message is null if string is empty', async function () { + const session = { + mode: 'payment', + metadata: { + ghost_donation: true, + attribution_id: 'attr_123', + attribution_url: 'https://example.com', + attribution_type: 'referral', + referrer_source: 'google', + referrer_medium: 'cpc', + referrer_url: 'https://referrer.com' + }, + amount_total: 5000, + currency: 'usd', + customer: 'cust_123', + customer_details: { + name: 'JW', + email: 'jw@ily.co' + }, + custom_fields: [{ + key: 'donation_message', + text: { + value: '' + } + }] + }; + + const member = { + id: 'member_123', + get: sinon.stub() + }; + + member.get.withArgs('name').returns('JW'); + member.get.withArgs('email').returns('jw@ily.co'); + + deps.memberRepository.get.resolves(member); + + await controller.checkoutSessionEvent(session); + + expect(deps.memberRepository.get.calledWith({customer_id: 'cust_123'})).to.be.true; + + const savedDonationEvent = deps.donationRepository.save.getCall(0).args[0]; + + expect(savedDonationEvent.donationMessage).to.equal(null); + }); +});
-

From:

- -

Amount received:

-

{{donation.amount}}

-
- {{#if memberData}} - View member - {{else}} - View dashboard - {{/if}} + + + + + +
+

From:

+ +

Amount received:

+

{{donation.amount}}

+ {{#if donation.donationMessage}} +

“{{donation.donationMessage}}”

+ {{/if}} +
+ + + + + + +
+ + + + {{#if donation.donationMessage}} + + + {{else}} + + {{/if}} + + +
+ + + + +
+ Reply +
+
+ + + + +
+ {{#if memberData}} + View member + {{else}} + View dashboard + {{/if}} +
+
+ {{#if memberData}} + View member + {{else}} + View dashboard + {{/if}} +
+