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 new file mode 100644 index 0000000000..67a223dba8 --- /dev/null +++ b/ghost/core/test/e2e-api/members/__snapshots__/donation-checkout-session.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Create Stripe Checkout Session for Donations Can create a member checkout session for a donation 1: [body] 1`] = ` +Object { + "url": "https://checkout.stripe.com/c/pay/fake-data", +} +`; + +exports[`Create Stripe Checkout Session for Donations Can create an anonymous checkout session for a donation 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 new file mode 100644 index 0000000000..528f006190 --- /dev/null +++ b/ghost/core/test/e2e-api/members/donation-checkout-session.test.js @@ -0,0 +1,194 @@ +const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework'); +const {stripeMocker} = require('../../utils/e2e-framework-mock-manager'); +const models = require('../../../core/server/models'); +const assert = require('assert/strict'); +const urlService = require('../../../core/server/services/url'); +const DomainEvents = require('@tryghost/domain-events'); + +let membersAgent, adminAgent; + +async function getPost(id) { + // eslint-disable-next-line dot-notation + return await models['Post'].where('id', id).fetch({require: true}); +} + +describe('Create Stripe Checkout Session for Donations', function () { + before(async function () { + const agents = await agentProvider.getAgentsForMembers(); + membersAgent = agents.membersAgent; + adminAgent = agents.adminAgent; + + await fixtureManager.init('posts', 'members'); + await adminAgent.loginAsOwner(); + }); + + beforeEach(function () { + mockManager.mockStripe(); + mockManager.mockMail(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('Can create an anonymous checkout session for a donation', async function () { + // Fake a visit to a post + 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({ + customerEmail: 'paid@test.com', + type: 'donation', + successUrl: 'https://example.com/?type=success', + cancelUrl: 'https://example.com/?type=cancel', + metadata: { + test: 'hello', + urlHistory: [ + { + path: url, + time: Date.now(), + referrerMedium: null, + referrerSource: 'ghost-explore', + referrerUrl: 'https://example.com/blog/' + } + ] + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + // Send a webhook of a paid invoice for this session + await stripeMocker.sendWebhook({ + type: 'invoice.payment_succeeded', + data: { + object: { + type: 'invoice', + paid: true, + amount_paid: 1200, + currency: 'usd', + customer: (stripeMocker.checkoutSessions[0].customer), + customer_name: 'Paid Test', + customer_email: 'exampledonation@example.com', + metadata: { + ...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {}) + } + } + } + }); + + // Check email received + mockManager.assert.sentEmail({ + subject: '💰 One-time payment received: $12.00 from Paid Test', + to: 'jbloggs@example.com' + }); + + // Check stored in database + 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); + + // Check referrer + assert.equal(lastDonation.get('referrer_url'), 'example.com'); + assert.equal(lastDonation.get('referrer_medium'), 'Ghost Network'); + assert.equal(lastDonation.get('referrer_source'), 'Ghost Explore'); + + // Check attributed correctly + assert.equal(lastDonation.get('attribution_id'), post.id); + assert.equal(lastDonation.get('attribution_type'), 'post'); + assert.equal(lastDonation.get('attribution_url'), url); + }); + + it('Can create a member checkout session for a donation', async function () { + // Fake a visit to a post + const post = await getPost(fixtureManager.get('posts', 0).id); + const url = urlService.getUrlByResourceId(post.id, {absolute: false}); + + const email = 'test-member-create-donation-session@email.com'; + + const membersService = require('../../../core/server/services/members'); + const member = await membersService.api.members.create({email, name: 'Member Test'}); + const token = await membersService.api.getMemberIdentityToken(email); + + await DomainEvents.allSettled(); + + // Check email received + mockManager.assert.sentEmail({ + subject: '🥳 Free member signup: Member Test', + to: 'jbloggs@example.com' + }); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + customerEmail: email, + identity: token, + type: 'donation', + successUrl: 'https://example.com/?type=success', + cancelUrl: 'https://example.com/?type=cancel', + metadata: { + test: 'hello', + urlHistory: [ + { + path: url, + time: Date.now(), + referrerMedium: null, + referrerSource: 'ghost-explore', + referrerUrl: 'https://example.com/blog/' + } + ] + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + // Send a webhook of a paid invoice for this session + await stripeMocker.sendWebhook({ + type: 'invoice.payment_succeeded', + data: { + object: { + type: 'invoice', + paid: true, + amount_paid: 1220, + currency: 'eur', + customer: (stripeMocker.checkoutSessions[0].customer), + customer_name: 'Member Test', + customer_email: email, + metadata: { + ...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {}) + } + } + } + }); + + // Check email received + mockManager.assert.sentEmail({ + subject: '💰 One-time payment received: €12.20 from Member Test', + to: 'jbloggs@example.com' + }); + + // Check stored in database + const lastDonation = await models.DonationPaymentEvent.findOne({ + email + }, {require: true}); + assert.equal(lastDonation.get('amount'), 1220); + assert.equal(lastDonation.get('currency'), 'eur'); + assert.equal(lastDonation.get('email'), email); + assert.equal(lastDonation.get('name'), 'Member Test'); + assert.equal(lastDonation.get('member_id'), member.id); + + // Check referrer + assert.equal(lastDonation.get('referrer_url'), 'example.com'); + assert.equal(lastDonation.get('referrer_medium'), 'Ghost Network'); + assert.equal(lastDonation.get('referrer_source'), 'Ghost Explore'); + + // Check attributed correctly + assert.equal(lastDonation.get('attribution_id'), post.id); + assert.equal(lastDonation.get('attribution_type'), 'post'); + assert.equal(lastDonation.get('attribution_url'), url); + }); +}); diff --git a/ghost/core/test/utils/e2e-framework-mock-manager.js b/ghost/core/test/utils/e2e-framework-mock-manager.js index eb3a0f91d9..d50bd3ad07 100644 --- a/ghost/core/test/utils/e2e-framework-mock-manager.js +++ b/ghost/core/test/utils/e2e-framework-mock-manager.js @@ -80,6 +80,7 @@ const allowStripe = () => { const mockStripe = () => { disableNetwork(); + stripeMocker.reset(); stripeMocker.stub(); }; diff --git a/ghost/core/test/utils/stripe-mocker.js b/ghost/core/test/utils/stripe-mocker.js index 10e3df0f61..0e46a9033b 100644 --- a/ghost/core/test/utils/stripe-mocker.js +++ b/ghost/core/test/utils/stripe-mocker.js @@ -18,6 +18,7 @@ class StripeMocker { coupons = []; prices = []; products = []; + checkoutSessions = []; nockInterceptors = []; @@ -39,6 +40,7 @@ class StripeMocker { this.coupons = []; this.prices = []; this.products = []; + this.checkoutSessions = []; // Fix for now, because of importing order breaking some things when they are not initialized members = require('../../core/server/services/members'); @@ -227,6 +229,17 @@ class StripeMocker { } } + if (resource === 'checkout') { + if (!id) { + // Add default fields + decoded = { + object: 'checkout.session', + ...decoded, + url: 'https://checkout.stripe.com/c/pay/fake-data' + }; + } + } + if (resource === 'subscriptions') { // Convert price to price object if (Array.isArray(decoded.items)) { @@ -381,6 +394,10 @@ class StripeMocker { return this.#postData(this.products, id, body, resource); } + if (resource === 'checkout' && id === 'sessions') { + return this.#postData(this.checkoutSessions, null, body, resource); + } + return [500]; }); diff --git a/ghost/payments/lib/PaymentsService.js b/ghost/payments/lib/PaymentsService.js index c94f82eacb..6b8fe8db7f 100644 --- a/ghost/payments/lib/PaymentsService.js +++ b/ghost/payments/lib/PaymentsService.js @@ -117,13 +117,14 @@ class PaymentsService { * @param {Object.} [params.metadata] * @param {string} params.successUrl * @param {string} params.cancelUrl + * @param {boolean} [params.isAuthenticated] * @param {string} [params.email] * * @returns {Promise} */ - async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email}) { + async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email, isAuthenticated}) { let customer = null; - if (member) { + if (member && isAuthenticated) { customer = await this.getCustomerForMember(member); } diff --git a/ghost/stripe/lib/WebhookController.js b/ghost/stripe/lib/WebhookController.js index 8a43a3edbe..d72fcb29f9 100644 --- a/ghost/stripe/lib/WebhookController.js +++ b/ghost/stripe/lib/WebhookController.js @@ -115,9 +115,9 @@ module.exports = class WebhookController { // Track a one time payment event const amount = invoice.amount_paid; - const member = await this.deps.memberRepository.get({ + 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,