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:
parent
32edc12cc2
commit
e8e1b8ea2f
@ -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",
|
||||
}
|
||||
`;
|
||||
|
@ -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!/
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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')
|
||||
|
@ -28,26 +28,72 @@
|
||||
{{/if}}
|
||||
<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;">
|
||||
<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;">
|
||||
<tbody>
|
||||
<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 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: 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>
|
||||
</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>
|
||||
<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}}
|
||||
<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}}
|
||||
<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}}
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
|
||||
|
@ -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}.
|
||||
|
@ -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 () {
|
||||
|
@ -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) {
|
||||
|
@ -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({
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
174
ghost/stripe/test/unit/lib/WebhookController.test.js
Normal file
174
ghost/stripe/test/unit/lib/WebhookController.test.js
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user