Skip to content

Fix Stripe/Trans amount parser truncating locale-mismatched separators - #3380

Open
vivi-the-going-merry[bot] wants to merge 2 commits into
masterfrom
fix/issue-3379-locale-mismatched-amount-separators
Open

vivi-the-going-merry[bot] wants to merge 2 commits into
masterfrom
fix/issue-3379-locale-mismatched-amount-separators

Conversation

@vivi-the-going-merry

Copy link
Copy Markdown
Contributor

What was broken

FrmTransLiteActionsController::maybe_use_decimal()/normalize_number() decided the decimal separator purely from the currency's configured thousand_separator/decimal_separator, never checking whether the amount string already contained the other separator character in a conflicting role.

A user typing an amount in a different locale's format than the form's configured currency (e.g. US-style 1,030.21 on a EUR form expecting . as the thousand separator) got both punctuation marks collided into one string (1.030.21), and PHP's (float) cast silently stopped parsing at the second ., truncating the charge amount by ~1000x (1030.211.03).

What changed

maybe_use_decimal() and normalize_number() are replaced by a single find_decimal_position() that returns the position of the real decimal separator (or false for a whole-number amount):

  • When both . and , appear in the string, whichever one appears last is the real decimal point, regardless of what the currency configures.
  • When only one separator character appears, the existing currency-driven heuristic is preserved exactly (a lone . with a 1-2 digit tail on a .-thousands currency still reads as a decimal point; a lone , on a ,-thousands currency is never reinterpreted as decimal — both existing, tested behaviors).

normalize_number() then splits the string at that position and strips every other occurrence of either separator character as grouping noise, rather than blanket-replacing by character — closing a second gap found during self-review, where a repeated occurrence of the resolved decimal character (e.g. 1,234.567,89) could still collide into a second decimal point and reproduce the same truncation via three separators instead of two.

How it was verified

  • TDD: wrote failing regression tests first, confirmed red against the unmodified code (reproduced the exact 1030.211.03 truncation and its GBP-form mirror image), then confirmed green after the fix.
  • Added find_decimal_position() unit coverage (tests/phpunit/stripe/test_FrmTransLiteActionsController.php) and end-to-end prepare_amount() regression cases (tests/phpunit/square/test_FrmSquareLiteAppController.php's shared amount_format_provider), including the reported bug, its mirror image, and the repeated-separator adversarial case.
  • Verified every existing currency-native and single-separator case (EUR/GBP/USD/JPY/BRL/CZK, multi-thousands-group, the existing "GBP comma is never a decimal" case) is unchanged, via a standalone Reflection harness against the real class file (no CI available in this sandbox to run the full WP-bootstrapped PHPUnit suite locally) — happy to add the run tests label for CI's own PHPUnit run.
  • 4-angle self-review (reuse/simplification/efficiency/altitude) + a security review pass, both applied: consolidated the two-function split into one decision point, and fixed the multi-separator collision the security pass found.

Known follow-up (out of scope for this PR)

FrmCurrencyHelper::prepare_price()/maybe_use_decimal() (classes/helpers/FrmCurrencyHelper.php) has the same root-cause bug in a separate code path (Total field price calculations, consumed by FrmFieldTotal) — confirmed via the same trace, not touched here since it's a different consumer than what this issue reports. Filing a follow-up issue for it.

Closes #3379

maybe_use_decimal()/normalize_number() decided the decimal separator
purely from the currency's configured thousand/decimal separators, never
checking whether the amount string already contained the other separator
character in a conflicting role. A user typing an amount in a different
locale's format than the form's configured currency (e.g. US-style
"1,030.21" on a EUR form expecting '.' as the thousand separator) got
both punctuation marks collided into one string, and PHP's float cast
silently truncated the result by ~1000x (1030.21 -> 1.03).

When both '.' and ',' appear in the string, the real decimal separator
is now detected from the string itself (whichever separator appears
last), instead of trusting the currency's configured roles -- which are
only reliable when a single separator character appears.

Closes #3379
Self-review surfaced two problems with the first pass:

- maybe_use_decimal() and normalize_number() split one decision (which
  character is the real decimal separator) across two methods,
  coordinated only by an early-return contract and an intermediate
  by-reference mutation -- fragile, and redundant (the conflict check
  ran twice on the same string).
- The conflict-case fix picked the right decimal separator character
  but then swapped every occurrence of it via str_replace. A second
  occurrence of that same character (e.g. "1,234.567,89") collided into
  a second decimal point and reproduced the exact ~1000x truncation the
  fix was written to close, just via three separators instead of two.

Both are replaced by a single find_decimal_position() that returns the
rightmost real decimal separator's position (or false for a
whole-number amount), which normalize_number() then uses to split the
string into integer/fractional parts and strip every other separator
occurrence as grouping noise -- so a repeated separator character can
no longer collide into a second decimal point.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 04571daf-5867-4ed0-b481-ad6c0bee6174

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@deepsource-io

deepsource-io Bot commented Sep 18, 2026

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in 3030efe...718fdda on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Sep 18, 2026 4:56p.m. Review ↗
JavaScript Sep 18, 2026 4:56p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid fix — the find_decimal_position() design (rightmost separator wins when both appear, single-separator heuristic preserved exactly) is correct and the new test coverage (mixed separators, repeated-separator collision, thousands-only) exercises the right edge cases. One blocking CI failure below; everything else is clean.

}

$present = false !== $last_dot ? '.' : ',';
$position = false !== $last_dot ? $last_dot : $last_comma;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — CI is red on this line (PHPStan: Only numeric types are allowed in -, int|false given on the right side at line 425, https://github.com/Strategy11/formidable-forms/actions/runs/35371337494/job/105685768338).

Runtime-wise $position can never actually be false here — the two guards above establish exactly one of $last_dot/$last_comma is non-false — but PHPStan can't derive that XOR relationship across two separate if blocks, so it sees $position as int|false going into the subtraction on line 425. Add an explicit narrowing guard right after the assignment:

Suggested change
$position = false !== $last_dot ? $last_dot : $last_comma;
$position = false !== $last_dot ? $last_dot : $last_comma;
if ( false === $position ) {
// Unreachable given the guards above -- narrows the type for PHPStan.
return false;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stripe/Trans amount parser silently truncates on locale-mismatched separators

0 participants