Skip to content
Draft
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
13 changes: 12 additions & 1 deletion CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# WIP Release notes for Commerce 5.6

- Shipping rule categories are now eager loaded on shipping rules automatically. ([#4220](https://github.com/craftcms/commerce/issues/4220))
- Added `craft\commerce\services\ShippingRuleCategories::getShippingRuleCategoriesByRuleIds()`.
- Added `craft\commerce\services\ShippingRuleCategories::getShippingRuleCategoriesByRuleIds()`.

### Store Management
- Added a new "Use Payment Currency Rate Snapshot" store setting. Payment currency rates are now snapshotted when an order is completed, and when this setting is enabled, subsequent payments use the snapshotted exchange rates instead of current rates.
- Snapshotted payment currency rates are now displayed in the Transactions tab on order edit pages, with a comparison to current rates.

### Extensibility
- Added `craft\commerce\elements\Order::$paymentCurrencyRates`.
- Added `craft\commerce\elements\Order::setPaymentCurrencyRates()`.
- Added `craft\commerce\elements\db\OrderQuery::$paymentCurrencyRates`.
- Added `craft\commerce\models\Store::getUsesSnapshotPaymentCurrencyRate()`.
- Added `craft\commerce\models\Store::setUsesSnapshotPaymentCurrencyRate()`.
14 changes: 13 additions & 1 deletion src/controllers/OrdersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1327,7 +1327,19 @@ public function actionPaymentAmountData(): Response
$paymentAmount = MoneyHelper::toMoney(['value' => $paymentAmount, 'currency' => $baseCurrency, 'locale' => $locale]);
$paymentAmount = MoneyHelper::toDecimal($paymentAmount);

$baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency((float)$paymentAmount, $paymentCurrency, $baseCurrency);
// Check if we should use snapshotted rates
$useSnapshotRate = $order->isCompleted
&& $order->paymentCurrencyRates !== null
&& $order->getStore()->getUsesSnapshotPaymentCurrencyRate()
&& isset($order->paymentCurrencyRates[$paymentCurrency]);

if ($useSnapshotRate) {
// Convert back to base currency using the inverse of the snapshotted rate
$snapshotRate = $order->paymentCurrencyRates[$paymentCurrency];
$baseCurrencyPaymentAmount = (float)$paymentAmount / $snapshotRate;
} else {
$baseCurrencyPaymentAmount = $paymentCurrencies->convertCurrency((float)$paymentAmount, $paymentCurrency, $baseCurrency);
}
$baseCurrencyPaymentAmountAsCurrency = Craft::t('commerce', 'Pay {amount} of {currency} on the order.', ['amount' => Currency::formatAsCurrency($baseCurrencyPaymentAmount, $baseCurrency), 'currency' => $baseCurrency]);

$outstandingBalance = $order->outstandingBalance;
Expand Down
1 change: 1 addition & 0 deletions src/controllers/StoresController.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ public function actionSaveStore(): ?Response
$store->setRequireShippingMethodSelectionAtCheckout($this->request->getBodyParam('requireShippingMethodSelectionAtCheckout'));
$store->setUseBillingAddressForTax($this->request->getBodyParam('useBillingAddressForTax'));
$store->setValidateOrganizationTaxIdAsVatId($this->request->getBodyParam('validateOrganizationTaxIdAsVatId'));
$store->setUsesSnapshotPaymentCurrencyRate($this->request->getBodyParam('usesSnapshotPaymentCurrencyRate'));
$store->setOrderReferenceFormat($this->request->getBodyParam('orderReferenceFormat'));
$store->setFreeOrderPaymentStrategy($this->request->getBodyParam('freeOrderPaymentStrategy'));
$store->setMinimumTotalPriceStrategy($this->request->getBodyParam('minimumTotalPriceStrategy'));
Expand Down
68 changes: 63 additions & 5 deletions src/elements/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,21 @@ class Order extends Element implements HasStoreInterface
*/
public ?int $storedTotalQty = null;

/**
* The payment currency rates at the time the order was completed. Used to lock in exchange rates for subsequent payments.
* Stored as an associative array keyed by currency ISO code, e.g. ['EUR' => 0.85, 'GBP' => 0.73]
*
* @var array|null
* ---
* ```php
* echo $order->paymentCurrencyRates['EUR'];
* ```
* ```twig
* {{ order.paymentCurrencyRates['EUR'] }}
* ```
*/
public ?array $paymentCurrencyRates = null;

/**
* @var string|null
* @see Order::setRecalculationMode() To set the current recalculation mode
Expand Down Expand Up @@ -1827,6 +1842,16 @@ public function markAsComplete(): bool
$this->estimatedBillingAddressId = null;
$this->orderCompletedEmail = $this->getEmail();

// Capture all payment currency rates for subsequent payments
$paymentCurrencies = Plugin::getInstance()->getPaymentCurrencies()
->getAllPaymentCurrencies($this->getStore()->id);
if ($paymentCurrencies->isNotEmpty()) {
$this->paymentCurrencyRates = [];
foreach ($paymentCurrencies as $paymentCurrency) {
$this->paymentCurrencyRates[$paymentCurrency->iso] = (float)$paymentCurrency->rate;
}
}

$orderStatus = Plugin::getInstance()->getOrderStatuses()->getDefaultOrderStatusForOrder($this);

// If the order status returned was overridden by a plugin, use the configured default order status if they give us a bogus one with no ID.
Expand Down Expand Up @@ -2309,6 +2334,7 @@ public function afterSave(bool $isNew): void
$orderRecord->orderSiteId = $this->orderSiteId;
$orderRecord->origin = $this->origin;
$orderRecord->paymentCurrency = $this->paymentCurrency;
$orderRecord->paymentCurrencyRates = $this->paymentCurrencyRates ? json_encode($this->paymentCurrencyRates) : null;
$orderRecord->customerId = $this->getCustomerId();
$orderRecord->registerUserOnOrderComplete = $this->registerUserOnOrderComplete;
$orderRecord->saveBillingAddressOnOrderComplete = $this->saveBillingAddressOnOrderComplete;
Expand Down Expand Up @@ -2649,12 +2675,29 @@ public function getPaymentAmount(): float

// Only convert if we have differing currencies
if ($this->currency !== $this->getPaymentCurrency()) {
$teller = $this->getTeller();
$tellerTo = Plugin::getInstance()->getCurrencies()->getTeller($this->getPaymentCurrency());
$outstandingBalanceAmount = $teller->convertToMoney($this->getOutstandingBalance());
$outstandingBalanceInPaymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->convertAmount($outstandingBalanceAmount, $this->getPaymentCurrency(), $this->getStore()->id);
// Check if we should use snapshotted rates
$useSnapshotRate = $this->isCompleted
&& $this->paymentCurrencyRates !== null
&& $this->getStore()->getUsesSnapshotPaymentCurrencyRate();

$paymentCurrencyIso = $this->getPaymentCurrency();
$snapshotRate = $this->paymentCurrencyRates[$paymentCurrencyIso] ?? null;

if ($useSnapshotRate && $snapshotRate !== null) {
// Use the snapshotted rate for this payment currency
$paymentCurrency = Plugin::getInstance()->getPaymentCurrencies()
->getPaymentCurrencyByIso($paymentCurrencyIso, $this->getStore()->id);
$paymentAmount = $this->getOutstandingBalance() * $snapshotRate;
$paymentAmount = Currency::round($paymentAmount, $paymentCurrency);
} else {
// Fall back to current rate (either setting disabled, no snapshot, or currency not in snapshot)
$teller = $this->getTeller();
$tellerTo = Plugin::getInstance()->getCurrencies()->getTeller($paymentCurrencyIso);
$outstandingBalanceAmount = $teller->convertToMoney($this->getOutstandingBalance());
$outstandingBalanceInPaymentCurrency = Plugin::getInstance()->getPaymentCurrencies()->convertAmount($outstandingBalanceAmount, $paymentCurrencyIso, $this->getStore()->id);

$paymentAmount = (float)$tellerTo->convertToString($outstandingBalanceInPaymentCurrency);
$paymentAmount = (float)$tellerTo->convertToString($outstandingBalanceInPaymentCurrency);
}
}

if (isset($this->_paymentAmount) && $this->_paymentAmount >= 0 && $this->_paymentAmount <= $paymentAmount) {
Expand Down Expand Up @@ -3502,6 +3545,21 @@ public function setPaymentCurrency(string $value): void
$this->_paymentCurrency = $value;
}

/**
* Sets the snapshotted payment currency rates.
* Accepts either an array or a JSON-encoded string (from DB).
*
* @param array|string|null $value
*/
public function setPaymentCurrencyRates(array|string|null $value): void
{
if (is_string($value)) {
$this->paymentCurrencyRates = json_decode($value, true);
} else {
$this->paymentCurrencyRates = $value;
}
}

/**
* Returns the order's selected payment source if any.
*
Expand Down
1 change: 1 addition & 0 deletions src/elements/db/OrderQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,7 @@ protected function beforePrepare(): bool
'commerce_orders.dateFirstPaid',
'commerce_orders.currency',
'commerce_orders.paymentCurrency',
'commerce_orders.paymentCurrencyRates',
'commerce_orders.lastIp',
'commerce_orders.orderLanguage',
'commerce_orders.message',
Expand Down
2 changes: 2 additions & 0 deletions src/migrations/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ public function createTables(): void
'dateAuthorized' => $this->dateTime(),
'currency' => $this->string(),
'paymentCurrency' => $this->string(),
'paymentCurrencyRates' => $this->text(),
'lastIp' => $this->string(),
'orderLanguage' => $this->string(12)->notNull(),
'origin' => $this->enum('origin', ['web', 'cp', 'remote'])->notNull()->defaultValue('web'),
Expand Down Expand Up @@ -896,6 +897,7 @@ public function createTables(): void
'requireShippingMethodSelectionAtCheckout' => $this->string()->notNull()->defaultValue('false'),
'useBillingAddressForTax' => $this->string()->notNull()->defaultValue('false'),
'validateOrganizationTaxIdAsVatId' => $this->string()->notNull()->defaultValue('false'),
'usesSnapshotPaymentCurrencyRate' => $this->string()->notNull()->defaultValue('false'),
'orderReferenceFormat' => $this->string(),
'freeOrderPaymentStrategy' => $this->string()->defaultValue('complete'),
'minimumTotalPriceStrategy' => $this->string()->defaultValue('default'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace craft\commerce\migrations;

use craft\commerce\db\Table;
use craft\db\Migration;

/**
* m250130_000001_add_payment_currency_rate_to_orders migration.
*/
class m250130_000001_add_payment_currency_rate_to_orders extends Migration
{
/**
* @inheritdoc
*/
public function safeUp(): bool
{
if (!$this->db->columnExists(Table::ORDERS, 'paymentCurrencyRates')) {
$this->addColumn(Table::ORDERS, 'paymentCurrencyRates', $this->text()->null());
}

return true;
}

/**
* @inheritdoc
*/
public function safeDown(): bool
{
echo "m250130_000001_add_payment_currency_rate_to_orders cannot be reverted.\n";
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace craft\commerce\migrations;

use craft\commerce\db\Table;
use craft\db\Migration;

/**
* m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores migration.
*/
class m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores extends Migration
{
/**
* @inheritdoc
*/
public function safeUp(): bool
{
if (!$this->db->columnExists(Table::STORES, 'usesSnapshotPaymentCurrencyRate')) {
$this->addColumn(Table::STORES, 'usesSnapshotPaymentCurrencyRate', $this->string()->notNull()->defaultValue('false'));
}

return true;
}

/**
* @inheritdoc
*/
public function safeDown(): bool
{
echo "m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores cannot be reverted.\n";
return false;
}
}
29 changes: 29 additions & 0 deletions src/models/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ public function attributes(): array
*/
private bool|string $_validateOrganizationTaxIdAsVatId = false;

/**
* @var bool
* @see setUsesSnapshotPaymentCurrencyRate()
* @see getUsesSnapshotPaymentCurrencyRate()
*/
private bool|string $_usesSnapshotPaymentCurrencyRate = false;

/**
* @var string
* @see setOrderReferenceFormat()
Expand Down Expand Up @@ -229,6 +236,7 @@ function($attribute) {
'sortOrder',
'uid',
'useBillingAddressForTax',
'usesSnapshotPaymentCurrencyRate',
'validateOrganizationTaxIdAsVatId',
], 'safe'];

Expand Down Expand Up @@ -348,6 +356,7 @@ public function getConfig(): array
'requireShippingMethodSelectionAtCheckout' => $this->getRequireShippingMethodSelectionAtCheckout(false),
'sortOrder' => $this->sortOrder,
'useBillingAddressForTax' => $this->getUseBillingAddressForTax(false),
'usesSnapshotPaymentCurrencyRate' => $this->getUsesSnapshotPaymentCurrencyRate(false),
'validateOrganizationTaxIdAsVatId' => $this->getValidateOrganizationTaxIdAsVatId(false),
'currency' => $this->getCurrency()->getCode(),
];
Expand Down Expand Up @@ -602,6 +611,26 @@ public function getValidateOrganizationTaxIdAsVatId(bool $parse = true): bool|st
return $parse ? (App::parseBooleanEnv($this->_validateOrganizationTaxIdAsVatId) ?? false) : $this->_validateOrganizationTaxIdAsVatId;
}

/**
* @param bool|string $usesSnapshotPaymentCurrencyRate
* @return void
*/
public function setUsesSnapshotPaymentCurrencyRate(bool|string $usesSnapshotPaymentCurrencyRate): void
{
$this->_usesSnapshotPaymentCurrencyRate = $usesSnapshotPaymentCurrencyRate;
}

/**
* Whether to use the snapshotted payment currency rate on order completion for subsequent payments.
*
* @param bool $parse
* @return bool|string
*/
public function getUsesSnapshotPaymentCurrencyRate(bool $parse = true): bool|string
{
return $parse ? App::parseBooleanEnv($this->_usesSnapshotPaymentCurrencyRate) : $this->_usesSnapshotPaymentCurrencyRate;
}

/**
* @param string|null $orderReferenceFormat
* @return void
Expand Down
1 change: 1 addition & 0 deletions src/records/Order.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
* @property int $orderStatusId
* @property string $paidStatus
* @property string $paymentCurrency
* @property string $paymentCurrencyRates
* @property int $paymentSourceId
* @property bool $registerUserOnOrderComplete
* @property bool $saveBillingAddressOnOrderComplete
Expand Down
1 change: 1 addition & 0 deletions src/records/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* @property bool $requireShippingMethodSelectionAtCheckout
* @property bool $useBillingAddressForTax
* @property bool $validateOrganizationTaxIdAsVatId
* @property bool $usesSnapshotPaymentCurrencyRate
* @property bool $autoSetPaymentSource
* @property string $orderReferenceFormat
* @property string $freeOrderPaymentStrategy
Expand Down
2 changes: 2 additions & 0 deletions src/services/Stores.php
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ public function handleChangedStore(ConfigEvent $event): void
$storeRecord->requireShippingMethodSelectionAtCheckout = ($data['requireShippingMethodSelectionAtCheckout'] ?? false);
$storeRecord->useBillingAddressForTax = ($data['useBillingAddressForTax'] ?? false);
$storeRecord->validateOrganizationTaxIdAsVatId = ($data['validateOrganizationTaxIdAsVatId'] ?? false);
$storeRecord->usesSnapshotPaymentCurrencyRate = ($data['usesSnapshotPaymentCurrencyRate'] ?? false);
$storeRecord->freeOrderPaymentStrategy = ($data['freeOrderPaymentStrategy'] ?? 'complete');
$storeRecord->minimumTotalPriceStrategy = ($data['minimumTotalPriceStrategy'] ?? 'default');
$storeRecord->orderReferenceFormat = ($data['orderReferenceFormat'] ?? '{{number[:7]}}');
Expand Down Expand Up @@ -706,6 +707,7 @@ private function _createStoreQuery(): Query
'requireShippingMethodSelectionAtCheckout',
'sortOrder',
'useBillingAddressForTax',
'usesSnapshotPaymentCurrencyRate',
'validateOrganizationTaxIdAsVatId',
]);
}
Expand Down
Loading
Loading