Problem/Motivation
Every payment in HUF (Hungarian forint) is sent to Stripe at one hundredth of the order total.
commerce_stripe converts amounts with $this->minorUnitsConverter->toMinorUnits(). \Drupal\commerce_price\MinorUnitsConverter asks CurrencyRepository::getDefaultFractionDigits(), which reads commerceguys/intl's base definitions. Those changed meaning in intl 2.x: they used to carry ISO 4217 minor units, and now carry CLDR display digits.
// commerceguys/intl v1.1.2, src/Currency/CurrencyRepository.php:226
'HUF' => ['348', 2],
// commerceguys/intl v2.0.8, src/Currency/CurrencyRepository.php:232
'HUF' => ['348', 0],
Both numbers are correct answers to different questions — the forint is written without decimals, and it is billed in hundredths — but Stripe wants the ISO 4217 one. With 0 fraction digits the x100 in toMinorUnits() disappears, so a 9 505 Ft order goes out as amount: 9505, which Stripe reads as 95.05 HUF.
Two consequences:
- Orders below 17 500 Ft are refused outright. Stripe's minimum charge for HUF is 175.00 Ft, so anything under that floor comes back as
amount_too_small, andStripeReviewturns it into the generic "We encountered an unexpected error processing your payment. Please try again using a different payment method or try again later." The real error is never shown, which is what made this hard to find. - Orders above that floor are silently charged 1/100. Nothing fails — not the build, not the test suite, not checkout.
Because MinorUnitsConverter reads the fraction digits in both directions, the two errors cancel out in the recorded payment: the intent goes out at a hundredth, and fromMinorUnits() brings it back to a figure that matches the order. Only the charge is wrong, which is why this can run unnoticed for a long time. It also means that a fix correcting only the outgoing side will record every payment at 100x the order total.
Drupal Commerce 3.x requires commerceguys/intl ^2, so every Commerce 3 + Stripe store selling in HUF is affected. TWD may be in the same position (Stripe lists it alongside HUF as a currency it treats as zero-decimal for payouts but two-decimal for charges); I have not tested it.
Steps to reproduce
With a store selling in HUF:
$converter = \Drupal::service('commerce_price.minor_units_converter');
$converter->toMinorUnits(new \Drupal\commerce_price\Price('9505', 'HUF'));
// 9505 — Stripe reads this as 95.05 HUF. Expected: 950500.
Or straight against the API with any test key:
curl https://api.stripe.com/v1/payment_intents -u sk_test_xxx: \
-d amount=9505 -d currency=huf -d "payment_method_types[]=card"
# "code": "amount_too_small"
# "message": "Amount must be at least 175.00 Ft HUF"
curl https://api.stripe.com/v1/payment_intents -u sk_test_xxx: \
-d amount=950500 -d currency=huf -d "payment_method_types[]=card"
# accepted; shows as 9 505 Ft in the dashboard
Note that setting fractionDigits on the commerce_currency config entity does not help: getDefaultFractionDigits() reads intl's table, not the entity.
Proposed resolution
I am not sure which layer the maintainers want this in, so rather than attaching a patch to the wrong one:
- commerce_stripe supplies its own
MinorUnitsConverterInterfacefor its gateways, built on ISO 4217 rather than CLDR. This keeps the knowledge of what Stripe expects inside the module that has it.PaymentGatewayBase::create()currently hardcodes$container->get('commerce_price.minor_units_converter'), so this needs a way to override the converter per gateway. - Treat it as a commerce_price issue.
getDefaultFractionDigits()documents itself as the number payment gateways use when converting to minor units — that is ISO 4217 — so arguably it should not be answering in CLDR display digits. - At minimum, document the mismatch, and pass Stripe's own message through instead of the generic one.
amount_too_smallis actionable; the generic text sent us looking in the wrong place.
We are currently running option 1 locally as a decorator on commerce_price.currency_repository that answers getDefaultFractionDigits() from a small ISO 4217 table and falls through to the inner service for anything unlisted — deliberately a listed table rather than one computed from a library, since a table that answered from a library is what failed here.
Remaining tasks
- Decide which layer owns the fix (commerce_stripe / commerce_price)
- Check TWD, and whether any other currency moved in the same intl change
- Test coverage that asserts the amount that reaches the gateway rather than the fraction digits — a test written against the fraction digits would have gone green on both sides of this
Environment
Drupal Commerce 3.3.9, commerce_stripe 2.2.2, commerceguys/intl 2.0.8, Payment Element gateway, Drupal 11.4, PHP 8.5.
Issue fork commerce-3625166
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
jsacksick commentedFixing this in Stripe isn't the right way, this should be fixed in Commerce core itself as this is for sure affecting other gateways.
Comment #3
jsacksick commentedComment #4
jsacksick commentedComment #7
jsacksick commentedThanks for the detailed report. The root cause was in
commerceguys/intl: v2.0.8 updated to CLDR 48, which changed the fraction digits of COP, HUF, IDR and PKR from 2 to 0. CLDR's digits are a display preference ("how many decimals people usually write"), not ISO 4217 minor units, so every payment gateway usingMinorUnitsConverterwas sending these currencies at 1/100 of the amount. Stripe was only the most visible case.Fixed in commerceguys/intl v2.0.10: fraction digits are now taken from ISO 4217 instead of CLDR, so they no longer change when CLDR changes its display preferences. (v2.0.9 was tagged by mistake and is identical to v2.0.8, please skip it.)
What to do: Composer sites can get the fix right away with
composer update commerceguys/intl, no Commerce update needed. Existing currency configuration (display and rounding) is not affected.The MR bumps the minimum required version to
^2.0.10and updatesludwig.json, so sites installing Commerce without Composer get the fix too.