Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions src/events/PaymentCurrencyRateEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\commerce\events;

use craft\commerce\models\PaymentCurrency;
use craft\commerce\models\Transaction;
use yii\base\Event;

/**
* Payment Currency Rate Event
*
* @since 5.7.0
*/
class PaymentCurrencyRateEvent extends Event
{
/**
* @var float The rate that will be used. Set this to override the rate.
*/
public float $rate;

/**
* @var PaymentCurrency The payment currency the rate is being resolved for.
*/
public PaymentCurrency $paymentCurrency;

/**
* @var Transaction|null The transaction the rate is being resolved for, if any.
*/
public ?Transaction $transaction = null;
}
33 changes: 30 additions & 3 deletions src/services/PaymentCurrencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
use Craft;
use craft\commerce\db\Table;
use craft\commerce\errors\CurrencyException;
use craft\commerce\events\PaymentCurrencyRateEvent;
use craft\commerce\helpers\Currency as CurrencyHelper;
use craft\commerce\models\PaymentCurrency;
use craft\commerce\models\Transaction;
use craft\commerce\Plugin;
use craft\commerce\records\PaymentCurrency as PaymentCurrencyRecord;
use craft\db\Query;
Expand Down Expand Up @@ -40,11 +42,36 @@
*/
class PaymentCurrencies extends Component
{
/**
* @event PaymentCurrencyRateEvent The event that is triggered when a payment currency rate is being resolved.
* Set `$event->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<PaymentCurrency>[]
*/
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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()];

Expand Down
6 changes: 3 additions & 3 deletions src/services/Transactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/services/PaymentCurrenciesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}
}
}
Loading