Skip to content

Added support for continuing a gift sub as paid sub#27432

Open
sagzy wants to merge 1 commit intomainfrom
gift-subscriptions/upgrade-gift-to-paid
Open

Added support for continuing a gift sub as paid sub#27432
sagzy wants to merge 1 commit intomainfrom
gift-subscriptions/upgrade-gift-to-paid

Conversation

@sagzy
Copy link
Copy Markdown
Contributor

@sagzy sagzy commented Apr 16, 2026

ref https://linear.app/ghost/issue/BER-3477

Summary

When a member with an active gift subscription continues with a paid plan, they currently lose the remaining days of their gift. This PR preserves that value by converting the remaining gift days into a Stripe free trial period.

Changes

Backend

  • Gift repository: Added getByRedeemerMemberId(memberId) to look up a gift by the member who redeemed it.
  • Gift service: Added getActiveByRedeemer(memberId) which returns the member's gift if it exists and hasn't been consumed.
  • Payments service: Wired in giftService. In getPaymentLink(), if there's no offer-based trial set, we check for an active gift and convert the remaining days (capped at Stripe's 730-day max) into trial_period_days on the Stripe checkout session. The existing coupon logic still wins — if an offer adds a coupon, trial days are cleared.

Trial day priority:

  1. Offer trial days
  2. Gift remaining days
  3. Tier default trial days

Frontend (Portal)

  • Added a GiftTrialBanner to the "Change plan" page in Portal. For gift members, it shows above the plan cards with the message:

    Your gift subscription is valid until {date}. If you continue with a paid subscription, the remaining days will be added as a free trial.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 16, 2026

Walkthrough

Adds UI banner GiftTrialBanner in the portal to display gift subscription expiry and bumps @tryghost/portal version. Introduces getRedeemedByMember(memberId) on the Bookshelf repository, repository interface, and GiftService. Wires giftService into MembersAPI/PaymentsService; PaymentsService.getPaymentLink can compute remaining trial days from a redeemed gift and include them in Stripe checkout session. Adds unit tests for repository and service, and an E2E test verifying trial-day calculation when continuing a gift to a paid subscription.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 and concisely summarizes the main change: adding support for continuing a gift subscription as a paid subscription.
Description check ✅ Passed The description is directly related to the changeset, providing context, detailed changes, and priority logic for the feature.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gift-subscriptions/upgrade-gift-to-paid

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

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9df5e573cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +292 to +293
if (gift.isConsumed()) {
return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude refunded gifts from gift-trial eligibility

getActiveByRedeemer() currently treats any non-consumed gift as active, so a refunded redeemed gift (which still has redeemer_member_id and often a future consumesAt) is returned and converted into trial_period_days during checkout. In the refund flow this allows members to receive a new free trial after their gift has already been refunded, which is a revenue-impacting eligibility bug.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
const model = await this.model.findOne({
redeemer_member_id: memberId
}, {require: false});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Select the current redeemed gift for a member

getByRedeemerMemberId() uses an unconstrained findOne by redeemer_member_id, so members with multiple historical gifts can get an arbitrary row (often an older consumed/refunded one) instead of their currently active redeemed gift. That causes incorrect trial behavior in checkout (for example, no trial when a newer gift still has remaining days), because getActiveByRedeemer() only sees this single returned record.

Useful? React with 👍 / 👎.

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: 3

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

Inline comments:
In `@apps/portal/src/components/pages/account-plan-page.js`:
- Around line 97-123: GiftTrialBanner currently uses getSubscriptionExpiry()
which doesn't read gift expiry; update the component to read the expiry from the
gift product on the member (member.products[].expiry_at) when member?.status ===
'gift' instead of relying on getSubscriptionExpiry(). In the GiftTrialBanner
component, detect isGift and look up the product entry with an expiry_at (e.g.,
first product or the one with a gift marker) and use that expiry value as
expiryDate passed into the Interpolate string; keep existing behavior for
non-gift members (use getSubscriptionExpiry) and ensure null checks for
member.products to avoid runtime errors.

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 78-83: The current getByRedeemerMemberId method does a
nondeterministic findOne({redeemer_member_id}) which can return an old
consumed/refunded row; change the query in getByRedeemerMemberId (and/or the
caller getActiveByRedeemer) to only return the active redeemed gift by adding a
status constraint (e.g. status = 'redeemed' and not consumed/refunded) and order
by the latest redeem timestamp (redeemed_at or created_at) descending so you
return the most recent active gift; update the model.findOne call to include the
status filter and an order clause (latest first) so the method deterministically
returns the active redeemed gift.

In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 285-297: The getActiveByRedeemer method currently only checks
gift.isConsumed() after retrieving a gift via
deps.giftRepository.getByRedeemerMemberId(memberId); update this method
(getActiveByRedeemer) to also verify that gift.status === 'redeemed' (in
addition to !gift.isConsumed()) before returning the gift, and return null if
the status is anything else so refunded/non-redeemed gifts are not returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d823ca8d-45db-4409-8adf-98374da013eb

📥 Commits

Reviewing files that changed from the base of the PR and between 434af39 and 9df5e57.

📒 Files selected for processing (7)
  • apps/portal/src/components/pages/account-plan-page.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/payments-service.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js

Comment on lines +97 to +123
const GiftTrialBanner = () => {
const {member} = useContext(AppContext);
const isGift = member?.status === 'gift';
const subscriptionExpiry = getSubscriptionExpiry({member});

if (!isGift || !subscriptionExpiry) {
return null;
}

return (
<div style={{
display: 'flex',
justifyContent: 'center',
margin: '8px 0 32px'
}}>
<div style={{
background: '#FEF3C7',
borderRadius: '5px',
padding: '16px 24px',
maxWidth: '420px',
fontSize: '1.35rem',
lineHeight: '1.5em',
color: '#1d1d1d',
textAlign: 'center'
}}>
<Interpolate
string={t('Your gift subscription is valid until <strong>{expiryDate}</strong>. If you continue with a paid subscription, the remaining days will be added as a free trial.', {expiryDate: subscriptionExpiry})}
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

Read the expiry from gift membership data, not getSubscriptionExpiry().

apps/portal/src/utils/helpers.js:96-102 only reads subscription.tier.expiry_at, but gift redemption stores the end date on member.products[].expiry_at (ghost/core/core/server/services/gifts/gift-service.ts:248-254, also reflected in ghost/core/test/e2e-api/members/gift-subscriptions.test.js:446-452). In this component that leaves subscriptionExpiry empty for gift members, so the new banner never renders for the users it is meant for.

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

In `@apps/portal/src/components/pages/account-plan-page.js` around lines 97 - 123,
GiftTrialBanner currently uses getSubscriptionExpiry() which doesn't read gift
expiry; update the component to read the expiry from the gift product on the
member (member.products[].expiry_at) when member?.status === 'gift' instead of
relying on getSubscriptionExpiry(). In the GiftTrialBanner component, detect
isGift and look up the product entry with an expiry_at (e.g., first product or
the one with a gift marker) and use that expiry value as expiryDate passed into
the Interpolate string; keep existing behavior for non-gift members (use
getSubscriptionExpiry) and ensure null checks for member.products to avoid
runtime errors.

Comment on lines +78 to +83
async getByRedeemerMemberId(memberId: string): Promise<Gift | null> {
const model = await this.model.findOne({
redeemer_member_id: memberId
}, {require: false});

return model ? this.toGift(model) : null;
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

Don't fetch an arbitrary gift row for a redeemer.

This lookup is nondeterministic once a member has historical gifts. findOne({redeemer_member_id}) can return an older consumed/refunded record instead of the current redeemed one, which means getActiveByRedeemer() may miss the active gift and fail to grant the intended trial days. The query needs to constrain itself to the active redeemed gift, or at least order by the latest gift before returning one.

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

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around
lines 78 - 83, The current getByRedeemerMemberId method does a nondeterministic
findOne({redeemer_member_id}) which can return an old consumed/refunded row;
change the query in getByRedeemerMemberId (and/or the caller
getActiveByRedeemer) to only return the active redeemed gift by adding a status
constraint (e.g. status = 'redeemed' and not consumed/refunded) and order by the
latest redeem timestamp (redeemed_at or created_at) descending so you return the
most recent active gift; update the model.findOne call to include the status
filter and an order clause (latest first) so the method deterministically
returns the active redeemed gift.

Comment on lines +285 to +297
async getActiveByRedeemer(memberId: string): Promise<Gift | null> {
const gift = await this.deps.giftRepository.getByRedeemerMemberId(memberId);

if (!gift) {
return null;
}

if (gift.isConsumed()) {
return null;
}

return gift;
}
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

Only return gifts that are still in the redeemed state.

isConsumed() is not enough here. A refunded gift still has a redeemerMemberId, so this method can hand payments-service a non-consumed but refunded gift and grant trial days that should no longer exist. Gate this on gift.status === 'redeemed' as well.

Possible fix
     async getActiveByRedeemer(memberId: string): Promise<Gift | null> {
         const gift = await this.deps.giftRepository.getByRedeemerMemberId(memberId);

         if (!gift) {
             return null;
         }

-        if (gift.isConsumed()) {
+        if (gift.status !== 'redeemed' || gift.isConsumed()) {
             return null;
         }

         return gift;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async getActiveByRedeemer(memberId: string): Promise<Gift | null> {
const gift = await this.deps.giftRepository.getByRedeemerMemberId(memberId);
if (!gift) {
return null;
}
if (gift.isConsumed()) {
return null;
}
return gift;
}
async getActiveByRedeemer(memberId: string): Promise<Gift | null> {
const gift = await this.deps.giftRepository.getByRedeemerMemberId(memberId);
if (!gift) {
return null;
}
if (gift.status !== 'redeemed' || gift.isConsumed()) {
return null;
}
return gift;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 285 -
297, The getActiveByRedeemer method currently only checks gift.isConsumed()
after retrieving a gift via deps.giftRepository.getByRedeemerMemberId(memberId);
update this method (getActiveByRedeemer) to also verify that gift.status ===
'redeemed' (in addition to !gift.isConsumed()) before returning the gift, and
return null if the status is anything else so refunded/non-redeemed gifts are
not returned.

@sagzy sagzy force-pushed the gift-subscriptions/upgrade-gift-to-paid branch from 9df5e57 to cc9e18c Compare April 16, 2026 15:44
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

♻️ Duplicate comments (1)
apps/portal/src/components/pages/account-plan-page.js (1)

99-103: ⚠️ Potential issue | 🟠 Major

Use gift product expiry, not getSubscriptionExpiry(), for gift members.

At Line 100, getSubscriptionExpiry({member}) resolves from active paid subscription tier expiry, so gift members can end up with an empty value and the banner won’t render. Read expiry from member.products[].expiry_at for member.status === 'gift'.

💡 Suggested fix
 const GiftTrialBanner = () => {
     const {member} = useContext(AppContext);
     const isGift = member?.status === 'gift';
-    const subscriptionExpiry = getSubscriptionExpiry({member});
+    const giftExpiryAt = member?.products?.find(product => product?.expiry_at)?.expiry_at;
+    const subscriptionExpiry = isGift ? getDateString(giftExpiryAt) : getSubscriptionExpiry({member});

     if (!isGift || !subscriptionExpiry) {
         return null;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/components/pages/account-plan-page.js` around lines 99 - 103,
The banner currently uses getSubscriptionExpiry({member}) which returns
paid-subscription expiry and can be empty for gift members; change the logic in
the block that computes subscriptionExpiry (and the early return that uses
isGift) to use member.products[].expiry_at when member.status === 'gift' (i.e.,
if isGift true, derive subscriptionExpiry from the gift product's expiry_at),
otherwise keep using getSubscriptionExpiry({member}); ensure the variable names
isGift and subscriptionExpiry are preserved so the subsequent return logic still
works.
🧹 Nitpick comments (1)
ghost/core/core/server/services/members/members-api/services/payments-service.js (1)

95-107: Skip the gift lookup when a coupon already exists.

Coupon offers still win later at Line 125, so this branch does an unnecessary gift read for every coupon-backed checkout. Guarding on !coupon keeps the priority order explicit and avoids extra work on the hot path.

♻️ Suggested tweak
-        if (trialDays === null && member) {
+        if (trialDays === null && !coupon && member) {
             const gift = await this.giftService.service.getActiveByRedeemer(member.id);

             if (gift && gift.consumesAt) {
                 const now = new Date();
                 const diffMs = gift.consumesAt.getTime() - now.getTime();
🤖 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/services/payments-service.js`
around lines 95 - 107, The gift lookup block currently runs even when a coupon
is present; update the conditional to only attempt the gift lookup when coupon
is not set so we avoid unnecessary reads on the coupon-backed checkout hot path.
Specifically, change the if that checks "trialDays === null && member" to also
require "!coupon" before calling this.giftService.service.getActiveByRedeemer
and computing diffDays; keep the existing logic that caps trialDays to
Math.min(diffDays, 730) and retain the later coupon-priority logic (the code
that handles coupon offers).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/portal/src/components/pages/account-plan-page.js`:
- Around line 122-126: Replace the hardcoded paragraph that uses
subscriptionExpiry with a single translatable string using t() and Interpolate
from `@doist/react-interpolate`: create one t() call that includes a placeholder
like "{expiry}" and pass subscriptionExpiry as the interpolation value,
embedding the <strong> element via Interpolate so the bolded expiry remains a
React element; update the JSX in the AccountPlanPage component (where the <p>
currently renders subscriptionExpiry) to use t(...) + <Interpolate ... /> and
remove the literal string to satisfy i18n linting.

---

Duplicate comments:
In `@apps/portal/src/components/pages/account-plan-page.js`:
- Around line 99-103: The banner currently uses getSubscriptionExpiry({member})
which returns paid-subscription expiry and can be empty for gift members; change
the logic in the block that computes subscriptionExpiry (and the early return
that uses isGift) to use member.products[].expiry_at when member.status ===
'gift' (i.e., if isGift true, derive subscriptionExpiry from the gift product's
expiry_at), otherwise keep using getSubscriptionExpiry({member}); ensure the
variable names isGift and subscriptionExpiry are preserved so the subsequent
return logic still works.

---

Nitpick comments:
In
`@ghost/core/core/server/services/members/members-api/services/payments-service.js`:
- Around line 95-107: The gift lookup block currently runs even when a coupon is
present; update the conditional to only attempt the gift lookup when coupon is
not set so we avoid unnecessary reads on the coupon-backed checkout hot path.
Specifically, change the if that checks "trialDays === null && member" to also
require "!coupon" before calling this.giftService.service.getActiveByRedeemer
and computing diffDays; keep the existing logic that caps trialDays to
Math.min(diffDays, 730) and retain the later coupon-priority logic (the code
that handles coupon offers).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ffe69000-5c76-44be-b0a2-c52ce028df17

📥 Commits

Reviewing files that changed from the base of the PR and between 9df5e57 and cc9e18c.

📒 Files selected for processing (8)
  • apps/portal/package.json
  • apps/portal/src/components/pages/account-plan-page.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/payments-service.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
✅ Files skipped from review due to trivial changes (5)
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • apps/portal/package.json
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/core/server/services/gifts/gift-service.ts

Comment on lines +122 to +126
{/* TODO: Add translation strings once copy has been finalised */}
{/* eslint-disable i18next/no-literal-string */}
<p>
Your gift subscription is valid until <strong>{subscriptionExpiry}</strong>. If you continue with a paid subscription, the remaining days will be added as a free trial.
</p>
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

Localize banner copy with a single translatable string + interpolation.

Line 125 is hardcoded and bypasses i18n linting. This should use t() + Interpolate with {variable} placeholders.

💡 Suggested fix
-                {/* TODO: Add translation strings once copy has been finalised */}
-                {/* eslint-disable i18next/no-literal-string */}
                 <p>
-                    Your gift subscription is valid until <strong>{subscriptionExpiry}</strong>. If you continue with a paid subscription, the remaining days will be added as a free trial.
+                    <Interpolate
+                        string={t('Your gift subscription is valid until {expiryDate}. If you continue with a paid subscription, the remaining days will be added as a free trial.', {expiryDate: subscriptionExpiry})}
+                        mapping={{
+                            expiryDate: <strong>{subscriptionExpiry}</strong>
+                        }}
+                    />
                 </p>

As per coding guidelines: “Never split sentences across multiple t() calls. Use @doist/react-interpolate to embed React elements within a single translatable string” and “Use {variable} syntax for dynamic values in translation strings.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{/* TODO: Add translation strings once copy has been finalised */}
{/* eslint-disable i18next/no-literal-string */}
<p>
Your gift subscription is valid until <strong>{subscriptionExpiry}</strong>. If you continue with a paid subscription, the remaining days will be added as a free trial.
</p>
<p>
<Interpolate
string={t('Your gift subscription is valid until {expiryDate}. If you continue with a paid subscription, the remaining days will be added as a free trial.', {expiryDate: subscriptionExpiry})}
mapping={{
expiryDate: <strong>{subscriptionExpiry}</strong>
}}
/>
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/components/pages/account-plan-page.js` around lines 122 -
126, Replace the hardcoded paragraph that uses subscriptionExpiry with a single
translatable string using t() and Interpolate from `@doist/react-interpolate`:
create one t() call that includes a placeholder like "{expiry}" and pass
subscriptionExpiry as the interpolation value, embedding the <strong> element
via Interpolate so the bolded expiry remains a React element; update the JSX in
the AccountPlanPage component (where the <p> currently renders
subscriptionExpiry) to use t(...) + <Interpolate ... /> and remove the literal
string to satisfy i18n linting.

@sagzy sagzy force-pushed the gift-subscriptions/upgrade-gift-to-paid branch from cc9e18c to 53a0fe7 Compare April 16, 2026 16:17
@sonarqubecloud
Copy link
Copy Markdown

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

🧹 Nitpick comments (2)
ghost/core/test/unit/server/services/gifts/gift-service.test.ts (1)

956-963: Add a repository-call assertion in the null-path test.

This test currently validates output only. Also assert the repository is called with the expected member id to prevent false positives.

Suggested patch
         it('returns null when the repository has no redeemed gift for the member', async function () {
             giftRepository.getRedeemedByMember.resolves(null);

             const service = createService();
             const result = await service.getRedeemedByMember('member_without_gift');

             assert.equal(result, null);
+            sinon.assert.calledOnceWithExactly(giftRepository.getRedeemedByMember, 'member_without_gift');
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts` around lines
956 - 963, Add an assertion in the "returns null when the repository has no
redeemed gift for the member" test to verify the repository method was invoked
with the expected member id: after calling
service.getRedeemedByMember('member_without_gift') assert that
giftRepository.getRedeemedByMember was called with 'member_without_gift' (e.g.,
using sinon/Chai call assertion such as sinon.assert.calledWith or
assert.calledOnceWithExactly) so the test checks both the return value and that
the repository was queried with the correct parameter.
ghost/core/core/server/services/members/members-api/services/payments-service.js (1)

33-34: Consider adding JSDoc type annotation for consistency.

Other dependencies in the constructor have JSDoc type annotations (e.g., lines 13-16). Adding one for giftService would improve discoverability and IDE support.

🤖 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/services/payments-service.js`
around lines 33 - 34, Add a JSDoc type annotation for the giftService dependency
to match the other constructor annotations: locate the constructor where
this.giftService = deps.giftService is assigned and add a JSDoc line (similar to
the existing annotations above it) documenting the expected type/interface for
giftService (e.g., `@param` {GiftService} deps.giftService or `@type`
{import('path').GiftService}) so IDEs and consumers can discover the type from
the PaymentsService constructor/giftService field.
🤖 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/services/payments-service.js`:
- Around line 95-107: The code assumes this.giftService exists when computing
gift-based trialDays; add a defensive guard to only call
this.giftService.service.getRedeemedByMember(member.id) if this.giftService and
this.giftService.service are defined (or otherwise skip gift logic), so that
PaymentsService can be instantiated without giftService; update the block around
the getRedeemedByMember call in payments-service.js (where giftService,
this.giftService.service and getRedeemedByMember are referenced) to check
existence before awaiting and proceed without error if absent.

---

Nitpick comments:
In
`@ghost/core/core/server/services/members/members-api/services/payments-service.js`:
- Around line 33-34: Add a JSDoc type annotation for the giftService dependency
to match the other constructor annotations: locate the constructor where
this.giftService = deps.giftService is assigned and add a JSDoc line (similar to
the existing annotations above it) documenting the expected type/interface for
giftService (e.g., `@param` {GiftService} deps.giftService or `@type`
{import('path').GiftService}) so IDEs and consumers can discover the type from
the PaymentsService constructor/giftService field.

In `@ghost/core/test/unit/server/services/gifts/gift-service.test.ts`:
- Around line 956-963: Add an assertion in the "returns null when the repository
has no redeemed gift for the member" test to verify the repository method was
invoked with the expected member id: after calling
service.getRedeemedByMember('member_without_gift') assert that
giftRepository.getRedeemedByMember was called with 'member_without_gift' (e.g.,
using sinon/Chai call assertion such as sinon.assert.calledWith or
assert.calledOnceWithExactly) so the test checks both the return value and that
the repository was queried with the correct parameter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d865da2-340f-4682-a415-babcfc49e588

📥 Commits

Reviewing files that changed from the base of the PR and between cc9e18c and 53a0fe7.

📒 Files selected for processing (10)
  • apps/portal/package.json
  • apps/portal/src/components/pages/account-plan-page.js
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/core/server/services/members/members-api/services/payments-service.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
✅ Files skipped from review due to trivial changes (5)
  • apps/portal/package.json
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/members/members-api/members-api.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • apps/portal/src/components/pages/account-plan-page.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js

Comment on lines +95 to +107
// If active gift subscription, add remaining days as trial
if (trialDays === null && member) {
const gift = await this.giftService.service.getRedeemedByMember(member.id);

if (gift && gift.consumesAt) {
const now = new Date();
const diffMs = gift.consumesAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > 0) {
trialDays = Math.min(diffDays, 730); // Stripe max trial period
}
}
}
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how giftService is constructed and passed to PaymentsService

# Find where PaymentsService is instantiated and giftService is passed
rg -n -A 5 "new PaymentsService" --type js

# Check the giftService structure to understand the .service accessor
rg -n -A 10 "giftService" ghost/core/core/server/services/members/members-api/members-api.js

Repository: TryGhost/Ghost

Length of output: 4313


🏁 Script executed:

# Check the PaymentsService constructor definition
rg -n -B 5 -A 30 "constructor\(" ghost/core/core/server/services/members/members-api/services/payments-service.js | head -60

Repository: TryGhost/Ghost

Length of output: 1612


🏁 Script executed:

# Check all usages of giftService within PaymentsService
rg -n "giftService" ghost/core/core/server/services/members/members-api/services/payments-service.js

Repository: TryGhost/Ghost

Length of output: 196


🏁 Script executed:

# Check if giftService is optional in PaymentsService tests
rg -n -B 5 -A 5 "giftService" ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js | head -80

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Search for tests that might test the gift trial logic
rg -n -B 3 -A 10 "getRedeemedByMember\|gift.*trial" ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check the PaymentsService instantiation in members-api.js more carefully, particularly the giftService parameter
cat -n ghost/core/core/server/services/members/members-api/members-api.js | sed -n '180,195p'

Repository: TryGhost/Ghost

Length of output: 506


🏁 Script executed:

# Check what giftService actually is at the module level in members-api.js
rg -n "giftService\s*=" ghost/core/core/server/services/members/members-api/members-api.js | head -5

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check broader context - what is giftService at the top of members-api.js
head -100 ghost/core/core/server/services/members/members-api/members-api.js | grep -n "giftService"

Repository: TryGhost/Ghost

Length of output: 76


🏁 Script executed:

# Search for where giftService is passed/defined in members-api initialization
rg -n "giftService" ghost/core/core/server/services/members/members-api/ --type js | head -20

Repository: TryGhost/Ghost

Length of output: 775


🏁 Script executed:

# Check if there are any integration tests that might cover this code path
rg -n "getRedeemedByMember" ghost/core/test --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check how members-api is initialized and where giftService comes from
rg -n "members-api.init\|require.*members-api" ghost/core/core/server/services/members --type js | head -20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Look at the function signature of members-api to understand dependencies
sed -n '60,100p' ghost/core/core/server/services/members/members-api/members-api.js | cat -n

Repository: TryGhost/Ghost

Length of output: 1183


🏁 Script executed:

# Check if giftService is conditionally passed or always provided
rg -n -B 10 "members-api\.init" ghost/core/core/server/services/members/index.js | head -50

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check members/index.js to see how members-api is initialized with giftService
cat ghost/core/core/server/services/members/index.js | head -150

Repository: TryGhost/Ghost

Length of output: 96


🏁 Script executed:

# Look for where giftService service is defined/created
rg -n "giftService\s*=" ghost/core/core/server/services/ --type js | grep -v "this.giftService\|deps.giftService"

Repository: TryGhost/Ghost

Length of output: 357


🏁 Script executed:

# Check the gifts service to understand the structure
cat ghost/core/core/server/services/gifts/index.js | head -80

Repository: TryGhost/Ghost

Length of output: 163


🏁 Script executed:

# Check members/api.js where giftService is required and how it's used
cat -n ghost/core/core/server/services/members/api.js | head -50

Repository: TryGhost/Ghost

Length of output: 2446


🏁 Script executed:

# Check the GiftServiceWrapper to understand the .service property
cat ghost/core/core/server/services/gifts/gift-service-wrapper.js | head -100

Repository: TryGhost/Ghost

Length of output: 2064


🏁 Script executed:

# Check if there's any code path where giftService might not be provided
rg -n "giftService" ghost/core/core/server/services/members/api.js

Repository: TryGhost/Ghost

Length of output: 125


🏁 Script executed:

# Check if giftService.init() is called before PaymentsService is used
rg -n "giftService\.init\|await giftService" ghost/core/core/server/services/members/ --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check the MembersApi initialization to see if giftService is properly set up
rg -n "async function createApiInstance\|giftService" ghost/core/core/server/services/members/api.js | head -20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check when giftService gets initialized - look for boot/initialization sequence
rg -n "giftService" ghost/core/core/server/services/members/index.js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Search for where giftService.init() is called in the initialization sequence
rg -n "\.init\(\)" ghost/core/core/server/services/ --type js | grep -i gift

Repository: TryGhost/Ghost

Length of output: 154


🏁 Script executed:

# Check the main service initialization in core/server/services
rg -n "giftService\|gifts" ghost/core/core/server/services/index.js

Repository: TryGhost/Ghost

Length of output: 138


🏁 Script executed:

# Look for when PaymentsService methods are actually called and if giftService would be initialized
rg -n "paymentsService\|getTrialDaysFromTierOrOffer" ghost/core/core/server/services/members --type js | head -20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check the main initialization sequence in Ghost - find where services are initialized
find ghost/core/core/server -name "index.js" -o -name "init.js" | head -10

Repository: TryGhost/Ghost

Length of output: 655


🏁 Script executed:

# Check Ghost's main entry points to understand initialization order
rg -n "giftService\|membersService\|init()" ghost/core/core/index.js --type js | head -30

Repository: TryGhost/Ghost

Length of output: 122


🏁 Script executed:

# Search for where giftService.init() is called globally
rg -n "await.*giftService\.init\|giftService\.init" ghost/core --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check the server startup sequence
find ghost/core/core/server -name "*.js" -type f | xargs grep -l "giftService\.init\|membersService" | head -10

Repository: TryGhost/Ghost

Length of output: 698


🏁 Script executed:

# Look for where memberService/paymentsService methods are called with giftService context
rg -n "getTrialDaysFromTierOrOffer" ghost/core --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check if giftService.service is ever checked for null/undefined in the codebase
rg -n "giftService\?.service\|giftService && giftService\.service\|giftService\.service\?" ghost/core --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check if there's any initialization or setup code that validates giftService before PaymentsService is used
rg -n "PaymentsService\|paymentsService.*getTrialDaysFromTierOrOffer" ghost/core/core/server/services/members --type js -B 5 -A 5 | head -50

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Look at the actual test file to see if giftService is tested with PaymentsService
cat ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js | grep -n -A 10 -B 10 "gift\|trial" | head -80

Repository: TryGhost/Ghost

Length of output: 2909


🏁 Script executed:

# Check if giftService is ever made optional or conditional in any initialization
rg -n "giftService.*\?" ghost/core/core/server/services/ --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check the test for PaymentsService to see if giftService is ever tested with getRedeemedByMember
rg -n -B 5 -A 15 "getTrialDaysFromTierOrOffer\|gift.*trial" ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check if the code at lines 95-107 is ever actually tested
rg -n "trialDays.*null\|giftService.*getRedeemedByMember" ghost/core/test --type js

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Verify: can PaymentsService be instantiated without giftService?
cat -n ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js | sed -n '308,320p'

Repository: TryGhost/Ghost

Length of output: 636


🏁 Script executed:

# Confirm: Check how many instances of PaymentsService are created in tests WITHOUT giftService
rg -n -B 2 -A 8 "new PaymentsService" ghost/core/test/unit/server/services/members/members-api/services/payments-service.test.js | grep -A 8 "new PaymentsService"

Repository: TryGhost/Ghost

Length of output: 964


🏁 Script executed:

# Double-check: Are there ANY null/undefined guards before calling giftService in payments-service.js?
grep -n "giftService" ghost/core/core/server/services/members/members-api/services/payments-service.js

Repository: TryGhost/Ghost

Length of output: 196


Add defensive check for optional giftService parameter.

The code at line 97 unconditionally accesses this.giftService.service without checking if giftService was provided. Test cases show PaymentsService can be instantiated without giftService, making it optional, yet the code will throw TypeError: Cannot read property 'service' of undefined if absent.

Add the guard:

Suggested fix
         // If active gift subscription, add remaining days as trial
-        if (trialDays === null && member) {
+        if (trialDays === null && member && this.giftService) {
             const gift = await this.giftService.service.getRedeemedByMember(member.id);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If active gift subscription, add remaining days as trial
if (trialDays === null && member) {
const gift = await this.giftService.service.getRedeemedByMember(member.id);
if (gift && gift.consumesAt) {
const now = new Date();
const diffMs = gift.consumesAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > 0) {
trialDays = Math.min(diffDays, 730); // Stripe max trial period
}
}
}
// If active gift subscription, add remaining days as trial
if (trialDays === null && member && this.giftService) {
const gift = await this.giftService.service.getRedeemedByMember(member.id);
if (gift && gift.consumesAt) {
const now = new Date();
const diffMs = gift.consumesAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > 0) {
trialDays = Math.min(diffDays, 730); // Stripe max trial period
}
}
}
🤖 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/services/payments-service.js`
around lines 95 - 107, The code assumes this.giftService exists when computing
gift-based trialDays; add a defensive guard to only call
this.giftService.service.getRedeemedByMember(member.id) if this.giftService and
this.giftService.service are defined (or otherwise skip gift logic), so that
PaymentsService can be instantiated without giftService; update the block around
the getRedeemedByMember call in payments-service.js (where giftService,
this.giftService.service and getRedeemedByMember are referenced) to check
existence before awaiting and proceed without error if absent.

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.

1 participant