diff --git a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js index 57e3a5dc8f2..a5ac6241c8f 100644 --- a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js +++ b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js @@ -69,6 +69,12 @@ const RESERVED_CHECKOUT_METADATA_KEYS = new Set([ 'ghost_gift', 'ghostSignupContext', 'gift_token', + 'gift_delivery_method', + 'gift_recipient_email', + 'gift_recipient_name', + 'gift_buyer_name', + 'gift_personal_message', + 'gift_deliver_at', 'tier_id', 'cadence', 'duration' @@ -684,6 +690,12 @@ module.exports = class RouterController { * @param {string} [options.offerId] * @param {string} [options.cadence] * @param {number} [options.duration] + * @param {'link'|'email'} [options.deliveryMethod] + * @param {string} [options.recipientEmail] + * @param {string} [options.recipientName] + * @param {string} [options.buyerName] + * @param {string} [options.personalMessage] + * @param {string|null} [options.deliverAt] * @param {string} [options.email] * @param {string} options.successUrl * @param {string} options.cancelUrl @@ -705,6 +717,12 @@ module.exports = class RouterController { offerId: options.offerId, cadence: options.cadence, duration: options.duration, + deliveryMethod: options.deliveryMethod, + recipientEmail: options.recipientEmail, + recipientName: options.recipientName, + buyerName: options.buyerName, + personalMessage: options.personalMessage, + deliverAt: options.deliverAt, metadata: options.metadata, successUrl: options.successUrl, cancelUrl: options.cancelUrl, @@ -857,6 +875,12 @@ module.exports = class RouterController { offerId: req.body.offerId, cadence: req.body.cadence, duration: req.body.duration, + deliveryMethod: req.body.deliveryMethod, + recipientEmail: req.body.recipientEmail, + recipientName: req.body.recipientName, + buyerName: req.body.buyerName, + personalMessage: req.body.personalMessage, + deliverAt: req.body.deliverAt, successUrl: siteUrl, cancelUrl: options.cancelUrl || siteUrl }); diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index b0d01d09729..b8f4e21f387 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -105,7 +105,13 @@ module.exports = class CheckoutSessionEventService { currency: session.currency, amount: session.amount_total, stripeCheckoutSessionId: session.id, - stripePaymentIntentId: getStripeResourceId(session.payment_intent) + stripePaymentIntentId: getStripeResourceId(session.payment_intent), + deliveryMethod: session.metadata?.gift_delivery_method ?? 'link', + recipientEmail: session.metadata?.gift_recipient_email || null, + recipientName: session.metadata?.gift_recipient_name || null, + buyerName: session.metadata?.gift_buyer_name || null, + personalMessage: session.metadata?.gift_personal_message || null, + deliverAt: null }); } diff --git a/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts index e4dcf2f6f36..dec502158ad 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts @@ -3,6 +3,11 @@ import sinon from 'sinon'; import {GiftService} from '../../../../../core/server/services/gifts/gift-service'; import {buildGift} from './utils'; +function hasInvalidDeliveryContext(error: unknown): boolean { + const context = error && typeof error === 'object' ? (error as {context?: unknown}).context : null; + return typeof context === 'string' && context.startsWith('Invalid gift delivery data:'); +} + describe('GiftService interface', function () { afterEach(function () { sinon.restore(); @@ -113,6 +118,142 @@ describe('GiftService interface', function () { assert.equal(successUrl.searchParams.get('gift_duration'), null); }); + it('validates email delivery and writes only normalized reserved metadata', async function () { + const {service, checkoutAdapter} = createService({customizationEnabled: true}); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + recipientEmail: ' recipient@example.com ', + recipientName: ' Recipient ', + buyerName: ' Buyer ', + personalMessage: ' Enjoy your gift ', + deliverAt: null, + metadata: { + gift_recipient_email: 'attacker@example.com' + }, + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }); + + const metadata = checkoutAdapter.createSession.firstCall.firstArg.metadata; + assert.equal(metadata.gift_delivery_method, 'email'); + assert.equal(metadata.gift_recipient_email, 'recipient@example.com'); + assert.equal(metadata.gift_recipient_name, 'Recipient'); + assert.equal(metadata.gift_buyer_name, 'Buyer'); + assert.equal(metadata.gift_personal_message, 'Enjoy your gift'); + assert.equal(metadata.gift_deliver_at, ''); + }); + + it('prefers the checkout buyer name over the authenticated member name', async function () { + const {service, checkoutAdapter} = createService({customizationEnabled: true}); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + buyerName: 'Mum', + metadata: {}, + successUrl: 'https://example.com/', + buyer: { + memberId: 'member_1', + email: 'buyer@example.com', + name: 'Account Name', + isAuthenticated: true + } + }); + + const metadata = checkoutAdapter.createSession.firstCall.firstArg.metadata; + assert.equal(metadata.gift_buyer_name, 'Mum'); + }); + + it('rejects invalid or scheduled email delivery input', async function () { + const {service, checkoutAdapter} = createService({customizationEnabled: true}); + const input = { + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + recipientEmail: 'not-an-email', + metadata: {}, + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }; + + await assert.rejects(() => service.startCheckout(input), hasInvalidDeliveryContext); + await assert.rejects(() => service.startCheckout({ + ...input, + recipientEmail: 'recipient@example.com', + deliverAt: '2030-01-01T00:00:00.000Z' + }), hasInvalidDeliveryContext); + await assert.rejects(() => service.startCheckout({ + ...input, + recipientEmail: 'recipient@example.com', + personalMessage: 'x'.repeat(251) + }), hasInvalidDeliveryContext); + sinon.assert.notCalled(checkoutAdapter.createSession); + }); + + it('rejects email-only fields in link mode', async function () { + const {service, checkoutAdapter} = createService({customizationEnabled: true}); + + await assert.rejects(() => service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'link', + recipientEmail: 'recipient@example.com', + metadata: {}, + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }), hasInvalidDeliveryContext); + sinon.assert.notCalled(checkoutAdapter.createSession); + }); + + it('keeps omitted and explicit link delivery compatible while the flag is disabled', async function () { + const {service, checkoutAdapter} = createService(); + const base = { + tierId: 'tier_1', + cadence: 'year', + metadata: {}, + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }; + + await service.startCheckout(base); + await service.startCheckout({...base, deliveryMethod: 'link'}); + await assert.rejects(() => service.startCheckout({ + ...base, + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com' + }), {context: 'Gift email delivery is not available'}); + await assert.rejects(() => service.startCheckout({ + ...base, + buyerName: 'Buyer' + }), {context: 'Gift email delivery is not available'}); + assert.equal(checkoutAdapter.createSession.callCount, 2); + }); + for (const duration of [3, 6]) { it(`owns the customized ${duration}-month checkout decision`, async function () { const {service, checkoutAdapter} = createService({customizationEnabled: true}); diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index bb0df92e137..aae345e6016 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -879,6 +879,40 @@ describe('RouterController', function () { })); }); + it('passes gift delivery fields separately and removes client-supplied reserved metadata', async function () { + const controller = createGiftController({tiersService: paidTierService()}); + + await controller.createCheckoutSession({ + body: { + type: 'gift', + tierId: 'tier_123', + cadence: 'month', + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + recipientName: 'Recipient', + buyerName: 'Buyer', + personalMessage: 'Enjoy this gift', + deliverAt: null, + metadata: { + gift_recipient_email: 'attacker@example.com', + gift_delivery_method: 'link', + custom_key: 'preserved' + } + } + }, mockRes); + + const input = giftService.service.startCheckout.firstCall.firstArg; + assert.equal(input.deliveryMethod, 'email'); + assert.equal(input.recipientEmail, 'recipient@example.com'); + assert.equal(input.recipientName, 'Recipient'); + assert.equal(input.buyerName, 'Buyer'); + assert.equal(input.personalMessage, 'Enjoy this gift'); + assert.equal(input.deliverAt, null); + assert.equal(input.metadata.gift_recipient_email, undefined); + assert.equal(input.metadata.gift_delivery_method, undefined); + assert.equal(input.metadata.custom_key, 'preserved'); + }); + it('rejects when offerId is provided', async function () { giftService.service.startCheckout.rejects(new errors.BadRequestError({ message: 'Bad Request.', diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js index 087c27fb63c..654ccffb27a 100644 --- a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js @@ -638,7 +638,13 @@ describe('CheckoutSessionEventService', function () { gift_token: 'abc-123-token', tier_id: 'tier_456', cadence: 'year', - duration: '1' + duration: '1', + gift_delivery_method: 'email', + gift_recipient_email: 'recipient@example.com', + gift_recipient_name: 'Recipient', + gift_buyer_name: 'Buyer', + gift_personal_message: 'Enjoy this gift', + gift_deliver_at: '' } }; @@ -658,6 +664,12 @@ describe('CheckoutSessionEventService', function () { assert.equal(purchaseData.amount, 5000); assert.equal(purchaseData.stripeCheckoutSessionId, 'cs_test_123'); assert.equal(purchaseData.stripePaymentIntentId, 'pi_test_456'); + assert.equal(purchaseData.deliveryMethod, 'email'); + assert.equal(purchaseData.recipientEmail, 'recipient@example.com'); + assert.equal(purchaseData.recipientName, 'Recipient'); + assert.equal(purchaseData.buyerName, 'Buyer'); + assert.equal(purchaseData.personalMessage, 'Enjoy this gift'); + assert.equal(purchaseData.deliverAt, null); }); it('passes null stripeCustomerId for unauthenticated purchasers', async function () {