Skip to content

Commit 1e58969

Browse files
Copilotwilr
andauthored
[WIP] Add multi-currency support to shop architecture (#863)
* Initial plan * Implement multi-currency support for silvershop-core - Add CurrencyService interface and SimpleCurrencyService implementation - Add ProductCurrencyPrice model for per-currency price overrides - Update Product: has_many Prices, sellingPrice() uses active currency - Update Variation: sellingPrice() uses active currency conversion - Update Order: add Currency and ExchangeRate DB fields - Update ShoppingCart: set Currency on new orders - Update OrderProcessor: store ExchangeRate when order is placed - Update ShopConfigExtension: add supported_currencies config - Update ShopTools: use CurrencyService for price_for_display - Register CurrencyService in injector config - Add tests for SimpleCurrencyService and ProductCurrencyPrice - Update example_config.yml with multi-currency configuration examples Agent-Logs-Url: https://github.com/silvershop/silvershop-core/sessions/9ea8f7d5-bfca-4c21-a17f-f048b08ff414 Co-authored-by: wilr <101629+wilr@users.noreply.github.com> * Fix code review issues: null checks in ProductCurrencyPrice, fix PHPDoc type Agent-Logs-Url: https://github.com/silvershop/silvershop-core/sessions/9ea8f7d5-bfca-4c21-a17f-f048b08ff414 Co-authored-by: wilr <101629+wilr@users.noreply.github.com> * Simplify exchange rate storage logic in OrderProcessor Agent-Logs-Url: https://github.com/silvershop/silvershop-core/sessions/9ea8f7d5-bfca-4c21-a17f-f048b08ff414 Co-authored-by: wilr <101629+wilr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: wilr <101629+wilr@users.noreply.github.com>
1 parent 9075e0a commit 1e58969

14 files changed

Lines changed: 649 additions & 2 deletions

_config/currency.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
Name: silvershop-currency
3+
After: silvershop-config
4+
---
5+
6+
# Register the default currency service implementation.
7+
# Override this to use a custom implementation, e.g. one that fetches live exchange rates.
8+
SilverStripe\Core\Injector\Injector:
9+
SilverShop\Currency\CurrencyService:
10+
class: SilverShop\Currency\SimpleCurrencyService

example_config.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ SilverShop\Page\ProductCategory:
7676
SilverShop\Extension\ShopConfigExtension:
7777
base_currency: 'NZD'
7878
email_from: sales@myshop.com
79+
# Uncomment to enable multi-currency support. List all supported currencies.
80+
# The first entry should match base_currency.
81+
# supported_currencies:
82+
# - 'NZD'
83+
# - 'USD'
84+
# - 'EUR'
85+
86+
# Configure exchange rates relative to base currency for multi-currency support.
87+
# Used by SimpleCurrencyService. Replace with a custom CurrencyService for live rates.
88+
# SilverShop\Currency\SimpleCurrencyService:
89+
# exchange_rates:
90+
# USD: 0.60
91+
# EUR: 0.55
92+
# GBP: 0.48
93+
94+
# To use a custom currency service implementation, override the injector:
95+
# SilverStripe\Core\Injector\Injector:
96+
# SilverShop\Currency\CurrencyService:
97+
# class: MyApp\Currency\LiveExchangeRateService
7998

8099
SilverShop\ORM\FieldType\ShopCurrency:
81100
decimal_delimiter: '.'

src/Cart/ShoppingCart.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use SilverStripe\Core\Validation\ValidationException;
88
use Exception;
9+
use SilverShop\Currency\CurrencyService;
910
use SilverShop\Extension\OrderManipulationExtension;
1011
use SilverShop\Extension\ProductVariationsExtension;
1112
use SilverShop\Model\Buyable;
@@ -17,6 +18,7 @@
1718
use SilverStripe\Core\Config\Config;
1819
use SilverStripe\Core\Config\Configurable;
1920
use SilverStripe\Core\Injector\Injectable;
21+
use SilverStripe\Core\Injector\Injector;
2022
use SilverStripe\ORM\FieldType\DBField;
2123
use SilverStripe\Security\Member;
2224
use SilverStripe\Security\Security;
@@ -109,6 +111,10 @@ protected function findOrMake(): Order
109111
$this->order->MemberID = $member->ID;
110112
}
111113

114+
// Set the active currency on the order
115+
$currencyService = Injector::inst()->get(CurrencyService::class);
116+
$this->order->Currency = $currencyService->getActiveCurrency();
117+
112118
$this->order->write();
113119
$this->order->extend('onStartOrder');
114120

src/Checkout/OrderProcessor.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
use ErrorException;
88
use Exception;
99
use SilverShop\Cart\ShoppingCart;
10+
use SilverShop\Currency\CurrencyService;
1011
use SilverShop\Extension\OrderManipulationExtension;
1112
use SilverShop\Extension\ShopConfigExtension;
1213
use SilverShop\Model\Order;
1314
use SilverShop\ShopTools;
1415
use SilverStripe\Control\Controller;
1516
use SilverStripe\Core\Config\Configurable;
1617
use SilverStripe\Core\Injector\Injectable;
18+
use SilverStripe\Core\Injector\Injector;
1719
use SilverStripe\Omnipay\Exception\InvalidConfigurationException;
1820
use SilverStripe\Omnipay\GatewayInfo;
1921
use SilverStripe\Omnipay\Model\Payment;
@@ -335,6 +337,16 @@ function ($severity, $message, $file, $line): bool {
335337
}
336338
}
337339

340+
// Store the exchange rate at time of placement for historical records
341+
if (!$this->order->ExchangeRate) {
342+
$currencyService = Injector::inst()->get(CurrencyService::class);
343+
$baseCurrency = ShopConfigExtension::get_site_currency();
344+
if (!$this->order->Currency) {
345+
$this->order->Currency = $currencyService->getActiveCurrency();
346+
}
347+
$this->order->ExchangeRate = $currencyService->getExchangeRate($baseCurrency, $this->order->Currency);
348+
}
349+
338350
//allow decorators to do stuff when order is saved.
339351
$this->order->extend('onPlaceOrder');
340352
$this->order->write();

src/Currency/CurrencyService.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SilverShop\Currency;
6+
7+
/**
8+
* Interface for currency conversion services.
9+
*
10+
* Implementations of this interface handle the conversion of prices between
11+
* currencies and track the currently active currency for the shop.
12+
*/
13+
interface CurrencyService
14+
{
15+
/**
16+
* Convert a price from one currency to another.
17+
*
18+
* @param float $price the price to convert
19+
* @param string $from the source currency code (e.g. "USD")
20+
* @param string $to the target currency code (e.g. "EUR")
21+
* @return float the converted price
22+
*/
23+
public function convert(float $price, string $from, string $to): float;
24+
25+
/**
26+
* Get the currently active shop currency code.
27+
*
28+
* @return string currency code (e.g. "NZD")
29+
*/
30+
public function getActiveCurrency(): string;
31+
32+
/**
33+
* Set the active currency for the current session.
34+
*
35+
* @param string $currency currency code (e.g. "EUR")
36+
*/
37+
public function setActiveCurrency(string $currency): void;
38+
39+
/**
40+
* Get the exchange rate from one currency to another.
41+
*
42+
* @param string $from the source currency code
43+
* @param string $to the target currency code
44+
* @return float the exchange rate (multiply $from price by this to get $to price)
45+
*/
46+
public function getExchangeRate(string $from, string $to): float;
47+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SilverShop\Currency;
6+
7+
use SilverShop\Extension\ShopConfigExtension;
8+
use SilverShop\ShopTools;
9+
use SilverStripe\Core\Config\Configurable;
10+
use SilverStripe\Core\Injector\Injectable;
11+
12+
/**
13+
* Simple currency service that uses statically configured exchange rates.
14+
*
15+
* Exchange rates are defined relative to the shop's base currency.
16+
* For example, if the base currency is "NZD" and the rate for "USD" is 0.65,
17+
* then 1 NZD = 0.65 USD.
18+
*
19+
* Configuration example (YAML):
20+
* <code>
21+
* SilverShop\Currency\SimpleCurrencyService:
22+
* exchange_rates:
23+
* USD: 0.65
24+
* EUR: 0.58
25+
* GBP: 0.50
26+
* </code>
27+
*/
28+
class SimpleCurrencyService implements CurrencyService
29+
{
30+
use Injectable;
31+
use Configurable;
32+
33+
/**
34+
* Session key for storing the active currency
35+
*/
36+
private static string $session_key = 'SilverShop.activeCurrency';
37+
38+
/**
39+
* Exchange rates relative to the shop base currency.
40+
* Keys are currency codes, values are the exchange rate (base → foreign).
41+
*
42+
* @var array<string, float>
43+
*/
44+
private static array $exchange_rates = [];
45+
46+
public function convert(float $price, string $from, string $to): float
47+
{
48+
if ($from === $to) {
49+
return $price;
50+
}
51+
52+
$rate = $this->getExchangeRate($from, $to);
53+
return $price * $rate;
54+
}
55+
56+
public function getActiveCurrency(): string
57+
{
58+
$session = ShopTools::getSession();
59+
$currency = $session->get(self::config()->session_key);
60+
61+
if ($currency) {
62+
return (string)$currency;
63+
}
64+
65+
return ShopConfigExtension::get_site_currency();
66+
}
67+
68+
public function setActiveCurrency(string $currency): void
69+
{
70+
$session = ShopTools::getSession();
71+
$session->set(self::config()->session_key, $currency);
72+
}
73+
74+
public function getExchangeRate(string $from, string $to): float
75+
{
76+
if ($from === $to) {
77+
return 1.0;
78+
}
79+
80+
$baseCurrency = ShopConfigExtension::get_site_currency();
81+
$rates = self::config()->exchange_rates;
82+
83+
// Build rate from $from to $to using the base currency as pivot
84+
$fromRate = $this->getRateToBase($from, $baseCurrency, $rates);
85+
$toRate = $this->getRateFromBase($to, $baseCurrency, $rates);
86+
87+
return $fromRate * $toRate;
88+
}
89+
90+
/**
91+
* Get the rate to convert from $currency to the base currency.
92+
* (i.e. how many base-currency units equal 1 unit of $currency)
93+
*
94+
* @param string $currency
95+
* @param string $baseCurrency
96+
* @param array<string, float> $rates
97+
*/
98+
private function getRateToBase(string $currency, string $baseCurrency, array $rates): float
99+
{
100+
if ($currency === $baseCurrency) {
101+
return 1.0;
102+
}
103+
104+
if (isset($rates[$currency]) && $rates[$currency] > 0) {
105+
// rates[$currency] = base→foreign, so foreign→base = 1/$rates[$currency]
106+
return 1.0 / (float)$rates[$currency];
107+
}
108+
109+
return 1.0;
110+
}
111+
112+
/**
113+
* Get the rate to convert from the base currency to $currency.
114+
* (i.e. how many units of $currency equal 1 base-currency unit)
115+
*
116+
* @param string $currency
117+
* @param string $baseCurrency
118+
* @param array<string, float> $rates
119+
*/
120+
private function getRateFromBase(string $currency, string $baseCurrency, array $rates): float
121+
{
122+
if ($currency === $baseCurrency) {
123+
return 1.0;
124+
}
125+
126+
if (isset($rates[$currency])) {
127+
return (float)$rates[$currency];
128+
}
129+
130+
return 1.0;
131+
}
132+
}

src/Extension/ShopConfigExtension.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ class ShopConfigExtension extends Extension
5555
*/
5656
private static string $base_currency = 'NZD';
5757

58+
/**
59+
* Supported currencies in the shop (ISO 4217 currency codes).
60+
* When set to more than one currency, multi-currency support is enabled.
61+
* The first entry should always be (or match) the base_currency.
62+
*
63+
* @var string[]
64+
*/
65+
private static array $supported_currencies = [];
66+
5867
private static bool $forms_use_button_tag = false;
5968

6069
public static function current(): SiteConfig
@@ -67,6 +76,22 @@ public static function get_site_currency(): string
6776
return self::config()->base_currency;
6877
}
6978

79+
/**
80+
* Get the list of supported currencies.
81+
* If empty, only the base currency is supported.
82+
*
83+
* @return string[] list of ISO 4217 currency codes
84+
*/
85+
public static function get_supported_currencies(): array
86+
{
87+
$currencies = self::config()->supported_currencies;
88+
if (empty($currencies)) {
89+
return [self::get_site_currency()];
90+
}
91+
92+
return $currencies;
93+
}
94+
7095
public function updateCMSFields(FieldList $fieldList): void
7196
{
7297
$fieldList->insertBefore('Access', $shoptab = Tab::create('Shop', 'Shop'));

src/Model/Order.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
* @property ?string $IPAddress
5656
* @property bool $SeparateBillingAddress
5757
* @property ?string $Locale
58+
* @property ?string $Currency
59+
* @property ?float $ExchangeRate
5860
* @property int $MemberID
5961
* @property int $ShippingAddressID
6062
* @property int $BillingAddressID
@@ -99,6 +101,9 @@ class Order extends DataObject
99101
'SeparateBillingAddress' => 'Boolean',
100102
// keep track of customer locale
101103
'Locale' => 'Locale',
104+
// currency information
105+
'Currency' => 'Varchar(3)', // ISO 4217 currency code used for this order
106+
'ExchangeRate' => 'Decimal(19,6)', // Exchange rate from base currency at the time of the order
102107
];
103108

104109
private static array $has_one = [

0 commit comments

Comments
 (0)