-
-
Notifications
You must be signed in to change notification settings - Fork 11.5k
Refactored hasActiveOffer and _getActiveDiscount to share common logic
#26543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
ghost/core/core/server/services/members/members-api/utils/get-discount-window.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| /** | ||
| * Computes the discount window for a subscription based on available data. | ||
| * Returns {start, end} if a discount window can be determined, null otherwise. | ||
| * | ||
| * Handles two data paths: | ||
| * 1. Stripe coupon discounts (post-6.16) - uses discount_start / discount_end | ||
| * 2. Legacy fallback - computes from offer duration and start_date | ||
| * | ||
| * @param {object} subscription - plain object with: discount_start, discount_end, start_date | ||
| * @param {object|null} offer - offer data with: duration, duration_in_months. | ||
| * Pass null to skip offer-dependent checks. | ||
| * @returns {{start: *, end: *}|null} | ||
| */ | ||
| module.exports = function getDiscountWindow(subscription, offer) { | ||
| // Stripe coupon discount (post-6.16 data) | ||
| if (subscription.discount_start) { | ||
| return { | ||
| start: subscription.discount_start, | ||
| end: subscription.discount_end || null | ||
| }; | ||
| } | ||
|
|
||
| // Legacy fallback: compute window from offer duration | ||
| if (!offer) { | ||
| return null; | ||
| } | ||
|
|
||
| if (offer.duration === 'once') { | ||
| return null; | ||
| } | ||
|
|
||
| if (offer.duration === 'forever') { | ||
| return {start: subscription.start_date, end: null}; | ||
| } | ||
|
|
||
| if (offer.duration === 'repeating' && offer.duration_in_months > 0) { | ||
| const end = new Date(subscription.start_date); | ||
| end.setUTCMonth(end.getUTCMonth() + offer.duration_in_months); | ||
|
|
||
| if (new Date() >= end) { | ||
| return null; | ||
| } | ||
|
|
||
| return {start: subscription.start_date, end}; | ||
| } | ||
|
|
||
| return null; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
ghost/core/test/unit/server/services/members/members-api/utils/get-discount-window.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| const assert = require('node:assert/strict'); | ||
| const getDiscountWindow = require('../../../../../../../core/server/services/members/members-api/utils/get-discount-window'); | ||
|
|
||
| describe('getDiscountWindow', function () { | ||
| function createSubscription(overrides = {}) { | ||
| return { | ||
| discount_start: null, | ||
| discount_end: null, | ||
| start_date: new Date('2025-01-01T00:00:00.000Z'), | ||
| ...overrides | ||
| }; | ||
| } | ||
|
|
||
| // Stripe coupon discount (post-6.16 data) | ||
|
|
||
| describe('stripe coupon discount', function () { | ||
| it('returns window when discount_start is present with discount_end', function () { | ||
| const discountStart = new Date('2025-05-01T00:00:00.000Z'); | ||
| const discountEnd = new Date('2025-11-01T00:00:00.000Z'); | ||
| const subscription = createSubscription({ | ||
| discount_start: discountStart, | ||
| discount_end: discountEnd | ||
| }); | ||
|
|
||
| const result = getDiscountWindow(subscription, null); | ||
|
|
||
| assert.deepEqual(result, {start: discountStart, end: discountEnd}); | ||
| }); | ||
|
|
||
| it('returns window with null end for forever discounts', function () { | ||
| const discountStart = new Date('2025-05-01T00:00:00.000Z'); | ||
| const subscription = createSubscription({ | ||
| discount_start: discountStart, | ||
| discount_end: null | ||
| }); | ||
|
|
||
| const result = getDiscountWindow(subscription, null); | ||
|
|
||
| assert.deepEqual(result, {start: discountStart, end: null}); | ||
| }); | ||
|
|
||
| it('returns window even when discount_end is in the past (trusts Stripe data)', function () { | ||
| const discountStart = new Date('2025-01-01T00:00:00.000Z'); | ||
| const discountEnd = new Date('2025-06-01T00:00:00.000Z'); | ||
| const subscription = createSubscription({ | ||
| discount_start: discountStart, | ||
| discount_end: discountEnd | ||
| }); | ||
|
|
||
| const result = getDiscountWindow(subscription, null); | ||
|
|
||
| assert.deepEqual(result, {start: discountStart, end: discountEnd}); | ||
| }); | ||
|
|
||
| it('discount_start takes precedence over legacy offer data', function () { | ||
| const discountStart = new Date('2025-05-01T00:00:00.000Z'); | ||
| const subscription = createSubscription({ | ||
| discount_start: discountStart, | ||
| discount_end: null | ||
| }); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'once'}); | ||
|
|
||
| assert.deepEqual(result, {start: discountStart, end: null}); | ||
| }); | ||
| }); | ||
|
|
||
| // Legacy fallback (no discount_start, uses offer duration) | ||
|
|
||
| describe('legacy fallback', function () { | ||
| it('returns null when no offer is provided', function () { | ||
| const subscription = createSubscription(); | ||
|
|
||
| const result = getDiscountWindow(subscription, null); | ||
|
|
||
| assert.equal(result, null); | ||
| }); | ||
|
|
||
| it('returns null for once duration', function () { | ||
| const subscription = createSubscription(); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'once'}); | ||
|
|
||
| assert.equal(result, null); | ||
| }); | ||
|
|
||
| it('returns window with null end for forever duration', function () { | ||
| const startDate = new Date('2025-01-01T00:00:00.000Z'); | ||
| const subscription = createSubscription({start_date: startDate}); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'forever'}); | ||
|
|
||
| assert.deepEqual(result, {start: startDate, end: null}); | ||
| }); | ||
|
|
||
| it('returns window for repeating offer still within duration', function () { | ||
| const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // 3 months ago | ||
| const subscription = createSubscription({start_date: startDate}); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'repeating', duration_in_months: 6}); | ||
|
|
||
| assert.notEqual(result, null); | ||
| assert.deepEqual(result.start, startDate); | ||
| assert.ok(result.end instanceof Date); | ||
| assert.ok(result.end > new Date()); | ||
| }); | ||
|
|
||
| it('returns null for repeating offer past its duration', function () { | ||
| const startDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // 1 year ago | ||
| const subscription = createSubscription({start_date: startDate}); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'repeating', duration_in_months: 6}); | ||
|
|
||
| assert.equal(result, null); | ||
| }); | ||
|
|
||
| it('returns null for repeating offer with zero duration_in_months', function () { | ||
| const subscription = createSubscription(); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'repeating', duration_in_months: 0}); | ||
|
|
||
| assert.equal(result, null); | ||
| }); | ||
|
|
||
| it('returns null for unknown duration', function () { | ||
| const subscription = createSubscription(); | ||
|
|
||
| const result = getDiscountWindow(subscription, {duration: 'unknown'}); | ||
|
|
||
| assert.equal(result, null); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expired/future Stripe windows can be treated as active discounts.
Line 112 returns
getDiscountWindow(...)directly. Since Stripe windows may include pastendvalues,_getActiveDiscountcan return non-active discounts andcalculate()will still reduce the next payment amount.Suggested fix
_getActiveDiscount(subscription, offer) { // Skip if there's no Stripe discount data and the offer isn't eligible for legacy backport: // - signup offers are backported for legacy data without discount_start // - retention offers are excluded because they were introduced after discount_start // was available, so missing discount_start means there's no active discount if (!subscription.discount_start && offer.redemption_type !== 'signup') { return null; } - return getDiscountWindow(subscription, offer); + const discountWindow = getDiscountWindow(subscription, offer); + if (!discountWindow) { + return null; + } + + const now = new Date(); + const start = discountWindow.start ? new Date(discountWindow.start) : null; + const end = discountWindow.end ? new Date(discountWindow.end) : null; + + if (start && now < start) { + return null; + } + + if (end && now >= end) { + return null; + } + + return discountWindow; }📝 Committable suggestion
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this was existing behaviour before this refactor right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mike182ukYes, you're absolutely right! Looking at the old code, whensubscription.discount_startwas present, it directly returned the discount window without any validation:There was no check to verify whether the discount was actually active (e.g., whether
endhad passed orstartwas in the future). The refactor preserves this existing behavior. I apologize for flagging this as if it were a new issue introduced by the refactor.🧠 Learnings used