Skip to content

Fixed discounted price formatting missing trailing zero#26774

Merged
sagzy merged 9 commits intomainfrom
offer-price-format-BER-3427
Mar 18, 2026
Merged

Fixed discounted price formatting missing trailing zero#26774
sagzy merged 9 commits intomainfrom
offer-price-format-BER-3427

Conversation

@minimaluminium
Copy link
Copy Markdown
Member

@minimaluminium minimaluminium commented Mar 11, 2026

ref https://linear.app/ghost/issue/BER-3427/fix-price-formatting-issue

Non-whole discounted prices (e.g. $5.40) were displayed without trailing zeros (e.g. $5.4) in two places:

  • Portal: Retention offer discounted price in the cancellation flow used formatNumber() which calls .toLocaleString() without enforcing decimal places. Fixed by applying the same .toFixed(2) rounding that the signup offer page already uses in renderRoundedPrice().
  • Admin: Member detail subscription discounted price used {{format-number}} which doesn't enforce decimal places. Fixed by switching to {{gh-price-amount}} (with cents=false), which already handles this correctly via {minimumFractionDigits: 2}.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Price formatting was centralized by adding formatPrice to portal helpers and replacing format-number usages with formatPrice or gh-price-amount where cent-based amount values are now rendered. getDiscountPrice was extended to include numeric amount on discountedPrice and originalPrice. Tests were added/updated: unit tests for formatPrice, portal component tests for formatted retention offers, subscription-data tests expecting amount, and an acceptance test asserting trailing zeros for subscription prices (duplicated insertion noted). No exported function removals occurred.

🚥 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 specifically describes the main change: fixing price formatting to display trailing zeros in discounted prices.
Description check ✅ Passed The description is directly related to the changeset, explaining the specific issue (missing trailing zeros in discounted prices) and detailing fixes in both Portal and Admin components.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch offer-price-format-BER-3427
📝 Coding Plan
  • Generate coding plan for human review comments

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

🧹 Nitpick comments (1)
apps/portal/src/components/pages/account-plan-page.js (1)

325-329: The fix correctly ensures two decimal places for fractional amounts.

The conditional toFixed(2) approach properly addresses the trailing zero issue. For $5.40, this produces "5.40" instead of "5.4".

One optional simplification—the Number() wrapper and Math.round() are redundant since toFixed(2) handles both conversion and rounding:

♻️ Optional simplification
 const originalAmount = price.amount / 100;
-const originalPrice = formatNumber(originalAmount % 1 !== 0 ? Number(Math.round(originalAmount * 100) / 100).toFixed(2) : originalAmount);
+const originalPrice = formatNumber(originalAmount % 1 !== 0 ? originalAmount.toFixed(2) : originalAmount);
 const currency = getCurrencySymbol(price.currency);
 const updatedAmount = getUpdatedOfferPrice({offer, price});
-const discountedPrice = formatNumber(updatedAmount % 1 !== 0 ? Number(Math.round(updatedAmount * 100) / 100).toFixed(2) : updatedAmount);
+const discountedPrice = formatNumber(updatedAmount % 1 !== 0 ? updatedAmount.toFixed(2) : updatedAmount);
🤖 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 325 -
329, The conditional formatting for originalPrice and discountedPrice is fine
but you can simplify by removing the redundant Number() and Math.round()
wrappers; change the ternary branches to use originalAmount % 1 !== 0 ?
originalAmount.toFixed(2) : originalAmount and updatedAmount % 1 !== 0 ?
updatedAmount.toFixed(2) : updatedAmount respectively (keep formatNumber and the
surrounding variables: originalAmount, updatedAmount, originalPrice,
discountedPrice, getUpdatedOfferPrice).
🤖 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/admin/app/components/gh-member-settings-form.hbs`:
- Around line 123-127: The template is passing already-dollar values
(nonDecimalAmount) into the gh-price-amount helper with cents=false which causes
incorrect division; update the three uses (sub.discountedPrice.nonDecimalAmount,
sub.originalPrice.nonDecimalAmount, sub.price.nonDecimalAmount) to provide
cent-based integers instead (e.g., sub.discountedPrice.amount,
sub.originalPrice.amount, sub.price.amount) or multiply the nonDecimalAmount by
100 before passing, so gh-price-amount receives cents units and renders
correctly.

---

Nitpick comments:
In `@apps/portal/src/components/pages/account-plan-page.js`:
- Around line 325-329: The conditional formatting for originalPrice and
discountedPrice is fine but you can simplify by removing the redundant Number()
and Math.round() wrappers; change the ternary branches to use originalAmount % 1
!== 0 ? originalAmount.toFixed(2) : originalAmount and updatedAmount % 1 !== 0 ?
updatedAmount.toFixed(2) : updatedAmount respectively (keep formatNumber and the
surrounding variables: originalAmount, updatedAmount, originalPrice,
discountedPrice, getUpdatedOfferPrice).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 50167fba-a678-423a-9292-53fa2b731cf0

📥 Commits

Reviewing files that changed from the base of the PR and between 84ae287 and 2b74b56.

📒 Files selected for processing (2)
  • apps/portal/src/components/pages/account-plan-page.js
  • ghost/admin/app/components/gh-member-settings-form.hbs

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.

♻️ Duplicate comments (1)
ghost/admin/app/components/gh-member-settings-form.hbs (1)

123-124: ⚠️ Potential issue | 🔴 Critical

Pass cent amounts here; cents=false makes the discounted values wrong.

gh-price-amount still divides by 100 when cents=false, so feeding it nonDecimalAmount here turns values like 5.40 into 0. Even swapping to .amount without removing cents=false would still round away the decimals, so this won't render the required trailing zeros. Use the cent-based fields and let the helper keep its default behavior.

Suggested fix
-                                                    <span class="amount">{{gh-price-amount sub.discountedPrice.nonDecimalAmount cents=false}}</span>
-                                                    <span class="gh-membertier-original-price">{{sub.originalPrice.currencySymbol}}{{gh-price-amount sub.originalPrice.nonDecimalAmount cents=false}}</span>
+                                                    <span class="amount">{{gh-price-amount sub.discountedPrice.amount}}</span>
+                                                    <span class="gh-membertier-original-price">{{sub.originalPrice.currencySymbol}}{{gh-price-amount sub.originalPrice.amount}}</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/admin/app/components/gh-member-settings-form.hbs` around lines 123 -
124, The template is passing nonDecimalAmount and forcing cents=false which
causes gh-price-amount to divide by 100 twice and lose decimals; change the
usage in gh-member-settings-form.hbs to pass the cent-based fields (e.g.,
sub.discountedPrice.amount and sub.originalPrice.amount or the existing cent
fields like sub.discountedPrice.cents) and remove the cents=false attribute so
gh-price-amount uses its default cent-handling behavior; update the two spans
that reference gh-price-amount (the one showing discountedPrice and the one
showing originalPrice) to use the cent fields and no cents flag.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ghost/admin/app/components/gh-member-settings-form.hbs`:
- Around line 123-124: The template is passing nonDecimalAmount and forcing
cents=false which causes gh-price-amount to divide by 100 twice and lose
decimals; change the usage in gh-member-settings-form.hbs to pass the cent-based
fields (e.g., sub.discountedPrice.amount and sub.originalPrice.amount or the
existing cent fields like sub.discountedPrice.cents) and remove the cents=false
attribute so gh-price-amount uses its default cent-handling behavior; update the
two spans that reference gh-price-amount (the one showing discountedPrice and
the one showing originalPrice) to use the cent fields and no cents flag.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2e73c585-8227-4487-b150-f3ab7a88d606

📥 Commits

Reviewing files that changed from the base of the PR and between 2b74b56 and db7f469.

📒 Files selected for processing (1)
  • ghost/admin/app/components/gh-member-settings-form.hbs

@ErisDS
Copy link
Copy Markdown
Member

ErisDS commented Mar 11, 2026

🤖 Velo CI Failure Analysis

Classification: 🟠 SOFT FAIL

  • Workflow: CI
  • Failed Step: Check app version bump
  • Run: View failed run
    What failed: CI failure - likely code issue
    Why: The failure appears to be related to code changes. Check the error output for details.
    Action:
    Review the error logs and fix the issue in your code.

@minimaluminium minimaluminium requested a review from sagzy March 11, 2026 08:48
Copy link
Copy Markdown
Contributor

@sagzy sagzy left a comment

Choose a reason for hiding this comment

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

@minimaluminium left a suggestion to extract the new formatting logic to a helper

Moreover, in Admin > Member page, I'm still seeing a single decimal place (missing trailing 0):

CleanShot 2026-03-11 at 11 29 11@2x

const price = getPriceFromSubscription({subscription});
const originalPrice = formatNumber(price.amount / 100);
const originalAmount = price.amount / 100;
const originalPrice = formatNumber(originalAmount % 1 !== 0 ? Number(Math.round(originalAmount * 100) / 100).toFixed(2) : originalAmount);
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.

suggestion: shall we move that formatting logic into the original formatNumber() helper? Or if this helper is used to display non-prices, we could have a formatPrice() helper?

That way, we can also add unit tests for the helper, such as "formats decimal prices correctly"

Copy link
Copy Markdown
Contributor

@sagzy sagzy left a comment

Choose a reason for hiding this comment

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

Additional review points from Codex, look valid:

[P2] The admin member detail page still leaves the original bug in place for non-discounted prices. The discounted branch now uses gh-price-amount, but the non-discounted branch still renders sub.price.nonDecimalAmount with format-number, so a plan like 590 cents will still show 5.9 instead of 5.90. See gh-member-settings-form.hbs (line 121) and subscription-data.js (line 17)

[P2] The portal fix preserves trailing zeros by converting decimals to strings before calling formatNumber, but that also disables locale number formatting for larger values. formatNumber just does amount.toLocaleString(), so "1234.50" stays "1234.50" instead of "1,234.50". See account-plan-page.js (line 324) and helpers.js (line 756)

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.

🧹 Nitpick comments (1)
apps/portal/test/utils/helpers.test.js (1)

300-303: Consider adding a non-numeric input case for completeness.

formatPrice guards against invalid numeric input; adding one assertion (e.g. 'abc' or NaN) would lock that behavior down.

Optional test addition
     test('returns empty string for null/undefined input', () => {
         expect(formatPrice(null)).toBe('');
         expect(formatPrice(undefined)).toBe('');
     });
+
+    test('returns empty string for non-numeric input', () => {
+        expect(formatPrice('abc')).toBe('');
+        expect(formatPrice(NaN)).toBe('');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/test/utils/helpers.test.js` around lines 300 - 303, Extend the
existing test for formatPrice (in the test named "returns empty string for
null/undefined input") to also assert behavior for non-numeric input: call
formatPrice with a non-number value such as 'abc' and/or NaN and expect an empty
string; this ensures formatPrice('abc') and formatPrice(NaN) return '' like the
null/undefined cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/portal/test/utils/helpers.test.js`:
- Around line 300-303: Extend the existing test for formatPrice (in the test
named "returns empty string for null/undefined input") to also assert behavior
for non-numeric input: call formatPrice with a non-number value such as 'abc'
and/or NaN and expect an empty string; this ensures formatPrice('abc') and
formatPrice(NaN) return '' like the null/undefined cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5076c802-4e6c-4ca9-b2f1-a43690c90f91

📥 Commits

Reviewing files that changed from the base of the PR and between e434e82 and c3c2bac.

📒 Files selected for processing (6)
  • apps/portal/src/components/pages/account-plan-page.js
  • apps/portal/src/utils/helpers.js
  • apps/portal/test/unit/components/pages/account-plan-page.test.js
  • apps/portal/test/utils/helpers.test.js
  • ghost/admin/app/components/gh-member-settings-form.hbs
  • ghost/admin/tests/acceptance/members/details-test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/portal/src/components/pages/account-plan-page.js
  • ghost/admin/app/components/gh-member-settings-form.hbs

@minimaluminium minimaluminium force-pushed the offer-price-format-BER-3427 branch from c3c2bac to 7114fb7 Compare March 17, 2026 00:49
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 `@apps/portal/test/utils/helpers.test.js`:
- Around line 296-298: The test is fragile because formatPrice uses
toLocaleString(undefined, options) which depends on system locale; update the
implementation of formatPrice to pass an explicit locale (e.g., 'en-US') to
toLocaleString so it always formats as '1,234.50', and run/update the test in
apps/portal/test/utils/helpers.test.js if needed; locate the function named
formatPrice in helpers.js and replace toLocaleString(undefined, options) with
toLocaleString('en-US', options) (or accept an optional locale parameter and
default it to 'en-US') to ensure deterministic behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 24cc4f7a-525e-4c14-8732-975a72eb7987

📥 Commits

Reviewing files that changed from the base of the PR and between c3c2bac and 7114fb7.

📒 Files selected for processing (4)
  • apps/portal/src/components/pages/account-plan-page.js
  • apps/portal/src/utils/helpers.js
  • apps/portal/test/unit/components/pages/account-plan-page.test.js
  • apps/portal/test/utils/helpers.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/portal/src/components/pages/account-plan-page.js
  • apps/portal/src/utils/helpers.js

@minimaluminium minimaluminium requested a review from sagzy March 17, 2026 04:18
Copy link
Copy Markdown
Contributor

@sagzy sagzy left a comment

Choose a reason for hiding this comment

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

Besides the comment above on forcing en-US locale, I think it'd be good to also apply the new helper for fixed amounts here: (

return `${getCurrencySymbol(offer.currency)}${offer.amount / 100}`;
)

${getCurrencySymbol(offer.currency)}${formatPrice(offer.amount / 100)}

We don't use fixed-amount offers for retention, but good to fix for any future use of that helper

? undefined
: {minimumFractionDigits: 2, maximumFractionDigits: 2};

return normalizedAmount.toLocaleString('en-US', options);
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.

I'm unsure about forcing en-US formatting here instead of keeping the user locale.

This would mean that prices are printed with 1,234.50 format for all countries, whereas I believe currently we render:

  • "1,234.50" for en-US (comma-separated thousands)
  • but e.g. "1  234,50" for fr-FR (space-separated thousands)

@sagzy sagzy force-pushed the offer-price-format-BER-3427 branch from 71fbee0 to 47c6f0c Compare March 18, 2026 17:39
@sagzy sagzy enabled auto-merge (squash) March 18, 2026 17:39
@sagzy sagzy disabled auto-merge March 18, 2026 17:49
@sagzy sagzy force-pushed the offer-price-format-BER-3427 branch from 47c6f0c to 0459d6a Compare March 18, 2026 18:14
@sagzy sagzy enabled auto-merge (squash) March 18, 2026 18:15
@sagzy sagzy merged commit d4c3eb7 into main Mar 18, 2026
28 checks passed
@sagzy sagzy deleted the offer-price-format-BER-3427 branch March 18, 2026 18:46
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