Skip to content
Merged
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
31 changes: 23 additions & 8 deletions Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function registerSettings()
'category' => "Currency",
'order' => 510,
'permissions' => ['responsiv.currency.access_settings']
]
],
];
}

Expand Down Expand Up @@ -133,18 +133,33 @@ public function registerMarkupTags()
public function registerListColumnTypes()
{
return [
'currency' => function($value, $column) {
if ($value === null) {
return null;
}

return Currency::format($value, [
'currency' => function($value, $column, $record = null) {
$options = [
'format' => $column->format,
'from' => $column->fromCode,
'to' => $column->toCode,
'in' => $column->inCode,
'site' => $column->site ?? false
]);
];

// Per-record currency: read currency code from another attribute
if ($column->currencyFrom && $record) {
$options['in'] = data_get($record, $column->currencyFrom);
}

// Non-currencyable models: value is raw from DB in primary currency,
// so force primary to prevent format() using edit site currency
if (!$options['in'] && !$options['to'] && !$options['site']) {
if (!$record || !method_exists($record, 'getCurrencyableBaseValue')) {
$options['in'] = Currency::getPrimaryCode();
}
}

if ($value === null) {
return null;
}

return Currency::format($value, $options);
}
];
}
Expand Down
158 changes: 134 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Tools for dealing with currency display and conversions. You can configure curre

- Settings → Currencies
- Settings → Exchange Rates
- Settings → Site Definitions

## Get Started

Expand All @@ -25,56 +24,167 @@ Article | Purpose

## Understanding Currency Definitions

There are multiple currency definition types that are important to operation the Currency plugin. Each definition type is described in more detail below.
There are multiple currency definition types that are important to operating the Currency plugin. Each definition type is described in more detail below.

### Default Currency

The default currency is used when there is no multisite context or when there is no currency set in one of the other definitions. In the currency form widget, if the model does not implement the multisite feature, then the value is stored in the default currency.
The default currency is the global anchor — all prices are stored in this currency. It is used when there is no site context or when no other currency is configured. In the currency form widget, if the model does not implement the multisite feature, the value is stored in the default currency.

> **Note**: The default currency is set by opening the **Settings → Currencies** page and checking the **Default** checkbox on a currency listed on this page.

### Primary / Base Currency
### Base Currency (Site Group)

The primary currency is a base currency that sets the currency for use when writing values in a multisite context. For example, if the model implements the multisite feature, then the value is stored in the primary currency set by the active site.
The base currency can be set on a **Site Group** to override the stored currency for all sites in that group. This is an edge case for installations where different groups of sites store prices in different currencies (e.g. a US group stores in USD while a UK group stores in GBP).

The primary currency is available in Twig as `this.site.base_currency` and `this.site.base_currency_code`.
When no base currency is set on the site group, the global default currency is used.

> **Note**: The base currency is set by opening the **Settings → Site Groups** page and selecting a currency in the **Base Currency** dropdown.

### Site Currency

The site currency defines what currency is displayed to users visiting that site. When set, the currency filter automatically converts from the base currency to the site currency using exchange rates.

The site currency is available in Twig as `this.site.currency` and `this.site.currency_code`.

```twig
{{ this.site.base_currency_code }}
{{ this.site.currency_code }}
```

> **Note**: The primary currency is set by opening the **Settings → Site Definitions** page and selecting a currency in the **Base Currency** dropdown.
For example, if a value is stored in the default currency as USD and the site has a currency of AUD, the `site` option handles conversion automatically.

```twig
{{ product.price|currency({ from: this.site.base_currency_code })}}
{{ product.price|currency({ site: true }) }}
```

### Display Currency
> **Note**: The site currency is set by opening the **Settings → Site Definitions** page and selecting a currency in the **Currency** dropdown.

The display currency has a specific purpose of converting a currency from its stored value before displaying it.
## Currencyable Trait

The display currency is available in Twig as `this.site.currency` and `this.site.currency_code`.
The `Currencyable` trait allows models to store explicit per-currency price overrides in a sidecar table. It uses attribute interception (similar to the Translatable trait) so the model's `$attributes` always hold primary currency values. When a non-primary currency is active, reads return the explicit override if one exists, or fall back to exchange-rate conversion automatically.

```twig
{{ this.site.currency_code }}
### Setup

Add the trait and define which attributes support currency overrides:

```php
class Product extends Model
{
use \Responsiv\Currency\Traits\Currencyable;

public $currencyable = ['price', 'cost'];
}
```

For example, if a value is stored in the primary currency as USD and the site definition has a display currency of AUD.
### How It Works

```twig
{{ product.price|currency({
from: this.site.base_currency_code,
to: this.site.currency_code
})}}
1. **Attribute interception**: `getAttribute()` and `setAttribute()` are overridden. When the active currency differs from the primary, reads and writes are redirected to the sidecar cache instead of `$attributes`.
2. **Exchange-rate fallback**: When no explicit override exists for a currency, `getCurrencyOverride()` automatically converts the base value using `Currency::convert()`.
3. **Before save**: `syncCurrencyableAttributes()` persists any dirty sidecar data and restores the original primary-currency values on the model so the main table is never written with override values.
4. **Clearing overrides**: Setting a currency override to null or empty deletes the sidecar row, reverting the attribute to automatic exchange-rate conversion.

### Storage

Overrides are stored in the `responsiv_currency_attributes` table:

Column | Purpose
------ | -------
`model_type` | Morph type (model class)
`model_id` | Foreign key to the model
`currency_code` | Currency code (e.g. `EUR`, `GBP`)
`attribute` | Attribute name (e.g. `price`)
`value` | Override value

### Reading Overrides

```php
// Get override for a specific currency (falls back to exchange-rate conversion)
$eurPrice = $product->getCurrencyOverride('price', 'EUR');

// Get override without fallback (returns null if no override)
$eurPrice = $product->getCurrencyOverride('price', 'EUR', false);

// Check if an explicit override exists (not exchange-rate converted)
if ($product->hasCurrencyOverride('price', 'EUR')) {
// ...
}

// Get all stored currency values for an attribute
$allPrices = $product->getCurrencyOverrides('price');
// Returns: ['USD' => 1000, 'EUR' => 950, 'GBP' => 800]

// Get the primary-currency value (always reads from $attributes)
$basePrice = $product->getCurrencyableBaseValue('price');
```

This can be shortened by setting the `site` option to `true`.
### Writing Overrides

```twig
{{ product.price|currency({ site: true })}}
```php
// Set a single override
$product->setCurrencyOverride('price', 'EUR', 950);

// Set multiple currencies at once
$product->setCurrencyOverrides('price', [
'EUR' => 950,
'GBP' => 800,
]);

// Remove an override (reverts to exchange rate conversion)
$product->forgetCurrencyOverride('price', 'EUR');

// Remove all overrides for an attribute
$product->forgetCurrencyOverrides('price');

// Remove all overrides for a currency
$product->forgetAllCurrencyOverrides('EUR');
```

### Context Switching

```php
// Override the currency context for a model instance
$product->setCurrency('EUR');
echo $product->price; // Returns EUR override or exchange-rate converted value

// Get the active currency code
$code = $product->getCurrency();
```

### Query Scopes

```php
// Filter by currency override value
Product::whereCurrencyOverride('price', 'EUR', 950)->get();

// Eager load overrides for the active currency
Product::withCurrencyOverride()->get();

// Eager load overrides for a specific currency
Product::withCurrencyOverride('EUR')->get();

// Eager load all currency overrides
Product::withCurrencyOverrides()->get();
```

> **Note**: The primary currency is set by opening the **Settings → Site Definitions** page and selecting a currency in the **Display Currency** dropdown.
### Currency Resolution

The trait resolves currencies through the `CurrencyManager`:

Method | Returns | Purpose
------ | ------- | -------
`getCurrencyableDefault()` | Base currency code | Site group's base currency, or global default
`getCurrencyableContext()` | Active currency code | Site's currency, or falls back to base
`shouldConvertCurrency()` | `bool` | `true` when active differs from base

### Enabling / Disabling

The trait is enabled by default. Override `isCurrencyableEnabled()` in the model to disable it conditionally:

```php
public function isCurrencyableEnabled()
{
return false; // Disable currency overrides for this model
}
```

### License

Expand Down
28 changes: 28 additions & 0 deletions behaviors/CurrencyModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ public function getCurrencyCodeAttribute()
return $this->model->currency ? $this->model->currency->code : null;
}

/**
* getBaseCurrencyCodeAttribute resolves the base currency code by walking
* up to the site group, falling back to the global default. This allows
* Twig templates to use `this.site.base_currency_code` transparently.
* @return string
*/
public function getBaseCurrencyCodeAttribute()
{
$model = $this->model;

if ($model->group && $model->group->base_currency_id) {
return $model->group->base_currency->code;
}

return Currency::getDefaultCode();
}

/**
* getHardCurrencyCodeAttribute will always return a currency code no matter
* what, falling back to the base currency. Mirrors the hard_locale pattern.
*/
public function getHardCurrencyCodeAttribute()
{
return $this->model->currency
? $this->model->currency->code
: $this->getBaseCurrencyCodeAttribute();
}

/**
* setCurrencyIdAttribute ensures an integer value is set, otherwise nullable.
*/
Expand Down
35 changes: 16 additions & 19 deletions classes/CurrencyManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,79 +37,76 @@ public static function instance(): static
}

/**
* getPrimary returns the default currency for source values, regardless of the site context.
* getDefault returns the global default currency, ignoring site context.
*/
public function getDefault()
{
return CurrencyModel::getDefault();
}

/**
* getDefaultCode returns the primary currency code for source values.
* getDefaultCode returns the global default currency code.
*/
public function getDefaultCode()
{
return $this->getDefault()->code;
}

/**
* getPrimary returns the primary currency for source values.
* getPrimary returns the base currency for the current site group.
* Falls back to the global default when no group override is set.
* This is the currency that prices are stored in.
*/
public function getPrimary()
{
$site = Site::getSiteFromContext();

if ($site->base_currency_id) {
return $site->base_currency;
if ($site && $site->group && $site->group->base_currency_id) {
return $site->group->base_currency;
}

return CurrencyModel::getDefault();
}

/**
* getPrimaryCode returns the primary currency code for source values.
* getPrimaryCode returns the base currency code for the current site group.
*/
public function getPrimaryCode()
{
return $this->getPrimary()->code;
}

/**
* getActive returns the active currency for display purposes.
* getActive returns the active currency for the current site context.
* Falls back to the global default when no site currency is set.
*/
public function getActive()
{
$site = Site::getSiteFromContext();

if ($site->currency_id) {
if ($site && $site->currency_id) {
return $site->currency;
}

if ($site->base_currency_id) {
return $site->base_currency;
}

return CurrencyModel::getDefault();
}

/**
* getActiveCode returns the active currency code for display purposes.
* getActiveCode returns the active currency code for the current site context.
*/
public function getActiveCode()
{
return $this->getActive()->code;
}

/**
* getForModel returns the current to use for a specific model or model attribute
* getForModel returns the currency to use for a specific model or model attribute.
* Always returns the primary currency since prices are stored in primary currency
* and the Currencyable trait handles conversion via promotion.
*/
public function getForModel($model, $attr = null)
{
if (Site::isModelMultisite($model, $attr)) {
return $this->getPrimary();
}

return $this->getDefault();
return $this->getPrimary();
}

/**
Expand Down
Loading