Skip to content

Commit 4810b37

Browse files
dgjlindsayclaude
andcommitted
ABN-298/feat: currency-aware surcharge fixed fees with FX conversion
Fixed fees and limits in the surcharge grid are now saved with the store's base currency at time of configuration. When a sale uses a different currency, Magento's built-in exchange rates convert the amounts; orders are rejected if no rate exists. The admin grid headers now display the active currency code. Explicit zero limits are respected (null = no cap, 0 = cap at zero). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 40ddf19 commit 4810b37

8 files changed

Lines changed: 317 additions & 25 deletions

File tree

‎Api/Config/RepositoryInterface.php‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ interface RepositoryInterface
4646
public const XML_PATH_SURCHARGE_DIFFERENTIAL = 'payment/two_payment/surcharge_differential';
4747
public const XML_PATH_SURCHARGE_LINE_DESCRIPTION = 'payment/two_payment/surcharge_line_description';
4848
public const XML_PATH_SURCHARGE_TAX_RATE = 'payment/two_payment/surcharge_tax_rate';
49+
public const XML_PATH_SURCHARGE_FIXED_CURRENCY = 'payment/two_payment/surcharge_fixed_currency';
4950
public const XML_PATH_DEFAULT_PRODUCT_TAX_CLASS = 'tax/classes/default_product_tax_class';
5051
public const XML_PATH_VERSION = 'payment/two_payment/version';
5152
public const XML_PATH_DEBUG = 'payment/two_payment/debug';
@@ -352,7 +353,18 @@ public function getSurchargeTaxRate(?int $storeId = null): float;
352353
* @param int $days
353354
* @param int|null $storeId
354355
*
355-
* @return array{percentage: int, fixed: int, limit: float}
356+
* @return array{percentage: int, fixed: int, limit: float|null}
356357
*/
357358
public function getSurchargeConfig(int $days, ?int $storeId = null): array;
359+
360+
/**
361+
* Get the currency code in which surcharge fixed amounts were saved.
362+
*
363+
* Returns empty string if no currency was recorded (legacy data).
364+
*
365+
* @param int|null $storeId
366+
*
367+
* @return string
368+
*/
369+
public function getSurchargeFixedCurrency(?int $storeId = null): string;
358370
}

‎Block/Adminhtml/System/Config/Field/SurchargeGrid.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Magento\Config\Block\System\Config\Form\Field;
1212
use Magento\Framework\App\Config\ScopeConfigInterface;
1313
use Magento\Framework\Data\Form\Element\AbstractElement;
14+
use Magento\Store\Model\StoreManagerInterface;
1415
use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository;
1516

1617
/**
@@ -28,6 +29,9 @@ class SurchargeGrid extends Field
2829
/** @var ScopeConfigInterface */
2930
private $scopeConfig;
3031

32+
/** @var StoreManagerInterface */
33+
private $storeManager;
34+
3135
/** @var string */
3236
private $scope = 'default';
3337

@@ -37,10 +41,12 @@ class SurchargeGrid extends Field
3741
public function __construct(
3842
Context $context,
3943
ScopeConfigInterface $scopeConfig,
44+
StoreManagerInterface $storeManager,
4045
array $data = []
4146
) {
4247
parent::__construct($context, $data);
4348
$this->scopeConfig = $scopeConfig;
49+
$this->storeManager = $storeManager;
4450
}
4551

4652
/**
@@ -115,6 +121,26 @@ public function getMaxPercentage(): int
115121
return ConfigRepository::SURCHARGE_PERCENTAGE_MAX;
116122
}
117123

124+
/**
125+
* Get the base currency code for the current scope.
126+
*/
127+
public function getBaseCurrencyCode(): string
128+
{
129+
if ($this->scope !== 'default' && $this->scopeId > 0) {
130+
try {
131+
if ($this->scope === 'stores') {
132+
return $this->storeManager->getStore($this->scopeId)->getBaseCurrencyCode();
133+
}
134+
if ($this->scope === 'websites') {
135+
return $this->storeManager->getWebsite($this->scopeId)->getBaseCurrencyCode();
136+
}
137+
} catch (\Exception $e) {
138+
// Fall through to default
139+
}
140+
}
141+
return (string)$this->scopeConfig->getValue('currency/options/base') ?: 'USD';
142+
}
143+
118144
/**
119145
* Get the HTML field name for a surcharge input.
120146
*

‎Model/Config/Backend/SurchargeGrid.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Magento\Framework\App\Cache\TypeListInterface;
1717
use Magento\Framework\Model\ResourceModel\AbstractResource;
1818
use Magento\Framework\Data\Collection\AbstractDb;
19+
use Magento\Store\Model\StoreManagerInterface;
1920
use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository;
2021

2122
/**
@@ -32,18 +33,23 @@ class SurchargeGrid extends Value
3233
/** @var WriterInterface */
3334
private $configWriter;
3435

36+
/** @var StoreManagerInterface */
37+
private $storeManager;
38+
3539
public function __construct(
3640
Context $context,
3741
Registry $registry,
3842
ScopeConfigInterface $config,
3943
TypeListInterface $cacheTypeList,
4044
WriterInterface $configWriter,
45+
StoreManagerInterface $storeManager,
4146
AbstractResource $resource = null,
4247
AbstractDb $resourceCollection = null,
4348
array $data = []
4449
) {
4550
parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data);
4651
$this->configWriter = $configWriter;
52+
$this->storeManager = $storeManager;
4753
}
4854

4955
/**
@@ -116,9 +122,39 @@ public function afterSave()
116122
}
117123
}
118124

125+
// Persist the base currency so fixed amounts remain meaningful
126+
// even if the store's default currency changes later
127+
$currencyCode = $this->resolveBaseCurrency($scope, $scopeId);
128+
$this->configWriter->save(
129+
ConfigRepository::XML_PATH_SURCHARGE_FIXED_CURRENCY,
130+
$currencyCode,
131+
$scope,
132+
$scopeId
133+
);
134+
119135
return parent::afterSave();
120136
}
121137

138+
/**
139+
* Get the base currency for the scope being saved.
140+
*/
141+
private function resolveBaseCurrency(string $scope, int $scopeId): string
142+
{
143+
try {
144+
if ($scope === 'stores' && $scopeId > 0) {
145+
return $this->storeManager->getStore($scopeId)->getBaseCurrencyCode();
146+
}
147+
if ($scope === 'websites' && $scopeId > 0) {
148+
return $this->storeManager->getWebsite($scopeId)->getBaseCurrencyCode();
149+
}
150+
} catch (\Exception $e) {
151+
// Fall through to global default
152+
}
153+
return (string)$this->getFieldsetDataValue('currency/options/base')
154+
?: (string)$this->_config->getValue('currency/options/base')
155+
?: 'USD';
156+
}
157+
122158
/**
123159
* Validate a surcharge field value.
124160
*

‎Model/Config/Repository.php‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,10 +492,19 @@ public function getDefaultTaxRate(?int $storeId = null): float
492492
public function getSurchargeConfig(int $days, ?int $storeId = null): array
493493
{
494494
$prefix = sprintf('payment/two_payment/surcharge_%d_', $days);
495+
$limitValue = $this->getConfig($prefix . 'limit', $storeId);
495496
return [
496497
'percentage' => (int)$this->getConfig($prefix . 'percentage', $storeId),
497498
'fixed' => (int)$this->getConfig($prefix . 'fixed', $storeId),
498-
'limit' => (float)$this->getConfig($prefix . 'limit', $storeId),
499+
'limit' => $limitValue !== null ? (float)$limitValue : null,
499500
];
500501
}
502+
503+
/**
504+
* @inheritDoc
505+
*/
506+
public function getSurchargeFixedCurrency(?int $storeId = null): string
507+
{
508+
return (string)$this->getConfig(self::XML_PATH_SURCHARGE_FIXED_CURRENCY, $storeId);
509+
}
501510
}

‎Service/Order/ComposeOrder.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public function execute(Order $order, string $orderReference, array $additionalD
7171
(float)$order->getGrandTotal(),
7272
$selectedTermDays,
7373
$buyerCountry,
74+
$order->getOrderCurrencyCode(),
7475
$storeId
7576
);
7677

‎Service/Order/SurchargeCalculator.php‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace Two\Gateway\Service\Order;
99

10+
use Magento\Directory\Model\CurrencyFactory;
11+
use Magento\Framework\Exception\LocalizedException;
1012
use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository;
1113
use Two\Gateway\Api\Log\RepositoryInterface as LogRepository;
1214
use Two\Gateway\Model\Config\Source\SurchargeType;
@@ -38,14 +40,21 @@ class SurchargeCalculator
3840
*/
3941
private $logRepository;
4042

43+
/**
44+
* @var CurrencyFactory
45+
*/
46+
private $currencyFactory;
47+
4148
public function __construct(
4249
ConfigRepository $configRepository,
4350
Adapter $apiAdapter,
44-
LogRepository $logRepository
51+
LogRepository $logRepository,
52+
CurrencyFactory $currencyFactory
4553
) {
4654
$this->configRepository = $configRepository;
4755
$this->apiAdapter = $apiAdapter;
4856
$this->logRepository = $logRepository;
57+
$this->currencyFactory = $currencyFactory;
4958
}
5059

5160
/**
@@ -54,14 +63,17 @@ public function __construct(
5463
* @param float $grossAmount Order gross amount
5564
* @param int $selectedTermDays The term the buyer selected
5665
* @param string $buyerCountry ISO Alpha-2 country code
66+
* @param string $orderCurrency ISO currency code of the order
5767
* @param int|null $storeId
5868
*
5969
* @return array{amount: float, tax_rate: float, description: string}
70+
* @throws LocalizedException if fixed fee currency conversion fails
6071
*/
6172
public function calculate(
6273
float $grossAmount,
6374
int $selectedTermDays,
6475
string $buyerCountry,
76+
string $orderCurrency,
6577
?int $storeId = null
6678
): array {
6779
$surchargeType = $this->configRepository->getSurchargeType($storeId);
@@ -72,6 +84,7 @@ public function calculate(
7284

7385
$feeBase = $this->getFeeBase($grossAmount, $selectedTermDays, $buyerCountry, $storeId);
7486
$config = $this->configRepository->getSurchargeConfig($selectedTermDays, $storeId);
87+
$fixedCurrency = $this->configRepository->getSurchargeFixedCurrency($storeId);
7588

7689
$surcharge = 0.0;
7790

@@ -82,12 +95,17 @@ public function calculate(
8295
$surcharge += $feeBase * ($config['percentage'] / 100);
8396
}
8497
if ($hasFixed) {
85-
$surcharge += $config['fixed'];
98+
$fixedAmount = (float)$config['fixed'];
99+
$surcharge += $this->convertAmount($fixedAmount, $fixedCurrency, $orderCurrency);
86100
}
87101

88-
// Apply limit cap
89-
if ($config['limit'] > 0 && $surcharge > $config['limit']) {
90-
$surcharge = $config['limit'];
102+
// Apply limit cap (null = not set = no cap; 0 = explicit zero cap)
103+
$limit = $config['limit'];
104+
if ($limit !== null) {
105+
$limit = $this->convertAmount($limit, $fixedCurrency, $orderCurrency);
106+
if ($surcharge > $limit) {
107+
$surcharge = $limit;
108+
}
91109
}
92110

93111
// Round up to next cent
@@ -98,6 +116,8 @@ public function calculate(
98116
'fee_base' => $feeBase,
99117
'surcharge_type' => $surchargeType,
100118
'config' => $config,
119+
'fixed_currency' => $fixedCurrency,
120+
'order_currency' => $orderCurrency,
101121
'result' => $surcharge,
102122
]);
103123

@@ -160,4 +180,30 @@ private function fetchMerchantFee(
160180

161181
return (float)($response['total_fee'] ?? 0);
162182
}
183+
184+
/**
185+
* Convert an amount between currencies if needed.
186+
*
187+
* @throws LocalizedException if Magento has no exchange rate for the pair
188+
*/
189+
private function convertAmount(float $amount, string $fromCurrency, string $toCurrency): float
190+
{
191+
if ($amount == 0 || $fromCurrency === '' || $fromCurrency === $toCurrency) {
192+
return $amount;
193+
}
194+
195+
try {
196+
$currency = $this->currencyFactory->create()->load($fromCurrency);
197+
return (float)$currency->convert($amount, $toCurrency);
198+
} catch (\Exception $e) {
199+
throw new LocalizedException(
200+
__(
201+
'Cannot convert surcharge from %1 to %2. '
202+
. 'Please configure currency exchange rates under Stores > Currency Rates.',
203+
$fromCurrency,
204+
$toCurrency
205+
)
206+
);
207+
}
208+
}
163209
}

0 commit comments

Comments
 (0)