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.
-
- From:
- {{donation.name}} ({{donation.email}})
- Amount received:
- {{donation.amount}}
- |
-
-
-
- {{#if memberData}}
- View member
- {{else}}
- View dashboard
- {{/if}}
+ |
+
+
+
+ From:
+ {{donation.name}} ({{donation.email}})
+ Amount received:
+ {{donation.amount}}
+ {{#if donation.donationMessage}}
+ “{{donation.donationMessage}}”
+ {{/if}}
+ |
+
+
+
+
+
+
+
+
+
+ {{#if donation.donationMessage}}
+
+
+ |
+
+
+ |
+ {{else}}
+
+ {{#if memberData}}
+ View member
+ {{else}}
+ View dashboard
+ {{/if}}
+ |
+ {{/if}}
+
+
+
+ |
+
+
+
|
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);
+ });
+});
|