Appearance
Shop
Products with categories and tags, cart and checkout, orders, customers and shipping — on WooCommerce's data.
kitloom/shop · admins: kitloom/shop-filament, kitloom/shop-nova
A shop on WordPress data in WooCommerce's format: products with their categories and tags, the cart, checkout with online and offline payment, and orders in WooCommerce's HPOS tables. Everything is kept in WooCommerce's tables, meta keys and options, so a live WooCommerce shop opens here as it is. The move is one way — a shop goes from WooCommerce to kitloom, and WooCommerce is not expected to read kitloom's data back:
- arrays are written as JSON, which WooCommerce cannot read (kitloom reads WooCommerce's serialized ones, and a value it reads that way becomes JSON on its next save): a product's
_product_attributes,_upsell_ids,_crosssell_idsand_wc_rating_count; a coupon'sproduct_categories,exclude_product_categoriesandcustomer_email; an order line's_line_tax_data, the shipping line'staxesand a coupon line'scoupon_data; the options that hold lists or settings — the countries of selling and shipping,woocommerce_{method}_{instance}_settings,woocommerce_{id}_settingsof the emails,woocommerce_permalinks; - orders are read and written in the HPOS tables only (with a placeholder post each): a shop that still keeps its orders as posts (
shop_orderposts with their meta — WooCommerce's legacy storage) shows none of them here until WooCommerce has moved them to HPOS; - WooCommerce's lookup and analytics tables (
wc_product_meta_lookup,wc_order_stats,wc_order_product_lookup,wc_customer_lookup,wc_reserved_stock, …) and its transients and caches are not kept up to date — the one exception iswc_attribute_taxonomies, dropped when the attributes change.
Payments come from kitloom/payments; the admin is kitloom/shop-filament or kitloom/shop-nova.
Where things are kept
| What | Where |
|---|---|
| Products | posts of type product; price, SKU, stock and the rest in WooCommerce's meta (_regular_price, _sale_price, _price, _sku, _manage_stock, _stock, _stock_status, _backorders, _low_stock_amount, _sold_individually, _virtual, _downloadable with its files _downloadable_files, _download_limit and _download_expiry, _weight, _length, _width, _height, _tax_status, _tax_class, _purchase_note, _upsell_ids, _crosssell_ids, _product_image_gallery, total_sales); "Enable reviews" is the post's comment_status |
| Variations | a variable product is a product with the product_type term variable; each variation a post of type product_variation under it (post_parent), published while enabled and private while disabled, titled "Hoodie - Blue, L" with "Color: Blue, Size: L" as its excerpt, in menu_order; its values in attribute_pa_color (a term slug) and attribute_size (a custom value) — empty for any value — and its own _regular_price, _sale_price, _price, _sku, _manage_stock, _stock, _stock_status, _backorders, _weight, _length, _width, _height, _virtual, _downloadable, _thumbnail_id, _variation_description, _tax_class (parent — the product's) and its own product_shipping_class term; the product's _price is the lowest of its enabled variations'. In the cart a line keeps variation_id and the values chosen (variation); an order's line _variation_id and an item meta per attribute (pa_color => blue, size => L) |
| Reviews | comments of type review on the product, the stars in the comment meta rating, a buyer's mark in verified; on the product _wc_average_rating, _wc_review_count, _wc_rating_count |
| Categories, tags | the taxonomies product_cat and product_tag; product_type and product_visibility as WooCommerce keeps them |
| Attributes | global ones in WooCommerce's table woocommerce_attribute_taxonomies (the migration creates it when missing), each with its terms in a taxonomy pa_{slug}; a product's attributes listed in the meta _product_attributes (JSON here; WooCommerce's serialized one is read too), the values of a global one as the product's terms, of a custom one in that meta; a term's place under "Custom ordering" in the term meta order |
| Orders | the HPOS tables wc_orders, wc_order_addresses, wc_order_operational_data, wc_orders_meta, with a placeholder post per order, and the lines in woocommerce_order_items and woocommerce_order_itemmeta — the package's migration creates whichever of them the database lacks |
| Order stock | on each line of an order that holds its stock, _reduced_stock: the units it really took (fewer than ordered when a product stops at zero) — what a cancellation or a changed line gives back, as WooCommerce reads it; gone once they are back. The order's order_stock_reduced (operational data) says it holds them |
| Order sales, downloads | the products' total_sales, with recorded_sales in the order's operational data saying whether the order is counted in them; a paid order's download permissions in WooCommerce's table woocommerce_downloadable_product_permissions — a row per file (download_id, product_id, order_id, order_key, user_email, user_id, downloads_remaining, access_granted, access_expires, download_count; the migration creates the table when missing) — and download_permission_granted in the operational data once they are written |
| Order notes | comments of type order_note on the order — kept out of every list of comments (the Comments screens, widgets, an agent's tools), as WooCommerce keeps them, with its webhook_delivery and action_log; the order's page reads them |
| Refunds | a row of wc_orders of type shop_order_refund under the order (parent_order_id, a negative total, its own placeholder post), the amount, reason, who gave it and whether the gateway sent the money in its meta (_refund_amount, _refund_reason, _refunded_by, _refunded_payment; kitloom adds _refund_payment_id, kitloom/payments' refund row, so a refund the gateway reports is recorded once), the refunded lines as its items with negative quantities and _refunded_item_id; a line put back in stock keeps _restock_refunded_items |
| Fees | order items of type fee (_fee_amount, _line_total, _line_tax, _tax_class, _tax_status, _line_tax_data) |
| Coupons | posts of type shop_coupon: the code (in lower case) as the title, the description as the excerpt, the rules in WooCommerce's meta (discount_type, coupon_amount, free_shipping, date_expires — a unix timestamp, minimum_amount, maximum_amount, individual_use, exclude_sale_items, product_ids and exclude_product_ids — ids, comma separated, product_categories and exclude_product_categories — JSON here, WooCommerce's serialized ones are read too, customer_email, usage_limit, usage_limit_per_user, limit_usage_to_x_items, usage_count), a _used_by row per use; on an order, an item of type coupon per code (discount_amount, discount_amount_tax, coupon_data), the lines' _line_subtotal before the discount and _line_total after it, the discount in discount_total_amount; the codes applied to a cart, in the session |
| Shipping | WooCommerce's zone tables woocommerce_shipping_zones, woocommerce_shipping_zone_locations (country US, state US:CA, continent EU, postcode), woocommerce_shipping_zone_methods (the migration creates whichever are missing; the default zone has no row, its methods have zone 0); a method's settings in the option woocommerce_{method}_{instance}_settings (a flat rate's class costs as class_cost_{term_id}, no_class_cost and type there); shipping classes in the taxonomy product_shipping_class, a product's class as its term; where the shopper said the cart goes, in the session |
| Taxes | the classes in WooCommerce's table wc_tax_rate_classes (name and slug; the standard class has no row — its slug is ''), the rates in woocommerce_tax_rates (tax_rate_country, tax_rate_state, tax_rate — the percent to four places, tax_rate_name, tax_rate_priority, tax_rate_compound, tax_rate_shipping, tax_rate_order, tax_rate_class) with their postcodes and cities in woocommerce_tax_rate_locations (the migration creates whichever are missing, the classes table with WooCommerce's "Reduced rate" and "Zero rate"); on an order, a line's _line_subtotal_tax, _line_tax and _line_tax_data (the tax of each rate, JSON here — WooCommerce's serialized one is read too), the shipping line's total_tax and taxes, a coupon's discount_amount_tax, an item of type tax per rate (the rate's code as its name, rate_id, label, compound, tax_amount, shipping_tax_amount, rate_percent), the order's tax_amount — the items' and the fees' tax alone, WooCommerce's cart tax — and shipping_tax_amount, discount_tax_amount, prices_include_tax in its operational data; the order's tax is the two added up, as WooCommerce's get_total_tax() |
| Customers | not stored apart: read from the orders, one per billing email; a signed-in customer's saved addresses in the user meta billing_* and shipping_* |
| Settings | WooCommerce's options: the store's address and where it sells and ships (woocommerce_store_address, _address_2, _city, woocommerce_store_postcode, woocommerce_default_country, woocommerce_allowed_countries with woocommerce_all_except_countries and woocommerce_specific_allowed_countries, woocommerce_ship_to_countries with woocommerce_specific_ship_to_countries, woocommerce_default_customer_address), how the checkout asks (woocommerce_enable_guest_checkout, woocommerce_enable_checkout_login_reminder, woocommerce_enable_signup_and_login_from_checkout, woocommerce_registration_generate_password, woocommerce_terms_page_id, woocommerce_checkout_phone_field, _company_field, _address_2_field, woocommerce_ship_to_destination), the taxes (woocommerce_calc_taxes, woocommerce_prices_include_tax, woocommerce_tax_based_on, woocommerce_shipping_tax_class, woocommerce_tax_round_at_subtotal, woocommerce_tax_display_shop, woocommerce_tax_display_cart, woocommerce_price_display_suffix, woocommerce_tax_total_display), currency and its format (woocommerce_currency, woocommerce_currency_pos, separators, decimals), the Shop, Cart, Checkout and My account pages (woocommerce_*_page_id), sign-up on the account page (woocommerce_enable_myaccount_registration), the cart's shipping calculator and whether rates wait for an address (woocommerce_enable_shipping_calc, woocommerce_shipping_cost_requires_address), the product addresses (woocommerce_permalinks), the catalog grid and its default sorting (woocommerce_catalog_columns, woocommerce_catalog_rows, woocommerce_default_catalog_orderby), the product image sizes (woocommerce_thumbnail_image_width, woocommerce_thumbnail_cropping and its _custom_width/_custom_height, woocommerce_single_image_width), woocommerce_hold_stock_minutes, woocommerce_downloads_grant_access_after_payment, woocommerce_cart_redirect_after_add, the units (woocommerce_weight_unit, woocommerce_dimension_unit), woocommerce_notify_low_stock_amount, woocommerce_hide_out_of_stock_items, the reviews (woocommerce_enable_reviews, woocommerce_review_rating_verification_label, woocommerce_review_rating_verification_required), the emails (woocommerce_email_from_name, woocommerce_email_from_address, woocommerce_email_footer_text, and woocommerce_{id}_settings per email) |
In the admin
Everything of the shop is in one Shop section, in both admins:
| Screen | What it is for | Who opens it |
|---|---|---|
| Orders | The orders, by status, with bulk status changes; an order's items, fees, shipping, addresses, customer, notes, status, refunds and emails; "Add order" — see Orders | edit_shop_orders (administrators, editors) |
| Coupons | The shop's coupons: their code, type, amount, usage and limit, expiry and description; a coupon's General, Usage restriction and Usage limits — see Coupons | edit_shop_coupons (as in WooCommerce, not the roles that write posts: the roles that manage the site's options — administrators — get it when the shop is installed; change it on the roles screen) |
| Customers | Everyone who ordered — guests too — whether they have an account (Registered) or not (Guest), the number of orders, what the paid ones (processing, completed) add up to less their refunds (Total spend), and the latest; a row (in Nova, its email) opens that customer's orders | edit_shop_orders |
| All Products, Add New Product, Categories, Tags | The catalogue; the product list filters by category, stock status and whether a product is on sale | edit_products, manage_product_terms |
| Attributes | The global attributes and, under "Configure terms" (in Nova, on an attribute's page), their terms; see Attributes | manage_product_terms |
| Shipping | The shipping zones and their methods, see below | manage_woocommerce (administrators) |
| Shipping classes | The classes a flat rate may cost more for, see below | manage_product_terms |
| Tax rates | The tax rates of each tax class, see Taxes | manage_woocommerce |
| Settings | General first — the store's address and country, where it sells and ships, where a new customer's address starts — then Accounts & checkout — guest orders, the login reminder, accounts made at the checkout, the terms page, which address fields are asked, where orders are shipped by default — and Tax (see Taxes); the Shop, Cart, Checkout and My account pages, whether customers sign up on the account page, the currency and how prices are written, how many products a catalog page shows and how it is sorted by default, whether adding to the cart leads to the cart, the units of weight and dimensions, the product image sizes, how long an unpaid order holds its stock, the low and out of stock thresholds and who is emailed about them, whether out of stock products leave the catalog, the reviews, the shop's emails | manage_woocommerce |
| How it works | This page | whoever sees orders or products |
Who may do what. The shop asks WooCommerce's own capabilities: edit_shop_orders for the orders, the customers, the reports and an agent's order tools; manage_woocommerce for the shop's settings, shipping and tax rates; edit_products, manage_product_terms, edit_shop_coupons for the catalogue and the coupons. The shop declares edit_shop_orders and manage_woocommerce itself (with kitloom/permissions installed), so they are there with no admin package at all — a shop run by its AI agents only: when they first appear they go to the roles that manage the site's options (administrators, and any role made with manage_options), and edit_shop_orders to editors too, as the admins give it. WooCommerce's Shop manager role is not created for you: make one on the roles screen with these capabilities if you want it; one brought over from a WordPress database keeps what its row lists. Who holds which permission is changed on the roles screen.
Shipping
Shop → Shipping lists the shipping zones, WooCommerce's way of offering each address its own methods. A zone has a name, the regions it covers and its methods. A customer is offered the methods of the first zone, top to bottom, that covers their address; drag the zones (Filament) or move them up and down (Nova) to set that order. Locations not covered by your other zones is always last: its methods are for every address no zone covers. When the zone that covers an address has no method that applies — say, only free shipping from 50 and the cart is 30 — the customer sees no shipping options and cannot place the order, as in WooCommerce.
A zone's regions, on its page:
| Region | Covers |
|---|---|
| Countries / Regions | An address in one of them |
| States | An address in that state of that country (US:CA — California) |
| Continents | An address in a country of that continent (Europe, North America, …) |
| Postcodes | One per line: 90210 exactly, 902* — every postcode starting so, 90210...90215 — a range (numbers compared as numbers, anything else letter by letter). Spaces, dashes and case do not matter |
A zone with countries, states or continents and postcodes needs both: the country and one of the postcodes. A zone of postcodes alone takes them in any country. A zone with no regions at all takes every address the zones above it leave. Until the customer says where they are, the cart uses their saved address when they are signed in, otherwise the store's country (Shop → Settings → General, Default customer location) — or nowhere, when that option says so: then only a zone of no regions, or the last one, applies.
Below its regions, a zone's page lists its methods, in the order the customer sees them at checkout; drag them (Filament) or move them up and down (Nova) to change it. Each method:
| Method | Settings |
|---|---|
| Flat rate | A title and a cost. The cost is a number, or a sum: [qty] is the number of items in the cart that are shipped (virtual ones are not), [cost] what they cost after the coupons, without tax, [fee percent="10" min_fee="2" max_fee="20"] a share of that within limits. 4 + 1.5 * [qty] — 4 per order and 1.5 per item. With shipping classes, a cost per class too — see below |
| Free shipping | Always; from a minimum order amount; with a coupon that allows free shipping; with either; or with both. The minimum is measured after the coupons, unless Apply minimum order rule before coupon discount is on |
| Local pickup | A title and a cost, usually none. |
The customer's choice of rate stays while the rates offered stay the same. When they change — another address, a coupon that allows free shipping, a cart that reaches the free shipping minimum — the cart chooses again, as WooCommerce does: local pickup while it was chosen and is still offered, else free shipping while a coupon allows it, else the first rate.
A method switched off stays in the list and is not offered. Nor is a flat rate whose cost is left empty and that no class cost prices — as WooCommerce leaves it out: write 0 for a free one. Deleting a zone deletes its regions and its methods; Locations not covered by your other zones has neither a name nor regions to change and is never deleted — only its methods change.
Until the shop has a method in any zone, it offers what the site's config says: a flat rate of 5.99 and free shipping from 50 (see Configuration). The first method added replaces both. The zones list says so meanwhile — above the zones in Filament, on the Locations not covered by your other zones row in Nova.
A shop that comes from WooCommerce keeps its zones, regions and methods as they are. Methods of other plugins (table rates and the like) are listed and can be removed, but not edited, and are not offered.
Shipping classes
Shop → Shipping classes lists the classes — "Bulky", "Fragile" — that group products shipped at another cost. A product has one class at most: Shipping class in the Shipping tab of its form. A flat rate then has, under Shipping class costs, a cost for each class and one for No shipping class — sums like its cost, where [qty] and [cost] are the quantity and the cost of that class's items in the cart — and a Calculation type:
- Per class (WooCommerce's default) — each class in the cart costs its cost once, on top of the method's cost: two sofas of "Bulky" at
10 * [qty]and a vase of "Fragile" at3make 5 + 20 + 3 with a cost of 5; - Per order — only the dearest class's cost is added: 5 + 20.
A class cost left empty adds nothing; items that are not shipped (virtual products) belong to no class. Class costs count only while the shop has classes.
Products
A product saved anywhere — either admin, a script, an agent — goes through ProductWriter, which leaves what WooCommerce leaves on save:
- the price shown (
_price) is the sale price while it is below the regular one, otherwise the regular price; - the sale price must be below the regular price — a sale price without a regular one is refused, as WooCommerce's form refuses it — and an SKU is unique across products (an empty one may repeat);
- the shipping class must be one of Shop → Shipping classes and the tax class one of the shop's — or the class the product holds already: a class taken off Shop → Settings → Tax stays on its products, shown on the form as "zero-rate (removed)", and the product saves with it unchanged until another is chosen;
- with managed stock, a product whose stock reaches zero is out of stock — or on backorder, when it allows backorders — and the
outofstockvisibility flag follows; - prices are stored as WooCommerce rounds them, to the shop's Number of decimals (Settings → General; 2 unless set, 3 for a dinar, 0 for a yen — the catalog's price filter and sort, the coupons' discounts, the cart's lines and totals, the taxes, the shipping costs and the order's amounts all count in the same unit):
19.50is19.5; a percentage coupon's amount is not money and is kept as typed; weight and dimensions as typed, without trailing zeros (1.50is1.5), nothing rounded.
Both admins save the product form through ProductWriter as the signed-in user, with these rules and this format — the same as a script's or an agent's. The product, its fields, its categories, tags and image, its attributes and its variations are saved together or not at all: when the writer refuses something — a variation row's sale price above its regular one, two attributes of one name — the message is shown on that field (and as a notice, since the row may be folded or on another tab) and nothing of the save is kept, the title included.
The product form has WooCommerce's "Product data" box, in both admins:
| Where | Field | What it does |
|---|---|---|
| Above the tabs | Product type | Simple or variable — see Variable products. A variable product has no price of its own: the General tab loses the prices and a Variations tab appears |
| Virtual | Not shipped. A cart of virtual products only has no delivery: no shipping rates, nothing to choose at checkout, and the order has no shipping line | |
| Downloadable | The mark, as WooCommerce keeps it. The files themselves, the download limit and the expiry are WooCommerce's meta (_downloadable_files, _download_limit, _download_expiry): a product brought over from WooCommerce keeps them, and its buyers get their download permissions when their orders are paid (see Orders → Sales and downloads). The product forms do not edit the files yet, and the shop does not serve the downloads | |
| General | Regular price, Sale price | See above |
| Tax status, Tax class | Taxable, shipping only (the product carries no tax, but the shipping of an order with it does) or none; the standard class or one of the shop's (Shop → Settings → Tax). See Taxes | |
| Inventory | SKU, Track stock quantity, Quantity, Stock status | The stock status is chosen while the quantity is not tracked: in stock, out of stock, on backorder |
| Allow backorders? | With a tracked quantity: no; allow, but notify the customer; allow. A product that allows them is sold past its stock — the quantity goes below zero, the product is "On backorder", the cart and the checkout do not stop at the stock | |
| Low stock threshold | At or below this many units the product lists say "Low stock", and an order that leaves the product there emails the shop "Product low in stock" (see Emails). Empty — the shop's Low stock threshold in Shop → Settings → Inventory (2); a variation without one goes by its product's | |
| Limit purchases to 1 item per order | The cart keeps it at one: adding it again says "You cannot add another “…” to your cart." | |
| Shipping | Weight, Length, Width, Height | In the units of Shop → Settings → Product catalog (kg and cm by default). Hidden for a virtual product |
| Shipping class | One of Shop → Shipping classes, or none: what a flat rate's class costs go by (see Shipping) | |
| Linked products | Upsells | Shown on the product's page under "You may also like…" |
| Cross-sells | Offered in the cart while the product is in it, under "You may be interested in…": up to four, none already in the cart | |
| Advanced | Purchase note | Shown to the customer with the product on the "order received" page and the order's page in My account, once the order is paid (processing or completed), as WooCommerce shows it |
| Menu order | The catalog's default order: lower first | |
| Enable reviews | Whether the product takes reviews, see Reviews |
Beside them, in the Publish box (in Nova at the end of Product data), Catalog visibility: shop and search results; shop only; search results only; hidden. It is kept in WooCommerce's product_visibility terms exclude-from-catalog and exclude-from-search. The product lists show the stock as WooCommerce does: "In stock (12)", "Low stock (2)", "On backorder", "Out of stock".
Stock sold while you edit. The product form remembers the quantity it opened with (WooCommerce's _original_stock). Saving without touching the quantity leaves the stored stock as it is — the orders placed while the form was open stay counted. If you change the quantity and the stock moved in the meantime, the form refuses: "The stock has changed since you opened the product: it is N now." Enter the quantity again from there. The same holds for each variation's own stock. A stock that is not tracked keeps whatever quantity is stored; the quantity fields show only while "Track stock quantity" (a variation's "Manage stock?") is on.
On sale. The product list's "On sale" filter lists products with a sale price; a variable product counts as on sale when one of its enabled variations has a sale price. The same rule applies to the AI agent's products_list (on_sale: true or false).
Grouped and external products. A shop brought over from WooCommerce may have them. They keep their type but are not sold here, as WooCommerce's is_purchasable() says of them: the cart refuses one ("Sorry, this product cannot be purchased.") and drops one already in a cart. A theme draws no "Add to cart" for them — the product card says purchasable: false, as it does for a product without a price — an external product's button leads to the site that sells it (externalUrl, _product_url; buttonText, its _button_text or "Buy product"), a grouped product's card to its page ("View products"). The product form offers neither type, and a grouped product's page does not list its products yet. An order taken by hand in the admins may still have them, as WooCommerce's order screen allows.
Tags and shipping classes have a description, as in WooCommerce (both admins).
Products are guarded by edit_products and the rest of WooCommerce's capabilities, the product taxonomies by manage_product_terms and the like.
Products keep revisions (supports has revisions, which WooCommerce leaves out): their title, description, short description and custom fields can be taken back from Revisions in the admin or by an agent. Prices, stock and the other product meta are not in them.
Attributes
Attributes describe products: Color — Red, Blue; Material — Linen. As in WooCommerce there are two kinds:
- Global attributes are made once, in Shop → Attributes, and their values — the terms — are kept apart from the products, so every product picks from the same list: Red is the same Red on a mug and on a scarf, a catalog can be filtered by it, and a term may have a page of its own. Use them for anything shoppers compare or filter by.
- Custom attributes are typed on one product, name and values ("Filling: Feather | Foam"). Nothing else shares them and the catalog does not filter by them.
A WooCommerce export imported here (kitloom/import-export) names its products' global attributes by their terms only — the attributes themselves are rows of WooCommerce's own table, which no export file carries. As WooCommerce's importer does, the shop makes each attribute the moment its first term arrives: named as its slug (color), a select in custom ordering, without archives. Rename it in Shop → Attributes.
An attribute in Shop → Attributes has:
| Field | What it does |
|---|---|
| Name | What shoppers see: "Color" — with kitloom/languages, translated on Languages → Site Texts → Shop Attributes ("Poids" on the French pages) |
| Slug | Its key — the taxonomy is pa_{slug} and the catalog's filter filter_{slug}. Empty — made from the name, cut to 28 characters and numbered when taken (size, size-2). One typed in must be at most 28 characters, not in use and not one of WordPress's reserved words (type, name, order, …). Changing it moves its terms and renames it on its products |
| Enable archives? | Its terms get pages listing their products, at /{slug}/{term}/ — /color/red/ |
| Type | Select, WooCommerce's only one; a type another plugin wrote is kept |
| Default sort order | How its values are listed on a product and in the filters: custom ordering (each term's Order, lower first), name, name read as a number (2 before 10), term ID |
Configure terms (in Nova, the attribute's page) lists its terms: name, slug, description and Order, added, changed and deleted there. A deleted term goes to the trash first, as other terms in kitloom do (WooCommerce deletes it at once): the Trash tab (in Nova, Trash in the terms' Status filter) restores it or deletes it for good, and until then products and filters leave it out. Deleting an attribute deletes its terms too, as WooCommerce does; products show nothing for it from then on. Attributes and their terms are guarded by manage_product_terms.
On a product, the Attributes tab of the product form (in both admins) has a row per attribute, in the order they are dragged into: a global one with its values picked from its terms (a new value is added to the attribute as a term — in Filament with "+" beside the values, in Nova in "Add new values", separated by |), or a custom one with its name and values separated by |. Each row has Visible on the product page — shown under "Additional information" on the product's page, below the product's weight and dimensions with the units of Shop → Settings (a virtual product has none; a variation's own once it is picked) — and Used for variations: a variable product's variations are made of the values of these attributes (see Variable products). A row left without values is dropped; a global attribute taken off a product takes its values off it too.
Variable products
A hoodie in blue and red, small and large, each with its own price, SKU, stock and picture: a variable product, as WooCommerce has it. The shopper picks the colour and the size on the product's page and buys that variation; the catalog shows the product once, with its price range — "$10.00 – $20.00" — and "Select options" instead of "Add to cart".
To make one, on the product form (in both admins):
- Product type → Variable product.
- Attributes tab: the attributes the variations differ by, each with its values and Used for variations ticked — a global one (Color) or a custom one (Size: S | L). Attributes not used for variations stay "Additional information". Save the product: the Variations tab works with the saved attributes, as WooCommerce asks.
- Variations tab: a row per variation, or Generate variations (in Nova, the product's action of that name) — one row for every combination of the values that no variation covers yet, up to 50 at a time, without prices. Each row has:
| Field | What it does |
|---|---|
| One select per attribute | The value the variation is for, or Any …: a variation for "any size" is sold in every size, the shopper's choice kept on the order |
| Enabled | A disabled variation is kept but not sold (WooCommerce keeps it private) |
| Regular price, Sale price | Its own; a variation without a price is not sold. The sale price must be below the regular one |
| SKU | Its own, unique across products and variations; empty — the product's |
| Manage stock?, Stock quantity, Allow backorders?, Stock status | Its own stock. Off, it sells from the product's stock when the product tracks one (Inventory tab) — every such variation shares it — or else it is in stock, out of stock or on backorder as chosen |
| Low stock threshold | With its own stock: at or below this many units it is low in stock (see Emails). Empty — the product's threshold, or else the shop's |
| Virtual, Downloadable | As a product's: a virtual variation is not shipped |
| Weight, Length, Width, Height | Its own; empty — the product's |
| Shipping class | Its own; Same as parent — the product's |
| Tax class | Same as parent (the default), standard or one of the shop's; a class taken off the shop's list stays, shown as removed, until another is chosen |
| Image | Shown on the product's page when the variation is picked, and in the cart; none — the product's |
| Description | Shown under the options when the variation is picked |
"Generate variations" appears once the product is saved as a variable product — choosing "Variable product" on the form is not enough; save first. It works from the saved attributes and variations, so it refuses while the Attributes or Variations tab has changes not saved yet ("Save the product first").
SKUs are unique across all products and variations. Two rows of one product may not share a SKU; a SKU taken off a removed row can be given to a new row in the same save.
The rows are saved with the product and replace its variations: a row removed deletes that variation for good, as WooCommerce deletes it. A row the shop refuses keeps the whole save from happening, in both admins (see Products). What the product keeps from them, as WooCommerce syncs it on save: its price is the lowest price of its enabled variations (the product's own regular and sale prices go), it is in stock while one of them is (on backorder while one is, out of stock otherwise — and without any), and it is on sale while one of them is. The product lists show its price range and, for the stock, the product's own units with those of the variations that count theirs: "In stock (9)".
The tax status, "Limit purchases to 1 item per order", the categories, tags, linked products, purchase note and reviews are the product's for all its variations. A coupon for the product discounts all its variations; one for a variation (its id in the coupon's products) only that one; a coupon that leaves sale items out goes by the variation's price.
On the site the product's page lists one select per attribute; once each has a value, the variation is found — its own value or "any" — and the price, the stock, the SKU and the picture become the variation's, and "Add to cart" puts it in the cart. A value no variation is made for is not offered; a combination none is made for says so. The cart, the checkout, the order and the emails name the line "Hoodie - Blue, L" with "Color: Blue, Size: L" under it; a variation made for any size names the size chosen too — the order line of "Hoodie - Red" bought in L is "Hoodie - Red, L", as WooCommerce names it. The variation's own part of the name is made from its product's title and its values, as WooCommerce makes it, whatever title the variation was saved with (a product varying by three attributes or more — or by two, one of them with a dash in its slug — names its variations by the product's title alone, and the values are listed under it). The order's shipping line lists what it carries in its Items as WooCommerce does: the lines that are shipped (a virtual product is not), each by its product's name and quantity — "Hoodie - Red × 2". Stock goes down on the variation when it counts its own, on the product otherwise; the sales are counted on the product.
Reviews
A product's page has its approved reviews, newest first, ten at a time (?reviews-page=2 on the product's address is the next ten; the theme draws More reviews), the stars per rating, and the form for a new one: the stars (one to five), the review, and for a visitor a name and an email; a signed-in customer reviews under their account's name and email. Under each review come the approved answers to it — written in the Comments screen or by an agent's comments_reply — at any depth, laid flat in the order they were written; an answer by someone who moderates comments, or by the product's author, is the shop's own ("Reply from the store"). A review is a WordPress comment of type review, so it is moderated on the Comments screen of either admin (filter by Type → Review), where its Rating column shows the stars (4/5) and whether the reviewer is a verified owner; approving, unapproving, spamming or trashing one recounts the product's rating.
A product takes reviews while:
- Enable product reviews in Shop → Settings → Reviews is yes (WooCommerce's
woocommerce_enable_reviews), and - the product's own Enable reviews (Advanced tab) is on, and it is published.
What else decides:
| Setting | Where | Effect |
|---|---|---|
| Reviews only from verified owners | Shop → Settings → Reviews (woocommerce_review_rating_verification_required) | Only an email with a paid order (processing or completed) of the product may review it — "Only logged in customers who have purchased this product may leave a review." |
| Show "verified owner" label | Shop → Settings → Reviews (woocommerce_review_rating_verification_label) | A review whose email bought the product says "verified owner"; whether it did is kept on the review either way |
| WordPress's discussion options | Settings → Discussion (kitloom/comments) | A review goes through the same rules as a comment, as WooCommerce sends it through wp_handle_comment_submission(): Users must be registered and logged in (a visitor is asked to sign in — "You must be logged in to post a review."), Comment author must fill out name and email, Comment must be manually approved (a new review waits as pending — "Your review is awaiting approval" — and shows on the page once approved), Comment author must have a previously approved comment, the moderation and disallowed keys (held, or straight to the trash), too many links, the same review twice, and one every few seconds from the same visitor. The review form carries the anti-spam the comment forms have (kitloom/forms' honeypot and Turnstile, Contact → Settings) |
Without kitloom/comments the shop applies what the options say on their own: comment_registration, require_name_email (on unless turned off), comment_moderation, and the site's anti-spam for the form; the keys, links, "previously approved", duplicates and flooding need the module.
A review is written by wp-schema's CommentWriter, as any comment is: kitloom/activity-log logs it, and with kitloom/comments an agent lists, moderates and answers reviews (comments_list with type: review, comments_moderate, comments_reply). With kitloom/comments installed, a review held for moderation is mailed to the site's administration email ("Please moderate", while Discussion's Email me whenever a comment is held for moderation is on) — not to the product's author, as WooCommerce sends it — and an approved one to the product's author ("New comment on your post", while Email me whenever anyone posts a comment is on), as WordPress does.
The product's rating is what WooCommerce keeps, from the approved reviews: the average to two decimals (_wc_average_rating), their number (_wc_review_count), the number per star (_wc_rating_count, JSON here; WooCommerce's serialized one is read too) and one rated-1 … rated-5 term of the rounded average, which the catalog sorts and filters by.
The form posts to POST /wp-json/wc/store/v1/products/{id}/reviews {rating, review, author, email} — five a minute from one visitor. A script (Accept: application/json) gets the message and whether the review is approved or held, or WordPress's error (see the Store API's errors below): rest_invalid_param (400) with the fields that failed in data.params, woocommerce_rest_review_error (422) when the product takes no review from them, and the discussion rules' own — not_logged_in (401), comment_spam (403), comment_duplicate (409), comment_flood (429); a form goes back to the product with the notice, its fields and their errors.
Product images
The catalog and the product page show WooCommerce's image sizes, which the shop has kitloom/media cut on every upload — on a site that never ran WooCommerce too. They follow Shop → Settings → Product images, as the WooCommerce Customizer works them out:
| Size | What | From |
|---|---|---|
woocommerce_thumbnail | The catalog's and the product rows' images | Thumbnail width (300); Thumbnail cropping: 1:1 — a square, custom — the ratio of Custom ratio width and height (4:3 of 300 is 300×225), uncropped — the image's own proportions |
woocommerce_single | The main image of a product page | Main image width (600), never cropped |
woocommerce_gallery_thumbnail | The small images under it | 100×100, cropped |
A changed size applies to images uploaded from then on; php artisan media:regenerate cuts the earlier ones again. The shop declares them to kitloom/media as an image size provider (Catalog\ProductImageSizes); an application that configures one of these names in kitloom-media.sizes keeps its own. The admins and the widget forms call them Product thumbnail, Product image and Product gallery thumbnail.
On the site
Products, categories and tags have pages of their own at WooCommerce's addresses — /product/…, /product-category/…, /product-tag/… by default, or the bases chosen in woocommerce_permalinks (autoloaded, as every page reads it: the shop's migration puts an imported option into autoload and saves WooCommerce's defaults where a site has none, as WooCommerce does the first time it reads them). The Shop page and the archives list published products a page at a time — in the menu order, then by name, or the Default product sorting of Settings → Product catalog (the sorting dropdown then leaves "Default sorting" out, as WooCommerce's does) — filtered and sorted with WooCommerce's parameters (orderby, min_price, max_price) and kitloom's own — rating_filter=4 keeps the products whose average rating is at least 4 (WooCommerce's takes a list of exact ratings, rating_filter=4,5), on_sale=1 and in_stock=1 the ones on sale or in stock (WooCommerce's catalog has no such parameters); a page past the last product is not found. With Hide out of stock items (Settings → Inventory) on, out of stock products leave the catalog, its filters' counts and price range, related products and the product shortcodes; their own pages stay. On a category, tag or attribute archive the page's list of posts — what a Blade theme or a JSON client reads as the main query — is that same page of products, as WooCommerce makes its product query the main one.
The catalog is filtered by global attributes as WooCommerce's layered nav does it: ?filter_color=red,blue lists products that have both Red and Blue, &query_type_color=or those that have either. Filters of several attributes add up: ?filter_color=red&filter_size=l is red and large. Each catalog page gives the theme its attributeFilters: every attribute with the terms its products have, how many products each term would leave, and which are chosen — what a theme draws as checkboxes (Pantry does, in its filter bar, with "or" within an attribute). A term of an attribute with archives (/color/red/) is a catalog page of its own.
A search of the catalog (?s= on the Shop page) finds a product by its title, or by its SKU typed from the start: MUG finds MUG-01 and MUG-02. It follows the product's catalog visibility as WooCommerce does: a "Search results only" product is found by a search and not listed in the catalog, a "Shop only" one the other way round, a hidden one neither. The site's own search (/?s=, every type at once) follows the same flag: a "Shop only" or hidden product is not found there either, while posts and pages are. With kitloom/search the index still holds such a product — it indexes every published post of the searched types — so the hit is dropped when the page is read: that page shows one result fewer, and the number of results found still counts it.
Prices and dates in another language. The currency's position, its separators and the number of decimals (Settings → General) are how the default language writes a price; every page in it, the admin and the emails follow them. A page in another language of the site (kitloom/languages, /fr/…) writes a price as its locale does, with the shop's symbol and decimals: 6,90 $ in fr_CA, $6.90 in en_CA (PHP's intl; without it the settings stand everywhere). Dates — an order's, a review's and its replies' — are Settings → General → Date Format in the site's timezone, with the months and days in the page's language; kitloom/languages translates the format itself (j F Y for French: 15 octobre 2026).
A product page carries the product for search engines in its head, as schema.org data — WooCommerce's structured data: the name, the short description (the description when there is none), the images, the SKU (the product's ID without one), the stars once it has reviews, and the offer with the price, the currency and whether it is in stock, out of stock or on backorder — for a variable product of several prices an aggregate offer from the lowest to the highest. No brand: the shop has none to give.
Shortcodes, with WooCommerce's names and attributes:
| Shortcode | What |
|---|---|
[products] | A row of products: limit, columns, orderby, category, tag, on_sale, best_selling, top_rated, visibility="featured"; the theme's title and link |
[product_categories] | Category tiles: number, columns, parent (top level by default), hide_empty, ids — the categories named, wherever they are in the tree, parent aside; the theme's title |
[woocommerce_cart] | The cart page |
[woocommerce_checkout] | The checkout page, and the "order received" page after it |
[woocommerce_my_account] | The customer's account page, see below |
A shop brought over from WooCommerce keeps its Cart and Checkout pages as they are: WooCommerce's Cart and Checkout blocks (woocommerce/cart, woocommerce/checkout — its pages since 8.3) and its Classic Shortcode block draw [woocommerce_cart] and [woocommerce_checkout]. They go with the shortcodes (shortcodes.register).
Every page also knows the mini cart — the number of items, their subtotal as the cart shows it (with or without tax; shipping and coupons are the cart page's), the cart and checkout addresses and where "Add to cart" posts — and the notice of the last shop action ("added to the cart", "out of stock").
Cart and checkout
The cart lives in the shopper's session; it keeps products and quantities only and takes the current prices on every read. A signed-in shopper's lines are kept on their account too, in WooCommerce's user meta _woocommerce_persistent_cart_1 (JSON here; WooCommerce's serialized one is read), as WooCommerce's persistent cart keeps them: signing in brings them back under the session's — the session's line wins for the same product — so a cart filled on another device, or saved by a WooCommerce shop, is there; an emptied cart is no longer kept. Checkout does what WC_Checkout does: checks the cart again, chooses the shipping rate on the form, writes the order with that rate and the payment method, takes the stock, starts the payment through kitloom/payments and empties the cart. Everything the order keeps — the lines, the coupons, the taxes, the total — is read from the cart priced once the rate is chosen, so the order's total is the one the checkout showed. The shopper goes to the gateway, or to the "order received" page for an offline method. A payment that cannot start fails the order, gives its stock back and leaves the cart as it was; the low stock, out of stock and backorder emails go only once the payment has started. Two orders for the last item cannot both take it: the stock rows are locked while they change. A cart line's maxQuantity (what a theme's quantity field goes up to) is null when nothing caps it, else never less than the line holds: a line at or past its stock cannot grow, and the checkout says what is short.
The Checkout page with an empty cart sends the shopper to the Cart page (a 302), as WooCommerce does; its "order received" and "pay for order" addresses (?order-received=, ?order-pay=) stay where they are. A page drawn without a visitor — an agent's render_page, the console — is the checkout with its "your cart is empty" view.
An order with nothing to pay — free products, a coupon that takes it all, free shipping — asks for no payment method and goes to no gateway; it is paid at once, as WooCommerce's payment_complete() leaves it, with the note "Payment complete.": Completed when every product in it is virtual and downloadable (there is nothing for the shop to do), Processing otherwise, and the shopper lands on the "order received" page. The cart's data says needsPayment, so a theme can hide the payment methods.
Adding to the cart and placing an order are limited to 60 requests a minute per visitor, as a guard against scripts; applying coupons, paying or cancelling an order and the account's and reviews' forms have limits of their own (see the routes below). Each route counts its own requests: a full cart leaves a review and the account's forms all their tries.
A form that adds a product goes back to the page it came from, with the notice. With Redirect to the cart after adding in Shop → Settings (WooCommerce's woocommerce_cart_redirect_after_add) it goes to the cart page instead, when there is one; a script adding to the cart gets the cart back either way, and a redirect address to follow when the option is on.
The shipping rates are the enabled methods of the zone the cart's address ships in (see Shipping) that apply to the cart; the first one is chosen until the shopper picks another, and again when a new address no longer offers the one picked. The cart page's Calculate shipping asks for the country, state, city and postcode (WooCommerce's calc_shipping_* fields, or the Store API below) and shows the rates there. At checkout the rates are those of the address the order goes to — the shipping address when "Ship to a different address?" is ticked, the billing one otherwise: the order is priced again for it, and a rate of another zone, chosen for the address before, is refused ("Invalid shipping method").
The "order received" page opens only with the order's key, so nobody reads another's order by guessing numbers. It tells the payer what an offline method asks of them, as WooCommerce's gateways do: the method's instructions (paymentInstructions; Instructions under Payments → Settings) and, for a bank transfer, "Our bank details" (bankDetails, the accounts WooCommerce kept in woocommerce_bacs_accounts — see kitloom/payments). The customer's emails say the same before the order: a bank transfer's while the order waits for the money (on hold), cash on delivery's in each of them; the shop's own emails do not.
Countries, states and addresses
Shop → Settings → General says where the store is (its address and Country / State, US:CA — a country and, where it has a list, a state) and where it sells: Selling location(s) — all countries, all but some, or only some; Shipping location(s) — the selling ones, all, only some, or none ("Disable shipping": no shipping address is asked). The checkout offers only those countries. Default customer location starts a new customer's form in the store's country, or with none; WooCommerce's Geolocate choices are listed as not supported — one imported from WooCommerce is kept on save and works as the store's country.
A country with a list of states — the United States, Canada, Australia, Ukraine, Ireland, Italy, Spain, Brazil, India, Mexico, New Zealand, Japan, South Africa, Türkiye — takes its state from that list, under WooCommerce's codes (CA, UA30, JP13); a state typed by its name is read as its code. The state is required there, except in New Zealand and Ukraine; elsewhere it is free text. Postcodes are checked in the United States (12345 or 12345-6789), Canada, the United Kingdom and Ukraine (five digits); elsewhere any postcode goes. The two fields are named as the country names them, on the checkout, the account's addresses and the cart's calculator, and in the errors: State and ZIP Code in the United States, Province and Postal code in Canada, County in the United Kingdom and Ireland (with Eircode), Prefecture in Japan, State in Australia, Brazil, India and Mexico, Region in Ukraine and New Zealand, Province in Spain, Italy, China and South Africa — elsewhere State / County and Postcode / ZIP. Addresses — on the order pages, in the admin, in the emails — are written the way their country writes them (San Francisco, CA 94105; 10115 Berlin; the postcode first in Japan), the country left out when it is the store's own.
An email must be an address the shop's emails can go to: anna@example, with no dot in its domain, is refused, as WooCommerce refuses it — at the checkout, on the account's forms and on the order forms of both admins.
The checkout's options
Shop → Settings → Accounts & checkout, as WooCommerce's Accounts & Privacy, the Customizer's checkout options and the cart's two Shipping options:
| Option | What it does |
|---|---|
Guest checkout (woocommerce_enable_guest_checkout, yes) | Off: a visitor is asked to sign in on the checkout page — or, while accounts can be made at the checkout, makes one with the order |
Login reminder (woocommerce_enable_checkout_login_reminder, no) | "Returning customer? Click here to login" above the form |
Account creation at checkout (woocommerce_enable_signup_and_login_from_checkout, no) | "Create an account?" on the form: the account is made with the billing email and name before the order, the order is theirs, and they are signed in. An email that has an account already is refused ("Please log in") |
Send password setup link (woocommerce_registration_generate_password, yes) | The account made at the checkout gets no password field; the "New account" email brings a link to choose one. Off: the customer types the password on the form |
Terms and conditions page (woocommerce_terms_page_id) | A checkbox with the page's link that must be ticked to place the order (or to pay one later) |
| Company name, Address line 2, Phone fields | Required, optional or hidden — on both addresses |
Shipping destination (woocommerce_ship_to_destination) | "Ship to a different address?" starts unticked (billing, WooCommerce's default) or ticked (shipping); "billing only" asks for no shipping address |
Enable the shipping calculator on the cart page (woocommerce_enable_shipping_calc, yes) | "Calculate shipping" on the cart page: the shopper picks a country, state, city and postcode and sees that address's rates. Off: the cart shows the rates of where it goes by default |
Hide shipping costs until an address is entered (woocommerce_shipping_cost_requires_address, no) | Until the shopper gives an address — on the cart's calculator or as they type it on the checkout — or has one saved in their account, the cart offers no rates and its total has no shipping ("Enter your address to view shipping options"); the store's own country does not count |
The shipping address
With "Ship to a different address?" ticked the order goes to an address of its own — first and last name, company, country, street, city, state, postcode and an optional phone for the courier — kept as the order's shipping address in wc_order_addresses; unticked, the shipping address is a copy of the billing one, as in WooCommerce — without its phone, unless the order ships nothing at all: then it is the billing address whole, phone too, as WooCommerce's checkout copies it. The shop must ship to wherever the order goes ("Unfortunately we do not ship to …"). A signed-in customer's form starts from their saved addresses; an order placed signed in saves its billing address back, and its shipping address when it had one of its own.
Paying later, cancelling
An order still owed — pending payment, or failed — is paid later on the checkout page's order-pay endpoint, ?order-pay=ID&key=wc_order_…: the order and the payment methods that are on, as WooCommerce's "Pay for order" page. The payment starts as at the checkout (an offline method gives the order its status, an online one sends the customer to the gateway); a failed order takes its stock back first, and a payment that cannot start fails the order again. A payment started earlier and still waiting is called off once the new one starts, so the order cannot be paid twice; its gateway's answer to that leaves the order as it is. The same order can be cancelled by the customer: it goes to cancelled with the note "Order cancelled by customer.", its stock comes back, a payment still waiting is called off, and the customer lands on the cart page with "Your order was cancelled.". Both need the order's key; an order of a customer's account needs that customer signed in too. My account shows Pay and Cancel next to such an order.
The cart and checkout answer under the site's REST root, where WooCommerce's Store API lives, with the web group (the cart is the session's, so a post from a form needs the CSRF token, which a theme's form and axios carry by themselves). The route names are what shortcodes and themes build the addresses from:
| Route | Name, limit | Does |
|---|---|---|
GET /wp-json/wc/store/v1/cart | shop.cart.show | The cart summary |
POST …/cart/add-item {product_id, quantity} | shop.cart.add, 60 a minute | Adds a product; answers with the cart and the add_to_cart event, or redirects back (or to the cart, see above) with a notice for a form. A variable product's variation: {product_id, variation_id, variation: {attribute_size: 'L'}} — the values of the attributes it takes any value of (WooCommerce's messages when one is missing); without variation_id the variation is found by the values; a variation's id as product_id stands for both |
POST …/cart/update-items {quantities: {key: n}} | shop.cart.update | New quantities for every line |
POST …/cart/remove-item/{key} | shop.cart.remove | Removes a line |
POST …/cart/select-shipping-rate {shipping_method} | shop.cart.shipping | Chooses the rate |
POST …/cart/apply-coupon {code} (or the cart form's coupon_code) | shop.cart.apply-coupon, 20 a minute | Applies a coupon: answers with "Coupon code applied successfully." and the cart (coupons, discountTotalFormatted, each line's subtotal and total), or WooCommerce's reason it does not apply (422); a form is redirected back with the notice |
POST …/cart/remove-coupon {code} | shop.cart.remove-coupon | Takes a coupon off: "Coupon code removed successfully.", or a coupon's refusal (422) when it is not applied |
POST …/cart/update-customer {shipping_address: {country, state, postcode, city}} (or billing_address, or the cart form's calc_shipping_*) | shop.cart.customer | Where the cart goes: answers with the cart and its rates there (destination, and destinationFormatted — the place as WooCommerce's "Shipping to …" writes it, "NY 10001" or "10115 Berlin, Germany", without the country at home), or redirects back with "Shipping costs updated." for a form. A country the shop does not ship to, or a state not of the country's list, is refused. A billing_address — alone or beside the shipping one — is also where the cart is billed, kept for the session: with taxes based on the billing address, a checkout that posts it as the form is filled shows the totals the order will have |
POST …/wc/store/v1/checkout | shop.checkout, 60 a minute | Places the order; answers with the order, the payment and where to go next, or redirects there for a form |
POST …/checkout/order-pay {order, key, payment_method} | shop.checkout.order-pay, 10 a minute | Pays an order still owed, see above; answers as the checkout does |
POST …/orders/{id}/cancel {key} | shop.orders.cancel, 10 a minute | Cancels an order still owed; redirects to the cart with the notice, or answers with the message and the cart's address |
POST …/account/details {first_name, last_name, display_name, email, password_current, password, password_confirmation} | shop.account.details, 6 a minute | The signed-in customer's account details, see My account |
POST …/account/address {type: billing|shipping, billing_* or shipping_*} | shop.account.address | The signed-in customer's saved address |
POST …/account/register {name, email, password, password_confirmation} | shop.account.register, 6 a minute | "Register" on the account page, while sign-up is open; the new customer is signed in |
POST …/products/{id}/reviews | shop.reviews.store, 5 a minute | A review of the product, see Reviews |
Errors answer one way on every route, WordPress's: {"code": "…", "message": "…", "data": {"status": …}}. A refusal the shopper can put right — the cart's, a coupon's (woocommerce_rest_cart_error, woocommerce_rest_cart_coupon_error), a payment that could not start, an order no longer cancellable, a review not taken — is 422; a review WordPress's discussion rules turn away has its own code (see Reviews); fields that failed are rest_invalid_param with 400 and each field's message in data.params; a guest where an account is needed 401 (woocommerce_rest_checkout_login_required at a checkout that takes no guests, rest_not_logged_in on the account's routes), someone else's order 403 (401 for a guest, who may sign in), an order, a product or a closed sign-up 404, too many tries 429. A form a browser posted gets none of that JSON: it goes back where it came from with the message as the shop's notice (and the fields' errors and what was typed — never a password — as Laravel keeps them; to the path the form names in redirect, when it is one of the site's) — a page left open until its session expired (419) included. Until the routes are there (routes.register off, see Configuration) the addresses are null and the pages draw without the forms that would post to them.
Shortcodes hand the theme these addresses (urls), built from the names above through Kitloom\Shop\Rest\StoreRoutes::url('cart.add') — null while the routes are off. The checkout's data carries the billing and shipping fields as the options make them, the countries of selling and of shipping, the states of those countries, what those countries call the state and the postcode (addressLabels, country => state, postcode; the fields' own labels for the rest), and whether to show the terms, the account fields and the login reminder.
My account
A page holding [woocommerce_my_account], chosen as the My account page in Shop → Settings, is the customer's own corner of the shop:
| Who | What the page shows |
|---|---|
| A visitor | The sign-in form with "Lost your password?" — the emailed link comes back to this page with the form for a new password. The sign-up form too, while Account sign-up in Shop → Settings is on (WooCommerce's woocommerce_enable_myaccount_registration) or Settings → General → Membership «Anyone can register» is; a new customer gets the «New User Default Role» from there and is signed in |
| A signed-in customer | The dashboard with the newest orders; Orders — every order placed while signed in, ten a page, with Pay and Cancel for one still owed; an order's page with its items, totals, addresses and the notes sent to the customer (a line links to its product while the product is published — not to a draft, a private or a trashed one — and the dates are in the site's timezone, Settings → General); Account details — first and last name, the name shown, the email and the password; Addresses — the billing address and, unless the shop asks for none, the shipping address, each edited apart with the countries and states the checkout offers |
The sections are WooCommerce's endpoints in their plain form, after the page's address: ?orders, ?view-order=123, ?edit-account, ?edit-address (both addresses), ?edit-address=billing and ?edit-address=shipping. A customer only ever sees their own orders.
The name is kept where WordPress keeps it (first_name, last_name, the user's display name and email); the addresses in the user meta billing_* and shipping_*, as WooCommerce does. The checkout starts from those addresses for a signed-in customer, and an order placed signed in saves them back. Changing the email or the password asks for the current password. A form that fails comes back filled in, but never with a password (password, password_current, password_1, password_2, the checkout's account_password). Dates of orders are the site's (Settings → General → Timezone).
A site whose account lives on the site's own Account page (Settings → Reading, kitloom/site) gets the shop's two sections there instead: Orders at ?section=orders (&list=2 a page of them, &id=123 one order) and Addresses at ?section=addresses (&type=billing or &type=shipping the form, posted to the same account/address with redirect — the section's own address, where a form sent without JavaScript comes back to). The site's own Account details stands for the shop's. Both pages may be used at once; one is enough. With the shop's routes off (routes.register) both sections stay: the orders are listed, the addresses shown without their edit links and form.
Coupons
Shop → Coupons lists the shop's coupons — the code, its type and amount, how often it was used out of how many, when it expires and its description — with tabs by status; New coupon adds one. Only a published coupon applies; a draft is kept for later, a coupon in the trash is not found. The code is what the customer types: it is kept in lower case, so SUMMER10 and summer10 are one code, and no two coupons outside the trash share it.
A coupon's page has WooCommerce's three tabs:
| Tab | Field | What it does |
|---|---|---|
| General | Discount type | Percentage discount — a share of each line it applies to; Fixed cart discount — an amount off the cart, shared equally among the units of the lines it applies to, as WooCommerce shares it: each unit the same whole cents, a line no more than it has left — the rest goes round again — and the last cents one a unit to the dearest; Fixed product discount — an amount off each unit of the lines it applies to |
| Coupon amount | The percent, or the money | |
| Allow free shipping | The cart's free shipping method that asks for a coupon (Shipping → a zone's Free shipping, "requires a coupon", "either" or "both") is offered | |
| Coupon expiry date | From 00:00 of this date, in the site's time, the coupon no longer applies | |
| Usage restriction | Minimum spend, Maximum spend | The cart's subtotal, before the coupons, must be within them — otherwise 'The minimum spend for coupon "summer10" is $30.00.' |
| Individual use only | The coupon is used alone: applying it takes the others off the cart, and no other joins it | |
| Exclude sale items | Products on sale are not discounted; a fixed cart coupon refuses a cart that has one | |
| Products, Exclude products | Only these products are discounted, and the cart needs one of them; the excluded ones never are. A variable product is listed with its variations under it ("Tote - Navy"): the product stands for all of them, a variation for itself | |
| Product categories, Exclude categories | The same by category — a category takes its subcategories in | |
| Allowed emails | Only customers with one of these billing emails, *@example.com for a whole domain — checked at the checkout (and in the cart for a signed-in customer) | |
| Usage limits | Usage limit per coupon | How many orders may use it in all |
| Limit usage to X items | A percentage or fixed product discount reaches this many items at most, the dearest first | |
| Usage limit per user | How many orders one customer may use it in: counted by their account, or by the billing email of a guest |
Several coupons may be applied together. The cart takes them as WooCommerce's cart does — fixed product discounts first, then percentages, then fixed cart discounts — each discounting what the others left (a percentage is of the price before any coupon), and no line goes below zero. A fixed cart coupon refuses a cart with an excluded product, category or sale item; a percentage or fixed product coupon just skips those lines, and needs one line it can discount. The amounts are worked out in the currency's smallest unit — cents, fils with three decimals, whole yen with none — on the prices as they were entered: with Prices entered with tax a fixed amount holds its tax too, so "5 off" takes 5.00 off what the customer pays (4.17 and 0.83 of tax at 20 %). A percentage is rounded down per line, and the cents it leaves of the whole go one a unit to the dearest units. Each coupon's line keeps what it took off and the tax of that, each rounded on its own (half down, as WooCommerce rounds a discount); the cart's discount and its tax are rounded once, so the coupons' lines may differ from them by a cent.
On the site. The cart page has a coupon box — "Apply coupon" — and lists the coupons applied, each with [Remove], and what they take off among the totals; the checkout has WooCommerce's "Have a coupon? Click here to enter your code" above the form (Pantry draws both; a theme of its own posts code to cart/apply-coupon). The cart checks its coupons each time it is read, as WooCommerce does: a coupon that no longer applies — the cart fell below its minimum spend, it expired, it was used up — comes off with "Sorry, it seems the coupon … is invalid - it has now been removed from your order." Placing the order checks them once more for the customer: their account or billing email for the per-user limit, the billing email for the allowed emails ("Sorry, it seems the coupon … is not yours - it has now been removed from your order."); a coupon that fails comes off and the order is not placed, so the customer sees the new total first.
On the order. Each coupon is a line of the order with what it took off; the lines keep their price before and after it, the totals show Discount (on the order page in both admins as Coupon: code, on the customer's order page and in the emails). Placing the order counts one use of each coupon (and remembers who used it); an order cancelled or failed gives its uses back (a refunded one keeps them, as WooCommerce does), and one brought back to pending, on hold, processing or completed takes them again. Two orders cannot both take a coupon's last use: the coupon's row is locked while the use is counted.
Editing an order's items on its page (while it is pending, on hold or failed) applies its coupons again, as WooCommerce does: a line added gets its share of the discount, a changed quantity is discounted anew, and each coupon line shows what it takes off now (see The order page).
Taxes
The shop calculates taxes as WooCommerce does once Enable tax rates and calculations is on in Shop → Settings → Tax (woocommerce_calc_taxes; off by default — then nothing below applies and no price changes).
Rates. Shop → Tax rates has a tab (in Nova, the Tax class filter) per tax class — Standard, Reduced rate, Zero rate and the classes the shop adds — each with WooCommerce's columns:
| Column | What it does |
|---|---|
| Country code | US; empty or * — any country |
| State code | CA; empty or * — any state |
| Postcode / ZIP | Separated by ;: 90210, 902* (every postcode starting so), 90210...90215 (a range). Empty — any |
| City | Separated by ;, letter case aside. Empty — any |
| Rate % | The percent, to four places |
| Tax name | What the customer reads among the totals ("VAT", "Sales tax"); empty — "Tax" |
| Priority | Of the rates that match an address, only one per priority applies — the most specific one (a country before any, a state before any, postcodes and cities before none), then the first in the list. For several taxes in one place — a state tax and a city tax — give each a priority of its own |
| Compound | Charged on top of the other taxes: a 5% compound rate after a 10% one takes 5% of the price with the 10% in it |
| Shipping | The rate taxes shipping too |
Drag the rows (Filament) or move them up and down (Nova, Move up / Move down on a rate) to set their order within the class. A rate applies to a product of its class: the product's Tax class on its General tab. An address without a country has no taxes.
Options (Shop → Settings → Tax):
| Option | What it does |
|---|---|
Prices entered with tax (woocommerce_prices_include_tax, no) | Yes — the prices typed in hold their tax: the shop takes it out (compound taxes first, then the others in proportion to their percents). A customer taxed somewhere else than the store pays the price without the store's tax, plus their own — WooCommerce's default |
Calculate tax based on (woocommerce_tax_based_on, shipping) | The address whose rates apply: where the order is shipped, the billing address, or the shop's own (Settings → General). An order picked up (local pickup) is always taxed at the shop's address — also when pickup is simply the first rate offered and nobody chose another. A cart with nothing to ship is taxed at the billing address under "shipping" too, as WooCommerce does. Until the customer says where they are, the cart uses the address it would ship to (see Shipping) — a saved address of a signed-in customer included |
| Additional tax classes | One per line, besides the standard one; each gets a tab of rates. A class taken off the list goes with its rates; products keep its name and find none |
Shipping tax class (woocommerce_shipping_tax_class, inherit) | The class shipping is taxed in — "based on cart items": the standard class when one of the shipped items is in it, otherwise the first of the shop's classes they use; or one class always. Shipping is taxed only by rates with Shipping on, never when no shipped item is taxable, and not by a method whose Tax status is None |
Rounding (woocommerce_tax_round_at_subtotal, no) | Each line's tax rounded to the shop's Number of decimals (the cent, the fils, the yen) — half down while prices are entered with tax, half up otherwise, as WooCommerce rounds taxes — or the taxes and the line amounts kept unrounded — to two places more than the shop's decimals, six at least, as WooCommerce keeps them — each rate rounded once among the totals and the total rounded once: three items of 0.10 at 5 % carry 0.02 of tax, not 0.03. The shipping's taxes stay unrounded too, and are rounded on their own among the totals. With prices entered with tax, a line's amount without the tax is kept unrounded (as WooCommerce writes it on the order line), so three items of 10.00 with 20 % in them come to 30.00, not 29.99; the cart shows each line rounded to the shop's decimals |
Display prices in the shop (woocommerce_tax_display_shop, excl) | The catalog's and the product pages' prices with or without their tax. They are taxed at the shop's address: a catalog page is the same for every visitor |
Display prices during cart and checkout (woocommerce_tax_display_cart, excl) | Without tax: the cart and the checkout list the taxes among the totals. With: every amount holds its tax and the total says "(includes …)" |
Price display suffix (woocommerce_price_display_suffix) | Text after the catalog's prices of taxable products, such as "inc. VAT"; {price_including_tax} and {price_excluding_tax} are filled in with the price |
Display tax totals (woocommerce_tax_total_display, itemized) | A line per tax (rates of one name and place merge), or one "Tax" line. A tax that comes to nothing — a 0 % rate — has no line among the totals; the order still keeps its tax line, as WooCommerce does |
How a cart is taxed. Each line by the rates of its product's class at the cart's address — a product of tax status None carries no tax, one of Shipping only carries none but makes its shipping taxable. Coupons discount the price first: the tax is on what is left, and the discount's tax is kept apart (WooCommerce's discount tax), worked out line by line and rounded once — on the order, as the lines' tax before the coupons less after. A coupon's minimum and maximum spend are measured by the subtotal the cart shows — with its tax while the cart shows prices with tax. The shipping is taxed as above, never with the tax inside its cost; a line counts as shipped by what it sells — a virtual variation of a physical product is not shipped and does not set the shipping's tax class. The total is the lines, the shipping and the taxes added up in the shop's smallest unit and rounded once to its number of decimals: a shop of three decimals totals 3 × 1.234 KWD as 3.702, one of none totals in whole yen.
On the order. The order keeps what the checkout worked out, as WooCommerce writes it: each line's tax before and after the coupons and per rate, the shipping's, a line per rate with the tax of the items and of the shipping, the order's tax — the items' and the fees' in tax_amount, the shipping's apart, as WooCommerce keeps them, so an order brought over shows the same tax — and whether prices were entered with tax. The customer's order page, the emails and the admins list the taxes among the totals. An order added by hand is taxed for its own address; editing its items (while pending, on hold or failed) works its taxes out again, each line by its tax class — the line amounts on an order are without their tax. A fee added by hand is taxed in the standard class; a negative fee takes the tax off in that class.
Orders
| Status | What happens |
|---|---|
| Pending payment | Placed, the payment not finished |
| Processing | Paid; the goods are to be sent |
| On hold | Waiting for the shop (an offline payment: a bank transfer; cash on delivery for an order with a file to download, which would be the buyer's before the cash is) |
| Completed | Sent — settled |
| Cancelled, Failed | The stock comes back — what each line took, no more — and so do the uses of its coupons (see Coupons) |
| Refunded | Settled. Nothing goes back on the shelf by the status — only what a refund restocked (see Refunds) — and the uses of its coupons stay used, as in WooCommerce: the coupon was used on an order that was placed and paid (a once-per-customer coupon does not become usable again by refunding). Chosen as a status for a paid order, the rest of the money is recorded as a refund ("Order fully refunded.", by hand, nothing through the gateway), so nothing is left to refund |
An order left unpaid is not kept for ever: past Hold stock in Shop → Settings (WooCommerce's woocommerce_hold_stock_minutes, 60 minutes by default) since it last changed, an order still pending payment is cancelled with the note "Unpaid order cancelled - time limit reached." and its stock comes back; 0 keeps them. Only orders placed at the checkout are cancelled, and an on-hold order — a bank transfer, a cheque waiting for the shop — is not pending and is left alone, as in WooCommerce. A payment that arrives after all still brings the order to processing. The shop runs php artisan shop:cancel-unpaid-orders every ten minutes, so the site needs Laravel's scheduler (php artisan schedule:run every minute from cron); the command may be run by hand too. One server of several runs it, by a lock in the cache, so a site on several servers needs a shared cache store (Redis, the database) — with each server's own cache the lock is local and the task runs on every one.
A settled order stays settled unless you mean it: a completed order goes on only to refunded, a refunded one nowhere — Reopen order on its page may still move it back (there it is on purpose), the order form's status field, a bulk action or an agent may not. A payment arriving for a refunded order (a gateway's late answer) leaves it refunded and notes the payment for you to settle. Changing a status asks edit_shop_orders of whoever does it and may leave a note on the order; a note for the customer shows on their order page, the others stay with the staff.
Payments that come twice or short. A second payment for an order that is paid already (the customer paid in two tabs, a gateway confirmed an old checkout late) changes nothing but a note on the order — "Payment #… received for an order already paid — refund it in the gateway" — and the order keeps the first payment's reference: give the money back in the gateway's dashboard (its refund webhook then records it here). A payment of less than the order's total puts the order On hold with the note "Paid $10.00 of $25.00." instead of processing; the goods do not go out until you settle it. WooCommerce only notes these too; no email is sent.
Two changes at once. Paying, cancelling, changing the status, refunding and the gateway's answers each read the order again under a lock before they decide: a customer cancelling in one tab while paying in another, two admins refunding the same money, or a gateway's late decline of an order paid since — the second one finds the order as the first left it and is refused or does nothing.
Sales and downloads. A product's Total sales (total_sales) count an order once it is processing, on hold or completed, and take it back when it is cancelled from one of those — counted again if it comes back — as WooCommerce counts them; a pending, failed or refunded order leaves the count as it is. Once an order is paid — processing, or completed — its buyer gets the files of the downloadable products it bought, as WooCommerce grants them: a permission per file, with the product's download limit (times the quantity bought) and its expiry in days from the day the order was completed (or from the day it was granted). Downloads after payment in Shop → Settings → Inventory (WooCommerce's woocommerce_downloads_grant_access_after_payment, on by default) off, they wait until the order is completed. A refunded line takes its product's permissions away. An order is granted its downloads once; one that sells nothing but virtual downloadable products needs no processing, so a payment completes it at once. The shop keeps the permissions where WooCommerce does, but does not serve the downloads yet — no download links on the order or in the emails, no Downloads in My account: a WooCommerce shop reading the same database would serve them.
Which orders the lists show. Orders in the trash, the Store API's checkout drafts (checkout-draft, a cart's order while the form is filled) and auto-drafts are not in the order list or its counts, the reports, the Customers screen or an agent's tools — as WooCommerce leaves them out. The date filters ("Placed from", "Placed until", an agent's from/to) are days of the site's timezone (Settings → General), the same days the reports use.
The order list
Shop → Orders lists the orders as WooCommerce does: the order (number and customer), the date, the status, Billing (name, company, the payment method) and Ship to (the address, the shipping method), and the total with what was refunded of it. The tabs (Filament) or the Status filter count the orders of each status; the filters narrow the list to a date range ("Placed from", "Placed until") and to a registered customer. The search box finds an order by its number (#123 or 123), the billing email, the customer's first or last name or both ("Ada Lovelace"), the company, or a product among the lines. Times on the order screens — the list's date, "placed", the notes — are in the site's timezone (Settings → General), as WordPress shows them.
Bulk actions: select orders and "Change status to processing", "on-hold", "completed" or "cancelled". Each order goes through the same rules as one changed by hand — its dates, its stock, its note, its emails. A completed or refunded order that the change would unsettle (to processing, on hold or cancelled) is skipped, and the message says which ones; open it and use Reopen order if that is what you mean.
The order page
- Customer, status, customer note — in the form. The customer is a registered user or nobody (a guest), found by name, login or email as you type (in Nova, in Find a customer above the list; the list shows the newest users and the order's own customer, however old). The status is changed only when you change it: saving a note or the customer leaves a status that changed meanwhile (a payment arrived, the customer cancelled) as it is now. A completed or refunded order is not moved back by the form — use Reopen order (both admins): pick the new status, optionally add a note; its items are taken out of stock again and its coupons counted, as for a new order.
- Add note — with Note to customer on, the note is emailed to the customer ("Customer note") and shown on their order page; off, it stays with the staff. Every change below leaves a note of its own, signed by whoever made it, as WooCommerce signs them ("Added line items: Mug × 2", "Adjusted stock: Mug (8→5)", "Order details manually updated by …", "Refunded $10.00 manually"). The shop notes what happens by itself in WooCommerce's words too: the status a new order takes from its payment method ("Awaiting BACS payment. Order status changed from Pending payment to On hold.", "Payment to be made upon delivery. …"), the stock an order takes and gives back ("Stock levels reduced: Mug (8→7)", "Stock levels increased: …"), every change of status. Notes are kept as WooCommerce keeps them, in HTML (
→for the arrow,<br>between lines), and shown as text — a WooCommerce shop's own notes read the same. - Edit addresses — the billing and shipping addresses, whatever the status: an address can always be put right. The billing email is the order's email.
- Items — while the order is pending payment, on hold or failed, its items may change, as in WooCommerce: Add product (at the product's price now; found by its name or SKU — in Nova, typed in Find a product; a variable product by one of its variations, "Hoodie - Blue, L"; a variation made for any value of an attribute — "Any Size" — asks for the value, which the order line keeps as the cart would), a line's quantity (at the price it was sold at; 0 removes it), remove a line, Add fee (a surcharge, or a discount with a negative amount), Shipping (any of the shop's methods, at what it charges for this order unless you type the cost; or no shipping). The totals are recalculated after each change; Recalculate does it again (only while the order is pending payment, on hold or failed — a paid order's total is what the customer paid; a refund is how it changes). A recalculation applies the order's coupons again, as WooCommerce does: a line added gets its share of the discount, a changed quantity is discounted anew rather than carrying its old discount, and each coupon line shows what it took off now (a coupon deleted since is applied as the order kept it). With prices entered with tax, a line keeps its price with the tax and the tax is taken out of it again, so the order's total does not move by a cent however often it is recalculated. An on-hold order already holds its stock, so a changed line takes or gives back the difference — giving back no more than the line took. A product with no price, or a disabled variation, cannot be added. Once the order is paid (processing) or settled, its items are what the customer paid for and are shown read-only — to give money back, refund it.
- Refund — see below.
- Send email — sends one of the order's emails again, switched off or not: the order details (the invoice, with the link to pay while it awaits payment), the new order notification, the customer's processing, completed or on-hold email… A note records it.
While the shop calculates taxes, every change of the items works the order's taxes out again for its address (see Taxes); with taxes off, the items keep the taxes they have — a WooCommerce order brought over keeps its own — and new ones carry none.
Refunds
Refund asks how many of each line go back (the amount fills itself from them, or type another — a refund need not be of items at all), whether to restock refunded items, and an optional reason. Left empty, the refund amount is what the selected items come to with their tax; the Filament form shows that sum as the field's placeholder. Then:
- Refund via <gateway> — when the order was paid online and the gateway can refund (shown only then): the money goes back through the gateway (kitloom/payments). If the gateway refuses, nothing is recorded and the message says why.
- Refund manually — the money went back some other way (cash, a bank transfer): the refund is recorded only. An order paid with an offline method that was marked paid gets the refund in its payment record too.
Filament has the two buttons. Nova's form has one, so Refund via <gateway> is a switch there, off until you turn it on: left off, the refund is a manual one. The quantities are checked when the refund is sent — whole numbers, none more than is left of its line.
A refund made in the gateway's own dashboard needs nothing here: when the gateway reports it (its webhook, through kitloom/payments), the refund is recorded under the order — the amount the gateway gave back, the reason "Refunded via the gateway", a note "Refunded $4.00 via …", the customer's refund email — with nothing put back in stock; what is left refunded there moves the order to Refunded. A refund given from the order's page through the gateway is reported too, and is not recorded twice.
A refunded line gives back its share of the line's total and tax: the amount the lines fill in includes the tax, and the refund's line keeps what it gave back — its subtotal the same as its total, as WooCommerce writes it — and the tax per rate as WooCommerce does; the reports read refunds written either way, kitloom's earlier ones (the line's price before the coupons as the subtotal) too. An amount typed with no lines carries no tax. A refund is never more than what is left of the order. Refunding part of it leaves the status as it is and emails the customer the "partially refunded" version of Refunded order; refunding what is left moves the order to Refunded (with the full refund email). A line refunded unit by unit comes back whole: each part is rounded to the shop's decimals (Settings → General) and the last units take what is left of the line (a 20.00 line of three: 6.67, 6.67, 6.66). Restocking puts back only the quantities given in the refund, and only for an order that held its stock — no more than the line took, and the line remembers only what really went back (_restock_refunded_items); moving an order to refunded does not put the rest back by itself, as in WooCommerce. A refund is written first and the gateway asked after, as WooCommerce does: the refund takes its share of the order at once, so a second refund of the same money — another admin, another tab — is refused before any gateway is asked. If the gateway refuses, the refund is deleted again and the message says why. Once the money is back nothing undoes the refund's record: if something after it fails (putting the items back in stock, the note), the message says the money went back and the refund is recorded, and what did not follow. Nova runs the refund outside its own action transaction for that reason, so a refund does not appear in Nova's action log. The refunds are listed under the order's totals — on the order page, on the customer's order page and in the emails — with the net payment.
Adding an order by hand
Add order (Filament) / Create Order (Nova) — WooCommerce's "Add order": a customer (their empty billing fields are filled from their account) or a guest's billing details, a shipping address when it differs, the products and quantities (at their prices now — a variable product by one of its variations, "Hoodie - Blue, L"; a variation made for any value of an attribute asks for its value — in Nova, typed in the row's Attribute values as Size: L, one per line; Nova's rows list the newest products, and any other is named by its SKU or ID in Or its SKU or ID), a shipping method (its cost for these products, or the one you type), a payment method — or none, when it is paid some other way — and a status, pending payment unless you choose another. The order is written pending and then moved to its status by the same rules as any other — choosing processing takes the stock and sends the customer's and the shop's emails. Nothing is charged: a pending order waits for the customer to pay (send them the order details), an offline one for you to mark it paid. An order needs at least one product. More products can be added on the order's page while it is pending, on hold or failed. Such orders are marked as created in the admin (created_via admin).
The shop publishes its sales — view_item, add_to_cart, begin_checkout, purchase — for the site's trackers (kitloom/analytics, kitloom/tag-manager, kitloom/ad-pixels), which pick them up if installed. An item is named the same in all of them: the product's ID and title, its first category (item_category), a variation's values as its variant ("Color: Blue, Size: L"). In a purchase, as GA4 counts a discounted item, its price is what was paid for one and discount what the coupons took off it (the two add up to the price it was put in the cart at), with the coupons' codes; prices are with tax when they were entered with it. A failed or cancelled order was not a sale.
Emails
The shop sends WooCommerce's emails about orders and stock, each when WooCommerce sends it:
| To | When | |
|---|---|---|
| New order | the shop | An order is placed with an offline method (bank transfer, cash on delivery), or an order waiting for its online payment is paid — once per order |
| Cancelled order | the shop | A processing or on-hold order is cancelled |
| Failed order | the shop | A pending or on-hold order fails — the payment was declined or could not start |
| Order on-hold | the customer | The order waits for the payment to arrive (a bank transfer) |
| Processing order | the customer | The order is paid and being prepared |
| Completed order | the customer | The order is marked completed |
| Refunded order | the customer | The order is refunded — in full, or in part (its "partial" subject and heading) |
| Customer note | the customer | A note "for the customer" is added to the order; notes for the staff are never emailed |
| New account | the customer | They create an account on the My account page or with an order at the checkout: their username and a link to the account — and, when the checkout made their password, a link to set one |
| Order details | the customer | Never by itself — it is sent on purpose, with Send email on the order's page: the order, with the link to pay while it awaits payment — kitloom/payments' billing/pay/… for a payment that waits at its gateway, otherwise (an order added by hand, a failed one) the checkout's order-pay page, where the customer picks the method |
| Product low in stock | the shop | An order leaves a product (or a variation that counts its own stock) at or below its low stock threshold — once, as the stock crosses it; the next order below it does not send it again |
| Product out of stock | the shop | An order leaves a product at or below the out of stock threshold (0) — once, as it crosses it; it is sent instead of the low stock one when an order crosses both |
| Product backorder | the shop | An order line is sold past the stock of a product that allows backorders: one per line, with how many units and the order number |
An order waiting for an online payment is not news yet: the shop hears of it, and the customer gets "Processing order", once the gateway confirms it.
The stock emails say which product (its name and SKU), what happened and the stock left, as WooCommerce's do; they go when the order takes the stock (it is placed, paid, or moved to processing, on hold or completed), not when stock is changed by hand. Every order email has the order in it — the items with their quantities and prices, the subtotal, shipping, fees, the payment method and the total, the refunds and the net total, the billing address (and the shipping address when it is another one) and the customer's note — under a heading and a line of what happened, and the shop's additional content and footer under it.
Shop → Settings → Emails changes them, in both admins:
- "From" name and "From" address — who the emails come from: the site title and the site's administration email while they are empty. The application's mail server must be allowed to send from that address.
- Footer text — under every email.
- Under Inventory: Low stock notifications and Out of stock notifications (yes — sent), the Out of stock threshold (0) and Notification recipient(s) of the three stock emails — the administration email while it is empty.
- For each email: Enabled (the order details have no switch: they are sent by hand; the low and out of stock emails are switched under Inventory), Recipients of the shop's emails (the stock emails': Notification recipient(s) under Inventory) — a comma separated list, the administration email while it is empty — the Subject, the Email heading and the Additional content. An empty subject or heading is WooCommerce's default, shown in the field; an empty additional content is none.
Texts may hold placeholders: {site_title}, {site_address} (the site's domain), {order_number}, {order_date}, {customer_name}.
Everything is kept where WooCommerce keeps it — the options woocommerce_email_from_name, woocommerce_email_from_address, woocommerce_email_footer_text and one option per email, woocommerce_{id}_settings (woocommerce_new_order_settings, woocommerce_customer_processing_order_settings…); the stock emails' woocommerce_notify_low_stock, woocommerce_notify_no_stock, woocommerce_notify_no_stock_amount and woocommerce_stock_email_recipient (WooCommerce has no texts for them: theirs are kitloom's woocommerce_low_stock_settings, woocommerce_no_stock_settings and woocommerce_backorder_settings, the backorder one with a switch WooCommerce lacks) — so the texts of a shop that comes from WooCommerce are used as they are. WooCommerce's header image, colours and plain-text choice are not read: the emails are Laravel's mail layout, in HTML with a plain-text part.
The emails go out through the application's mailer — its MAIL_* settings in .env (MAIL_MAILER, the SMTP host and its credentials, MAIL_FROM_ADDRESS when the shop's "From" address and the administration email are both empty) — and through its queue: with QUEUE_CONNECTION other than sync, a queue worker (php artisan queue:work) must be running, or nothing is sent. An email says what the order says when the queue sends it.
Personal data
With kitloom/privacy installed, Tools → Export Personal Data and Erase Personal Data (WordPress's own screens) reach the shop too, as WooCommerce's do. A person is known by their email address; their account is the user with that address.
Exported, each group in the archive the person downloads:
- Customer data — the billing and shipping addresses, phones and billing email the account keeps for the checkout.
- Orders — every order billed to the address or placed by its account: number, date, status, total, what was bought, payment method, IP address and browser, both addresses with their email and phone, the customer's note and the notes the shop sent them. The staff's private notes stay out.
- Downloads — the download permissions of the address or its account: file, order, product, downloads left and used, when access was given and when it ends.
Reviews are comments: WordPress's own comments exporter and eraser cover them.
Erased:
- Customer data — the saved addresses go. The account stays; deleting it is done on the Users screen.
- Orders — kept as they are by default, for the books, and the report says so. With the shop's option Remove personal data from orders on request set to
yes(WooCommerce'swoocommerce_erasure_request_removes_order_data) an order is made anonymous: names, company, street, city and postcode become "[deleted]", state and country are emptied, the email becomes[email protected], the phone's digits become zeros, the IP address keeps only its network, and the browser, the customer's note and the link to the account go. The order, what it sold and its money stay; it gets the note "Personal data removed." and the meta_anonymized. An order still pending payment, on hold or processing keeps its data even then — it is what the shop ships to and asks the money from — and is counted as kept; ask again once it is settled. - Downloads — kept by default. With Remove access to downloads on request set to
yes(woocommerce_erasure_request_removes_download_data) the address's download permissions are deleted, with the lines a WooCommerce database logged of their downloads (wc_download_log).
Both options are no by default, as in WooCommerce, and a site moved from WooCommerce keeps the values it had. Set them on Shop → Settings → Accounts & checkout, in either admin.
AI agents
With kitloom/ai installed, an agent with edit_products reads the catalogue (products_list, product_get — drafts, private products and purchase notes included, so not for everyone) and creates and changes products through ProductWriter (product_save, with every field of the product form — the shipping class by its id or slug — the attributes too — a global one by its pa_* taxonomy, which describe_site lists — and a variable product's type and variations, the whole list, a variation kept by its id — one left out of the list is disabled (kept, not sold), never deleted, unlike a row removed on the form; a new product is a draft). product_get gives the product's categories and tags with their ids, as product_save takes them, and each variation's low stock threshold; a variation's id given as a product's is answered with the product it belongs to. It reads the global attributes (attributes_list) and, with manage_product_terms, adds or changes one through AttributeWriter (attribute_save: name, slug, the order of its terms, archives); its values are terms of its pa_* taxonomy, written with the core term tools, and deleting an attribute stays in the admin. With edit_product_terms it gives a product category its image (product_category_image: an image from the library into the term meta thumbnail_id, 0 removes it); the category itself is made with the core term tools. With edit_shop_coupons it lists the coupons (coupons_list) and creates and changes them through CouponWriter (coupon_save, with every field of the coupon form; a new coupon is a draft until it is published). With edit_shop_orders it reads orders with their coupons (code and what each took off) and refunds (amount, reason, date), and in full with their lines, addresses and notes (orders_list, order_get), moves an order on (order_set_status — any status but refunded: refunds are given in the admin) and leaves internal notes (order_add_note); it takes an order by hand, as "Add order" (order_create through OrderCreator: a customer or a guest with an email, lines at the catalogue's prices — an agent names no price and no shipping cost —, a shipping method at its own cost, a payment method; it starts pending or on hold, nothing is charged, and its being paid is said later with order_set_status); an order's dates — when it was placed, its refunds, its notes — are in the site's time (Settings → General). For the shop's figures it has, with the same edit_shop_orders, three reports read from the orders: sales_report — over a period of the site's days, the sold orders (processing and completed: paid and kept — a refunded order's money came and went, and is counted among the orders placed but not sold, with pending, on-hold, cancelled and failed ones), revenue, the refunds of those orders, net (what the Customers screen counts as spent, so the two agree), the average order, products after discounts, discounts, shipping and taxes — each less what the refunds took back of it: a refunded line its total, tax, quantity and discount, a refund of an amount without lines a share of the whole order off every line, the shipping, the taxes and the discounts —, items sold, the customers (new, or back after an earlier order), and on request the period before to compare with (the month before a whole month) and a series by day, week or month; top_products — the products those orders sold, by quantity or revenue, refunds taken off the same way, a variation counted for its product; customers_list — the Customers screen's rows: one per email, guests too, with their orders and what their paid orders came to; a search by name or email finds the customer and counts all their orders. Amounts are rounded to the shop's decimals. A refund counts in its order's period, so a month's sales do not move when an old order is refunded, only its refunds. The tools never read what is in the trash or an editor left unsaved (auto-drafts) — orders in the trash and checkout drafts neither: an agent cannot read, move or note them. A category or an image in the trash gets no category image. A category image set by an agent is saved through the term writer, so whatever listens to a changed term (a cache, a search index) hears of it. An agent never changes a completed or refunded order, and its notes are for the staff only, never on the customer's order page. describe_site gets a shop block: the currency, whether taxes are on and prices entered with them, the tax classes a product names by slug, the shipping classes by id, the shipping zones in order with their enabled methods — or, while no zone has a method, a fallback entry saying the config's rates (kitloom-shop.shipping: free shipping from a minimum, else a flat rate) are what every address is offered —, and a line each on variations and coupons. kitloom-shop.ai.register false — no shop tools and no such block.
Demo
php artisan shop:demo fills a site with a demo shop: products with pictures, categories, reviews, the global attributes Material and Color — the Canvas Tote Bag is a variable product in three colours — the WELCOME10 coupon (10 % off), the Shop page and a storefront home page. A shop with no shipping methods of its own gets a zone for the store's country (a flat rate of 5.99 and free shipping from 50, as the config's rates) and an International flat rate of 19.99 for the rest of the world.
Configuration
The package reads kitloom-shop.* from the application's config; there is no file to publish. Add a config/kitloom-shop.php with what you change:
| Key | Default | What |
|---|---|---|
shipping.flat_title, shipping.flat_cost | Flat rate, 5.99 | The flat rate, while Shop → Shipping has no method |
shipping.free_title, shipping.free_minimum | Free shipping, 50 | Free shipping from this order amount, while Shop → Shipping has no method |
routes.register | true | The Store API under /wp-json/wc/store/v1/ — the cart, checkout, account and review routes. Off: the shop's pages still draw, with null for the addresses of the forms, which a theme leaves out |
emails.register | true | The order emails, see Emails |
shortcodes.register | true | The shortcodes above |
schedule.register | true | The scheduled cancelling of unpaid orders (shop:cancel-unpaid-orders) |
ai.register | true | The tools for AI agents |
privacy.register | true | The shop's exporters and erasers for kitloom/privacy, see Personal data |
filament.register, nova.register | true | The Shop section in the admin |
For developers
Shipping in code: Kitloom\Shop\Checkout\ShippingMethods gives the rates for an address (available($subtotal, $quantity, ['country' => 'US', 'state' => 'CA', 'postcode' => '90210'], $classes), zoneFor($destination), untilFree()), Kitloom\Shop\Shipping\ShippingZones::for($destination) the zone an address ships in (NULL — the default zone), Kitloom\Shop\Shipping\ShippingMethodWriter adds, changes, orders and removes zones (createZone(), updateZone(), setLocations($zone, [['country', 'US'], ['postcode', '902*']]), reorderZones(), deleteZone()) and methods (create($type, $settings, zone: $zone), update(), reorder(), delete()) with the acting user (manage_woocommerce is asked of them; without one it is the system). Zones are Kitloom\Shop\Models\ShippingZone (registry key wc_shipping_zone; ShippingZone::defaultZone() stands for zone 0). The cart's address: Cart::destination(), Cart::setDestination([...]), Cart::ofLines($lines, $rateId, $destination). A product's shipping class: ProductWriter field shipping_class (a term id or slug, '' — none), $product->shippingClassId(). Customers: Kitloom\Shop\Customers\Customers::query(); a customer's saved addresses and a new account through Kitloom\Shop\Customers\CustomerProfile (address($user, 'shipping'), register()). Countries and addresses: Kitloom\Shop\Support\Countries (all(), name(), selling(), shipping(), the store's baseCountry()), States (of('US'), has(), name('US', 'CA')), AddressFormat::lines($address). An order still owed: Kitloom\Shop\Orders\PendingOrders (find($id, $key), pay(), cancel(), urls()).
Coupons in code: Kitloom\Shop\Coupons\CouponWriter creates and changes them with the acting user (create(['code' => 'summer10', 'discount_type' => 'percent', 'amount' => 10, 'status' => 'publish'], $actor), update(); edit_shop_coupons is asked of the actor), Coupons::find($code) finds a published one, CouponValidator::check($coupon, new CouponContext($lines, $others, $customerId, $emails, $shownSubtotal)) says why one does not apply (CouponException, a CartException with WooCommerce's message and a reason), Discounts::calculate($lines, $coupons) works out what each line and coupon takes off, CouponUsage counts and gives back an order's uses. The cart: Cart::applyCoupon($code), removeCoupon(), coupons(), discountTotal(), checkCoupons($customerId, $billingEmail), notices(), Cart::ofLines($lines, $rateId, $destination, coupons: ['summer10'], customerId: 7, email: '[email protected]') — each line added as add() adds it, the coupons checked for that customer rather than whoever is signed in; Checkout::placeOrder($data, $customerId, cart: $cart) returns the payment started, or the Order itself when there was nothing to pay; its lines carry subtotal (before the coupons), discount and total (after). Coupons are Kitloom\Shop\Models\Coupon (registry key shop_coupon); an order's are $order->couponItems(), $order->discountTotal().
Attributes in code: $product->attributes() lists a product's (Kitloom\Shop\Attributes\ProductAttribute: name, label, isTaxonomy, isVisible, isVariation, position, options()); ProductWriter takes them as attributes ([{name, options, is_visible, is_variation, position}], the shape ProductAttribute::toArray() gives). Kitloom\Shop\Attributes\AttributeWriter adds, changes and deletes global attributes with the acting user (manage_product_terms); it also drops WooCommerce's cached copy of the table, the transient wc_attribute_taxonomies. The attributes are read by the scoped Kitloom\Shop\Attributes\AttributeTaxonomies, which registers their pa_* taxonomies: its rows stay in the application's cache (kitloom-shop.attribute-taxonomies) until AttributeWriter changes them, so a page asks the table nothing. A row written past the writer — SQL by hand, a database restored under a running site — shows after AttributeTaxonomies::forget() or php artisan cache:clear.
Orders in code, each asking edit_shop_orders of the acting user (none — the system) and leaving its note: Kitloom\Shop\Orders\OrderEditor changes an order (setAddresses, setCustomer, setCustomerNote at any status; addLine, setLineQuantity, removeLine, addFee, removeFee, setShipping, removeShipping while $order->isEditable(), each followed by recalculate(); resendEmail()); OrderCreator::create([...]) writes an order by hand (shippingChoices(), paymentChoices(), rateFor() for the forms; OrderProducts lists what a line may sell — choices($search), catalogue($search) with the variable products for the coupons, anyAttributes($variationId) the values to ask for, find($skuOrId)); OrderRefunder::refund($order, $amount, $reason, [$lineId => $qty], $restock, $actor, viaGateway: true) records a refund and sends the money (gatewayFor($order) names the gateway that can); OrderBulkStatus::change($orders, $status, $actor) is the list's bulk action. Refused changes throw OrderEditException. Refunds are Kitloom\Shop\Models\OrderRefund (registry key wc_order_refund): $order->refunds, totalRefunded(), remainingRefundable(), refundedQuantity($line). StockKeeper::countSales($order, $from) keeps total_sales after a status change. OrderDownloads grants (grant($order)) and revokes (revoke($order, $line)) an order's download permissions and says whether it needsProcessing() and hasDownloadableItem(). Kitloom\Shop\Orders\Orders::locked($order, fn (Order $order) => …) runs a decision with the order's row locked and its status and operational data read again; every change of an order takes its locks in one order — the order's row, its operational data, the stock rows by post id, the products' stock status and total_sales, then the coupons' rows by id — so two changes never wait on each other.
Taxes in code: Kitloom\Shop\Taxes\TaxRates::matching(['country' => 'US', 'state' => 'CA', 'postcode' => '90210', 'city' => ''], $class) finds the rates of an address as WC_Tax::find_rates(); TaxCalculator works out a price's taxes (calc($price, $rates, $priceIncludesTax)), shipping's (shippingRates($location, $itemClasses)), and the address a cart or an order is taxed at (taxLocation()); TaxSettings reads the options; TaxDisplay gives a catalog price as shown and its suffix; OrderTaxes::calculate($order) works an order's taxes out again. TaxRateWriter (create(['country' => 'US', 'state' => 'CA', 'postcodes' => '902*', 'rate' => 7.25, 'name' => 'CA Tax', 'priority' => 1, 'class' => ''], $actor), update(), delete(), reorder()) and TaxClassWriter (create($name), delete($slug), sync($names)) ask manage_woocommerce of the actor. Rates and classes are Kitloom\Shop\Models\TaxRate and TaxRateClass (registry keys wc_tax_rate, wc_tax_rate_class). The cart: Cart::itemTaxes(), shippingTaxes(), taxTotal(), discountTax(), taxItems(), taxLines(), setBillingLocation(); its lines carry subtotalTax, totalTax, taxes (per rate id, as _line_tax_data) and taxClass, their subtotal and total without the tax. An order's: $order->taxItems(), $order->taxTotal(), $item->lineTax(), $item->taxData().
Variations in code: $product->isVariable(), type(), variations() (enabled and disabled, in order; enabledVariations()), priceRange(), totalStock(); a variation is Kitloom\Shop\Models\ProductVariation (registry key product_variation: product(), attributes() — pa_color => blue, '' for any — price(), sku(), stockQuantity(), stockOwnerId(), taxClass(), shippingClassId(), the product's where it has none). ProductWriter takes type and variations; Kitloom\Shop\Products\VariationWriter creates, updates, deletes and sync()s them and generate()s the missing combinations with the acting user (edit_products); ProductFields checks and writes the fields products and variations share; ProductSync::syncVariable($product) derives the product's price and stock status; VariationAttributes resolves, labels (summary()) and matches values. The cart: Cart::add($productId, $quantity, $variationId, ['attribute_size' => 'L']), Cart::key($productId, $variationId, $values) (WooCommerce's cart item key); its lines carry variation and attributes. An order line: $item->variationId(), $item->variationValues(); OrderEditor::addLine($order, $productId, $quantity, $actor, $variationId, $values).
Orders announce what happens to them: Kitloom\Shop\Events\OrderPlaced (after the checkout, once the payment started), OrderStatusChanged (from, to — statuses as stored, wc-…), OrderNoteAdded (the note, whether it is for the customer), OrderRefunded (the refund, whether it was partial), CustomerRegistered (an account made on the My account page or at the checkout; whether its password was generated), each with the acting user; the order events are dispatched once the transaction they happened in commits. The emails listen to them (Kitloom\Shop\Orders\OrderEmails); OrderEmails::send($order, 'customer_invoice') sends an order's email on demand, switched off or not — the id is one of Kitloom\Shop\Notifications\EmailTypes. The emails are Laravel notifications (Kitloom\Shop\Notifications\*, queued, carrying ids only) drawn by the shop::mail.email Markdown view.
Tests
With the payments bootstrap — the domain from either skeleton, the Filament admin from the Filament one:
bash
php vendor/bin/phpunit --bootstrap ../kitloom-packages-site/tools/payments-bootstrap.php vendor/kitloom/shop/tests
php vendor/bin/phpunit --bootstrap ../kitloom-packages-site/tools/payments-bootstrap.php vendor/kitloom/shop-filament/tests