Click and collect
A collection point is a named branch a customer picks at checkout instead of delivery. It has a full address, opening hours, an optional charge, and it sets the country the order is taxed in. This page is for store administrators setting up branches.
The pickup shipping method type prices a collection but says nothing about where it is. A collection point adds the branch, the address, the hours and the tax country.
Before you begin
- At least one shipping zone, so the rest of checkout works.
- The country where each branch is located. This is required to determine the tax.
Create a collection point
- In the admin, open Shipping → Pickup locations and choose Add location.
- Give it a code and a name. The code is unique per store and is what documents print, so renaming a branch never changes the code an order was collected under.
- Fill in the address. Country is required.
- Optionally set the hours, an instruction key, a collection charge and a tax class.
- Leave Offered at checkout on.
The location appears at checkout immediately. A store with no collection points draws nothing at checkout.
Set opening hours
Hours are per weekday, each a list of open and close pairs, plus one free-text line for exceptions:
mon: [['09:00', '17:30']]
tue: [['09:00', '17:30']]
wed: [['09:00', '13:00'], ['14:00', '17:30']]
sat: []
exceptions: 'Closed 24-26 December'
- A weekday with an empty list is closed that day, and prints as "Closed".
- A weekday that is absent prints nothing at all. Filling in Monday to Friday does not declare the shop shut at the weekend.
Write the instruction
instructions_key holds a language key, never a sentence, so a bilingual store can translate it. Add your own key under PLUGIN_KAHUNACART in languages/en.yaml, or in any key namespace your site already uses, and put its name in the field.
Charge for collection
price_minor is the optional charge for collecting. Empty is not the same as zero:
| Value | What the customer pays | What prints |
|---|---|---|
| Empty | Nothing | No line at all |
0.00 |
Nothing | A line reading 0.00 |
Use zero when you want to say out loud that collection is free.
The charge is written as the order's shipping adjustment, with a snapshot naming the location's id, code, name and tax anchor. The order keeps that record after the branch has closed or the charge has changed.
The charge in another currency
On a store that sells in more than one currency a location can carry a charge in each currency, entered beside the base one. A location with a charge in the base currency is offered in another currency only where it has a charge there too. Wailuku charging 3.00 dollars and nothing in euro is not offered to a euro order, because the counter has not said what it charges in euro. A location with no charge, and one whose charge is 0, are offered in every currency, and the zero still prints its 0.00 line.
With typed pricing the charge is read by the order's currency and never converted, so the 2.00 in dollars is not 2.00 euro, and 300 in yen is 300 yen and not 3.00. A currency set to convert works a missing charge out from the base charge instead, to that currency's own decimal places, and a charge you typed still wins. An amount in a currency counts only where the base has a charge: clearing the base charge leaves a euro charge that does nothing.
The charge is written as the order's shipping adjustment, with a source_json snapshot naming the location's id, code, name and tax anchor — so "why 2.00?" is answerable years later, after the branch has closed and the charge has changed.
Tax: a collected order is taxed at the counter
When a customer collects an order:
- the tax zone is matched on the location's country, state and postcode;
- the customer's own address is not consulted for tax;
- there is no delivery address on the order to consult.
A shop in Ireland selling to a customer in France charges Irish VAT on an order collected in Dublin.
The collection charge is taxed like every shipping line, by the rates with apply_to_shipping set. The location's tax class is recorded on the adjustment's snapshot and decides which rate taxes the charge: a location on the services class is taxed by the services rate, not the goods' rate. See Tax.
A collected order has no delivery address
When a collection point is chosen:
- the checkout form stops asking for a delivery address;
- the submit path never stores one;
- the form parked in the session for a bounced submit carries none either;
- the country and state validation for a delivery address does not run.
Switching back to delivery is one radio button away, and the delivery fields come back with it.
What prints, and where
The location's name, address, hours and instruction print on all four documents, from one partial:
| Document | Where |
|---|---|
| The receipt | Above the item table, under "Collect from" |
| The confirmation email | Above the total, in both the HTML and the text body |
| The invoice | Beside the billing party, under "Collected from" |
| The packing slip | Above the delivery block, so whoever packs it knows which counter it goes behind |
The partial is partials/kahunacart-pickup-location.html.twig, with a plain-text twin for the text half of the email.
Turn collection off
Set shipping.pickup_enabled to false. The locations stop being offered but stay configured. An order carrying a stale pickup location is priced as a delivery while the switch is off.
Close a branch
Switch Offered at checkout off. Do not delete it: the orders that named it still have to print where they went. Delete is for a row created by mistake.
API reference
All four routes need kahunacart.products.manage, and each has an MCP tool that takes the same arguments.
| Route | MCP tool | What it does |
|---|---|---|
GET /kahunacart/pickup-locations |
pickup_locations_list |
Every location, enabled or not, in your order |
POST /kahunacart/pickup-locations |
pickup_location_create |
Add one. code, name and country are required |
PUT /kahunacart/pickup-locations/{id} |
pickup_location_update |
Change one |
DELETE /kahunacart/pickup-locations/{id} |
pickup_location_delete |
Remove one |
| Field | Notes |
|---|---|
code |
Required, unique per store, printed on documents |
name |
Required |
address_1, address_2, city, state, postcode |
The address as it is walked to |
country |
Required. The tax anchor |
phone |
The branch's contact phone number |
hours |
Weekday to a list of open and close pairs, plus exceptions |
instructions_key |
A language key, never a sentence |
price |
A decimal string in the base currency. Empty means no charge |
currency_amounts |
The charge in other currencies, like {"EUR": {"price": "1.80"}}. Only where price is set. An empty string takes one away |
tax_class |
Recorded on the adjustment snapshot, and the class the collection charge is taxed at |
enabled |
Offered at checkout |
position |
The order the picker draws them in |
On a store that sells in more than one currency each location also comes back with offered_in, the currencies it can be collected in. A single-currency store gets neither key.
Configuration
| Key | Default | Effect |
|---|---|---|
shipping.pickup_enabled |
true |
Set to false to stop offering collection points without deleting them |
Related
- Shipping - delivery methods, the five free-shipping modes, and why a method is not offered
- Tax - the zone matcher, and the pickup-location tax anchor
- Theming - the checkout region and the partials that draw it
- Countries and addresses - the formatter that lays out a branch address
- Currencies - selling in more than one currency