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:

  1. 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, and StripeReview turns 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.
  2. 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:

  1. commerce_stripe supplies its own MinorUnitsConverterInterface for 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.
  2. 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.
  3. At minimum, document the mismatch, and pass Stripe's own message through instead of the generic one. amount_too_small is 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

Command icon 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

norbert-goco created an issue. See original summary.

jsacksick’s picture

Fixing this in Stripe isn't the right way, this should be fixed in Commerce core itself as this is for sure affecting other gateways.

jsacksick’s picture

Project: Commerce Stripe » Commerce Core
Version: 2.2.2 » 3.x-dev
Component: Code » Documentation
jsacksick’s picture

Component: Documentation » Price

  • jsacksick committed da8a84d4 on 3.x
    fix: #3625166 HUF payments are sent to Stripe at 1/100 of the order...
jsacksick’s picture

Status: Active » Fixed

Thanks 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 using MinorUnitsConverter was 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.)

  • COP, HUF, IDR, PKR: back to 2 (as in v2.0.7 and earlier).
  • AFN, ALL, IRR, KPW, LAK, LBP, MGA, MMK, SOS, SYP, YER: 0 → 2, and IQD: 0 → 3, to match ISO 4217.
  • TWD was not affected (still 2).

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.10 and updates ludwig.json, so sites installing Commerce without Composer get the fix too.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.