Skip to content

Keep every store with an available currency - #836

Merged
KrzysztofPajak merged 1 commit into
developfrom
fix/store-without-available-currency
Sep 13, 2026
Merged

Keep every store with an available currency#836
KrzysztofPajak merged 1 commit into
developfrom
fix/store-without-available-currency

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: bugfix

Issue

A store can end up with no currency available to it, and when that happens every single request to that store fails.

WorkContextSetter.WorkingCurrency resolves the working currency from GetAllCurrencies(storeId: store.Id), which filters by the store ACL. When that list comes back empty it ends in throw new Exception("No currency could be loaded"). The throw happens inside ContextMiddleware, so GrandExceptionHandler redirects the whole storefront to /errorpage.htm (API callers get a 500 ProblemDetails). The store is dead until someone fixes the mapping in Main Admin.

Reaching that state does not require anything exotic. If every published currency is limited to stores (a normal SaaS setup), a store manager can do it from their own panel:

  1. Currency -> Unset default currency clears Store.DefaultCurrencyId, which disables the existing CantUnassignDefault guard.
  2. Unassign from store on each remaining currency mapped to their store.

The existing CantDeletePrimary guard does not help — the primary store currency may itself be limited to other stores, so it was never on this store's list to begin with. Main Admin can produce the same state by editing a currency's store mappings, because ValidateCurrencyUnpublish / ValidateCurrencyDelete only count currencies globally, never per store.

The equivalent language path is not affected: WorkingLanguage calls GetAllLanguages(showHidden) without a storeId, so store mappings never influence the work context. Unmapping every language only empties the storefront language selector.

Solution

Two layers, so neither a bug nor an admin mistake can take a store down:

  1. Fallback instead of a throwWorkContextSetter.WorkingCurrency now falls back to GetPrimaryStoreCurrency() when no published currency is mapped to the store. That currency is resolved by id from CurrencySettings regardless of publication or mapping, and it cannot be deleted (ValidateCurrencyDelete), so the path effectively always resolves. The throw is kept as the last resort for an installation whose setting points at a currency that no longer exists.

  2. Guards that stop a store being emptied

    • Grand.Web.Store/Controllers/CurrencyController.UnassignStore refuses when the currency being unassigned is the last one available to the store, counted exactly as the work context counts it.
    • New ICurrencyViewModelService.ValidateCurrencyStoreMapping, called from Grand.Web.Admin/Controllers/CurrencyController.Edit, simulates the submitted Published + Stores values and rejects the save if any store would be left without an available currency. This also covers unpublishing the last currency of a single store, which the global-count ValidateCurrencyUnpublish never saw.

New resources Admin.Configuration.Currencies.CantUnassignLast and Admin.Configuration.Currencies.CantLimitStores are added to DefaultLanguage.xml and to the Upgrade/en_240.xml migration.

Breaking changes

One, for anyone implementing the admin interface outside this repository: ICurrencyViewModelService gains ValidateCurrencyStoreMapping(Currency, CurrencyModel), and CurrencyViewModelService's constructor takes an additional IStoreService. Nothing in the storefront, plugin, or data contracts changes; no setting, permission, or schema change.

Testing

Prerequisite: an installation with at least two stores.

The crash no longer happens (layer 1)

  1. In Main Admin, open each published currency and limit it to store B only (Stores tab), so store A has no currency mapped to it. The save is now rejected — to reproduce the old crash state, apply the mapping directly in the database instead.
  2. Browse to store A's URL. Expected: the storefront renders, priced in the primary store currency, instead of redirecting to /errorpage.htm.

Store panel guard (layer 2)

  1. Log in to the store panel as a manager of store A. Go to Configuration -> Currencies.
  2. Make sure every currency listed is store-limited and that exactly one is assigned to store A, then use "Unset default currency" on it.
  3. Click "Unassign from store" on that last currency. Expected: rejected with "At least one currency must remain available for your store." and the assignment is unchanged.
  4. Assign a second currency to store A, then unassign the first. Expected: succeeds.

Main Admin guard (layer 2)

  1. In Main Admin, edit the only currency available to store A and, on the Stores tab, limit it to store B only. Save. Expected: rejected with "The store '{store A name}' would be left without an available currency."
  2. Repeat with the Published checkbox cleared instead. Expected: same rejection.
  3. Make a second currency global (no store limit), then repeat step 7. Expected: the save succeeds.

Automated

dotnet test src/Tests/Grand.Web.Admin.Tests/Grand.Web.Admin.Tests.csproj   # 1483 passed
dotnet test src/Tests/Grand.Web.Store.Tests/Grand.Web.Store.Tests.csproj   # 160 passed
dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj # 37 passed

Covering ValidateCurrencyStoreMapping (4 cases in CurrencyViewModelServiceTests) and the store-panel guard (new Grand.Web.Store.Tests/Controllers/CurrencyControllerTests).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GrGY1NnCzdkn7wUxbyHzsX

A store whose published currencies are all limited to other stores made
WorkingCurrency throw "No currency could be loaded" from ContextMiddleware,
which took down every request for that store. A store manager could reach
that state alone: unset the default currency, then unassign each remaining
store-limited currency.

Fall back to the primary store currency instead of throwing, and refuse the
edits that would empty a store in the first place - in the store panel when
unassigning the last available currency, and in Main Admin when saving store
mappings that would leave any store without one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrGY1NnCzdkn7wUxbyHzsX
Copilot AI lite review requested due to automatic review settings September 12, 2026 13:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


Assert.IsNotNull(result);
Assert.AreEqual("Admin.Configuration.Currencies.CantUnassignLast",
result.Value.GetType().GetProperty("message")!.GetValue(result.Value));
var result = await _controller.UnassignStore(currency.Id) as JsonResult;

Assert.IsNotNull(result);
Assert.IsTrue((bool)result.Value.GetType().GetProperty("success")!.GetValue(result.Value)!);
Comment on lines +109 to +119
foreach (var store in await _storeService.GetAllStores())
{
if (otherCurrencies.Any(c => !c.LimitedToStores || c.Stores.Contains(store.Id)))
continue;

if (model.Published && (!limitedToStores || model.Stores.Contains(store.Id)))
continue;

return (false, string.Format(
_translationService.GetResource("Admin.Configuration.Currencies.CantLimitStores"), store.Name));
}
@KrzysztofPajak
KrzysztofPajak merged commit ac02b16 into develop Sep 13, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/store-without-available-currency branch September 13, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants