Skip to content

Commit e195286

Browse files
authored
Fix Stripe Currency bugs (#484)
* Fix Stripe Currency bugs * Update composer.lock
1 parent 4fa5793 commit e195286

19 files changed

+470
-277
lines changed

backend/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=true
6262

6363
JWT_SECRET=2hoccgHb9r1fqW1lU16C6khSHVa7O0eai6FxkWK95UtQ0LqNDTO5mq1RzDwcq18I
6464
JWT_ALGO=HS256
65+
66+
# Only required for SAAS mode and if your're charging fees
67+
# OPEN_EXCHANGE_RATES_APP_ID=

backend/app/Helper/Currency.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@
66

77
class Currency
88
{
9+
private const ZERO_DECIMAL_CURRENCIES = [
10+
'BIF',
11+
'CLP',
12+
'DJF',
13+
'GNF',
14+
'JPY',
15+
'KMF',
16+
'KRW',
17+
'MGA',
18+
'PYG',
19+
'RWF',
20+
'UGX',
21+
'VND',
22+
'VUV',
23+
'XAF',
24+
'XOF',
25+
'XPF'
26+
];
27+
28+
public static function isZeroDecimalCurrency(string $currencyCode): bool
29+
{
30+
return in_array(strtoupper($currencyCode), self::ZERO_DECIMAL_CURRENCIES, true);
31+
}
32+
933
public static function format(float|int $amount, string $currencyCode, string $locale = 'en_US'): string
1034
{
1135
$currencyCode = strtoupper($currencyCode);

backend/app/Providers/AppServiceProvider.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
use HiEvents\DomainObjects\OrganizerDomainObject;
1010
use HiEvents\Models\Event;
1111
use HiEvents\Models\Organizer;
12+
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
13+
use HiEvents\Services\Infrastructure\CurrencyConversion\NoOpCurrencyConversionClient;
14+
use HiEvents\Services\Infrastructure\CurrencyConversion\OpenExchangeRatesCurrencyConversionClient;
1215
use Illuminate\Cache\RateLimiting\Limit;
1316
use Illuminate\Contracts\Queue\ShouldQueue;
1417
use Illuminate\Database\Eloquent\Model;
@@ -28,6 +31,7 @@ public function register(): void
2831
{
2932
$this->bindDoctrineConnection();
3033
$this->bindStripeClient();
34+
$this->bindCurrencyConversionClient();
3135
}
3236

3337
/**
@@ -130,4 +134,25 @@ private function disableLazyLoading(): void
130134
{
131135
Model::preventLazyLoading(!app()->isProduction());
132136
}
137+
138+
private function bindCurrencyConversionClient(): void
139+
{
140+
$this->app->bind(
141+
CurrencyConversionClientInterface::class,
142+
function () {
143+
if (config('services.open_exchange_rates.app_id')) {
144+
return new OpenExchangeRatesCurrencyConversionClient(
145+
apiKey: config('services.open_exchange_rates.app_id'),
146+
cache: $this->app->make('cache.store'),
147+
logger: $this->app->make('log')
148+
);
149+
}
150+
151+
// Fallback to no-op client if no other client is available
152+
return new NoOpCurrencyConversionClient(
153+
logger: $this->app->make('log')
154+
);
155+
}
156+
);
157+
}
133158
}

backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use Brick\Math\Exception\NumberFormatException;
77
use Brick\Math\Exception\RoundingNecessaryException;
88
use Brick\Money\Exception\UnknownCurrencyException;
9-
use Brick\Money\Money;
109
use HiEvents\DomainObjects\AccountConfigurationDomainObject;
1110
use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract;
1211
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -23,6 +22,7 @@
2322
use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO;
2423
use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentCreationService;
2524
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
25+
use HiEvents\Values\MoneyValue;
2626
use Stripe\Exception\ApiErrorException;
2727
use Throwable;
2828

@@ -84,7 +84,7 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO
8484
}
8585

8686
$paymentIntent = $this->stripePaymentService->createPaymentIntent(CreatePaymentIntentRequestDTO::fromArray([
87-
'amount' => Money::of($order->getTotalGross(), $order->getCurrency())->getMinorAmount()->toInt(),
87+
'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()),
8888
'currencyCode' => $order->getCurrency(),
8989
'account' => $account,
9090
'order' => $order,

backend/app/Services/Domain/Order/MarkOrderAsPaidService.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace HiEvents\Services\Domain\Order;
44

5+
use Brick\Math\Exception\MathException;
56
use HiEvents\DomainObjects\AccountConfigurationDomainObject;
67
use HiEvents\DomainObjects\AccountDomainObject;
78
use HiEvents\DomainObjects\AttendeeDomainObject;
@@ -142,6 +143,9 @@ private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void
142143
);
143144
}
144145

146+
/**
147+
* @throws MathException
148+
*/
145149
private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): void
146150
{
147151
/** @var EventDomainObject $event */
@@ -163,13 +167,14 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): vo
163167

164168
$this->orderApplicationFeeService->createOrderApplicationFee(
165169
orderId: $updatedOrder->getId(),
166-
applicationFeeAmount: $this->orderApplicationFeeCalculationService->calculateApplicationFee(
170+
applicationFeeAmountMinorUnit: $this->orderApplicationFeeCalculationService->calculateApplicationFee(
167171
$config,
168172
$updatedOrder->getTotalGross(),
169173
$event->getCurrency(),
170-
)->toFloat(),
174+
)->toMinorUnit(),
171175
orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT,
172176
paymentMethod: PaymentProviders::OFFLINE,
177+
currency: $updatedOrder->getCurrency(),
173178
);
174179
}
175180
}

backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
namespace HiEvents\Services\Domain\Order;
44

5+
use Brick\Money\Currency;
56
use HiEvents\DomainObjects\AccountConfigurationDomainObject;
7+
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
68
use HiEvents\Values\MoneyValue;
79
use Illuminate\Config\Repository;
810

911
class OrderApplicationFeeCalculationService
1012
{
13+
private const BASE_CURRENCY = 'USD';
14+
1115
public function __construct(
12-
private readonly Repository $config,
16+
private readonly Repository $config,
17+
private readonly CurrencyConversionClientInterface $currencyConversionClient
1318
)
1419
{
1520
}
@@ -24,12 +29,28 @@ public function calculateApplicationFee(
2429
return MoneyValue::zero($currency);
2530
}
2631

27-
$fixedFee = $accountConfiguration->getFixedApplicationFee();
32+
$fixedFee = $this->getConvertedFixedFee($accountConfiguration, $currency);
2833
$percentageFee = $accountConfiguration->getPercentageApplicationFee();
2934

3035
return MoneyValue::fromFloat(
31-
amount: $fixedFee + ($orderTotal * $percentageFee / 100),
36+
amount: $fixedFee->toFloat() + ($orderTotal * $percentageFee / 100),
3237
currency: $currency
3338
);
3439
}
40+
41+
private function getConvertedFixedFee(
42+
AccountConfigurationDomainObject $accountConfiguration,
43+
string $currency
44+
): MoneyValue
45+
{
46+
if ($currency === self::BASE_CURRENCY) {
47+
return MoneyValue::fromFloat($accountConfiguration->getFixedApplicationFee(), $currency);
48+
}
49+
50+
return $this->currencyConversionClient->convert(
51+
fromCurrency: Currency::of(self::BASE_CURRENCY),
52+
toCurrency: Currency::of($currency),
53+
amount: $accountConfiguration->getFixedApplicationFee()
54+
);
55+
}
3556
}

backend/app/Services/Domain/Order/OrderApplicationFeeService.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use HiEvents\DomainObjects\Enums\PaymentProviders;
66
use HiEvents\DomainObjects\Generated\OrderApplicationFeeDomainObjectAbstract;
77
use HiEvents\DomainObjects\Status\OrderApplicationFeeStatus;
8+
use HiEvents\Helper\Currency;
89
use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface;
910

1011
class OrderApplicationFeeService
@@ -17,16 +18,24 @@ public function __construct(
1718

1819
public function createOrderApplicationFee(
1920
int $orderId,
20-
float $applicationFeeAmount,
21+
int $applicationFeeAmountMinorUnit,
2122
OrderApplicationFeeStatus $orderApplicationFeeStatus,
2223
PaymentProviders $paymentMethod,
24+
string $currency,
2325
): void
2426
{
27+
$isZeroDecimalCurrency = Currency::isZeroDecimalCurrency($currency);
28+
29+
$applicationFeeAmount = $isZeroDecimalCurrency
30+
? $applicationFeeAmountMinorUnit
31+
: $applicationFeeAmountMinorUnit / 100;
32+
2533
$this->orderApplicationFeeRepository->create([
2634
OrderApplicationFeeDomainObjectAbstract::ORDER_ID => $orderId,
2735
OrderApplicationFeeDomainObjectAbstract::AMOUNT => $applicationFeeAmount,
2836
OrderApplicationFeeDomainObjectAbstract::STATUS => $orderApplicationFeeStatus->value,
2937
OrderApplicationFeeDomainObjectAbstract::PAYMENT_METHOD => $paymentMethod->value,
38+
ORderApplicationFeeDomainObjectAbstract::CURRENCY => $currency,
3039
OrderApplicationFeeDomainObjectAbstract::PAID_AT => $orderApplicationFeeStatus->value === OrderApplicationFeeStatus::PAID->value
3140
? now()->toDateTimeString()
3241
: null,

backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
use HiEvents\DataTransferObjects\BaseDTO;
66
use HiEvents\DomainObjects\AccountDomainObject;
77
use HiEvents\DomainObjects\OrderDomainObject;
8+
use HiEvents\Values\MoneyValue;
89

910
class CreatePaymentIntentRequestDTO extends BaseDTO
1011
{
1112
public function __construct(
12-
public readonly int $amount,
13-
public readonly string $currencyCode,
13+
public readonly MoneyValue $amount,
14+
public readonly string $currencyCode,
1415
public AccountDomainObject $account,
15-
public OrderDomainObject $order,
16+
public OrderDomainObject $order,
1617
)
1718
{
1819
}

backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,10 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder, Pay
233233
{
234234
$this->orderApplicationFeeService->createOrderApplicationFee(
235235
orderId: $updatedOrder->getId(),
236-
applicationFeeAmount: $paymentIntent->application_fee_amount / 100,
236+
applicationFeeAmountMinorUnit: $paymentIntent->application_fee_amount,
237237
orderApplicationFeeStatus: OrderApplicationFeeStatus::PAID,
238238
paymentMethod: PaymentProviders::STRIPE,
239+
currency: $updatedOrder->getCurrency(),
239240
);
240241
}
241242
}

backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent
6464

6565
$applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee(
6666
accountConfiguration: $paymentIntentDTO->account->getConfiguration(),
67-
orderTotal: $paymentIntentDTO->amount / 100,
67+
orderTotal: $paymentIntentDTO->amount->toFloat(),
6868
currency: $paymentIntentDTO->currencyCode,
6969
)->toMinorUnit();
7070

7171
$paymentIntent = $this->stripeClient->paymentIntents->create([
72-
'amount' => $paymentIntentDTO->amount,
72+
'amount' => $paymentIntentDTO->amount->toMinorUnit(),
7373
'currency' => $paymentIntentDTO->currencyCode,
7474
'customer' => $this->upsertStripeCustomer($paymentIntentDTO)->getStripeCustomerId(),
7575
'metadata' => [
@@ -103,6 +103,8 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent
103103
'paymentIntentDTO' => $paymentIntentDTO->toArray(['account']),
104104
]);
105105

106+
$this->databaseManager->rollBack();
107+
106108
throw new CreatePaymentIntentFailedException(
107109
__('There was an error communicating with the payment provider. Please try again later.')
108110
);
@@ -113,18 +115,6 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent
113115
}
114116
}
115117

116-
private function getApplicationFee(CreatePaymentIntentRequestDTO $paymentIntentDTO): float
117-
{
118-
if (!$this->config->get('app.saas_mode_enabled')) {
119-
return 0;
120-
}
121-
122-
$fixedFee = $paymentIntentDTO->account->getApplicationFee()->fixedFee;
123-
$percentageFee = $paymentIntentDTO->account->getApplicationFee()->percentageFee;
124-
125-
return ceil(($fixedFee * 100) + ($paymentIntentDTO->amount * $percentageFee / 100));
126-
}
127-
128118
/**
129119
* @throws CreatePaymentIntentFailedException
130120
*/
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace HiEvents\Services\Infrastructure\CurrencyConversion;
4+
5+
use Brick\Money\Currency;
6+
use HiEvents\Values\MoneyValue;
7+
8+
interface CurrencyConversionClientInterface
9+
{
10+
public function convert(Currency $fromCurrency, Currency $toCurrency, float $amount): MoneyValue;
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace HiEvents\Services\Infrastructure\CurrencyConversion\Exception;
4+
5+
use Exception;
6+
7+
class CurrencyConversionErrorException extends Exception
8+
{
9+
10+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace HiEvents\Services\Infrastructure\CurrencyConversion;
4+
5+
use Brick\Money\Currency;
6+
use HiEvents\Values\MoneyValue;
7+
use Psr\Log\LoggerInterface;
8+
9+
class NoOpCurrencyConversionClient implements CurrencyConversionClientInterface
10+
{
11+
private LoggerInterface $logger;
12+
13+
public function __construct(LoggerInterface $logger)
14+
{
15+
$this->logger = $logger;
16+
}
17+
18+
public function convert(Currency $fromCurrency, Currency $toCurrency, float $amount): MoneyValue
19+
{
20+
$this->logger->warning(
21+
'NoOpCurrencyConversionClient is being used. This should only be used as a last resort fallback. Never in production.',
22+
[
23+
'fromCurrency' => $fromCurrency->getCurrencyCode(),
24+
'toCurrency' => $toCurrency->getCurrencyCode(),
25+
'amount' => $amount,
26+
]
27+
);
28+
29+
return MoneyValue::fromFloat($amount, $toCurrency->getCurrencyCode());
30+
}
31+
}

0 commit comments

Comments
 (0)