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') }} +

+ + {% endif %} diff --git a/src/templates/settings/stores/_edit.twig b/src/templates/settings/stores/_edit.twig index 0fdd78042a..c1499c58f5 100644 --- a/src/templates/settings/stores/_edit.twig +++ b/src/templates/settings/stores/_edit.twig @@ -234,6 +234,17 @@ disabled: readOnly, }) }} + {{ forms.booleanMenuField({ + label: 'Use Payment Currency Rate Snapshot'|t('commerce'), + instructions: 'Payment currency exchange rates are captured when an order is completed. When this setting is enabled, these snapshotted rates are used for subsequent payments on the order, ensuring consistent pricing even if exchange rates change.'|t('commerce'), + id: 'usesSnapshotPaymentCurrencyRate', + name: 'usesSnapshotPaymentCurrencyRate', + value: store.getUsesSnapshotPaymentCurrencyRate(false), + errors: store.getErrors('usesSnapshotPaymentCurrencyRate'), + includeEnvVars: true, + disabled: readOnly, + }) }} + {{ forms.autosuggestField({ label: 'Order Reference Number Format'|t('commerce'), id: 'orderReferenceFormat', diff --git a/tests/unit/elements/order/OrderPaymentCurrencyRatesTest.php b/tests/unit/elements/order/OrderPaymentCurrencyRatesTest.php new file mode 100644 index 0000000000..9b4090bb39 --- /dev/null +++ b/tests/unit/elements/order/OrderPaymentCurrencyRatesTest.php @@ -0,0 +1,397 @@ + + * @since 5.6 + */ +class OrderPaymentCurrencyRatesTest extends Unit +{ + /** + * @var UnitTester + */ + protected UnitTester $tester; + + /** + * @var Plugin|null + */ + protected ?Plugin $pluginInstance = null; + + /** + * @var array + */ + private array $_deleteElementIds = []; + + /** + * @var Store|null + */ + protected ?Store $primaryStore = null; + + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'stores' => [ + 'class' => StoreFixture::class, + ], + 'orders' => [ + 'class' => OrdersFixture::class, + ], + 'payment-currencies' => [ + 'class' => PaymentCurrenciesFixture::class, + ], + ]; + } + + /** + * Test that payment currency rates are captured when an order is marked as complete. + * + * @group PaymentCurrencyRates + */ + public function testPaymentCurrencyRatesCapturedOnCompletion(): void + { + $order = new Order(); + $order->storeId = $this->primaryStore->id; + $email = 'test-rates@example.com'; + $user = Craft::$app->getUsers()->ensureUserByEmail($email); + $order->setCustomer($user); + + /** @var Order $completedOrder */ + $completedOrder = $this->tester->grabFixture('orders')->getElement('completed-new'); + $lineItem = $completedOrder->getLineItems()[0]; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $lineItem->purchasableId, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + + // Before completion, rates should be null + self::assertNull($order->paymentCurrencyRates); + + // Mark order as complete + self::assertTrue($order->markAsComplete()); + + // After completion, rates should be captured + self::assertNotNull($order->paymentCurrencyRates); + self::assertIsArray($order->paymentCurrencyRates); + + // Get the payment currencies for this store to verify + $paymentCurrencies = $this->pluginInstance->getPaymentCurrencies() + ->getAllPaymentCurrencies($this->primaryStore->id); + + // Should have captured all payment currencies for the store + foreach ($paymentCurrencies as $currency) { + self::assertArrayHasKey($currency->iso, $order->paymentCurrencyRates); + self::assertEquals((float)$currency->rate, $order->paymentCurrencyRates[$currency->iso]); + } + + $this->_deleteElementIds[] = $order->id; + $this->_deleteElementIds[] = $user->id; + } + + /** + * Test the setPaymentCurrencyRates setter with different input types. + * + * @dataProvider setPaymentCurrencyRatesDataProvider + * @group PaymentCurrencyRates + */ + public function testSetPaymentCurrencyRates(mixed $input, ?array $expected): void + { + $order = new Order(); + $order->setPaymentCurrencyRates($input); + + self::assertEquals($expected, $order->paymentCurrencyRates); + } + + /** + * @return array + */ + public function setPaymentCurrencyRatesDataProvider(): array + { + return [ + 'null-input' => [ + null, + null, + ], + 'array-input' => [ + ['EUR' => 0.85, 'GBP' => 0.73], + ['EUR' => 0.85, 'GBP' => 0.73], + ], + 'json-string-input' => [ + '{"EUR":0.85,"GBP":0.73}', + ['EUR' => 0.85, 'GBP' => 0.73], + ], + 'empty-array' => [ + [], + [], + ], + 'empty-json' => [ + '{}', + [], + ], + ]; + } + + /** + * Test that getPaymentAmount uses snapshotted rates when store setting is enabled. + * + * @group PaymentCurrencyRates + */ + public function testGetPaymentAmountUsesSnapshotRateWhenEnabled(): void + { + // Get any non-primary payment currency from the store + $paymentCurrencies = $this->pluginInstance->getPaymentCurrencies() + ->getAllPaymentCurrencies($this->primaryStore->id); + $testCurrency = $paymentCurrencies->firstWhere('primary', false); + + // Skip test if no non-primary payment currency available + if (!$testCurrency) { + $this->markTestSkipped('No non-primary payment currency available in fixtures'); + } + + $order = new Order(); + $order->id = 9999; + $order->storeId = $this->primaryStore->id; + $order->isCompleted = true; + $order->currency = 'USD'; + + // Set up a line item with a known price + $lineItem = new LineItem(); + $lineItem->price = 100; + $lineItem->qty = 1; + $order->setLineItems([$lineItem]); + + // Set snapshotted rate (different from current rate) + $snapshotRate = 0.8; + $order->setPaymentCurrencyRates([$testCurrency->iso => $snapshotRate]); + + // Set payment currency + $order->setPaymentCurrency($testCurrency->iso); + + // Enable the snapshot setting on the store + $this->primaryStore->setUsesSnapshotPaymentCurrencyRate(true); + + // The payment amount should use the snapshotted rate + // Outstanding balance is 100 USD, snapshotted rate is 0.8 + // So payment amount should be 100 * 0.8 = 80 + $paymentAmount = $order->getPaymentAmount(); + + self::assertEquals(80, $paymentAmount); + } + + /** + * Test that getPaymentAmount uses current rates when store setting is disabled. + * + * @group PaymentCurrencyRates + */ + public function testGetPaymentAmountUsesCurrentRateWhenDisabled(): void + { + // Get any non-primary payment currency from the store + $paymentCurrencies = $this->pluginInstance->getPaymentCurrencies() + ->getAllPaymentCurrencies($this->primaryStore->id); + $testCurrency = $paymentCurrencies->firstWhere('primary', false); + + // Skip test if no non-primary payment currency available + if (!$testCurrency) { + $this->markTestSkipped('No non-primary payment currency available in fixtures'); + } + + $order = new Order(); + $order->id = 9998; + $order->storeId = $this->primaryStore->id; + $order->isCompleted = true; + $order->currency = 'USD'; + + // Set up a line item with a known price + $lineItem = new LineItem(); + $lineItem->price = 100; + $lineItem->qty = 1; + $order->setLineItems([$lineItem]); + + // Set snapshotted rate (different from current rate) + $snapshotRate = (float)$testCurrency->rate + 0.5; // Different from current + $order->setPaymentCurrencyRates([$testCurrency->iso => $snapshotRate]); + + // Set payment currency + $order->setPaymentCurrency($testCurrency->iso); + + // Disable the snapshot setting on the store + $this->primaryStore->setUsesSnapshotPaymentCurrencyRate(false); + + // The payment amount should use the current rate (not snapshotted) + $expectedAmount = 100 * (float)$testCurrency->rate; + $paymentAmount = $order->getPaymentAmount(); + + self::assertEquals($expectedAmount, $paymentAmount); + } + + /** + * Test that incomplete orders don't use snapshotted rates even if setting is enabled. + * + * @group PaymentCurrencyRates + */ + public function testIncompleteOrderDoesNotUseSnapshotRate(): void + { + // Get any non-primary payment currency from the store + $paymentCurrencies = $this->pluginInstance->getPaymentCurrencies() + ->getAllPaymentCurrencies($this->primaryStore->id); + $testCurrency = $paymentCurrencies->firstWhere('primary', false); + + // Skip test if no non-primary payment currency available + if (!$testCurrency) { + $this->markTestSkipped('No non-primary payment currency available in fixtures'); + } + + $order = new Order(); + $order->id = 9997; + $order->storeId = $this->primaryStore->id; + $order->isCompleted = false; // Order not completed + $order->currency = 'USD'; + + // Set up a line item with a known price + $lineItem = new LineItem(); + $lineItem->price = 100; + $lineItem->qty = 1; + $order->setLineItems([$lineItem]); + + // Set snapshotted rate (shouldn't be used because order is not completed) + $snapshotRate = (float)$testCurrency->rate + 0.5; // Different from current + $order->setPaymentCurrencyRates([$testCurrency->iso => $snapshotRate]); + + // Set payment currency + $order->setPaymentCurrency($testCurrency->iso); + + // Enable the snapshot setting on the store + $this->primaryStore->setUsesSnapshotPaymentCurrencyRate(true); + + // The payment amount should use the current rate because order is not completed + $expectedAmount = 100 * (float)$testCurrency->rate; + $paymentAmount = $order->getPaymentAmount(); + + self::assertEquals($expectedAmount, $paymentAmount); + } + + /** + * Test that orders without snapshotted rates fall back to current rates. + * + * @group PaymentCurrencyRates + */ + public function testOrderWithoutSnapshotFallsBackToCurrentRate(): void + { + // Get any non-primary payment currency from the store + $paymentCurrencies = $this->pluginInstance->getPaymentCurrencies() + ->getAllPaymentCurrencies($this->primaryStore->id); + $testCurrency = $paymentCurrencies->firstWhere('primary', false); + + // Skip test if no non-primary payment currency available + if (!$testCurrency) { + $this->markTestSkipped('No non-primary payment currency available in fixtures'); + } + + $order = new Order(); + $order->id = 9996; + $order->storeId = $this->primaryStore->id; + $order->isCompleted = true; + $order->currency = 'USD'; + + // Set up a line item with a known price + $lineItem = new LineItem(); + $lineItem->price = 100; + $lineItem->qty = 1; + $order->setLineItems([$lineItem]); + + // Don't set snapshotted rates (simulating old order before feature) + $order->paymentCurrencyRates = null; + + // Set payment currency + $order->setPaymentCurrency($testCurrency->iso); + + // Enable the snapshot setting on the store + $this->primaryStore->setUsesSnapshotPaymentCurrencyRate(true); + + // The payment amount should use the current rate because no snapshot exists + $expectedAmount = 100 * (float)$testCurrency->rate; + $paymentAmount = $order->getPaymentAmount(); + + self::assertEquals($expectedAmount, $paymentAmount); + } + + /** + * Test Store model getter/setter for usesSnapshotPaymentCurrencyRate. + * + * @dataProvider storeUsesSnapshotPaymentCurrencyRateDataProvider + * @group PaymentCurrencyRates + */ + public function testStoreUsesSnapshotPaymentCurrencyRateSetting(bool|string $input, bool $expectedParsed, bool|string $expectedRaw): void + { + $store = $this->pluginInstance->getStores()->getPrimaryStore(); + $store->setUsesSnapshotPaymentCurrencyRate($input); + + // Test parsed value (default behavior) + self::assertEquals($expectedParsed, $store->getUsesSnapshotPaymentCurrencyRate()); + + // Test raw value (unparsed) + self::assertEquals($expectedRaw, $store->getUsesSnapshotPaymentCurrencyRate(false)); + } + + /** + * @return array + */ + public function storeUsesSnapshotPaymentCurrencyRateDataProvider(): array + { + return [ + 'true-boolean' => [true, true, true], + 'false-boolean' => [false, false, false], + 'true-string' => ['true', true, 'true'], + 'false-string' => ['false', false, 'false'], + '1-string' => ['1', true, '1'], + '0-string' => ['0', false, '0'], + ]; + } + + /** + * @inheritdoc + */ + protected function _before(): void + { + parent::_before(); + + $this->pluginInstance = Plugin::getInstance(); + $this->primaryStore = $this->pluginInstance->getStores()->getPrimaryStore(); + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + parent::_after(); + + // Cleanup data. + foreach ($this->_deleteElementIds as $elementId) { + Craft::$app->getElements()->deleteElementById($elementId, null, null, true); + } + } +}