Skip to content

Commit 0a89107

Browse files
authored
Merge pull request #3371 from craftcms/feature/payment-currency-rate-override
[5.7] Allow payment currency rate override
2 parents 23a3a9c + 05d55f7 commit 0a89107

5 files changed

Lines changed: 182 additions & 6 deletions

File tree

CHANGELOG-WIP.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
- Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`.
4141
- Added `craft\commerce\services\Carts::peekCart()`.
4242
- Added `craft\commerce\controllers\CartController::actionPeekCart()`.
43+
- Added `craft\commerce\events\PaymentCurrencyRateEvent`, allowing plugins to override a payment currency's exchange rate at the point of use without affecting the rate stored on the `PaymentCurrency` record.
44+
- Added `craft\commerce\services\PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE`.
45+
- Added `craft\commerce\services\PaymentCurrencies::getRateFor()`.
4346
- Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead.
4447
- Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypes()`. Use `getViewableProductTypes()` instead.
4548
- Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypeIds()`. Use `getViewableProductTypeIds()` instead.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
/**
3+
* @link https://craftcms.com/
4+
* @copyright Copyright (c) Pixel & Tonic, Inc.
5+
* @license https://craftcms.github.io/license/
6+
*/
7+
8+
namespace craft\commerce\events;
9+
10+
use craft\commerce\models\PaymentCurrency;
11+
use craft\commerce\models\Transaction;
12+
use yii\base\Event;
13+
14+
/**
15+
* Payment Currency Rate Event
16+
*
17+
* @since 5.7.0
18+
*/
19+
class PaymentCurrencyRateEvent extends Event
20+
{
21+
/**
22+
* @var float The rate that will be used. Set this to override the rate.
23+
*/
24+
public float $rate;
25+
26+
/**
27+
* @var PaymentCurrency The payment currency the rate is being resolved for.
28+
*/
29+
public PaymentCurrency $paymentCurrency;
30+
31+
/**
32+
* @var Transaction|null The transaction the rate is being resolved for, if any.
33+
*/
34+
public ?Transaction $transaction = null;
35+
}

src/services/PaymentCurrencies.php

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
use Craft;
1111
use craft\commerce\db\Table;
1212
use craft\commerce\errors\CurrencyException;
13+
use craft\commerce\events\PaymentCurrencyRateEvent;
1314
use craft\commerce\helpers\Currency as CurrencyHelper;
1415
use craft\commerce\models\PaymentCurrency;
16+
use craft\commerce\models\Transaction;
1517
use craft\commerce\Plugin;
1618
use craft\commerce\records\PaymentCurrency as PaymentCurrencyRecord;
1719
use craft\db\Query;
@@ -40,11 +42,36 @@
4042
*/
4143
class PaymentCurrencies extends Component
4244
{
45+
/**
46+
* @event PaymentCurrencyRateEvent The event that is triggered when a payment currency rate is being resolved.
47+
* Set `$event->rate` to override the rate used for conversions and historical transaction snapshots.
48+
* @since 5.7.0
49+
*/
50+
public const EVENT_DEFINE_PAYMENT_CURRENCY_RATE = 'definePaymentCurrencyRate';
51+
4352
/**
4453
* @var null|Collection<PaymentCurrency>[]
4554
*/
4655
private ?array $_allPaymentCurrencies = null;
4756

57+
/**
58+
* Returns the rate for a payment currency, after giving event handlers a chance to override it.
59+
*
60+
* @since 5.7.0
61+
*/
62+
public function getRateFor(PaymentCurrency $currency, ?Transaction $transaction = null): float
63+
{
64+
$event = new PaymentCurrencyRateEvent([
65+
'rate' => $currency->rate,
66+
'paymentCurrency' => $currency,
67+
'transaction' => $transaction,
68+
]);
69+
70+
$this->trigger(self::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $event);
71+
72+
return $event->rate;
73+
}
74+
4875
/**
4976
* Get payment currency by its ID.
5077
*
@@ -197,10 +224,10 @@ public function convertCurrency(float $amount, string $fromCurrency, string $toC
197224

198225
if ($this->getPrimaryPaymentCurrency()->iso != $fromCurrency) {
199226
// now the amount is in the primary currency
200-
$amount /= $fromCurrency->rate;
227+
$amount /= $this->getRateFor($fromCurrency);
201228
}
202229

203-
$result = $amount * $toCurrency->rate;
230+
$result = $amount * $this->getRateFor($toCurrency);
204231

205232
if ($round) {
206233
return CurrencyHelper::round($result, $toCurrency);
@@ -276,7 +303,7 @@ private function _getExchange(?int $storeId = null)
276303
$storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id;
277304

278305
$storeCurrency = Plugin::getInstance()->getStores()->getStoreById($storeId)->getCurrency();
279-
$nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId)->mapWithKeys(fn(PaymentCurrency $currency) => [$currency->iso => (string)$currency->rate]);
306+
$nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId)->mapWithKeys(fn(PaymentCurrency $currency) => [$currency->iso => (string)$this->getRateFor($currency)]);
280307

281308
$exchange = [$storeCurrency->getCode() => $nonPrimaryCurrencies->all()];
282309

src/services/Transactions.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,10 @@ public function createTransaction(Order $order = null, Transaction $parentTransa
230230
// Amount is always in the base currency
231231
$transaction->amount = $amount;
232232

233-
// Capture historical rate
234-
$transaction->paymentRate = $paymentCurrency->rate;
235-
236233
$transaction->setOrder($order);
234+
235+
// Capture historical rate
236+
$transaction->paymentRate = Plugin::getInstance()->getPaymentCurrencies()->getRateFor($paymentCurrency, $transaction);
237237
}
238238

239239
$user = Craft::$app->getUser()->getIdentity();

tests/unit/services/PaymentCurrenciesTest.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99

1010
use Codeception\Test\Unit;
1111
use craft\commerce\errors\CurrencyException;
12+
use craft\commerce\events\PaymentCurrencyRateEvent;
1213
use craft\commerce\Plugin;
14+
use craft\commerce\records\PaymentCurrency as PaymentCurrencyRecord;
1315
use craft\commerce\services\PaymentCurrencies;
1416
use craftcommercetests\fixtures\PaymentCurrenciesFixture;
17+
use Money\Currency;
18+
use Money\Money;
1519
use UnitTester;
20+
use yii\base\Event;
1621
use yii\base\InvalidConfigException;
1722

1823
/**
@@ -154,4 +159,110 @@ public function testConvertException(): void
154159
$this->expectException(CurrencyException::class);
155160
$this->pc->convert(20, 'aaa');
156161
}
162+
163+
/**
164+
* @group PaymentCurrencies
165+
*/
166+
public function testGetRateForReturnsRawRateWithoutHandler(): void
167+
{
168+
$eur = $this->pc->getPaymentCurrencyByIso('EUR');
169+
self::assertSame(0.5, $this->pc->getRateFor($eur));
170+
}
171+
172+
/**
173+
* @group PaymentCurrencies
174+
*/
175+
public function testGetRateForReturnsEventRate(): void
176+
{
177+
$handler = static function(PaymentCurrencyRateEvent $event) {
178+
if ($event->paymentCurrency->iso === 'EUR') {
179+
$event->rate = 0.25;
180+
}
181+
};
182+
Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
183+
184+
try {
185+
$eur = $this->pc->getPaymentCurrencyByIso('EUR');
186+
self::assertSame(0.25, $this->pc->getRateFor($eur));
187+
188+
$aud = $this->pc->getPaymentCurrencyByIso('AUD');
189+
self::assertSame(1.3, $this->pc->getRateFor($aud), 'Untouched currencies fall through to the raw rate.');
190+
} finally {
191+
Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
192+
}
193+
}
194+
195+
/**
196+
* @group PaymentCurrencies
197+
*/
198+
public function testConvertCurrencyUsesEventRate(): void
199+
{
200+
$handler = static function(PaymentCurrencyRateEvent $event) {
201+
if ($event->paymentCurrency->iso === 'EUR') {
202+
$event->rate = 0.25;
203+
}
204+
};
205+
Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
206+
207+
try {
208+
$converted = $this->pc->convertCurrency(40, $this->pc->getPrimaryPaymentCurrencyIso(), 'EUR');
209+
self::assertSame(10.0, $converted);
210+
} finally {
211+
Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
212+
}
213+
}
214+
215+
/**
216+
* @group PaymentCurrencies
217+
*/
218+
public function testConvertAmountUsesEventRate(): void
219+
{
220+
$handler = static function(PaymentCurrencyRateEvent $event) {
221+
if ($event->paymentCurrency->iso === 'EUR') {
222+
$event->rate = 0.25;
223+
}
224+
};
225+
Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
226+
227+
try {
228+
$usd = new Money(4000, new Currency('USD'));
229+
$converted = $this->pc->convertAmount($usd, 'EUR');
230+
self::assertSame('EUR', $converted->getCurrency()->getCode());
231+
self::assertSame('1000', $converted->getAmount());
232+
} finally {
233+
Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
234+
}
235+
}
236+
237+
/**
238+
* The event must not affect the rate that gets persisted when saving a
239+
* payment currency — saving isn't a conversion.
240+
*
241+
* @group PaymentCurrencies
242+
*/
243+
public function testSavePaymentCurrencyIgnoresEventRate(): void
244+
{
245+
$handler = static function(PaymentCurrencyRateEvent $event) {
246+
$event->rate = 999.0;
247+
};
248+
Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
249+
250+
try {
251+
$eur = $this->pc->getPaymentCurrencyByIso('EUR');
252+
$originalRate = $eur->rate;
253+
$eur->rate = 0.75;
254+
255+
self::assertTrue($this->pc->savePaymentCurrency($eur));
256+
257+
$record = PaymentCurrencyRecord::findOne(['id' => $eur->id]);
258+
self::assertNotNull($record);
259+
self::assertEquals(0.75, $record->rate, 'Raw admin-entered rate is persisted, not the event rate.');
260+
261+
// Restore for any tests that run after this one without isolation.
262+
$eur->rate = $originalRate;
263+
$this->pc->savePaymentCurrency($eur);
264+
} finally {
265+
Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler);
266+
}
267+
}
157268
}

0 commit comments

Comments
 (0)