diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 9c258c71a4..7fc8dace88 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -40,6 +40,9 @@ - Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. - Added `craft\commerce\services\Carts::peekCart()`. - Added `craft\commerce\controllers\CartController::actionPeekCart()`. +- 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. +- Added `craft\commerce\services\PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE`. +- Added `craft\commerce\services\PaymentCurrencies::getRateFor()`. - Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypes()`. Use `getViewableProductTypes()` instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypeIds()`. Use `getViewableProductTypeIds()` instead. diff --git a/src/events/PaymentCurrencyRateEvent.php b/src/events/PaymentCurrencyRateEvent.php new file mode 100644 index 0000000000..87200be420 --- /dev/null +++ b/src/events/PaymentCurrencyRateEvent.php @@ -0,0 +1,35 @@ +rate` to override the rate used for conversions and historical transaction snapshots. + * @since 5.7.0 + */ + public const EVENT_DEFINE_PAYMENT_CURRENCY_RATE = 'definePaymentCurrencyRate'; + /** * @var null|Collection[] */ private ?array $_allPaymentCurrencies = null; + /** + * Returns the rate for a payment currency, after giving event handlers a chance to override it. + * + * @since 5.7.0 + */ + public function getRateFor(PaymentCurrency $currency, ?Transaction $transaction = null): float + { + $event = new PaymentCurrencyRateEvent([ + 'rate' => $currency->rate, + 'paymentCurrency' => $currency, + 'transaction' => $transaction, + ]); + + $this->trigger(self::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $event); + + return $event->rate; + } + /** * Get payment currency by its ID. * @@ -197,10 +224,10 @@ public function convertCurrency(float $amount, string $fromCurrency, string $toC if ($this->getPrimaryPaymentCurrency()->iso != $fromCurrency) { // now the amount is in the primary currency - $amount /= $fromCurrency->rate; + $amount /= $this->getRateFor($fromCurrency); } - $result = $amount * $toCurrency->rate; + $result = $amount * $this->getRateFor($toCurrency); if ($round) { return CurrencyHelper::round($result, $toCurrency); @@ -276,7 +303,7 @@ private function _getExchange(?int $storeId = null) $storeId ??= Plugin::getInstance()->getStores()->getCurrentStore()->id; $storeCurrency = Plugin::getInstance()->getStores()->getStoreById($storeId)->getCurrency(); - $nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId)->mapWithKeys(fn(PaymentCurrency $currency) => [$currency->iso => (string)$currency->rate]); + $nonPrimaryCurrencies = $this->getNonPrimaryPaymentCurrencies($storeId)->mapWithKeys(fn(PaymentCurrency $currency) => [$currency->iso => (string)$this->getRateFor($currency)]); $exchange = [$storeCurrency->getCode() => $nonPrimaryCurrencies->all()]; diff --git a/src/services/Transactions.php b/src/services/Transactions.php index 55d5ba4b51..f994771c60 100644 --- a/src/services/Transactions.php +++ b/src/services/Transactions.php @@ -230,10 +230,10 @@ public function createTransaction(Order $order = null, Transaction $parentTransa // Amount is always in the base currency $transaction->amount = $amount; - // Capture historical rate - $transaction->paymentRate = $paymentCurrency->rate; - $transaction->setOrder($order); + + // Capture historical rate + $transaction->paymentRate = Plugin::getInstance()->getPaymentCurrencies()->getRateFor($paymentCurrency, $transaction); } $user = Craft::$app->getUser()->getIdentity(); diff --git a/tests/unit/services/PaymentCurrenciesTest.php b/tests/unit/services/PaymentCurrenciesTest.php index 3259b1f400..7988b7385b 100644 --- a/tests/unit/services/PaymentCurrenciesTest.php +++ b/tests/unit/services/PaymentCurrenciesTest.php @@ -9,10 +9,15 @@ use Codeception\Test\Unit; use craft\commerce\errors\CurrencyException; +use craft\commerce\events\PaymentCurrencyRateEvent; use craft\commerce\Plugin; +use craft\commerce\records\PaymentCurrency as PaymentCurrencyRecord; use craft\commerce\services\PaymentCurrencies; use craftcommercetests\fixtures\PaymentCurrenciesFixture; +use Money\Currency; +use Money\Money; use UnitTester; +use yii\base\Event; use yii\base\InvalidConfigException; /** @@ -154,4 +159,110 @@ public function testConvertException(): void $this->expectException(CurrencyException::class); $this->pc->convert(20, 'aaa'); } + + /** + * @group PaymentCurrencies + */ + public function testGetRateForReturnsRawRateWithoutHandler(): void + { + $eur = $this->pc->getPaymentCurrencyByIso('EUR'); + self::assertSame(0.5, $this->pc->getRateFor($eur)); + } + + /** + * @group PaymentCurrencies + */ + public function testGetRateForReturnsEventRate(): void + { + $handler = static function(PaymentCurrencyRateEvent $event) { + if ($event->paymentCurrency->iso === 'EUR') { + $event->rate = 0.25; + } + }; + Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + + try { + $eur = $this->pc->getPaymentCurrencyByIso('EUR'); + self::assertSame(0.25, $this->pc->getRateFor($eur)); + + $aud = $this->pc->getPaymentCurrencyByIso('AUD'); + self::assertSame(1.3, $this->pc->getRateFor($aud), 'Untouched currencies fall through to the raw rate.'); + } finally { + Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + } + } + + /** + * @group PaymentCurrencies + */ + public function testConvertCurrencyUsesEventRate(): void + { + $handler = static function(PaymentCurrencyRateEvent $event) { + if ($event->paymentCurrency->iso === 'EUR') { + $event->rate = 0.25; + } + }; + Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + + try { + $converted = $this->pc->convertCurrency(40, $this->pc->getPrimaryPaymentCurrencyIso(), 'EUR'); + self::assertSame(10.0, $converted); + } finally { + Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + } + } + + /** + * @group PaymentCurrencies + */ + public function testConvertAmountUsesEventRate(): void + { + $handler = static function(PaymentCurrencyRateEvent $event) { + if ($event->paymentCurrency->iso === 'EUR') { + $event->rate = 0.25; + } + }; + Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + + try { + $usd = new Money(4000, new Currency('USD')); + $converted = $this->pc->convertAmount($usd, 'EUR'); + self::assertSame('EUR', $converted->getCurrency()->getCode()); + self::assertSame('1000', $converted->getAmount()); + } finally { + Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + } + } + + /** + * The event must not affect the rate that gets persisted when saving a + * payment currency — saving isn't a conversion. + * + * @group PaymentCurrencies + */ + public function testSavePaymentCurrencyIgnoresEventRate(): void + { + $handler = static function(PaymentCurrencyRateEvent $event) { + $event->rate = 999.0; + }; + Event::on(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + + try { + $eur = $this->pc->getPaymentCurrencyByIso('EUR'); + $originalRate = $eur->rate; + $eur->rate = 0.75; + + self::assertTrue($this->pc->savePaymentCurrency($eur)); + + $record = PaymentCurrencyRecord::findOne(['id' => $eur->id]); + self::assertNotNull($record); + self::assertEquals(0.75, $record->rate, 'Raw admin-entered rate is persisted, not the event rate.'); + + // Restore for any tests that run after this one without isolation. + $eur->rate = $originalRate; + $this->pc->savePaymentCurrency($eur); + } finally { + Event::off(PaymentCurrencies::class, PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE, $handler); + } + } }