Repository navigation
Fix Stripe/Trans amount parser truncating locale-mismatched separators - #3380
vivi-the-going-merry[bot] wants to merge 3 commits into
Conversation
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.
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Repository: Strategy11/formidable-forms/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
|
Overall Grade |
Security Reliability Complexity Hygiene |
Code Review Summary
| Analyzer | Status | Updated (UTC) | Details |
|---|---|---|---|
| PHP | Sep 19, 2026 11:31a.m. | Review ↗ | |
| JavaScript | Sep 19, 2026 11:31a.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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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:
| $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; | |
| } |
The lone-separator branch computed $present/$position via two independent ternaries on $last_dot and $last_comma, so PHPStan couldn't correlate them and typed $position as int|false even though it's provably always int once we're past the 'neither present' return. Replaced with an if/elseif/else that assigns both from whichever variable was actually checked, narrowing $position to int in every reachable branch. Behavior is unchanged.
|
Method: in-place push Fixed the PHPStan minus.rightNonNumeric failure in find_decimal_position() -- the lone-separator branch computed present/position via two independent ternaries PHPStan could not correlate, so it typed position as int|false. Replaced with an if/elseif/else that assigns both from whichever variable was actually checked, narrowing position to int in every reachable branch. Behavior unchanged. The Spell Check with Typos failure on the prior run was a transient action-resolution error (crate-ci/typos@master), unrelated to this PR -- confirmed by other PRs passing it around the same time, and it passed clean on this push. |
There was a problem hiding this comment.
Re-review at ea0736d. Both fixed since my last CHANGES_REQUESTED:
- PHPStan (
minus.rightNonNumeric) — fixed.find_decimal_position()'s lone-separator branch now assigns$present/$positiontogether via if/elseif/else instead of two independent ternaries, narrowing$positiontointin every branch that reaches the subtraction. CI PHPStan is green.
Re-verified the whole diff from scratch, not just the delta:
- Traced
find_decimal_position()/normalize_number()by hand against every branch: both-separators-present (rightmost wins, regardless of currency config), single-separator matching the currency's own decimal separator (unconditional), single dot on a comma-decimal currency (1-2 digit tail only), single comma on a dot-decimal currency (never reinterpreted). All match the existing, preserved heuristics plus the new locale-mismatch/repeated-separator fixes described in the PR body. - Grepped the repo for other callers of the removed
maybe_use_decimal()/oldnormalize_number()— none outside this file.FrmCurrencyHelper::maybe_use_decimal()has an unrelated, already-disclosed duplicate of the same root-cause bug (different consumer, Total-field price calc) — already filed as #3381 per this PR's own description, not re-flagged here. - Ran
code-reviewagainst the diff. It flagged a theoretical CHF/CZK/SEK (apostrophe/space thousand-separator) truncation risk in the new noise-stripping — checked against source and it doesn't reach this code:get_amount_from_string()(unchanged, upstream ofnormalize_number()) regex-matches only[0-9,.], so a space or apostrophe already splits the string there, beforefind_decimal_position()ever sees it. That'stest_space_thousand_separator_keeps_the_leading_group()'s pre-existing, already-markTestIncomplete'd "Known bug" (predates this PR, confirmed viagit log— untouched by this diff, different code path than what's being fixed here). Not a regression from this change. - Test coverage is solid:
find_decimal_position()unit tests cover both-separators-present in both directions, the repeated-separator collision, and the comma-currency-never-decimal case;amount_format_provider()adds end-to-end cases for the same, on top of the existing currency-native/single-separator matrix. CI (PHPUnit on PHP 7.4/8) is green, so this coverage actually ran.
No other CI failures (Spell Check with Typos passed clean this run). No blocking findings. Approving.
What was broken
FrmTransLiteActionsController::maybe_use_decimal()/normalize_number()decided the decimal separator purely from the currency's configuredthousand_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.21on 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.21→1.03).What changed
maybe_use_decimal()andnormalize_number()are replaced by a singlefind_decimal_position()that returns the position of the real decimal separator (orfalsefor a whole-number amount):.and,appear in the string, whichever one appears last is the real decimal point, regardless of what the currency configures..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
1030.21→1.03truncation and its GBP-form mirror image), then confirmed green after the fix.find_decimal_position()unit coverage (tests/phpunit/stripe/test_FrmTransLiteActionsController.php) and end-to-endprepare_amount()regression cases (tests/phpunit/square/test_FrmSquareLiteAppController.php's sharedamount_format_provider), including the reported bug, its mirror image, and the repeated-separator adversarial case.run testslabel for CI's own PHPUnit run.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 byFrmFieldTotal) — 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