diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 2dc3a68d9f..a1e2546aa7 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -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()`. \ No newline at end of file +- 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()`. diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php index ebd079ac65..2f3eeaab3a 100644 --- a/src/controllers/OrdersController.php +++ b/src/controllers/OrdersController.php @@ -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; diff --git a/src/controllers/StoresController.php b/src/controllers/StoresController.php index 6c48478a01..8b75b79f9a 100644 --- a/src/controllers/StoresController.php +++ b/src/controllers/StoresController.php @@ -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')); diff --git a/src/elements/Order.php b/src/elements/Order.php index d912957597..88ab9fe69e 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -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 @@ -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. @@ -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; @@ -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) { @@ -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. * diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php index 886798dbba..72d2ca4c0c 100644 --- a/src/elements/db/OrderQuery.php +++ b/src/elements/db/OrderQuery.php @@ -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', diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 784645d8e2..6d489c6cb1 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -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'), @@ -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'), diff --git a/src/migrations/m250130_000001_add_payment_currency_rate_to_orders.php b/src/migrations/m250130_000001_add_payment_currency_rate_to_orders.php new file mode 100644 index 0000000000..3070828f3e --- /dev/null +++ b/src/migrations/m250130_000001_add_payment_currency_rate_to_orders.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/src/migrations/m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores.php b/src/migrations/m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores.php new file mode 100644 index 0000000000..b15df5b539 --- /dev/null +++ b/src/migrations/m250130_000002_add_uses_snapshot_payment_currency_rate_to_stores.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/src/models/Store.php b/src/models/Store.php index 468eca6112..7f93b82567 100644 --- a/src/models/Store.php +++ b/src/models/Store.php @@ -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() @@ -229,6 +236,7 @@ function($attribute) { 'sortOrder', 'uid', 'useBillingAddressForTax', + 'usesSnapshotPaymentCurrencyRate', 'validateOrganizationTaxIdAsVatId', ], 'safe']; @@ -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(), ]; @@ -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 diff --git a/src/records/Order.php b/src/records/Order.php index 7adc699b5a..0630224b08 100644 --- a/src/records/Order.php +++ b/src/records/Order.php @@ -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 diff --git a/src/records/Store.php b/src/records/Store.php index eeb2735eee..7fb69b018d 100644 --- a/src/records/Store.php +++ b/src/records/Store.php @@ -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 diff --git a/src/services/Stores.php b/src/services/Stores.php index b6f40e5eda..3e2419f4aa 100644 --- a/src/services/Stores.php +++ b/src/services/Stores.php @@ -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]}}'); @@ -706,6 +707,7 @@ private function _createStoreQuery(): Query 'requireShippingMethodSelectionAtCheckout', 'sortOrder', 'useBillingAddressForTax', + 'usesSnapshotPaymentCurrencyRate', 'validateOrganizationTaxIdAsVatId', ]); } diff --git a/src/templates/orders/_transactions.twig b/src/templates/orders/_transactions.twig index f9b8ded656..9f2bf89902 100644 --- a/src/templates/orders/_transactions.twig +++ b/src/templates/orders/_transactions.twig @@ -1,12 +1,20 @@ {% do view.registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset') -%} {% do view.registerTranslations('commerce', [ 'Amount', + 'Current Rate', + 'Currency', 'Date', + 'Difference', 'Gateway', 'info', + 'N/A', 'No transactions exist.', 'Payment Amount', + 'Show snapshotted payment currency rates', + 'Snapshotted Rate', 'Status', + 'These exchange rates were captured when the order was completed and are used for subsequent payments.', + 'These exchange rates were captured when the order was completed and are used for subsequent payments. This behavior is controlled by the {link}Use Payment Currency Rate Snapshot{endlink} store setting.', 'Type', ]) %} @@ -19,6 +27,60 @@ {{ 'Make a payment'|t('commerce') }} {% endif %} + + {% if order.isCompleted and order.paymentCurrencyRates is not empty and order.store.getUsesSnapshotPaymentCurrencyRate() %} +
+ {{ 'Show snapshotted payment currency rates'|t('commerce') }} +
+{{ 'These exchange rates were captured when the order was completed and are used for subsequent payments. This behavior is controlled by the {link}Use Payment Currency Rate Snapshot{endlink} store setting.'|t('commerce', { + link: '', + endlink: '' + })|raw }}
+ {% else %} +{{ 'These exchange rates were captured when the order was completed and are used for subsequent payments.'|t('commerce') }}
+ {% endif %} + {% set currentPaymentCurrencies = craft.commerce.paymentCurrencies.getAllPaymentCurrencies(order.store.id) %} +| {{ 'Currency'|t('commerce') }} | +{{ 'Snapshotted Rate'|t('commerce') }} | +{{ 'Current Rate'|t('commerce') }} | +{{ 'Difference'|t('commerce') }} | +
|---|---|---|---|
| {{ iso }} | +{{ rate|number_format(4) }} | ++ {% if currentRate is not null %} + {{ currentRate|number_format(4) }} + {% else %} + {{ 'N/A'|t('commerce') }} + {% endif %} + | ++ {% if currentRate is not null and currentRate != rate %} + {{ currentRate > rate ? '+' : '' }}{{ ((currentRate - rate) / rate * 100)|number_format(2) }}% + {% elseif currentRate is not null %} + — + {% else %} + {{ 'N/A'|t('commerce') }} + {% endif %} + | +