Skip to content

Fixed repeating offers end date anchor#26741

Merged
sagzy merged 4 commits intomainfrom
fix-repeating-offer-end-date
Mar 10, 2026
Merged

Fixed repeating offers end date anchor#26741
sagzy merged 4 commits intomainfrom
fix-repeating-offer-end-date

Conversation

@sagzy
Copy link
Copy Markdown
Contributor

@sagzy sagzy commented Mar 9, 2026

closes https://linear.app/ghost/issue/BER-3413/account-page-date-mismatch-between-discount-end-billing-period

  • In Stripe, discount dates are anchored around the redemption date, not the billing date. However, we're interested in whether the next payment(s) are discounted. This aligns the next payment object discount end date with the last payment with a discount.
  • Example:
    • Bob's billing cycle is from Mar 3 to April 3
    • Bob redeems a retention offer on Mar 1, that is repeating 3 months
    • Stripe will set discount start to Mar 1 and discount end to Jun 1
    • Bob's next 3 payments will be discounted: Mar 3, Apr 3 and May 3, and we want to display "Ends May 3" (not "Ends Jun 1")

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 9, 2026

Walkthrough

Updates add date utilities (getLastDayOfMonth, isLastDayOfMonth, getAnchoredBillingDate, getLastDiscountedPayment) and revise getDiscountWindow to handle repeating Stripe discounts and month-end anchoring. The function now documents and uses subscription fields current_period_end, plan.interval, and plan_interval. For Stripe discounts with a discount_end, the code returns null when the discount has expired or ends before the next billing date; otherwise it returns a window anchored to billing dates. Tests were extended with sinon fake timers and cases for expiry and anchoring.

Possibly related PRs

Suggested reviewers

  • mike182uk
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing how repeating offers end dates are anchored, which aligns with the core changes in the codebase.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation and providing a concrete example of the discount end date alignment issue being fixed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-repeating-offer-end-date

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/utils/get-discount-window.js`:
- Around line 27-38: The current getLastDiscountedPayment computes monthOffset
in single-month steps which fails for yearly plans — update
getLastDiscountedPayment to advance in billing periods based on the plan cadence
(subscription.plan.interval), not always by one month: derive periodLengthMonths
= (interval === 'year' ? 12 : 1) (or map other intervals similarly), compute
totalMonthsDiff between discountEnd and nextBillingDate, compute periodOffset =
Math.floor(totalMonthsDiff / periodLengthMonths), then call
getAnchoredBillingDate(nextBillingDate, periodOffset * periodLengthMonths) and
fall back to one period less if that date is after discountEnd; apply the same
cadence-aware logic to the other affected blocks referenced (around the code at
lines ~49-50 and ~58-75) so all computations use periodLengthMonths/periodOffset
rather than fixed one-month increments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 918f92cd-f74c-4453-9d5d-ff3b979e5002

📥 Commits

Reviewing files that changed from the base of the PR and between 5bf370b and 1af1411.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/members/members-api/utils/get-discount-window.js
  • ghost/core/test/unit/server/services/members/members-api/services/next-payment-calculator.test.js
  • ghost/core/test/unit/server/services/members/members-api/utils/get-discount-window.test.js

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented Mar 9, 2026

🤖 Velo CI Failure Analysis

Classification: 🔴 HARD FAIL

  • Workflow: CI
  • Failed Step: Load Image
  • Run: View failed run
    What failed: Docker pull failed due to a context deadline exceeded error
    Why: The root cause of the failure is a Docker pull error due to a context deadline exceeded. This is an infrastructure issue and not a code problem, as it indicates a network or registry connectivity problem.
    Action:
    Investigate the GitHub Actions runner environment and the connection to the container registry. Retry the job or check for any network issues that may be causing the timeout.

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented Mar 9, 2026

🤖 Velo CI Failure Analysis

Classification: 🟠 SOFT FAIL

  • Workflow: CI
  • Failed Step: Check app version bump
  • Run: View failed run
    What failed: Version bump not updated for the portal app
    Why: The logs indicate that the version for the portal app was changed but the version was not bumped above the main branch version. This is a code issue that the author should fix by running 'yarn ship' in the apps/portal directory to bump the package version.
    Action:
    The author should run 'yarn ship' in the apps/portal directory to bump the package version and resolve the version bump check failure.

@sagzy sagzy force-pushed the fix-repeating-offer-end-date branch from 33d0fd1 to dbd371b Compare March 9, 2026 18:02
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/utils/get-discount-window.js`:
- Around line 58-75: The legacy repeating-offer branch in get-discount-window.js
must be changed to compute the discount end as the last discounted payment
rather than start_date + duration_in_months; locate the legacy backport branch
(the code that uses subscription.discount_start and duration_in_months) and
replace its end calculation to call the same helper logic used above
(getLastDiscountedPayment) with the offer duration and dates so it returns the
last discounted billing date, keeping start as subscription.discount_start; also
add a regression test exercising a repeating legacy offer (e.g., start Jan 1,
duration_in_months 3) to assert discount.end equals the last discounted renewal
(Mar 1) and ensure NextPaymentCalculator behavior is covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6f592cd4-117e-43d5-bdf1-a2e4d172fbf3

📥 Commits

Reviewing files that changed from the base of the PR and between 44a925b and dbd371b.

📒 Files selected for processing (6)
  • apps/portal/package.json
  • apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js
  • apps/portal/test/unit/components/pages/AccountHomePage/paid-account-actions.test.js
  • ghost/core/core/server/services/members/members-api/utils/get-discount-window.js
  • ghost/core/test/unit/server/services/members/members-api/services/next-payment-calculator.test.js
  • ghost/core/test/unit/server/services/members/members-api/utils/get-discount-window.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/portal/package.json

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented Mar 10, 2026

🤖 Velo CI Failure Analysis

Classification: 🔴 HARD FAIL

  • Workflow: CI
  • Failed Step: Determine added packages
  • Run: View failed run
    What failed: Rate limited by external service
    Why: Infrastructure issue detected: rate limited by external service. This is typically not caused by code changes.
    Action:
    Try re-running the workflow. If it persists, investigate the infrastructure.

sagzy added 4 commits March 10, 2026 09:14
closes https://linear.app/ghost/issue/BER-3413/account-page-date-mismatch-between-discount-end-billing-period

- In Stripe, discount dates are anchored around the redemption date, not the billing date. However, we're interested in whether the next payment(s) are discounted. This aligns the next payment object discount end date with the last payment with a discount.
- Example:
    - Bob's billing cycle is from Mar 3 to April 3
    - Bob redeems a retention offer on Mar 1, that is repeating 3 months
    - Stripe will set discount start to Mar 1 and discount end to Jun 1
    - Bob's next 3 payments will be discounted: Mar 3, Apr 3 and May 3, and we want to display "Ends May 3" (not "Ends Jun 1")
- not needed anymore, as fix has moved to the backend
Changelog for v2.65.5 -> 2.65.6:
@sagzy sagzy force-pushed the fix-repeating-offer-end-date branch from 55945e4 to e6eb2cd Compare March 10, 2026 08:14
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/members/members-api/utils/get-discount-window.js`:
- Around line 27-38: getLastDiscountedPayment is inferring a month-end anchor
from nextBillingDate/current_period_end which breaks when a subscription is
shortened by February; update the function to accept and use the real billing
anchor (pass through the original billing anchor value instead of deriving it
from nextBillingDate) when calling getAnchoredBillingDate so month-length
normalization doesn’t convert a non-month-end anchor into month-end, and add a
regression test that covers the February-shortened case (e.g., start_date
2025-01-28, current_period_end 2025-02-28, discount_end 2025-04-01 expecting
last discounted billing date 2025-03-28) to ensure the anchored calculations
preserve the original anchor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae902c04-be51-4736-ae75-26a59c0a114c

📥 Commits

Reviewing files that changed from the base of the PR and between dbd371b and e6eb2cd.

📒 Files selected for processing (6)
  • apps/portal/package.json
  • apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js
  • apps/portal/test/unit/components/pages/AccountHomePage/paid-account-actions.test.js
  • ghost/core/core/server/services/members/members-api/utils/get-discount-window.js
  • ghost/core/test/unit/server/services/members/members-api/services/next-payment-calculator.test.js
  • ghost/core/test/unit/server/services/members/members-api/utils/get-discount-window.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/portal/package.json
  • apps/portal/test/unit/components/pages/AccountHomePage/paid-account-actions.test.js
  • apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js

Comment on lines +27 to +38
function getLastDiscountedPayment(nextBillingDate, discountEnd) {
const monthOffset =
((discountEnd.getUTCFullYear() - nextBillingDate.getUTCFullYear()) * 12) +
(discountEnd.getUTCMonth() - nextBillingDate.getUTCMonth());

let lastDiscountedBillingDate = getAnchoredBillingDate(nextBillingDate, monthOffset);

if (lastDiscountedBillingDate > discountEnd) {
lastDiscountedBillingDate = getAnchoredBillingDate(nextBillingDate, monthOffset - 1);
}

return lastDiscountedBillingDate;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

current_period_end is not a stable billing anchor here.

Once a non-month-end subscription is shortened by February, this helper starts treating it as a month-end plan. For example, with start_date = 2025-01-28, current_period_end = 2025-02-28, and discount_end = 2025-04-01, this returns 2025-03-31, even though the last discounted renewal should be 2025-03-28. Please carry the real billing anchor into this calculation instead of inferring month-end semantics from current_period_end, and add a regression for the February-shortened case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/members/members-api/utils/get-discount-window.js`
around lines 27 - 38, getLastDiscountedPayment is inferring a month-end anchor
from nextBillingDate/current_period_end which breaks when a subscription is
shortened by February; update the function to accept and use the real billing
anchor (pass through the original billing anchor value instead of deriving it
from nextBillingDate) when calling getAnchoredBillingDate so month-length
normalization doesn’t convert a non-month-end anchor into month-end, and add a
regression test that covers the February-shortened case (e.g., start_date
2025-01-28, current_period_end 2025-02-28, discount_end 2025-04-01 expecting
last discounted billing date 2025-03-28) to ensure the anchored calculations
preserve the original anchor.

@sagzy sagzy merged commit bf13394 into main Mar 10, 2026
30 checks passed
@sagzy sagzy deleted the fix-repeating-offer-end-date branch March 10, 2026 09:27
peterzimon pushed a commit that referenced this pull request Mar 10, 2026
closes
https://linear.app/ghost/issue/BER-3413/account-page-date-mismatch-between-discount-end-billing-period

- In Stripe, discount dates are anchored around the redemption date, not
the billing date. However, we're interested in whether the next
payment(s) are discounted. This aligns the next payment object discount
end date with the last payment with a discount.
- Example:
    - Bob's billing cycle is from Mar 3 to April 3
    - Bob redeems a retention offer on Mar 1, that is repeating 3 months
    - Stripe will set discount start to Mar 1 and discount end to Jun 1
- Bob's next 3 payments will be discounted: Mar 3, Apr 3 and May 3, and
we want to display "Ends May 3" (not "Ends Jun 1")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants