Skip to content

Powered by Grav CMS + Helios Theme

Selling through more than one company

One catalogue can sell through a UK company in GBP and an EU company in EUR. The order's currency picks its company, and a payment method limited to that currency can use that company's Stripe account. Products, stock and tax zones are still shared. Reports have no filter by company. When each company sells in its own currency, picking that currency in a report shows that company's orders.

This builds on Currencies. Read that page first if the store doesn't sell in more than one currency yet.

Set up the currencies and companies

Choose GBP as the store currency, enable EUR, map GB to GBP and EE (and the other countries you sell to through the EU company) to EUR. Turn on currencies.lock_to_country. Checkout then takes its currency from the shipping country, or from the billing country for a basket with nothing to ship. When the currency switches, the shopper is asked to check the recalculated prices before going on. See Letting the shipping country decide.

On a store that sells in more than one currency, Configuration → General adds a company section for each enabled currency, the base included. Each section has Company name, Company address, Tax ID, Invoice footer, Reverse-charge wording, Reply-to email, Invoice format, First invoice number and Start a new invoice book each year. Leave a section blank to use the store's existing seller details. You can also write the same settings in user/config/plugins/kahunacart.yaml:

YAML
currency: GBP
currencies:
  enabled: [EUR]
  map:
    GBP: [GB]
    EUR: [EE]
  lock_to_country: true
  sellers:
    GBP:
      name: UK Company Ltd
      address: "1 Example Street\nLondon"
      tax_id: GB-example
      support_email: u[email protected]
      invoice_footer: UK company registration details
      reverse_charge_text: Reverse charge applies
      invoice:
        format: 'UK-{year}-{number:5}'
        sequence_start: 1
        reset_yearly: true
    EUR:
      name: EU Company OÜ
      address: "1 Example Street\nTallinn"
      tax_id: EE-example
      support_email: e[email protected]
      invoice_footer: Your EU company and IOSS wording
      reverse_charge_text: Reverse charge applies to this sale
      invoice:
        format: 'EU-{year}-{number:5}'
        sequence_start: 1
        reset_yearly: true

These are example company details, not tax registrations. Set your tax zones, rates and price preferences as described in Tax. A company entry changes the details that are printed. It doesn't change how tax is worked out. Its tax ID and reverse-charge wording also apply to manual orders that carry a customer VAT ID. Put IOSS wording in a company's footer only where your tax setup calls for it. Typing a footer doesn't decide whether you are eligible for IOSS. A product's manufacturer and safety details stay product information.

To sell CAD through the same company as EUR, enable CAD and set currencies.sellers.CAD.use: EUR. All the details and the invoice book then come from EUR. A reference has to end at a company that is defined, and it can't loop back on itself.

Invoice books and fixed order details

Each company gets its own invoice book, invoice:GBP or invoice:EUR. A currency that shares a company with another uses that company's book. With a yearly reset the book also carries the year (UTC). The invoice format takes {number}, {number:5} and {year}. A yearly reset needs {year} in the format, or January would try to reuse last year's numbers. Set the first number before you issue invoices. Changing it later never lowers a counter that is already running. Changing a format after a book is in use can print duplicate numbers, so keep the format stable once you start.

A section you leave out, or a field you leave blank, inherits the store's name, address, tax ID, footer, support details and reverse-charge wording at the moment the order is issued. Those inherited values are saved on the order too, so editing your global settings later can't change an invoice that has gone out, or its reply-to address. The company's details are saved on the order in the same step that takes its invoice number. The order is locked first, so two orders completing at once can't take the same number. If a completion is rolled back, the number goes back into the book.

Credit notes use the company of their invoice and keep a book of their own, credit_note:<company>, with the store's existing credit-note format and yearly-reset settings. A printed credit-note number is unique within its company across every year. Different companies may print the same number. Orders issued before this existed keep the old store details and the global invoice book.

Invoices, credit notes, packing slips, receipts and order emails all read the company details saved on the order. Emails use the company name and its reply-to email, and still send from the store's configured sending address. Changing a company's settings doesn't rewrite invoices you have issued or move old orders into another book. The product pages' structured data and the product feeds use the company for the currency being shown. UCP catalogue products do the same, and the business profile, which has no currency of its own, publishes the mapping in an optional kahunacart.sellers extension.

Give each payment method its own Stripe account

Enable the Stripe plugin, then open Payments. Make a UK card method limited to GBP and an EU card method limited to EUR. In each one, under Payment account, tick Use this method’s own account, enter its Stripe account ID and keys, and save. The keys are write-only: when you reopen the method they are blank, and saving a blank secret keeps the one that is saved. Switch the account off and new payments go back to the plugin's ordinary Stripe settings, which stay the default for every method without an account of its own.

Each account chooses Managed Payments, Managed Payments tax code and API version for itself. A blank API version uses the plugin's own. Managed Payments is still for digital goods only. Checkout checks the goods and the required address fields against the selected account.

Accounts use Stripe's hosted checkout, including hosted authorisation when the plugin uses manual capture. Each method is checked against the whole basket and the plugin's suitability rules, using its own account. Saved cards, express wallets, agent wallet tokenization and recurring agreements stay on the default account in this release. Don't offer a method with its own account as the way to pay for those.

After saving:

  1. Press Check account. It confirms that the key belongs to the account ID you typed.
  2. Press Set up webhook. The method shows its Webhook endpoint, which ends in /webhook/stripe/<token>. Registering it automatically keeps the signing secret it gets back, encrypted, and never shows it to the admin.
  3. Reset this endpoint replaces that one endpoint and nothing else. An older account setup's endpoint has to stay registered while payments opened through it can still settle or be refunded.

What is kept, and where. Credentials are encrypted with the store's SettingsCipher, which is backed by the database's site secret. The encrypted settings are in the method's config_json, and each revision of an account is also kept in kahunacart_payment_accounts. Every transaction records its account_token. When you change, limit or delete a method, the revisions that are still needed to capture, void, refund or take another payment on a placed order are kept. The order's pay link offers the account it was opened with and sends every later payment there, even after its currency is switched off for new carts. A customer reference is keyed by the gateway plus the account ID and test or live mode, so a Stripe customer ID from one account is never handed to another. Database backups hold the encrypted credentials, so keep them and the site secret private. A configuration export leaves out payment methods, accounts and credentials. On another store, make the methods, type each account in again and register its webhook. An archived method stays on the original store for its old transactions.

Webhooks. The existing /webhook/stripe endpoint keeps handling the default account. Each token endpoint checks the signature with its own secret, and then checks that the matching transaction belongs to that account before it can change an order. A signature that is valid for another method is refused. An unknown token returns a 404. Events for payments this store doesn't know about are acknowledged and change nothing. An account webhook has to name one payment consistently. If its order, transaction or refund-parent identifiers disagree, KahunaCart refuses it and writes the refusal to the webhook log.

Refunds. A refund from the dashboard belongs to the purchase it names, including on an order with more than one charge. Manual refunds and refunds to store credit that are recorded from now on keep their purchase's account token. Recording a manual refund against a stranded row that already exists keeps that row's account. A hash of an authorisation and the settled capture of that same authorisation may point at the same payment. A capture of a different authorisation is refused.

Each attempt picks its account first. A payment attempt chooses its account before pricing, and uses that same account for the tax calculation and for starting the payment. If you edit the method while a checkout is running, the change applies from the next attempt.

Run bin/plugin kahunacart status after setup. It warns when a company has no enabled payment method limited to its currency, and when two such methods use the same account. Check that your provider plugin is enabled too: a method over a gateway that isn't registered can't take payment.

Admin API and MCP

The payment method create and update routes accept config.account_override, following the account_override_fields schema the gateway reports in GET /kahunacart/payment-methods. A field the schema doesn't list is refused. Secret fields are encrypted when written and come back as ********. Send a blank or masked value to keep the saved secret. Send account_override: null to go back to the default account. Core assigns account_token, and a client can't send one.

POST /kahunacart/payment-methods/{id}/check-account (check_payment_method_account) checks the saved account. POST /kahunacart/payment-methods/{id}/setup-webhook (setup_payment_method_webhook) registers its endpoint, and its optional reset boolean replaces only that revision's endpoint. Both need kahunacart.settings, neither returns a secret, and both contact the gateway only when someone runs them on purpose.

For developers

A manual order or an add-on that wraps order creation. On a server database, a transaction that will issue an order number or a credit-note number locks its counters before any ordinary read starts the transaction's snapshot. Manual orders built with OrderBuilder claim their books before they add lines. If an add-on wraps creation and completion in its own transaction, it has to call Sequence::lock() for the order counter, and InvoiceNumbering::lockForOrder() and CreditNoteNumbering::lockForOrder() for the books it will use, before it makes any ordinary reads. The locks and counter changes stay inside that transaction and roll back with it.

A gateway that wants a second account. See Account overrides in the provider contract and Account overrides in the developer guide.