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 <sannedv@protonmail.com>
This commit is contained in:
Ronald Langeveld 2024-08-28 21:08:42 +09:00 committed by GitHub
parent 32edc12cc2
commit e8e1b8ea2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 572 additions and 67 deletions

View File

@ -11,3 +11,9 @@ Object {
"url": "https://checkout.stripe.com/c/pay/fake-data", "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",
}
`;

View File

@ -58,21 +58,29 @@ describe('Create Stripe Checkout Session for Donations', function () {
.expectStatus(200) .expectStatus(200)
.matchBodySnapshot(); .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({ await stripeMocker.sendWebhook({
type: 'invoice.payment_succeeded', type: 'checkout.session.completed',
data: { data: {
object: { object: {
type: 'invoice', mode: 'payment',
paid: true, amount_total: 1200,
amount_paid: 1200,
currency: 'usd', currency: 'usd',
customer: (stripeMocker.checkoutSessions[0].customer), customer: (stripeMocker.checkoutSessions[0].customer),
customer_name: 'Paid Test', customer_details: {
customer_email: 'exampledonation@example.com', name: 'Paid Test',
email: 'exampledonation@example.com'
},
metadata: { 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({ const lastDonation = await models.DonationPaymentEvent.findOne({
email: 'exampledonation@example.com' email: 'exampledonation@example.com'
}, {require: true}); }, {require: true});
assert.equal(lastDonation.get('amount'), 1200); assert.equal(lastDonation.get('amount'), 1200);
assert.equal(lastDonation.get('currency'), 'usd'); assert.equal(lastDonation.get('currency'), 'usd');
assert.equal(lastDonation.get('email'), 'exampledonation@example.com'); assert.equal(lastDonation.get('email'), 'exampledonation@example.com');
assert.equal(lastDonation.get('name'), 'Paid Test'); assert.equal(lastDonation.get('name'), 'Paid Test');
assert.equal(lastDonation.get('member_id'), null); assert.equal(lastDonation.get('member_id'), null);
assert.equal(lastDonation.get('donation_message'), 'You are the best! Have a lovely day!');
// Check referrer // Check referrer
assert.equal(lastDonation.get('referrer_url'), 'example.com'); 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/') await membersAgent.post('/api/create-stripe-checkout-session/')
.body({ .body({
mode: 'payment',
customerEmail: email, customerEmail: email,
identity: token, identity: token,
type: 'donation', type: 'donation',
@ -146,21 +157,29 @@ describe('Create Stripe Checkout Session for Donations', function () {
.expectStatus(200) .expectStatus(200)
.matchBodySnapshot(); .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({ await stripeMocker.sendWebhook({
type: 'invoice.payment_succeeded', type: 'checkout.session.completed',
data: { data: {
object: { object: {
type: 'invoice', mode: 'payment',
paid: true, amount_total: 1220,
amount_paid: 1220,
currency: 'eur', currency: 'eur',
customer: (stripeMocker.checkoutSessions[0].customer), customer: (stripeMocker.checkoutSessions[0].customer),
customer_name: 'Member Test', customer_details: {
customer_email: email, name: 'Member Test',
email: email
},
metadata: { 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('email'), email);
assert.equal(lastDonation.get('name'), 'Member Test'); assert.equal(lastDonation.get('name'), 'Member Test');
assert.equal(lastDonation.get('member_id'), member.id); assert.equal(lastDonation.get('member_id'), member.id);
assert.equal(lastDonation.get('donation_message'), 'You are the best! Have a lovely day!');
// Check referrer // Check referrer
assert.equal(lastDonation.get('referrer_url'), 'example.com'); 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_type'), 'post');
assert.equal(lastDonation.get('attribution_url'), url); 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!/
});
});
}); });

View File

@ -12,6 +12,7 @@ type DonationEventModelInstance = BookshelfModelInstance & {
member_id: string | null; member_id: string | null;
amount: number; amount: number;
currency: string; currency: string;
donation_message: string | null;
attribution_id: string | null; attribution_id: string | null;
attribution_url: string | null; attribution_url: string | null;
@ -36,6 +37,7 @@ export class DonationBookshelfRepository implements DonationRepository {
member_id: event.memberId, member_id: event.memberId,
amount: event.amount, amount: event.amount,
currency: event.currency, currency: event.currency,
donation_message: event.donationMessage,
attribution_id: event.attributionId, attribution_id: event.attributionId,
attribution_url: event.attributionUrl, attribution_url: event.attributionUrl,

View File

@ -5,6 +5,7 @@ export class DonationPaymentEvent {
memberId: string | null; memberId: string | null;
amount: number; amount: number;
currency: string; currency: string;
donationMessage: string | null;
attributionId: string | null; attributionId: string | null;
attributionUrl: string | null; attributionUrl: string | null;
@ -21,6 +22,7 @@ export class DonationPaymentEvent {
this.memberId = data.memberId; this.memberId = data.memberId;
this.amount = data.amount; this.amount = data.amount;
this.currency = data.currency; this.currency = data.currency;
this.donationMessage = data.donationMessage;
this.attributionId = data.attributionId; this.attributionId = data.attributionId;
this.attributionUrl = data.attributionUrl; this.attributionUrl = data.attributionUrl;

View File

@ -296,7 +296,8 @@ class StaffServiceEmails {
donation: { donation: {
name: donationPaymentEvent.name ?? donationPaymentEvent.email, name: donationPaymentEvent.name ?? donationPaymentEvent.email,
email: donationPaymentEvent.email, email: donationPaymentEvent.email,
amount: formattedAmount amount: formattedAmount,
donationMessage: donationPaymentEvent.donationMessage
}, },
memberData, memberData,
accentColor: this.settingsCache.get('accent_color') accentColor: this.settingsCache.get('accent_color')

View File

@ -28,26 +28,72 @@
{{/if}} {{/if}}
<tr> <tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;"> <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<h1 style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 26px; color: #15212A; font-weight: bold; line-height: 28px; margin: 0; padding-bottom: 24px;">Cha-ching! You received a {{donation.amount}} tip from {{donation.name}}.</h1> <h1 style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 26px; color: #15212A; font-weight: bold; line-height: 28px; margin: 0; padding-bottom: 24px;">Cha-ching! You received a tip.</h1>
<table width="100" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F4F5F6; border-radius: 8px;"> <table width="100" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F4F5F6; border-radius: 8px;">
<tbody> <tbody>
<tr> <tr>
<td align="center" style="padding: 24px; text-align: center;"> <td align="left" style="padding: 24px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px; background-color: #F4F5F6; text-align: left; vertical-align: middle;" valign="middle">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 26px; padding: 0; text-align: left; margin: 0; color: #15171A; font-weight: 400;">From:</p> <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 26px; padding: 0; text-align: left; margin: 0; color: #15171A; font-weight: 400;">From:</p>
<p class="text-link" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; line-height: 26px; padding: 0; text-align: left; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 700;">{{donation.name}} (<span style="color:{{accentColor}}; text-decoration: none;">{{donation.email}}</span>)</p> <p class="text-link" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; line-height: 26px; padding: 0; padding-bottom: 24px; text-align: left; margin: 0; color: #15171A; font-weight: 700;">{{donation.name}} (<span style="color:{{accentColor}}; text-decoration: none;">{{donation.email}}</span>)</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 26px; padding: 0; text-align: left; margin: 0; color: #15171A; font-weight: 400;">Amount received:</p> <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 26px; padding: 0; text-align: left; margin: 0; color: #15171A; font-weight: 400;">Amount received:</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; line-height: 26px; padding: 0; text-align: left; margin: 0; color: #15171A; font-weight: 700;">{{donation.amount}}</p> <p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; line-height: 26px; padding: 0; padding-bottom: 24px; text-align: left; margin: 0; color: #15171A; font-weight: 700;">{{donation.amount}}</p>
{{#if donation.donationMessage}}
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 22px; padding: 0; padding-bottom: 28px; text-align: left; margin: 0; color: #15171A; font-weight: 400;">“{{donation.donationMessage}}”</p>
{{/if}}
</td> </td>
</tr> </tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr> <tr>
<td style="padding:0 24px 24px;"> <td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
{{#if donation.donationMessage}}
<td align="left" style="padding: 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; vertical-align: top; background-color: {{accentColor}}; border-radius: 8px; text-align: center;">
<a href="mailto:{{donation.email}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 8px; padding: 10px 20px; text-decoration: none;">Reply</a>
</td>
</tr>
</table>
</td>
<td align="left" style="padding: 0; padding-left: 8px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; vertical-align: top; background-color: #E5E9ED; border-radius: 8px; text-align: center;">
{{#if memberData}} {{#if memberData}}
<a href="{{memberData.adminUrl}}" style="border:solid 1px {{accentColor}};border-radius:8px;box-sizing:border-box;display:inline-block;font-size:15px;font-weight:normal;margin:0;padding:10px 20px;text-decoration:none;background-color:{{accentColor}};border-color:{{accentColor}};color:#ffffff">View member</a> <a href="{{memberData.adminUrl}}" target="_blank" style="display: inline-block; color: #15171A; background-color: #E5E9ED; border: solid 1px #E5E9ED; border-radius: 8px; padding: 10px 20px; text-decoration: none;">View member</a>
{{else}} {{else}}
<a href="{{adminUrl}}" style="border:solid 1px {{accentColor}};border-radius:8px;box-sizing:border-box;display:inline-block;font-size:15px;font-weight:normal;margin:0;padding:10px 20px;text-decoration:none;background-color:{{accentColor}};border-color:{{accentColor}};color:#ffffff">View dashboard</a> <a href="{{adminUrl}}" target="_blank" style="display: inline-block; color: #15171A; background-color: #E5E9ED; border: solid 1px #E5E9ED; border-radius: 8px; padding: 10px 20px; text-decoration: none;">View dashboard</a>
{{/if}} {{/if}}
</td> </td>
</tr> </tr>
</table>
</td>
{{else}}
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; vertical-align: top; background-color: {{accentColor}}; border-radius: 8px; text-align: center;">
{{#if memberData}}
<a href="{{memberData.adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 8px; padding: 10px 20px; text-decoration: none;">View member</a>
{{else}}
<a href="{{adminUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 8px; padding: 10px 20px; text-decoration: none;">View dashboard</a>
{{/if}}
</td>
{{/if}}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody> </tbody>
</table> </table>

View File

@ -5,6 +5,8 @@ Cha-ching!
You received a one-time payment from of ${data.donation.amount} from "${data.donation.name}". 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}. Sent to ${data.toEmail} from ${data.siteDomain}.

View File

@ -930,7 +930,8 @@ describe('StaffService', function () {
amount: 1500, amount: 1500,
currency: 'eur', currency: 'eur',
name: 'Simon', name: 'Simon',
email: 'simon@example.com' email: 'simon@example.com',
donationMessage: 'Thank you for the awesome newsletter!'
}; };
await service.emails.notifyDonationReceived({donationPaymentEvent}); 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')) sinon.match.has('html', sinon.match('One-time payment received: €15.00 from Simon'))
).should.be.true(); ).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 () { describe('renderText for webmentions', function () {

View File

@ -523,6 +523,14 @@ module.exports = class StripeAPI {
/** /**
* @type {Stripe.Checkout.SessionCreateParams} * @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 = { const stripeSessionOptions = {
mode: 'payment', mode: 'payment',
success_url: successUrl || this._config.checkoutSessionSuccessUrl, success_url: successUrl || this._config.checkoutSessionSuccessUrl,
@ -547,7 +555,18 @@ module.exports = class StripeAPI {
line_items: [{ line_items: [{
price: priceId, price: priceId,
quantity: 1 quantity: 1
}] }],
custom_fields: [
{
key: 'donation_message',
label: {
type: 'custom',
custom: 'Add a personal note'
},
type: 'text',
optional: true
}
]
}; };
if (customer && this._config.enableAutomaticTax) { if (customer && this._config.enableAutomaticTax) {

View File

@ -111,35 +111,8 @@ module.exports = class WebhookController {
async invoiceEvent(invoice) { async invoiceEvent(invoice) {
if (!invoice.subscription) { if (!invoice.subscription) {
// Check if this is a one time payment, related to a donation // Check if this is a one time payment, related to a donation
if (invoice.metadata.ghost_donation && invoice.paid) { // this is being handled in checkoutSessionEvent because we need to handle the custom donation message
// Track a one time payment event // which is not available in the invoice object
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
});
}
return; return;
} }
const subscription = await this.api.getSubscription(invoice.subscription, { const subscription = await this.api.getSubscription(invoice.subscription, {
@ -182,6 +155,40 @@ module.exports = class WebhookController {
* @private * @private
*/ */
async checkoutSessionEvent(session) { 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') { if (session.mode === 'setup') {
const setupIntent = await this.api.getSetupIntent(session.setup_intent); const setupIntent = await this.api.getSetupIntent(session.setup_intent);
const member = await this.deps.memberRepository.get({ const member = await this.deps.memberRepository.get({

View File

@ -508,8 +508,7 @@ describe('StripeAPI', function () {
it('passes metadata correctly', async function () { it('passes metadata correctly', async function () {
const metadata = { const metadata = {
key1: 'value1', ghost_donation: true
key2: 'value2'
}; };
await api.createDonationCheckoutSession({ await api.createDonationCheckoutSession({
@ -524,6 +523,56 @@ describe('StripeAPI', function () {
should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.metadata); should.exist(mockStripe.checkout.sessions.create.firstCall.firstArg.metadata);
should.deepEqual(mockStripe.checkout.sessions.create.firstCall.firstArg.metadata, 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);
});
}); });
}); });
}); });

View File

@ -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);
});
});