Document UGX exclusion and hardcode Stripe decimal lists (#4542)

* Address review comments on #4541: document UGX exclusion, hardcode Stripe decimal lists

- Rename STRIPE_ZERO_DECIMAL_CURRENCIES to STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES
  and document why UGX is excluded (zero-decimal per Stripe's list, but its
  special-case row requires charge amounts x100), plus ISK/HUF/TWD notes.
- Replace the CLDR-based three-decimal check with a hardcoded
  STRIPE_THREE_DECIMAL_CURRENCIES list (BHD, JOD, KWD, OMR, TND); drop the
  Symfony Intl dependency from this helper.
- Reword the StripeTest comment to state the UGX exception explicitly.

* Extend Stripe currency test coverage in #4541 follow-up

- getAmountInMinorUnits: add BHD (second three-decimal code), AFN/YER
  (more CLDR-zero codes Stripe charges x100), TWD (payout-zero/charge-two
  special case), unknown-code fallback (XXX) and lowercase inputs.
- Add getAmountFromMinorUnits round-trip test via invokePrivateMethod so the
  read-back path is covered too.
This commit is contained in:
Adam Daley authored and GitHub committed 2026-10-17 23:04:22 +01:00
1 parent 304b731f4e
commit 8c50583a91
2 files changed
+41 -10

No files matched your search

+19 -7
View File
@@ -22,11 +22,22 @@ use FOSSBilling\Doctrine\EntityManagerFactory;
use FOSSBilling\Doctrine\NamedLock;
use FOSSBilling\Period;
use Stripe\StripeClient;
use Symfony\Component\Intl\Currencies;
class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface, FOSSBilling\Payment\JsonWebhookAuthenticatorInterface
{
private const array STRIPE_ZERO_DECIMAL_CURRENCIES = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
/**
* Stripe's zero-decimal currencies for charges (https://docs.stripe.com/currencies).
* UGX is on Stripe's published zero-decimal list, but its special-case row requires charge amounts ×100,
* so it is deliberately excluded here. ISK was never on the list and likewise uses two decimals for
* charges; HUF and TWD are zero-decimal for payouts only, so they stay ×100 here. This helper is
* charge-side only (PaymentIntents, Prices) and must not be reused for payouts.
*/
private const array STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
/**
* Stripe's three-decimal currencies for charges (https://docs.stripe.com/currencies).
*/
private const array STRIPE_THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
protected ?Pimple\Container $di = null;
@@ -262,17 +273,18 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface, FOS
}
/**
* Stripe's own decimal rules, not CLDR's: Stripe's zero-decimal currencies are sent as-is, three-decimal ones
* keep three digits, and every other currency (including COP, HUF, IDR, ISK, PKR and UGX, which CLDR lists with
* no decimals) is sent ×100. See https://docs.stripe.com/currencies.
* Stripe's own decimal rules, not CLDR's: the currencies in STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES are sent
* as-is, the ones in STRIPE_THREE_DECIMAL_CURRENCIES keep three digits, and every other currency
* (including COP, HUF, IDR, ISK, PKR and UGX, which CLDR lists with no decimals) is sent ×100.
* See https://docs.stripe.com/currencies.
*/
private function getCurrencyFractionDigits(string $currency): int
{
$currency = strtoupper($currency);
if (in_array($currency, self::STRIPE_ZERO_DECIMAL_CURRENCIES, true)) {
if (in_array($currency, self::STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES, true)) {
return 0;
}
if (Currencies::exists($currency) && Currencies::getFractionDigits($currency) === 3) {
if (in_array($currency, self::STRIPE_THREE_DECIMAL_CURRENCIES, true)) {
return 3;
}
+22 -3
View File
@@ -2664,9 +2664,9 @@ describe('processTransaction payload handling', function (): void {
});
describe('getAmountInMinorUnits follows Stripe currency rules', function (): void {
// Stripe's zero-decimal list is "BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF", and ISK and UGX must
// be sent as two-decimal values (https://docs.stripe.com/currencies). Symfony Intl reports COP, HUF, IDR, ISK, PKR, UGX
// and others with no decimals, but Stripe expects them ×100.
// Stripe lists "BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF" as zero-decimal, but its
// special-cases row requires UGX charges ×100, like ISK (https://docs.stripe.com/currencies). Symfony Intl
// reports COP, HUF, IDR, ISK, PKR, UGX and others with no decimals, but Stripe expects them ×100.
test('sends the invoice total in the unit Stripe expects', function (string $currency, int $expected): void {
$invoice = createEntity(Invoice::class);
$invoice->id = 5;
@@ -2683,11 +2683,30 @@ describe('getAmountInMinorUnits follows Stripe currency rules', function (): voi
'USD' => ['USD', 100000],
'JPY (zero-decimal)' => ['JPY', 1000],
'KWD (three-decimal)' => ['KWD', 1000000],
'BHD (three-decimal)' => ['BHD', 1000000],
'COP' => ['COP', 100000],
'HUF' => ['HUF', 100000],
'IDR' => ['IDR', 100000],
'ISK (two-decimal representation)' => ['ISK', 100000],
'PKR' => ['PKR', 100000],
'UGX (two-decimal representation)' => ['UGX', 100000],
'AFN' => ['AFN', 100000],
'YER' => ['YER', 100000],
'TWD (two-decimal for charges)' => ['TWD', 100000],
'unknown code falls back to two decimals' => ['XXX', 100000],
'lowercase USD' => ['usd', 100000],
'lowercase JPY' => ['jpy', 1000],
]);
test('reads Stripe amounts back in the same unit', function (string $currency, int $minorUnits, float $expected): void {
expect(invokePrivateMethod($this->adapter, 'getAmountFromMinorUnits', [$minorUnits, $currency]))->toBe($expected);
})->with([
'USD' => ['USD', 100000, 1000.0],
'JPY (zero-decimal)' => ['JPY', 1000, 1000.0],
'KWD (three-decimal)' => ['KWD', 1000000, 1000.0],
'HUF' => ['HUF', 100000, 1000.0],
'UGX (two-decimal representation)' => ['UGX', 100000, 1000.0],
'unknown code falls back to two decimals' => ['XXX', 100000, 1000.0],
'lowercase currency' => ['usd', 100000, 1000.0],
]);
});