Skip to content

Commit 643769e

Browse files
authored
Fixed retention offer not rendering for 1 mo repeating offers (#26748)
closes https://linear.app/ghost/issue/BER-3413 - when a member signs ups with a repeating 1 month offer, the next payment is not discounted - therefore, they should be able to redeem a retention offer
1 parent 1d36c58 commit 643769e

5 files changed

Lines changed: 352 additions & 79 deletions

File tree

ghost/core/core/server/services/members/members-api/repositories/member-repository.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1725,7 +1725,7 @@ module.exports = class MemberRepository {
17251725
}
17261726

17271727
// Check subscription doesn't already have an active offer
1728-
if (await hasActiveOffer(subscriptionModel, this._offersAPI)) {
1728+
if (await hasActiveOffer(subscriptionModel, this._offersAPI, options)) {
17291729
throw new errors.BadRequestError({
17301730
message: tpl(messages.subscriptionHasOffer)
17311731
});

ghost/core/core/server/services/members/members-api/utils/has-active-offer.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
const getDiscountWindow = require('./get-discount-window');
22

33
/**
4-
* Determines if a subscription currently has an active offer.
4+
* Determines if a subscription has an offer that still affects the next payment,
5+
* or an active trial.
56
* Uses discount_start/discount_end (synced from Stripe) when available,
67
* falls back to offer duration lookup for legacy data (pre-6.16).
78
*
89
* @param {object} subscriptionModel - Bookshelf model for members_stripe_customers_subscriptions
910
* @param {object} offersAPI - OffersAPI instance with getOffer()
11+
* @param {object} [options] - Optional query options such as transacting
1012
* @returns {Promise<boolean>}
1113
*/
12-
module.exports = async function hasActiveOffer(subscriptionModel, offersAPI) {
14+
module.exports = async function hasActiveOffer(subscriptionModel, offersAPI, options = {}) {
1315
const subscriptionData = {
1416
discount_start: subscriptionModel.get('discount_start'),
1517
discount_end: subscriptionModel.get('discount_end'),
16-
start_date: subscriptionModel.get('start_date')
18+
start_date: subscriptionModel.get('start_date'),
19+
current_period_end: subscriptionModel.get('current_period_end')
1720
};
1821

19-
// Check for active Stripe discount (post-6.16 data)
20-
// discount_start takes precedence over trial and legacy fallback
21-
const discountWindow = getDiscountWindow(subscriptionData, null);
22-
if (discountWindow) {
23-
return !discountWindow.end || new Date(discountWindow.end) > new Date();
24-
}
25-
2622
// Check for active trial (trial offers)
2723
const trialEndAt = subscriptionModel.get('trial_end_at');
2824
if (trialEndAt && new Date(trialEndAt) > new Date()) {
@@ -37,14 +33,14 @@ module.exports = async function hasActiveOffer(subscriptionModel, offersAPI) {
3733

3834
// Look up the offer to determine if it's still active based on duration
3935
try {
40-
const offer = await offersAPI.getOffer({id: offerId});
36+
const offer = await offersAPI.getOffer({id: offerId}, options);
4137
if (!offer) {
4238
return false;
4339
}
4440

45-
const legacyWindow = getDiscountWindow(subscriptionData, offer);
46-
if (legacyWindow) {
47-
return !legacyWindow.end || new Date(legacyWindow.end) > new Date();
41+
const discountWindow = getDiscountWindow(subscriptionData, offer);
42+
if (discountWindow) {
43+
return !discountWindow.end || new Date(discountWindow.end) > new Date();
4844
}
4945

5046
return false;

ghost/core/test/e2e-api/members/member-offers.test.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,81 @@ describe('Members API - Member Offers', function () {
403403
}
404404
});
405405

406+
it('returns retention offers when a one-month repeating signup offer no longer applies to the next payment', async function () {
407+
const {subscription} = await getMemberSubscription('paid@test.com');
408+
const stripePrice = subscription.related('stripePrice');
409+
const stripeProduct = stripePrice.related('stripeProduct');
410+
const product = stripeProduct.related('product');
411+
412+
const tierId = product.id;
413+
const cadence = stripePrice.get('interval');
414+
const originalCurrentPeriodEnd = subscription.get('current_period_end');
415+
416+
const retentionOffer = await models.Offer.add({
417+
name: 'Retention Offer After Repeating Signup',
418+
code: 'retention-after-repeating-signup',
419+
portal_title: '20% off',
420+
portal_description: 'Stay with us!',
421+
discount_type: 'percent',
422+
discount_amount: 20,
423+
duration: 'once',
424+
interval: cadence,
425+
product_id: null,
426+
currency: null,
427+
active: true,
428+
redemption_type: 'retention'
429+
});
430+
431+
const signupOffer = await models.Offer.add({
432+
name: 'One Month Signup Offer',
433+
code: 'one-month-signup-offer',
434+
portal_title: '10% off for 1 month',
435+
portal_description: 'Welcome!',
436+
discount_type: 'percent',
437+
discount_amount: 10,
438+
duration: 'repeating',
439+
duration_in_months: 1,
440+
interval: cadence,
441+
product_id: tierId,
442+
currency: null,
443+
active: true,
444+
redemption_type: 'signup'
445+
});
446+
447+
const now = new Date();
448+
const currentPeriodEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
449+
const discountStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
450+
const discountEnd = new Date(currentPeriodEnd);
451+
452+
await subscription.save({
453+
offer_id: signupOffer.id,
454+
discount_start: discountStart,
455+
discount_end: discountEnd,
456+
current_period_end: currentPeriodEnd
457+
}, {patch: true});
458+
459+
try {
460+
const token = await getIdentityToken('paid@test.com');
461+
462+
const {body} = await membersAgent
463+
.post('/api/member/offers')
464+
.body({identity: token})
465+
.expectStatus(200);
466+
467+
assert.equal(body.offers.length, 1);
468+
assert.equal(body.offers[0].id, retentionOffer.id);
469+
} finally {
470+
await subscription.save({
471+
offer_id: null,
472+
discount_start: null,
473+
discount_end: null,
474+
current_period_end: originalCurrentPeriodEnd
475+
}, {patch: true});
476+
await models.Offer.destroy({id: retentionOffer.id});
477+
await models.Offer.destroy({id: signupOffer.id});
478+
}
479+
});
480+
406481
it('returns empty offers if subscription is already set to cancel', async function () {
407482
const {subscription} = await getMemberSubscription('paid@test.com');
408483
const stripePrice = subscription.related('stripePrice');
@@ -559,6 +634,151 @@ describe('Members API - Member Offers', function () {
559634
}
560635
});
561636

637+
it('allows applying a retention offer when a one-month repeating signup offer no longer applies to the next payment', async function () {
638+
const {subscription} = await getMemberSubscription('paid@test.com');
639+
const stripePrice = subscription.related('stripePrice');
640+
const stripeProduct = stripePrice.related('stripeProduct');
641+
const product = stripeProduct.related('product');
642+
643+
const stripeSubscriptionId = subscription.get('subscription_id');
644+
const customerId = subscription.get('customer_id');
645+
const tierId = product.id;
646+
const cadence = stripePrice.get('interval');
647+
const originalCurrentPeriodEnd = subscription.get('current_period_end');
648+
649+
const existingSignupOffer = await models.Offer.add({
650+
name: 'Repeating Signup Offer',
651+
code: 'repeating-signup-offer',
652+
portal_title: '10% off for 1 month',
653+
portal_description: 'Welcome!',
654+
discount_type: 'percent',
655+
discount_amount: 10,
656+
duration: 'repeating',
657+
duration_in_months: 1,
658+
interval: cadence,
659+
product_id: tierId,
660+
currency: null,
661+
active: true,
662+
redemption_type: 'signup'
663+
});
664+
665+
const stripeCouponId = 'coupon_redeem_after_repeating_signup';
666+
mockManager.stripeMocker.coupons.push({
667+
id: stripeCouponId,
668+
object: 'coupon',
669+
percent_off: 20,
670+
duration: 'once'
671+
});
672+
673+
const mockPrice = {
674+
id: stripePrice.get('stripe_price_id'),
675+
product: stripeProduct.get('stripe_product_id'),
676+
active: true,
677+
nickname: cadence,
678+
unit_amount: stripePrice.get('amount'),
679+
currency: stripePrice.get('currency'),
680+
type: 'recurring',
681+
recurring: {
682+
interval: cadence
683+
}
684+
};
685+
mockManager.stripeMocker.prices.push(mockPrice);
686+
687+
mockManager.stripeMocker.customers.push({
688+
id: customerId,
689+
object: 'customer',
690+
email: 'paid@test.com',
691+
invoice_settings: {
692+
default_payment_method: null
693+
},
694+
subscriptions: {
695+
type: 'list',
696+
data: []
697+
}
698+
});
699+
700+
const now = new Date();
701+
const currentPeriodEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
702+
const discountStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
703+
const discountEnd = new Date(currentPeriodEnd);
704+
705+
mockManager.stripeMocker.subscriptions.push({
706+
id: stripeSubscriptionId,
707+
object: 'subscription',
708+
status: 'active',
709+
customer: customerId,
710+
cancel_at_period_end: false,
711+
current_period_end: Math.floor(currentPeriodEnd.getTime() / 1000),
712+
start_date: Math.floor(discountStart.getTime() / 1000),
713+
items: {
714+
data: [{
715+
id: 'si_test_repeating_signup',
716+
price: mockPrice
717+
}]
718+
}
719+
});
720+
721+
const retentionOffer = await models.Offer.add({
722+
name: 'Retention Offer After Repeating Signup',
723+
code: 'redeem-after-repeating-signup',
724+
portal_title: '20% off',
725+
portal_description: 'Stay with us!',
726+
discount_type: 'percent',
727+
discount_amount: 20,
728+
duration: 'once',
729+
interval: cadence,
730+
product_id: null,
731+
currency: null,
732+
active: true,
733+
redemption_type: 'retention',
734+
stripe_coupon_id: stripeCouponId
735+
});
736+
737+
await subscription.save({
738+
offer_id: existingSignupOffer.id,
739+
discount_start: discountStart,
740+
discount_end: discountEnd,
741+
current_period_end: currentPeriodEnd
742+
}, {patch: true});
743+
744+
try {
745+
const token = await getIdentityToken('paid@test.com');
746+
747+
await membersAgent
748+
.post(`/api/subscriptions/${stripeSubscriptionId}/apply-offer`)
749+
.body({identity: token, offer_id: retentionOffer.id})
750+
.expectStatus(204);
751+
752+
await DomainEvents.allSettled();
753+
754+
await subscription.refresh();
755+
assert.equal(subscription.get('offer_id'), retentionOffer.id);
756+
757+
const redemption = await models.OfferRedemption.findOne({
758+
offer_id: retentionOffer.id,
759+
subscription_id: subscription.id
760+
});
761+
assert.ok(redemption, 'Offer redemption should be recorded');
762+
} finally {
763+
const redemption = await models.OfferRedemption.findOne({
764+
offer_id: retentionOffer.id,
765+
subscription_id: subscription.id
766+
});
767+
if (redemption) {
768+
await models.OfferRedemption.destroy({id: redemption.id});
769+
}
770+
771+
await subscription.save({
772+
offer_id: null,
773+
discount_start: null,
774+
discount_end: null,
775+
current_period_end: originalCurrentPeriodEnd
776+
}, {patch: true});
777+
await models.Offer.destroy({id: retentionOffer.id});
778+
await models.Offer.destroy({id: existingSignupOffer.id});
779+
}
780+
});
781+
562782
it('returns 400 when offer cadence does not match subscription', async function () {
563783
const {subscription} = await getMemberSubscription('paid@test.com');
564784
const stripePrice = subscription.related('stripePrice');

ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,17 @@ describe('RouterController', function () {
15121512
let res;
15131513
let responseData;
15141514

1515-
function createMockSubscription({id = 'sub_123', status = 'active', offerId = null, trialEndAt = null, discountStart = null, discountEnd = null, cancelAtPeriodEnd = false} = {}) {
1515+
function createMockSubscription({
1516+
id = 'sub_123',
1517+
status = 'active',
1518+
offerId = null,
1519+
trialEndAt = null,
1520+
discountStart = null,
1521+
discountEnd = null,
1522+
startDate = null,
1523+
currentPeriodEnd = new Date('2025-06-01T00:00:00.000Z'),
1524+
cancelAtPeriodEnd = false
1525+
} = {}) {
15161526
return {
15171527
id,
15181528
get: sinon.stub().callsFake((key) => {
@@ -1522,6 +1532,8 @@ describe('RouterController', function () {
15221532
trial_end_at: trialEndAt,
15231533
discount_start: discountStart,
15241534
discount_end: discountEnd,
1535+
start_date: startDate,
1536+
current_period_end: currentPeriodEnd,
15251537
cancel_at_period_end: cancelAtPeriodEnd
15261538
};
15271539
return values[key] ?? null;
@@ -1603,6 +1615,8 @@ describe('RouterController', function () {
16031615
});
16041616

16051617
it('returns empty offers when subscription has an active discount', async function () {
1618+
mockOffersAPI.getOffer.resolves({id: 'existing_offer_123', duration: 'forever'});
1619+
16061620
const routerController = createRouterController({
16071621
subscriptions: createMockSubscription({
16081622
offerId: 'existing_offer_123',
@@ -1622,6 +1636,7 @@ describe('RouterController', function () {
16221636
it('returns offers when subscription has an expired discount', async function () {
16231637
const mockOffer = {id: 'retention_offer'};
16241638
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
1639+
mockOffersAPI.getOffer.resolves({id: 'expired_offer_123', duration: 'once'});
16251640

16261641
const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
16271642
const routerController = createRouterController({
@@ -1640,6 +1655,33 @@ describe('RouterController', function () {
16401655
sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
16411656
});
16421657

1658+
it('returns offers when a one-month repeating signup offer no longer applies to the next payment', async function () {
1659+
const mockOffer = {id: 'retention_offer'};
1660+
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
1661+
mockOffersAPI.getOffer.resolves({
1662+
id: 'expiring_repeating_offer',
1663+
duration: 'repeating',
1664+
duration_in_months: 1
1665+
});
1666+
1667+
const routerController = createRouterController({
1668+
subscriptions: createMockSubscription({
1669+
offerId: 'expiring_repeating_offer',
1670+
startDate: new Date('2025-05-01T00:00:00.000Z'),
1671+
discountStart: new Date('2025-05-01T00:00:00.000Z'),
1672+
discountEnd: new Date('2025-06-01T00:00:00.000Z'),
1673+
currentPeriodEnd: new Date('2025-06-01T00:00:00.000Z')
1674+
})
1675+
});
1676+
1677+
await routerController.getMemberOffers({
1678+
body: {identity: 'valid-token'}
1679+
}, res);
1680+
1681+
assert.deepEqual(responseData, {offers: [mockOffer]});
1682+
sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
1683+
});
1684+
16431685
it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () {
16441686
const mockOffer = {id: 'retention_offer'};
16451687
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

0 commit comments

Comments
 (0)