diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md index 88fe24ac..4f062a1b 100644 --- a/SETUP_GUIDE.md +++ b/SETUP_GUIDE.md @@ -61,15 +61,18 @@ If you want to quickly test Commera with demo products, you have two options: bench --site your-site-name execute commera.install_summer_demo.install_summer_demo ``` -This seeds the Summer storefront in **SAR**. Pass `--kwargs '{"currency": "INR"}'` for a rupee store; -the supported currencies are the keys of `CURRENCY_PROFILES` in `commera/install_summer_demo.py`. +This seeds the Summer storefront in the **company's own currency**, so finish the setup wizard first: +without a company, a default currency and a chart of accounts the seeder stops and says so. +SAR, INR and IDR get hand-picked local prices (`CURRENCY_PROFILES` in `commera/install_summer_demo.py`); +any other currency is converted from the dollar catalogue at the USD exchange rate, which needs a +Currency Exchange record (or ERPNext's live rate lookup) to exist. The seeder is idempotent — run it as often as you like. -Currency is a parameter, not a constant: one run puts the system default, the company, every account -under it, all three price lists and every Item Price into the same currency, and restates the demo -catalogue, the shipping rule, the COD charge and the hero-slide copy at that currency's scale. A store -whose company and price list disagree cannot submit a single Sales Invoice, which is what makes this -the setting to get right before anything else. +The company, its accounts and its currency are never changed. What the seeder does align is the system +default, all three price lists and every Item Price, and it restates the demo catalogue, the shipping +rule, the COD charge and the hero-slide copy at that currency's scale. A store whose company and price +list disagree cannot submit a single Sales Invoice, which is what makes this the setting to get right +before anything else. #### Rebuilding a demo site from nothing @@ -82,7 +85,7 @@ bench new-site $SITE --admin-password admin bench --site $SITE install-app erpnext commera bench --site $SITE execute frappe.desk.page.setup_wizard.setup_wizard.setup_complete --kwargs '{"args": {"language": "en", "country": "Saudi Arabia", "timezone": "Asia/Riyadh", "currency": "SAR", "company_name": "Lifestyle Demo", "company_abbr": "LSD", "chart_of_accounts": "Standard", "fy_start_date": "2026-01-01", "fy_end_date": "2026-12-31", "full_name": "Administrator", "email": "admin@example.com", "password": "admin"}}' bench --site $SITE migrate -bench --site $SITE execute commera.install_summer_demo.install_summer_demo --kwargs '{"currency": "SAR"}' +bench --site $SITE execute commera.install_summer_demo.install_summer_demo ``` Two things about that order are load-bearing: diff --git a/commera/commera_ecommerce/doctype/commera_settings/commera_settings.py b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.py index 52f616c0..0722dc1f 100644 --- a/commera/commera_ecommerce/doctype/commera_settings/commera_settings.py +++ b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.py @@ -234,27 +234,19 @@ def sync_item_group_mapping_to_ecommerce_items(self): @frappe.whitelist() def install_demo_data(self): - """Seed the Summer demo storefront in the store's own currency. + """Seed the Summer demo storefront in the company's own currency. Not install_demo_data: its price lists are hardcoded USD, which blocks every Sales Invoice on a non-USD company. """ - from commera.install_summer_demo import ( - CURRENCY_PROFILES, - DEFAULT_CURRENCY, - get_company_currency, - install_summer_demo, - ) + from commera.install_summer_demo import get_currency_profile, get_demo_company, install_summer_demo - # A store already trading in a supported currency keeps it; anything else gets the default. - currency = get_company_currency() - if currency not in CURRENCY_PROFILES: - currency = DEFAULT_CURRENCY + # Refused here rather than in the job, so the merchant sees why nothing was seeded. + get_currency_profile(frappe.db.get_value("Company", get_demo_company(), "default_currency")) frappe.enqueue( install_summer_demo, queue="long", timeout=3000, enqueue_after_commit=True, - currency=currency, ) return "Demo data installation has been queued. This may take a few minutes. Check the background jobs for progress." diff --git a/commera/install_summer_demo.py b/commera/install_summer_demo.py index 4f189c55..ec18f95d 100644 --- a/commera/install_summer_demo.py +++ b/commera/install_summer_demo.py @@ -1,7 +1,10 @@ -"""One-shot seeder for a Summer storefront demo site, in whatever currency it is handed. +"""One-shot seeder for a Summer storefront demo site, in the currency of the company it seeds. create_price_lists is skipped: it hardcodes USD, which blocks Sales Invoices on a non-USD company.""" +import math + import frappe +from erpnext.setup.utils import get_exchange_rate from frappe.permissions import add_permission, update_permission_property from frappe.utils.data import flt @@ -25,14 +28,10 @@ COD_CHARGE_ACCOUNT = "Cash on Delivery Charges" SHOPPER_ROLE = "Customer" -DEFAULT_CURRENCY = "SAR" - -# FASHION_PRODUCTS carries dollar-scale numbers, so price_multiplier restates them in the site's own -# currency. Rates are recomputed from it on every run - scaling in place would compound on a re-run. +# FASHION_PRODUCTS prices are in dollars; unlisted currencies fall back to DOLLAR_PROFILE. +# Rates are recomputed on every run - scaling in place would compound on a re-run. CURRENCY_PROFILES = { "SAR": { - "country": "Saudi Arabia", - "symbol": "ر.س", "price_multiplier": 3.75, "price_rounding": 10, "free_shipping_above": 299, @@ -42,8 +41,6 @@ "contact_phone": "+966 55 123 4567", }, "INR": { - "country": "India", - "symbol": "₹", "price_multiplier": 50, "price_rounding": 100, "free_shipping_above": 999, @@ -52,6 +49,22 @@ "cod_charge_applicable_below": 999, "contact_phone": "+91 98765 43210", }, + "IDR": { + "price_multiplier": 16000, + "price_rounding": 1000, + "free_shipping_above": 300000, + "flat_shipping_charge": 15000, + "cod_charge": 5000, + "cod_charge_applicable_below": 300000, + "contact_phone": "+62 812 3456 7890", + }, +} + +DOLLAR_PROFILE = { + "free_shipping_above": 80, + "flat_shipping_charge": 7, + "cod_charge": 4, + "cod_charge_applicable_below": 80, } DEFAULT_HOMEPAGE_IMAGES = "/assets/commera/images/homepage/demo" @@ -106,11 +119,13 @@ ) -def install_summer_demo(currency=DEFAULT_CURRENCY): - """Seed a demo storefront end to end in `currency`. Safe to re-run.""" +def install_summer_demo(): + """Seed a demo storefront end to end in the company's own currency. Safe to re-run.""" + company = get_demo_company() + currency, country = frappe.db.get_value("Company", company, ["default_currency", "country"]) profile = get_currency_profile(currency) - configure_site_defaults(currency, profile) + configure_site_defaults(currency, country) align_store_currency(currency) save_sale_price_list(currency) create_item_attributes() @@ -143,33 +158,62 @@ def install_summer_demo(currency=DEFAULT_CURRENCY): def get_currency_profile(currency): - profile = CURRENCY_PROFILES.get(currency) - if not profile: + symbol = frappe.db.get_value("Currency", currency, "symbol") or currency + if currency in CURRENCY_PROFILES: + return {**CURRENCY_PROFILES[currency], "symbol": symbol} + + rate = 1 if currency == "USD" else flt(get_exchange_rate("USD", currency)) + if not rate: frappe.throw( - frappe._("No demo currency profile for {0}. Known: {1}.").format( - currency, ", ".join(sorted(CURRENCY_PROFILES)) - ) + frappe._( + "Demo data has no price profile for {0} and no USD exchange rate to derive one. " + "Add a Currency Exchange from USD to {0}, or use one of: {1}." + ).format(currency, ", ".join(sorted(CURRENCY_PROFILES))) ) + # One rounding step per order of magnitude of the rate: 3.75 rounds to tens, 16,000 to ten-thousands. + step = 10 ** max(0, round(math.log10(rate))) + profile = {"symbol": symbol, "price_multiplier": rate, "price_rounding": step} + for key, amount in DOLLAR_PROFILE.items(): + profile[key] = max(step, round(amount * rate / step) * step) + return profile +def get_demo_company(): + """The company to seed, refused until the setup wizard has given it a currency and a chart.""" + company = get_company() + if not company or not frappe.db.exists("Company", company): + frappe.throw(frappe._("Please set up a company first by completing the setup wizard.")) + + if not frappe.db.get_value("Company", company, "default_currency") or not frappe.db.exists( + "Account", {"company": company} + ): + frappe.throw( + frappe._( + "Please finish setting up {0} first: it needs a default currency and a chart of accounts." + ).format(company) + ) + + return company + + def seed_storefront_analytics(): """Give the analytics screen a store worth looking at.""" # Must run after the catalogue: the events and orders it writes reference real item codes. install_analytics_demo_data() -def configure_site_defaults(currency, profile): +def configure_site_defaults(currency, country): """The site-level settings that silently break a paid checkout later.""" # A blank System Settings.language crashes money_in_words (num2words(lang=None)) on every invoice; # a Contact whose email_id is not an address is rejected by the gateways. frappe.db.set_single_value("System Settings", "language", "en") - frappe.db.set_single_value("System Settings", "country", profile["country"]) + frappe.db.set_single_value("System Settings", "country", country) frappe.db.set_default("currency", currency) # A second, separate currency surface: utils.get_currency_symbol reads Global Defaults, not this default. frappe.db.set_single_value("Global Defaults", "default_currency", currency) - frappe.db.set_single_value("Global Defaults", "country", profile["country"]) + frappe.db.set_single_value("Global Defaults", "country", country) # Frappe ships most currencies disabled, and a disabled one cannot be picked on any document. frappe.db.set_value("Currency", currency, "enabled", 1) @@ -181,19 +225,9 @@ def configure_site_defaults(currency, profile): def align_store_currency(currency): - """Put the company, its accounts and every price its catalogue carries into one currency.""" - # The three-way mismatch this undoes (system INR, company SAR, price list USD) throws - # "Party Account ... currency and document currency should be same" on every Sales Invoice. - company = get_company() - frappe.db.set_value("Company", company, "default_currency", currency) - - # ponytail: written straight to the table because ERPNext refuses a currency change once a - # company has transactions — safe on a seeded demo, revisit if this is ever run on a live store. - account = frappe.qb.DocType("Account") - frappe.qb.update(account).set(account.account_currency, currency).where( - (account.company == company) & (account.account_currency != currency) - ).run() - + """Put every price the catalogue carries into the company's currency; the company itself is left alone.""" + # A price list in another currency (USD from install_demo_data) throws "Party Account ... currency + # and document currency should be same" on every Sales Invoice. price_list = frappe.qb.DocType("Price List") frappe.qb.update(price_list).set(price_list.currency, currency).where( price_list.currency != currency @@ -288,7 +322,8 @@ def apply_store_copy(profile): # Without this, set_cod_charges throws "Set a Charge Account Head in Commera Settings...". settings.charge_account_head = ensure_cod_charge_account() - settings.contact_phone = profile["contact_phone"] + if profile.get("contact_phone"): + settings.contact_phone = profile["contact_phone"] for fieldname, value in STORE_COPY.items(): settings.set(fieldname, value) @@ -462,18 +497,34 @@ def allow_shoppers_to_select_accounts(): def get_freight_account(): company = get_company() - abbr = frappe.db.get_value("Company", company, "abbr") + account = frappe.db.get_value( + "Account", {"account_name": "Freight and Forwarding Charges", "company": company}, "name" + ) + if account: + return account + + # Only the standard chart ships this account; country charts don't, so make it where the + # standard chart would put it, or under the deepest expense group the chart has. + expense_group = {"company": company, "root_type": "Expense", "is_group": 1} + parent_account = frappe.db.get_value( + "Account", {**expense_group, "account_name": "Stock Expenses"}, "name" + ) or frappe.db.get_value("Account", expense_group, "name", order_by="lft desc") return ( - frappe.db.get_value( - "Account", {"account_name": "Freight and Forwarding Charges", "company": company}, "name" + frappe.get_doc( + { + "doctype": "Account", + "account_name": "Freight and Forwarding Charges", + "company": company, + "parent_account": parent_account, + "account_type": "Chargeable", + "root_type": "Expense", + "is_group": 0, + } ) - or f"Freight and Forwarding Charges - {abbr}" + .insert(ignore_permissions=True) + .name ) def get_company(): return frappe.defaults.get_user_default("Company") or frappe.db.get_value("Company", {}, "name") - - -def get_company_currency(): - return frappe.db.get_value("Company", get_company(), "default_currency") or DEFAULT_CURRENCY diff --git a/commera/tests/test_summer_demo.py b/commera/tests/test_summer_demo.py new file mode 100644 index 00000000..c86dbda1 --- /dev/null +++ b/commera/tests/test_summer_demo.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026, company@bwhstudios.com and Contributors +# See license.txt + +"""The Summer demo is seeded in the company's own currency, into a company the setup wizard finished.""" + +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from commera import install_summer_demo as demo + +COMPANY = "ZZ Demo Company" +FREIGHT = "Freight and Forwarding Charges" + + +def get_test_company() -> str: + """An IDR company on ERPNext's standard chart: a bare CI site has no company at all.""" + if not frappe.db.exists("Company", COMPANY): + frappe.get_doc( + { + "doctype": "Company", + "company_name": COMPANY, + "abbr": "ZZDC", + "default_currency": "IDR", + "country": "Indonesia", + "chart_of_accounts": "Standard", + } + ).insert(ignore_permissions=True) + + return COMPANY + + +class TestCurrencyProfile(IntegrationTestCase): + def test_curated_currency_keeps_its_local_prices(self): + profile = demo.get_currency_profile("IDR") + + self.assertEqual(profile["flat_shipping_charge"], 15000) + self.assertEqual(profile["symbol"], "Rp") + self.assertEqual(demo.to_local_price(15, profile), 239999) + + def test_other_currency_is_derived_from_the_usd_rate(self): + with patch.object(demo, "get_exchange_rate", return_value=0.9): + profile = demo.get_currency_profile("EUR") + + self.assertEqual(profile["price_multiplier"], 0.9) + self.assertEqual(profile["price_rounding"], 1) + self.assertEqual(profile["flat_shipping_charge"], 6) + self.assertEqual(profile["free_shipping_above"], 72) + self.assertEqual(demo.to_local_price(129, profile), 115) + + def test_rounding_step_follows_the_rate_magnitude(self): + with patch.object(demo, "get_exchange_rate", return_value=16250): + profile = demo.get_currency_profile("VND") + + self.assertEqual(profile["price_rounding"], 10000) + self.assertEqual(profile["flat_shipping_charge"], 110000) + + def test_currency_without_a_rate_is_refused(self): + with ( + patch.object(demo, "get_exchange_rate", return_value=0), + self.assertRaises(frappe.ValidationError), + ): + demo.get_currency_profile("EUR") + + +class TestDemoCompany(IntegrationTestCase): + def test_no_company_asks_for_setup(self): + with ( + patch.object(demo, "get_company", return_value=None), + self.assertRaisesRegex(frappe.ValidationError, "set up a company"), + ): + demo.get_demo_company() + + def test_company_without_a_chart_asks_for_setup(self): + company = get_test_company() + real_exists = frappe.db.exists + + def no_accounts(doctype, *args, **kwargs): + return None if doctype == "Account" else real_exists(doctype, *args, **kwargs) + + with ( + patch.object(demo, "get_company", return_value=company), + patch.object(frappe.db, "exists", side_effect=no_accounts), + self.assertRaisesRegex(frappe.ValidationError, "chart of accounts"), + ): + demo.get_demo_company() + + def test_set_up_company_is_returned(self): + company = get_test_company() + with patch.object(demo, "get_company", return_value=company): + self.assertEqual(demo.get_demo_company(), company) + + +class TestStoreCurrency(IntegrationTestCase): + def test_alignment_leaves_the_company_and_its_accounts_alone(self): + company = get_test_company() + account = frappe.db.get_value("Account", {"company": company, "is_group": 0}, "name") + frappe.db.set_value("Account", account, "account_currency", "USD") + + with patch.object(demo, "get_company", return_value=company): + demo.align_store_currency("IDR") + + self.assertEqual(frappe.db.get_value("Company", company, "default_currency"), "IDR") + self.assertEqual(frappe.db.get_value("Account", account, "account_currency"), "USD") + + +class TestFreightAccount(IntegrationTestCase): + def test_chart_without_freight_account_gets_one(self): + company = get_test_company() + frappe.db.delete("Account", {"company": company, "account_name": FREIGHT}) + + with patch.object(demo, "get_company", return_value=company): + account = demo.get_freight_account() + self.assertEqual(demo.get_freight_account(), account) + + self.assertEqual( + frappe.db.get_value("Account", account, ["company", "root_type", "is_group"], as_dict=True), + {"company": company, "root_type": "Expense", "is_group": 0}, + ) + self.assertEqual(frappe.db.count("Account", {"company": company, "account_name": FREIGHT}), 1)