Taxes, Coupons, Currencies and i18n

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Four things decide what a WooCommerce shopper sees in the cart and what a merchant has to report afterwards: how tax is computed, which discounts apply, in which currency and number format the amounts are written, and in which language the interface speaks. All four are plain WordPress and WooCommerce configuration plus a handful of hooks, so they are easy to change and easy to get subtly wrong. This page explains the model behind each one, shows the settings, the WP-CLI commands, the REST calls and the PHP hooks that touch it, and builds them for Pocket Coffee. It was written against WooCommerce 11.2.1 on WordPress 7.1.3 and checked on 2026-10-27; everything described is part of WooCommerce core except where an extension is named explicitly.

The running example is the Pocket Coffee shop: single-origin beans taxed at a reduced rate in Spain and in Germany, a Coffee Club coupon that only members can use, euros as the store currency with a dollar price shown for customers outside the euro area, and a Spanish and English storefront. A small plugin, pocket-coffee-taxes, implements all of it. It was installed in a disposable WordPress 7.1.3 and WooCommerce 11.2.1 Docker stack and every command output shown below was produced there (store base: Madrid, ES:M; currency EUR; taxes enabled). The section What was run and what was not lists the limits. Related pages: Shipping Methods for the zones that decide shipping tax, Cart and Checkout Blocks for where coupons and totals are displayed, Store API for the shopper-facing cart endpoints, and REST API and Webhooks for the admin endpoints used below.

Taxes

The model

WooCommerce computes tax from four inputs.

  • A taxable item. Every product and variation has a tax status (taxable, shipping for "shipping only", or none) and a tax class. Shipping, fees and coupons are taxed through the same machinery.

  • A tax class. A named group of rates. WooCommerce ships the empty-slug Standard rate class plus Reduced rate and Zero rate, and the merchant can add more. In a store installed with 11.2.1 the REST route /wc/v3/taxes/classes returns standard, reduced-rate and zero-rate (checked on the test store; the slugs of custom classes are derived from their names, so Pocket Coffee Beans becomes pocket-coffee-beans).

  • A location. Country, state, postcode and city of whichever address the store is configured to use (shipping address, billing address or the shop base address).

  • A rate table. Rows of country, state, postcode, city, rate, name, priority, compound and shipping flags, stored per class.

WC_Tax::find_rates() receives the location and the class, looks the rows up in the woocommerce_tax_rates table (with its woocommerce_tax_rate_locations helper table for postcode and city lists), and returns the matching rates keyed by rate id. The result is cached in the taxes object-cache group, and the filter woocommerce_find_rates can change it.

How WooCommerce finds the tax for a Pocket Coffee line: product tax class, tax location, WC_Tax find_rates with priority, compound and rounding, then Madrid at 10 percent and Berlin at 7 percent

Settings

Taxes are calculated only when the switch behind woocommerce_calc_taxes (General tab, Enable tax rates and calculations) is on; the Tax tab appears after that. WooCommerce > Settings > Tax then holds the options below. The "Value on the test store" column was read with wp option get and shows the defaults of a store configured with WP-CLI.

Setting in the UI Option name Allowed values Value on the test store

Enable tax rates and calculations (General tab)

woocommerce_calc_taxes

yes, no

yes

Prices entered with tax

woocommerce_prices_include_tax

yes (inclusive), no (exclusive)

no

Calculate tax based on

woocommerce_tax_based_on

shipping, billing, base

shipping

Shipping tax class

woocommerce_shipping_tax_class

inherit (based on cart items), or a class slug (empty for Standard)

inherit

Round tax at subtotal level, instead of rounding per line

woocommerce_tax_round_at_subtotal

yes, no

no

Additional tax classes

woocommerce_tax_classes (legacy; classes live in the wc_tax_rate_classes table)

list of names

empty

Display prices in the shop

woocommerce_tax_display_shop

incl, excl

excl

Display prices during cart and checkout

woocommerce_tax_display_cart

incl, excl

excl

Price display suffix

woocommerce_price_display_suffix

text with {price_including_tax} and {price_excluding_tax}

empty

Display tax totals

woocommerce_tax_total_display

itemized, single

itemized

The first two are the ones that cause trouble. Prices entered with tax tells WooCommerce how to read the price stored on the product: as the net price (tax is added on top) or as the gross price (tax is inside). Changing it later does not rewrite products, it changes the meaning of every stored price, so decide it before importing the catalogue. Calculate tax based on chooses the location. For a shop that sells within one country the base address is enough; for cross-border sales the shipping address is the default, and the billing address is the alternative for services and digital goods taxed where the buyer lives.

The 11.2.0 changelog (2026-10-07) lists a fix that initialises new orders with the store’s current tax entry mode and preserves the saved order values when the store setting changes, so a historic order is not reinterpreted. An order exposes its mode as WC_Order::get_prices_include_tax() (on the test store order 15 reports exclusive).

Tax classes and rates

A rate is one row. The columns, from SHOW COLUMNS FROM wp_woocommerce_tax_rates on 11.2.1:

Column Admin label Meaning

tax_rate_id

(id)

Primary key, used by orders to link tax lines (and by the Analytics tax lookup).

tax_rate_country

Country code

Two letters, empty for "any country".

tax_rate_state

State code

State or province code; empty for any state of the country.

tax_rate

Rate %

A string of up to eight characters, for example 10.0000.

tax_rate_name

Tax name

Label shown to the customer and used in order tax lines.

tax_rate_priority

Priority

Integer. Among the rates that match, one rate per priority is applied; rates that share a priority are added together.

tax_rate_compound

Compound

1 when the rate is applied on the price plus the tax already computed by lower priorities.

tax_rate_shipping

Shipping

1 (default) when the rate also applies to shipping.

tax_rate_order

(order)

Display order in the admin table.

tax_rate_class

Tax class

Slug of the class; empty for Standard. Newer tables also reference wc_tax_rate_classes.

Postcodes and cities are stored in woocommerce_tax_rate_locations and accept lists separated by semicolons, ranges written as 90210...99000 and wildcards written as NR*. The rate table also exists as CSV: the admin page offers Import CSV and Export CSV with ten columns (country code, state code, ZIP/postcode, city, rate %, tax name, priority, compound, shipping, tax class), where an empty tax class means Standard.

Compound and priority are the parts people misread, so the arithmetic is worth seeing once. The test below passes two rates straight to the calculator: a regular 10% rate and a compound 5% rate. The compound rate is taken on the price plus the first tax, so on 100.00 the second tax is 5.50, not 5.00.

<?php
$rates = array(
	1 => array( 'rate' => '10', 'label' => 'A', 'shipping' => 'no', 'compound' => 'no' ),
	2 => array( 'rate' => '5', 'label' => 'B', 'shipping' => 'no', 'compound' => 'yes' ),
);
echo wp_json_encode( WC_Tax::calc_exclusive_tax( 100, $rates ) ), "\n";
echo wp_json_encode( WC_Tax::calc_inclusive_tax( 115.5, $rates ) ), "\n";

Output on the test store (run with wp eval):

{"1":10,"2":5.5}
{"1":10,"2":5.5}

The inclusive call returns the same two amounts for a gross price of 115.50, which shows that calc_inclusive_tax() works backwards through the compound rates first and then through the regular ones.

Pocket Coffee: a tax class created by code

Rates are normally typed into the admin, but a plugin that ships with a store (or a migration script) can create the class and its rows. WC_Tax::create_tax_class() returns the class as an array with slug and name, and WC_Tax::_insert_tax_rate() inserts one row; both are internal-use APIs of the tax settings screen, which is why _insert_tax_rate() has a leading underscore, and both clear the taxes cache group. The activation routine in the plugin below installs Pocket Coffee Beans with a 10% rate for Spain and a 7% rate for Germany. The rates are illustrative: the real VAT treatment of coffee in each country must come from a tax adviser, not from this page.

The whole plugin is listed in one piece in The Pocket Coffee Taxes plugin; the sections that follow refer to its functions. After wp plugin activate pocket-coffee-taxes:

wp wc tax_class list --user=admin --format=table
wp wc tax list --user=admin --format=table
slug	name
standard	Standard rate
pocket-coffee-beans	Pocket Coffee Beans
reduced-rate	Reduced rate
zero-rate	Zero rate
id	country	state	postcode	city	rate	name	priority	compound	shipping	order	class
1	ES				10.0000	IVA 10%	1			0	pocket-coffee-beans
2	DE				7.0000	MwSt 7%	1			1	pocket-coffee-beans

A product that uses the class is created with --tax_status and --tax_class (the class is addressed by slug; the product id 23 below is the id this store assigned):

wp wc product create --name="Single-origin beans 250 g (taxes demo)" --type=simple \
  --regular_price=9.90 --sku=PC-TAX-ETH-WHL-250 --tax_status=taxable \
  --tax_class=pocket-coffee-beans --user=admin --porcelain

What the customer pays

Asking the calculator directly shows the location matching. On the test store (base Madrid):

<?php
foreach ( array( 'ES', 'DE', 'FR' ) as $c ) {
	$rates = WC_Tax::find_rates( array( 'country' => $c, 'tax_class' => 'pocket-coffee-beans' ) );
	echo $c, ' ', wp_json_encode( $rates ), "\n";
}
ES {"1":{"rate":10,"label":"IVA 10%","shipping":"no","compound":"no"}}
DE {"2":{"rate":7,"label":"MwSt 7%","shipping":"no","compound":"no"}}
FR []

France has no row in this class, so a French customer would pay no tax on these beans. That silent zero is the most common configuration mistake: when a class is missing a country, WooCommerce does not fall back to the Standard class, it simply finds nothing.

Order totals confirm the same arithmetic. The script creates an order for two bags at 9.90 net, once with a Madrid address and once with a Berlin address, calls calculate_totals() and deletes the orders afterwards:

<?php
foreach ( array( 'ES', 'DE' ) as $country ) {
	$order = wc_create_order();
	$order->set_address( array(
		'first_name' => 'Ada', 'last_name' => 'Test', 'email' => 'ada@example.test',
		'address_1'  => 'Calle Mayor 1',
		'city'       => 'ES' === $country ? 'Madrid' : 'Berlin',
		'postcode'   => 'ES' === $country ? '28013' : '10115',
		'country'    => $country,
	), 'billing' );
	$order->set_address( $order->get_address( 'billing' ), 'shipping' );
	$order->add_product( wc_get_product( 23 ), 2 );
	$order->calculate_totals();
	echo $country, ' subtotal=', $order->get_subtotal(), ' tax=', $order->get_total_tax(), ' total=', $order->get_total(), "\n";
	$order->delete( true );
}
ES subtotal=19.8 tax=1.98 total=21.78
DE subtotal=19.8 tax=1.39 total=21.19

Shipping was not added, so the totals are for beans only. The same calculation through the shopper API, with the 450 shipping flat rate of the test store, returned total_items 1980, total_items_tax 198, total_shipping 450, total_shipping_tax 0 and tax_lines [{"name":"IVA 10%","price":"198","rate":"10%"}]: the Store API reports amounts in minor units (cents), and shipping carried no tax because shipping tax needs a rate whose Shipping flag is on in the resolved class (the rates of the class were created with Shipping off).

Prices entered with tax

A very common European setup is "prices include tax". Because the Pocket Coffee test store has the setting at no, the next experiment forces it for one script with a pre_option_ filter, so no stored setting changes. The product’s price of 9.90 is now treated as gross. A customer in the store’s own country pays exactly the shelf price; a customer in another tax zone gets the base tax removed and the local tax added:

<?php
add_filter( 'pre_option_woocommerce_prices_include_tax', fn() => 'yes' );
// ...the same order-building helper as above, for ES (28013) and DE (10115)...
prices include tax=yes, ES customer (subtotal / tax / total): 9 / 0.9 / 9.90
prices include tax=yes, DE customer: 9 / 0.63 / 9.63

The German customer pays 9.63, not 9.90: 9.90 minus 10% Spanish tax is 9.00, plus 7% German tax is 9.63. This price adjustment is on by default; the filter woocommerce_adjust_non_base_location_prices (return false) switches it off for the cart so that the price stays fixed at the shelf value and the tax is carved out of it at the local rate. EU stores that must show one fixed consumer price in every member state often turn it off, and then take the lower or higher margin knowingly.

Rounding, display and exemptions

  • Rounding. Internal calculations use wc_get_rounding_precision(), which is the number of price decimals plus two and never less than six, and totals are rounded to the display decimals when they are written. woocommerce_tax_round_at_subtotal rounds the total tax of a rate once at the end of the cart instead of once per line; the two modes can differ by a cent on carts with many lines, and invoices must follow the rule of the tax authority. The filter woocommerce_tax_round (argument: the unrounded value; documented in the WC_Tax::round() docblock with a "round to five cents" example) replaces the rounding function.

  • Display. woocommerce_tax_display_shop and woocommerce_tax_display_cart decide whether shelf prices and cart lines show net or gross amounts, woocommerce_price_display_suffix appends text such as {price_including_tax} to a price, and woocommerce_tax_total_display chooses one tax line (single) or one per rate (itemized). Product-level functions are wc_get_price_including_tax(), wc_get_price_excluding_tax() and wc_get_price_to_display(). For product 23 on the test store they returned 10.89, 9.9 and 9.9 (display is exclusive here), and for a quantity of two wc_get_price_including_tax( $product, array( 'qty' => 2 ) ) returned 21.78.

  • Exempt customers. WC()->customer->set_is_vat_exempt( true ) (read with get_is_vat_exempt()) removes tax for the current customer; the tax calculation of the cart honours it on every recalculation. Extensions use it to implement VAT-number validation; core does not validate VAT numbers by itself. A role- or meta-based exemption belongs in a small plugin that sets the flag on woocommerce_checkout_update_order_review or at login, and the choice must be recorded on the order for audit.

  • Shipping tax. Shipping is taxed by WC_Tax::get_shipping_tax_rates(), which picks the class from Shipping tax class: inherit uses the highest-rate class found in the cart, any other value forces a class. A rate only applies to shipping when its own Shipping flag is on.

  • Reports. Orders store one tax line per rate (WC_Order_Item_Tax), and the Analytics lookup table wc_order_tax_lookup holds one row per tax line. In 11.2.0 it was re-keyed on (order_id, tax_rate_id, order_item_id) and gained an order_item_id column, and the Taxes report gained a taxable amount column; these facts matter for custom SQL against the table.

Automated taxes and EU VAT

Maintaining rate tables by hand is workable for a few countries. For many jurisdictions Woo offers WooCommerce Tax (previously part of WooCommerce Shipping and Tax), an extension connected to a WordPress.com account that calculates rates automatically and switches the manual options off while it is on. It could not be installed in the test stack because it needs an account, so only its documented behaviour is described here. The Woo documentation also links a separate guide for EU VAT rates on digital products, because the place-of-supply rules for digital goods differ from those for physical goods. Core WooCommerce has no one-stop-shop reporting, no VAT-number checks and no reverse-charge invoices; those are extension or accounting-system territory.

Tax hooks

Hook Purpose Notes

woocommerce_find_rates

Filter the rates WC_Tax::find_rates() found.

Receives the matched rates and the arguments array.

woocommerce_matched_rates

Filter the rates matched for a tax class and customer.

Used by get_rates().

woocommerce_get_tax_location

Change the country, state, postcode and city used for tax.

Return a four-item array.

woocommerce_calc_tax

Change the computed tax array.

Four arguments: taxes, price, rates, price-includes-tax flag.

woocommerce_calc_shipping_tax

Same for shipping.

woocommerce_price_ex_tax_amount, woocommerce_price_inc_tax_amount

Adjust a single tax amount inside the calculators.

Advanced; runs per rate.

woocommerce_tax_round

Replace the rounding function.

See WC_Tax::round().

woocommerce_adjust_non_base_location_prices

Keep or remove base-location tax from gross prices.

Default true.

woocommerce_tax_rate_added

Action after a rate is inserted.

Receives the id and the data.

Tax rates through WP-CLI and REST

The wp wc tax and wp wc tax_class command groups call the REST controllers, so they need --user with an administrator. Creating and removing a French row on the test store:

wp wc tax create --country=FR --rate=5.5 --name="TVA 5.5%" --class=pocket-coffee-beans --shipping=false --user=admin --porcelain
wp wc tax list --class=pocket-coffee-beans --fields=id,country,rate,name,class --user=admin --format=table
wp wc tax delete 3 --force=true --user=admin
3
id	country	rate	name	class
1	ES	10.0000	IVA 10%	pocket-coffee-beans
3	FR	5.5000	TVA 5.5%	pocket-coffee-beans
2	DE	7.0000	MwSt 7%	pocket-coffee-beans
Success: Deleted tax 3.

The REST equivalents are GET /wp-json/wc/v3/taxes?class=pocket-coffee-beans, POST /wp-json/wc/v3/taxes, GET /wp-json/wc/v3/taxes/classes and POST /wp-json/wc/v3/taxes/classes; with consumer key and secret over HTTPS the first returned the two rows (1, ES, 10.0000, IVA 10%) and (2, DE, 7.0000, MwSt 7%). Keys are written as ck_xxxxxxxx and cs_xxxxxxxx in documentation; never put real ones in a repository.

curl -s -u "ck_xxxxxxxx:cs_xxxxxxxx" "https://shop.example.com/wp-json/wc/v3/taxes?class=pocket-coffee-beans"

Coupons

Types, fields and where they are applied

A coupon is a custom post (post type shop_coupon, stored with the WordPress post tables even when orders use HPOS) read and written through WC_Coupon. A shop enables coupons with WooCommerce > Settings > General > Enable coupons (woocommerce_enable_coupons, default yes); with it off, wc_coupons_enabled() is false and no coupon field is shown. The sibling option Calculate coupon discounts sequentially (woocommerce_calc_discounts_sequentially, default no) applies the second coupon to the price already reduced by the first one instead of to the original price; an 11.2.0 fix made sequential coupons with equal priority calculate in the order they were applied. WooCommerce does not apply coupons automatically; the customer (or code) must apply one.

There are three discount types, returned by wc_get_coupon_types() and extensible with woocommerce_coupon_discount_types: percent (percentage discount), fixed_cart (a fixed amount shared across the cart) and fixed_product (a fixed amount off each unit of the matching products). Coupons reduce product prices before tax, tax is recalculated on the reduced amount, and shipping is only affected by a coupon with Allow free shipping.

The data properties of WC_Coupon and their admin labels are:

Property (setter set_*) Admin label Meaning

code

Coupon code

Normalised through the woocommerce_coupon_code filter (lower case by default), so lookups are case-insensitive.

amount

Coupon amount

Number without currency or percent sign.

discount_type

Discount type

percent, fixed_cart, fixed_product.

description

Description

Internal only.

date_expires

Coupon expiry date

Timestamp or WC_DateTime; the coupon is rejected after it.

free_shipping

Allow free shipping

Needs a matching free shipping method option; see the shipping page.

individual_use

Individual use only

Removes other coupons when applied.

exclude_sale_items

Exclude sale items

Ignores products on sale.

minimum_amount, maximum_amount

Minimum spend, Maximum spend

Cart subtotal limits; the minimum must not exceed the maximum (validated since 11.2.0, and a saved lower maximum still enforces the minimum as in earlier versions).

product_ids, excluded_product_ids

Products, Exclude products

Restrict to or exclude products.

product_categories, excluded_product_categories

Product categories, Exclude categories

Term ids of product_cat.

email_restrictions

Allowed emails

Billing or account emails; wildcards such as *@example.test work.

usage_limit

Usage limit per coupon

Total number of uses.

limit_usage_to_x_items

Limit usage to X items

Applies to percent and fixed-product coupons only.

usage_limit_per_user

Usage limit per user

Counted by user id or billing email.

usage_count, used_by

(read only)

Maintained by WooCommerce when an order uses the coupon.

virtual

(internal)

Marks coupons created by WooCommerce itself.

The brand restrictions of the admin screen (Product brands and Exclude brands) are implemented by WC_Brands_Coupons, which hooks the same woocommerce_coupon_is_valid and woocommerce_coupon_error filters shown below.

Creating the Coffee Club coupon

Pocket Coffee has one standing discount, 10% off beans for members. The coupon is created through the CRUD class: a percent coupon, limited to the Beans category (term id 16 in the test store), five uses per customer, valid until the end of 2027.

<?php
$coupon = new WC_Coupon();
$coupon->set_code( 'coffeeclub10' );
$coupon->set_discount_type( 'percent' );
$coupon->set_amount( 10 );
$coupon->set_product_categories( array( 16 ) );
$coupon->set_individual_use( false );
$coupon->set_usage_limit_per_user( 5 );
$coupon->set_date_expires( strtotime( '2027-12-31' ) );
$coupon->set_description( 'Coffee Club: 10% off beans' );
echo 'coupon id ', $coupon->save(), "\n";

The same data can be created from WP-CLI and REST; both were run on the test store, and the second coupon below was removed afterwards:

wp wc shop_coupon create --code=welcome5 --discount_type=fixed_cart --amount=5 --minimum_amount=30 \
  --usage_limit=100 --date_expires=2027-12-31 --user=admin --porcelain
curl -s -X POST -H 'Content-Type: application/json' -u "ck_xxxxxxxx:cs_xxxxxxxx" \
  -d '{"code":"starter5","discount_type":"fixed_product","amount":"2.00","product_ids":[22],"exclude_sale_items":true,"email_restrictions":["*@example.test"]}' \
  https://shop.example.com/wp-json/wc/v3/coupons

The REST response echoed "amount":"2.00", "discount_type":"fixed_product", "product_ids":[22], "email_restrictions":["*@example.test"] and "exclude_sale_items":true. The wp wc shop_coupon get output for the first one listed amount 5.00, minimum_amount 30.00, usage_limit 100, date_expires 2027-12-31T00:00:00 and free_shipping false.

Validation, errors and the members-only rule

When a code is applied, WC_Discounts::is_coupon_valid() runs the checks in a fixed order and stops at the first failure. Each failure is an exception carrying a numeric code, and WC_Coupon::get_coupon_error() maps the code to a message.

Coupon validation and discount calculation: enabled and exists, dates and limits, spend limits, restrictions, the woocommerce_coupon_is_valid filter, then percent, fixed cart or fixed product discount before tax
Code Constant Typical cause

100

E_WC_COUPON_INVALID_FILTERED

A filter marked the coupon invalid.

101, 102

E_WC_COUPON_INVALID_REMOVED, E_WC_COUPON_NOT_YOURS_REMOVED

Coupon removed from the cart after it stopped being valid, or it belongs to another customer.

103, 104

E_WC_COUPON_ALREADY_APPLIED, E_WC_COUPON_ALREADY_APPLIED_INDIV_USE_ONLY

Applied twice, or an individual-use coupon is already applied.

105, 111

E_WC_COUPON_NOT_EXIST, E_WC_COUPON_PLEASE_ENTER

Unknown code, or an empty field.

106, 115, 116

E_WC_COUPON_USAGE_LIMIT_REACHED, E_WC_COUPON_USAGE_LIMIT_COUPON_STUCK, E_WC_COUPON_USAGE_LIMIT_COUPON_STUCK_GUEST

Usage limit reached; the two "stuck" codes report a limit reached while an order is still pending for that customer or guest.

107

E_WC_COUPON_EXPIRED

Past date_expires.

108, 112

E_WC_COUPON_MIN_SPEND_LIMIT_NOT_MET, E_WC_COUPON_MAX_SPEND_LIMIT_MET

Cart subtotal outside the range.

109

E_WC_COUPON_NOT_APPLICABLE

Restrictions (products, categories, emails) not met; a generic "not applicable" message.

110

E_WC_COUPON_NOT_VALID_SALE_ITEMS

The coupon excludes sale items and the cart has only those.

113, 114

E_WC_COUPON_EXCLUDED_PRODUCTS, E_WC_COUPON_EXCLUDED_CATEGORIES

A cart item is on an exclusion list.

200, 201

WC_COUPON_SUCCESS, WC_COUPON_REMOVED

Success messages.

The extension point is the filter woocommerce_coupon_is_valid, called with the current result, the WC_Coupon and the WC_Discounts object; a rule rejects a coupon by throwing an Exception whose message is shown to the customer and whose code selects the error. WooCommerce 11.1.0 added finer filters for the individual restriction checks, renamed in the same release to the woocommerce_coupon_is_valid_for_* family: woocommerce_coupon_is_valid_for_product_ids, woocommerce_coupon_is_valid_for_product_categories, woocommerce_coupon_is_valid_for_sale_items, woocommerce_coupon_is_valid_for_excluded_items, woocommerce_coupon_is_valid_for_excluded_product_ids and woocommerce_coupon_is_valid_for_excluded_product_categories (true means valid), alongside the older woocommerce_coupon_validate_expiry_date, woocommerce_coupon_validate_minimum_amount, woocommerce_coupon_validate_maximum_amount and woocommerce_coupon_validate_user_usage_limit. Use the narrow filter when one check needs changing, and the general one for a new business rule such as "members only".

The plugin implements that rule: only users whose user meta _coffee_club is set may use coffeeclub10. It was exercised through three paths. First through WC_Order::apply_coupon() as a guest, as a logged-in user without the flag, and as the flagged member (Ada, ada@example.test):

guest: This coupon is only for Coffee Club members.
ada without flag: This coupon is only for Coffee Club members.
ada member: applied
subtotal=19.8 discount=1.98 discount_tax=0.2 tax=1.78 total=19.60

The member’s order shows the "before tax" rule at work: the 10% discount is 1.98, the tax is recomputed on 17.82 and comes to 1.78 (the 0.2 discount_tax is the tax that the discount removed), and the total is 19.60. Second, through the Store API with a Cart-Token obtained from GET /wp-json/wc/store/v1/cart, where applying the coupon as a guest returned HTTP 400:

{"code":"woocommerce_rest_cart_coupon_error","message":"This coupon is only for Coffee Club members.","data":{"status":400}}
{"code":"woocommerce_rest_cart_coupon_error","message":"Coupon &quot;nope&quot; cannot be applied because it does not exist.","data":{"status":400}}

The second line is the answer for a code that does not exist. Third, under the Spanish locale the error text is translated (see Translations), which was checked by calling apply_coupon() inside switch_to_locale( 'es_ES' ): the message came back as Este cupón es solo para miembros del Coffee Club. The Store API endpoints involved are POST /cart/apply-coupon and POST /cart/remove-coupon with a JSON body {"code":"..."}, and GET /cart/coupons; the Cart and Checkout blocks call the same ones.

Two newer hooks help integrations: woocommerce_order_applied_coupon and, since 11.2.0, woocommerce_order_removed_coupon (parity with the cart), and the JavaScript events errored_coupon and errored_coupon_in_checkout fired on the classic cart and checkout when a coupon fails. A classic-checkout trick from older cookbooks, hiding the coupon form with a woocommerce_coupons_enabled filter, still works in the sense that the filter turns coupons off entirely, but custom discount logic for the blocks belongs in coupon filters and Store API extensions, not in template edits.

Discount filters

Hook Purpose

woocommerce_coupon_discount_types

Add a custom discount type (and handle it in woocommerce_coupon_get_discount_amount).

woocommerce_coupon_get_discount_amount

Change the discount computed for a coupon on an item.

woocommerce_coupon_error

Change the message for an error code.

woocommerce_coupon_is_valid

Add a validation rule (shown above).

woocommerce_coupon_is_valid_for_cart, woocommerce_coupon_is_valid_for_product

Decide if a cart-level or product-level coupon type applies.

woocommerce_coupons_enabled

Turn the coupon feature on or off.

Currencies

One store currency

A WooCommerce store has exactly one currency (woocommerce_currency, here EUR), and a few format settings under WooCommerce > Settings > General > Currency options: woocommerce_currency_pos (left, right, left_space, right_space; left on the test store), woocommerce_price_thousand_sep (,), woocommerce_price_decimal_sep (.) and woocommerce_price_num_decimals (2). The list of 164 currencies in 11.2.1 comes from the file i18n/currencies.php through get_woocommerce_currencies(); the filter woocommerce_currencies can extend it, and woocommerce_currency can change the active code at run time. Symbols come from get_woocommerce_currency_symbol() and the filter woocommerce_currency_symbol.

Formatting goes through wc_price( $amount, $args ). Its $args array overrides currency, decimal_separator, thousand_separator, decimals and price_format for one call, and the HTML result carries the classes woocommerce-Price-amount and woocommerce-Price-currencySymbol that themes and tests rely on:

<?php
echo wc_price( 8.9 );
echo wc_price( 1234.5, array( 'currency' => 'USD', 'decimal_separator' => ',', 'thousand_separator' => '.', 'decimals' => 2 ) );

Output, tags shortened to the text (the symbol of EUR is the entity € and the dollar sign $):

<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol" translate="no" dir="auto">&euro;</span>8.90</bdi></span>
<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol" translate="no" dir="auto">&#36;</span>1.234,50</bdi></span>

Several consequences are easy to miss.

  • Changing woocommerce_currency does not convert anything. Products keep their stored numbers, now labelled in the new currency, and existing orders keep the currency they were created in (order 15 of the test store reports EUR). Treat a currency change as a data migration.

  • The store formats numbers with its own settings, not with the visitor’s locale. WooCommerce ships i18n/currency-info.php with a per-locale table of separators and symbol position (for example es_ES uses . as thousand and , as decimal separator with the symbol on the right, and en_IE the reverse), but the only use of that file found in the PHP code of 11.2.1 is the marketing campaigns API; a store that wants locale-correct prices on every screen must set the options accordingly, or filter the option values per language with pre_option_woocommerce_price_decimal_sep and its siblings.

  • The Store API reports the money format next to each cart: currency_code, currency_symbol, currency_minor_unit, currency_decimal_separator, currency_thousand_separator, currency_prefix and currency_suffix, which the blocks read to format amounts in JavaScript.

  • Payment gateways declare which currencies they can charge; some gateways hide themselves at checkout when the order currency is not supported (see the payment gateways page).

A custom currency

A shop that sells loyalty points or a local currency registers it with two filters. The Pocket Coffee plugin adds a currency PCB ("Pocket Coffee points") with the symbol PCB followed by a space. Register the filter when the plugin loads, because the currency list is cached on first use:

PCB 120 | Pocket Coffee points | 165

That line is the output of wc_price( 120, array( 'currency' => 'PCB', 'decimals' => 0 ) ) with tags removed, the registered name, and the new total of known currencies (164 plus one).

Showing a second currency

Pocket Coffee shows dollars to customers outside the euro area. The plugin does this display-only: the helper pocket_coffee_taxes_usd() multiplies the displayed amount by a fixed rate and formats it with wc_price( ..., array( 'currency' => 'USD' ) ), and the shortcode [pocket_coffee_usd id="23"] prints both prices. The order is still created and paid in euros. With a rate of 1.08 on the 9.90 beans:

€9.90 (about $10.69)

(markup removed). A fixed rate in code is a demonstration; a real shop reads a rate from a service or option and says clearly that the shown figure is approximate.

Multi-currency: charging in another currency

Core WooCommerce has no multi-currency mode, so selling and charging in several currencies is the job of an extension. The choices differ in what they change.

Approach What happens Trade-offs

Display only (shortcode, price filter, geolocated notice)

The customer sees an approximate converted price; the checkout is in the store currency.

Easy and cache-friendly; the customer may see a different final amount, and refunds follow the store currency.

WooPayments Multi-Currency

Enabled in Payments > Settings > Advanced settings. Shows prices and charges in the customer’s chosen currency, with automatic exchange rates or manual rates, optional price rounding to a chosen increment and charm pricing (for example 19.99), a currency switcher block, widget or wc_get_currency_switcher_markup() helper, and automatic switching by geolocation.

Requires WooPayments (it could not be onboarded in the test stack because it needs a Woo account). Prices are converted, not entered per currency; Analytics keeps reporting in the store default currency, and refunds return the exact customer-currency amount while the deduction from the balance uses the rate at refund time.

WPML Multilingual and Multicurrency for WooCommerce

Per-product manual prices per currency, or automatic conversion from exchange rates; currency formats follow each currency; gateways can be assigned per currency; switcher as block, widget or shortcode.

Part of WPML’s Multilingual CMS and Agency plans; the free WooCommerce Multilingual plugin on WordPress.org only covers multi-currency on a single-language store.

Other multi-currency plugins

Many variants, free and paid.

Check the same four things: where the conversion happens, which gateways can charge each currency, how tax and shipping are converted, and how page caching is handled.

Whatever the extension, test the same four scenarios on a staging copy: a customer in the second currency adds beans to the cart, applies the fixed-amount coupon (fixed_cart and fixed_product coupons are amounts in the store currency and are converted by well-behaved extensions, but check), pays, and is refunded. Also check taxes: tax rates apply to the converted price, and rounding rules can add or remove a cent.

Translations

The figure separates what translations touch: interface text through gettext files, content through the database, number and currency formats through store settings, and extra currencies through an extension.

Where translations and currency formats come from: gettext files, translated content, currency and number settings, and multi-currency extensions, applied to Pocket Coffee

How WooCommerce text is translated

All interface text in WooCommerce core and in extensions is written with the WordPress gettext functions (__(), _e(), _n(), _x() and esc_html__()) and a text domain, woocommerce for the core plugin. Translators work on translate.wordpress.org; when a locale of a plugin reaches 90 percent for the stable version, WordPress offers it as a language pack that Dashboard > Updates > Update Translations (or wp language plugin update --all) downloads. On the test store:

wp language core install es_ES
wp language plugin install woocommerce es_ES
Translation updated successfully.
Language 'es_ES' installed.
Success: Installed 1 of 1 languages.
Translation updated successfully.
Language 'es_ES' installed.
Success: Installed 1 of 1 languages.

The pack for WooCommerce lands in wp-content/languages/plugins/ as woocommerce-es_ES.mo and woocommerce-es_ES.po, together with woocommerce-es_ES.l10n.php (the PHP-array format WordPress loads faster) and a set of hash-named JSON files for the script translations of the block editor and the Cart and Checkout blocks (wp_set_script_translations() reads them). With the pack in place, switching locale in code translates WooCommerce itself:

<?php
switch_to_locale( 'es_ES' );
echo __( 'Add to cart', 'woocommerce' ), ' | ', __( 'Cart', 'woocommerce' ), "\n";
restore_previous_locale();
Añadir al carrito | Carrito

switch_to_locale() is the WordPress function that renders text in another locale for one operation, and what the Pocket Coffee examples below use to compare Spanish and English output without changing the site language (the option WPLANG stayed en_US). It only switches to locales that are installed, which is why the language pack was installed first; on the test store the first attempt, before installing the pack, silently stayed in en_US.

Custom and override files

The loader in WooCommerce::load_plugin_textdomain() supports a custom location besides the standard one. If WP_LANG_DIR/woocommerce/woocommerce-LOCALE.mo exists it is loaded in addition to the language pack, and its strings win; that is the upgrade-safe place for a merchant’s own wording (wp-content/languages/woocommerce/woocommerce-es_ES.mo). The filter plugin_locale changes the locale WooCommerce asks for. The merchant documentation lists the other options: a manually downloaded .mo for locales below 90 percent, Loco Translate with its Custom folder, PoEdit starting from woocommerce.pot in i18n/languages/, and string-replacement plugins.

Making a plugin translatable

A plugin follows the WordPress standard: a Text Domain and Domain Path in the header, calls to the gettext functions with that domain, a .pot template, one .po per language compiled to .mo, and a call to load_plugin_textdomain(). WordPress 6.7 and later warn when a text domain is loaded before the init action, so the Pocket Coffee plugin loads it on init (see the listing). Plugins hosted on WordPress.org get language packs automatically and do not need load_plugin_textdomain() at all; the plugin loads it explicitly because it is distributed with the store.

The strings of the plugin are plain, one with a placeholder and one with plural forms:

  • esc_html__( 'This coupon is only for Coffee Club members.', 'pocket-coffee-taxes' ) in the coupon rule.

  • _n( '%d kilo of beans', '%d kilos of beans', $kilos, 'pocket-coffee-taxes' ) with a translators: comment, for plurals.

  • esc_html__( '%1$s (about %2$s)', ... ) with numbered placeholders, so that a language can reorder the price and the dollar figure.

The template was generated and the Spanish catalogue compiled with WP-CLI:

wp i18n make-pot wp-content/plugins/pocket-coffee-taxes wp-content/plugins/pocket-coffee-taxes/languages/pocket-coffee-taxes.pot --slug=pocket-coffee-taxes
wp i18n make-mo wp-content/plugins/pocket-coffee-taxes/languages
Plugin file detected.
Success: POT file successfully generated.
Success: Created 1 file.

The generated .pot (header trimmed) lists the four extracted messages with their references and the translators: comments:

#: pocket-coffee-taxes.php:91
msgid "This coupon is only for Coffee Club members."
msgstr ""

#. translators: 1: price in the store currency, 2: price in US dollars.
#: pocket-coffee-taxes.php:122
#, php-format
msgid "%1$s (about %2$s)"
msgstr ""

#. translators: %d: number of kilograms of beans.
#: pocket-coffee-taxes.php:138
#, php-format
msgid "%d kilo of beans"
msgid_plural "%d kilos of beans"
msgstr[0] ""
msgstr[1] ""

#: pocket-coffee-taxes.php:151
msgid "Pocket Coffee points"
msgstr ""

The Spanish catalogue pocket-coffee-taxes-es_ES.po (header with Language: es_ES and Plural-Forms: nplurals=2; plural=(n != 1);) translates three of them:

msgid "This coupon is only for Coffee Club members."
msgstr "Este cupón es solo para miembros del Coffee Club."

msgid "%1$s (about %2$s)"
msgstr "%1$s (unos %2$s)"

msgid "%d kilo of beans"
msgid_plural "%d kilos of beans"
msgstr[0] "%d kilo de café en grano"
msgstr[1] "%d kilos de café en grano"

Running the plural helper and the shortcode in both locales:

en_US: 1 kilo of beans | 3 kilos of beans
es_ES: 1 kilo de café en grano | 3 kilos de café en grano
es_ES: €9.90 (unos $10.69)

The wp i18n make-php command converts .po files to the .l10n.php format and wp i18n make-json extracts the JavaScript strings of a .po into the JSON files that wp_set_script_translations() loads; wp i18n update-po merges a new .pot into existing catalogues. They were not run for this plugin, which has no JavaScript.

Translating content, not just the interface

Gettext translates code. Products, categories, attributes, pages and the shop’s own notices are content stored in the database, and translating them takes a multilingual plugin. They differ in how they store translations.

Plugin WooCommerce support Notes (checked 2026-10-27)

WPML with Multilingual CMS or Agency

Translates all product types and variations (kept linked across languages), taxonomies, shipping and payment labels, checkout and My Account text and emails; multicurrency add-on.

Commercial. Translation can be manual or automatic.

Polylang 3.9.1

WordPress.org lists that free Polylang does not work with WooCommerce on its own; the premium Polylang for WooCommerce add-on makes them work together.

Free core, paid WooCommerce add-on. Requires WordPress 6.8 or newer.

TranslatePress 3.3.8

The WordPress.org page says the free plugin works out of the box with WooCommerce.

Translates by page text through a visual editor; the translation is stored by string.

Webis Multilingual (paid Woo extension)

Manages translations for strings, products, categories, posts, pages and menus under WooCommerce > Webis Multilingual.

Listed in the merchant localization guide.

None of them was installed in the test store, so the table reports each vendor’s documentation, not an experiment. For Pocket Coffee the setup is the same with every plugin: Spanish as default language with its own URL root, English as a second language, product titles and descriptions translated, the Beans category and the origin attribute terms translated, the Cart, Checkout, My Account and Shop pages translated and linked as translations of each other, and the language switcher in the header. Check that the coupon error, tax names (the Tax name column, IVA 10% and MwSt 7%, is stored once and is not translated by gettext; multilingual plugins that offer string translation can translate it) and email texts are covered, and test checkout in both languages with the same cart.

Search engines need each language on its own URL with hreflang links; full-page caches must vary by language cookie or URL. The caching page of the performance guide lists the cookies that must bypass the cache.

The Pocket Coffee Taxes plugin

The complete plugin used above. It declares compatibility with HPOS and the Cart and Checkout blocks, loads its text domain on init, creates the tax class and rates on activation, enforces the Coffee Club rule, formats a dollar display price, defines a plural-aware label and registers the custom PCB currency. It passes WooCommerce-Core PHP_CodeSniffer with no errors or warnings.

<?php
/**
 * Plugin Name:       Pocket Coffee Taxes
 * Description:       Tax class and rates, a Coffee Club coupon rule, a USD display price and translatable strings for Pocket Coffee.
 * Version:           0.1.0
 * Requires at least: 7.0
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * Text Domain:       pocket-coffee-taxes
 * Domain Path:       /languages
 * License:           GPL-2.0-or-later
 *
 * @package PocketCoffeeTaxes
 */

defined( 'ABSPATH' ) || exit;

const POCKET_COFFEE_TAXES_CLASS = 'Pocket Coffee Beans';

// Declare compatibility with HPOS (custom order tables) and the Cart and Checkout blocks.
add_action(
	'before_woocommerce_init',
	function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
		}
	}
);

// Load translations. WordPress 6.7+ wants this on init or later.
add_action(
	'init',
	function () {
		load_plugin_textdomain( 'pocket-coffee-taxes', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
	}
);

/**
 * Create the tax class and its rates once, on activation.
 */
function pocket_coffee_taxes_install() {
	if ( ! class_exists( 'WC_Tax' ) ) {
		return;
	}
	$class = WC_Tax::get_tax_class_by( 'name', POCKET_COFFEE_TAXES_CLASS );
	if ( ! $class ) {
		$class = WC_Tax::create_tax_class( POCKET_COFFEE_TAXES_CLASS );
		if ( is_wp_error( $class ) ) {
			return;
		}
	}
	if ( WC_Tax::get_rates_for_tax_class( $class['slug'] ) ) {
		return; // Already installed.
	}
	$rates = array(
		array( 'ES', '10.0000', 'IVA 10%' ),
		array( 'DE', '7.0000', 'MwSt 7%' ),
	);
	foreach ( $rates as $i => $r ) {
		WC_Tax::_insert_tax_rate(
			array(
				'tax_rate_country'  => $r[0],
				'tax_rate'          => $r[1],
				'tax_rate_name'     => $r[2],
				'tax_rate_priority' => 1,
				'tax_rate_compound' => 0,
				'tax_rate_shipping' => 0,
				'tax_rate_order'    => $i,
				'tax_rate_class'    => $class['slug'],
			)
		);
	}
}
register_activation_hook( __FILE__, 'pocket_coffee_taxes_install' );

/**
 * Coffee Club coupon: only customers flagged with the user meta _coffee_club may use it.
 *
 * @param bool      $valid  Whether the coupon is valid so far.
 * @param WC_Coupon $coupon The coupon being validated.
 * @return bool
 * @throws Exception With a customer-facing message when the coupon is refused.
 */
function pocket_coffee_taxes_coupon_is_valid( $valid, $coupon ) {
	if ( 'coffeeclub10' !== $coupon->get_code() ) {
		return $valid;
	}
	$user_id = get_current_user_id();
	if ( ! $user_id || ! get_user_meta( $user_id, '_coffee_club', true ) ) {
		throw new Exception( esc_html__( 'This coupon is only for Coffee Club members.', 'pocket-coffee-taxes' ), 109 );
	}
	return $valid;
}
add_filter( 'woocommerce_coupon_is_valid', 'pocket_coffee_taxes_coupon_is_valid', 10, 2 );

/**
 * Format an EUR amount as a USD display price (informational only; the order is still charged in the store currency).
 *
 * @param float $amount_eur Amount in the store currency.
 * @param float $rate       USD per EUR.
 * @return string HTML price.
 */
function pocket_coffee_taxes_usd( $amount_eur, $rate = 1.08 ) {
	return wc_price( $amount_eur * $rate, array( 'currency' => 'USD' ) );
}

/**
 * Shortcode [pocket_coffee_usd id="10"]: the product price shown in dollars.
 *
 * @param array $atts Shortcode attributes.
 * @return string
 */
function pocket_coffee_taxes_usd_shortcode( $atts ) {
	$atts    = shortcode_atts( array( 'id' => 0 ), $atts, 'pocket_coffee_usd' );
	$product = wc_get_product( (int) $atts['id'] );
	if ( ! $product ) {
		return '';
	}
	return sprintf(
		/* translators: 1: price in the store currency, 2: price in US dollars. */
		esc_html__( '%1$s (about %2$s)', 'pocket-coffee-taxes' ),
		wc_price( wc_get_price_to_display( $product ) ),
		pocket_coffee_taxes_usd( wc_get_price_to_display( $product ) )
	);
}
add_shortcode( 'pocket_coffee_usd', 'pocket_coffee_taxes_usd_shortcode' );

/**
 * A translatable label with plural forms for the Coffee Club discount.
 *
 * @param int $kilos Kilos in the cart.
 * @return string
 */
function pocket_coffee_taxes_label( $kilos ) {
	return sprintf(
		/* translators: %d: number of kilograms of beans. */
		_n( '%d kilo of beans', '%d kilos of beans', $kilos, 'pocket-coffee-taxes' ),
		$kilos
	);
}

/**
 * Register a custom currency, "PCB" (Pocket Coffee points), so it can be selected and formatted.
 * Register the filter at load time: WooCommerce caches the list on first use.
 *
 * @param array $currencies Currency code => name.
 * @return array
 */
function pocket_coffee_taxes_currencies( $currencies ) {
	$currencies['PCB'] = __( 'Pocket Coffee points', 'pocket-coffee-taxes' );
	return $currencies;
}
add_filter( 'woocommerce_currencies', 'pocket_coffee_taxes_currencies' );

/**
 * Symbol of the custom currency.
 *
 * @param string $symbol   Current symbol.
 * @param string $currency Currency code.
 * @return string
 */
function pocket_coffee_taxes_currency_symbol( $symbol, $currency ) {
	return 'PCB' === $currency ? 'PCB ' : $symbol;
}
add_filter( 'woocommerce_currency_symbol', 'pocket_coffee_taxes_currency_symbol', 10, 2 );

What was run and what was not

Run on WooCommerce 11.2.1, WordPress 7.1.3, PHP 8.3 and MariaDB 11.8 (Docker): plugin activation and the tax class and rate creation; WC_Tax::find_rates(), calc_exclusive_tax() and calc_inclusive_tax(); order totals for Madrid and Berlin with and without "prices include tax"; wp wc tax_class, wp wc tax, wp wc shop_coupon; the REST calls GET /wc/v3/taxes, GET /wc/v3/taxes/classes and POST /wc/v3/coupons; the coupon rule through WC_Order::apply_coupon() and through the Store API apply-coupon; wc_price() with and without arguments; the custom currency; wp i18n make-pot and make-mo; wp language core install and wp language plugin install woocommerce; locale switching with switch_to_locale(); and PHP_CodeSniffer on the plugin. All temporary products, coupons and orders were removed afterwards, and the test plugin was deactivated at the end.

Not run: the Core Profiler setup wizard and its tax steps, the WooCommerce Tax automated-tax extension, WooPayments and its Multi-Currency feature, WPML, Polylang and TranslatePress (all need accounts, licences or a site connection), tax CSV import through the admin screen, Webis Multilingual, classic checkout (the test store uses blocks) and wp i18n make-php, make-json and update-po. Their descriptions come from the vendors' pages listed in the references. Tax rates and rules in this page are examples for a fictional shop and are not tax advice.