diff --git a/CLAUDE.md b/CLAUDE.md
index b3bc3a5..f274206 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -821,15 +821,15 @@ WooCommerce has different hooks for different product types in the admin product
**Lesson:** When adding admin UI for variable product parents, use `woocommerce_product_options_general_product_data` and check `$product->is_type('variable')` to conditionally display. Using `woocommerce_product_options_pricing` will cause forms to never appear for variable products (as discovered in v1.2.6 → v1.2.7 fix).
-#### CRITICAL: Currency Symbol Display (Learned in v1.2.8)
+#### CRITICAL: Currency Symbol Display (Learned in v1.2.8, Corrected in v1.2.9)
When displaying currency symbols in admin interface table headers and input placeholders:
**Table Headers:**
```php
-// ✅ Correct - Use printf with translation and WooCommerce currency function
-
|
+// ✅ Correct - Use printf with esc_html__ for translation (CORRECTED in v1.2.9)
+ |
// ❌ Wrong - Hard-coded or missing currency
|
@@ -839,8 +839,8 @@ When displaying currency symbols in admin interface table headers and input plac
**Twig Template Placeholders:**
```twig
-{# ✅ Correct - Pass currency_symbol from PHP and concatenate in template #}
-placeholder="{{ ('e.g., 9.99 ' ~ currency_symbol)|__('wc-tier-package-prices') }}"
+{# ✅ Correct - Pass currency_symbol from PHP and concatenate (CORRECTED in v1.2.9 - no translation filter) #}
+placeholder="{{ 'e.g., 9.99 ' ~ currency_symbol }}"
{# ❌ Wrong - Hard-coded or missing currency #}
placeholder="{{ 'e.g., 9.99'|__('wc-tier-package-prices') }}"
@@ -917,6 +917,133 @@ if (isset($_POST['_wc_tpp_tiers'])) {
**Rule:** Always check `if (!empty($array))` before calling `update_post_meta()` for array data. If empty, call `delete_post_meta()` instead.
+#### CRITICAL: WordPress Translation Functions with printf (Learned in v1.2.9)
+
+When using `printf()` with WordPress translation functions, the text domain must be passed to the translation function, NOT to printf:
+
+**Wrong Pattern:**
+
+```php
+// ❌ WRONG - Text domain not passed to translation function
+printf(__('Price (%s)', 'wc-tier-package-prices'), get_woocommerce_currency_symbol());
+```
+
+**Problem:** The `__()` function receives the text domain as a second parameter, but in this pattern it's missing. This causes the string "Price (%s)" to not be found in translation files, so it won't be translated.
+
+**Correct Pattern:**
+
+```php
+// ✅ CORRECT - Text domain in translation function, with esc_html for output escaping
+printf(esc_html__('Price (%s)', 'wc-tier-package-prices'), get_woocommerce_currency_symbol());
+```
+
+**Why This Works:**
+
+- `esc_html__('Price (%s)', 'wc-tier-package-prices')` translates the string and returns it
+- `printf()` then substitutes the `%s` placeholder with the currency symbol
+- The translated string is used in the final output
+- `esc_html` ensures proper output escaping
+
+**Applied in v1.2.9:** All 6 table headers in `includes/class-wc-tpp-product-meta.php`
+
+#### CRITICAL: Twig Translation Filters and HTML Entity Encoding (Learned in v1.2.9)
+
+When concatenating dynamic values in Twig templates, applying the translation filter can cause HTML entity encoding issues:
+
+**Wrong Pattern:**
+
+```twig
+{# ❌ WRONG - Translation filter encodes special characters in concatenated string #}
+placeholder="{{ ('e.g., 9.99 ' ~ currency_symbol)|__('wc-tier-package-prices') }}"
+```
+
+**Problem:** When `currency_symbol` contains special characters (€, £, ¥, etc.), the concatenated string is passed through the translation function which treats it as a translatable string and encodes special characters as HTML entities (`€`, `£`, etc.).
+
+**Correct Pattern:**
+
+```twig
+{# ✅ CORRECT - No translation filter, just concatenation #}
+placeholder="{{ 'e.g., 9.99 ' ~ currency_symbol }}"
+```
+
+**Why This Works:**
+
+- Placeholder examples don't need translation - they're illustrative values
+- Direct concatenation preserves special characters
+- Currency symbol displays correctly (€ instead of €)
+
+**Rule:** Only apply translation filters to static text that needs translation, not to concatenated strings with dynamic values that contain special characters.
+
+**Applied in v1.2.9:**
+
+- `templates/admin/tier-row.twig` - Price input placeholder
+- `templates/admin/package-row.twig` - Price input placeholder
+
+#### CRITICAL: Defensive Programming for POST Data Processing (Learned in v1.2.9)
+
+The v1.2.8 fix for variation pricing deletion had the right logic but used a branching structure that could miss edge cases. The v1.2.9 refactor demonstrates a more defensive pattern:
+
+**Less Defensive Pattern (v1.2.8):**
+
+```php
+// ❌ BRITTLE - Multiple branches, easy to miss edge cases
+if (isset($_POST['wc_tpp_tiers'][$loop])) {
+ $tiers = array();
+ foreach ($_POST['wc_tpp_tiers'][$loop] as $tier) {
+ // ... populate tiers ...
+ }
+ if (!empty($tiers)) {
+ update_post_meta($variation_id, '_wc_tpp_tiers', $tiers);
+ } else {
+ delete_post_meta($variation_id, '_wc_tpp_tiers');
+ }
+} else {
+ delete_post_meta($variation_id, '_wc_tpp_tiers');
+}
+```
+
+**Problem:** Two separate code paths to `delete_post_meta()`. If logic changes, easy to update one path but forget the other.
+
+**More Defensive Pattern (v1.2.9):**
+
+```php
+// ✅ DEFENSIVE - Single decision point, guaranteed cleanup
+$tiers = array();
+if (isset($_POST['wc_tpp_tiers'][$loop]) && is_array($_POST['wc_tpp_tiers'][$loop])) {
+ foreach ($_POST['wc_tpp_tiers'][$loop] as $tier) {
+ // ... populate tiers ...
+ }
+}
+// Always execute one of these based on final state
+if (!empty($tiers)) {
+ update_post_meta($variation_id, '_wc_tpp_tiers', $tiers);
+} else {
+ delete_post_meta($variation_id, '_wc_tpp_tiers');
+}
+```
+
+**Why This Is Better:**
+
+- Initialize array at the start (guaranteed initial state)
+- Single conditional for populating (with extra `is_array()` safety check)
+- Single decision point for save/delete (one place to maintain)
+- Impossible to have a code path that doesn't call either update or delete
+- Much easier to reason about and modify
+
+**Key Principles:**
+
+1. **Initialize variables early** - Establish known initial state
+2. **Minimize branching** - Fewer code paths = fewer bugs
+3. **Single decision point** - One place determines final action
+4. **Add safety checks** - Validate assumptions (`is_array()`)
+5. **Guaranteed execution** - Always perform one of update/delete, never neither
+
+**Applied in v1.2.9:**
+
+- `save_variation_pricing_fields()` - Both tier and package pricing logic refactored
+
+**Rule:** When processing user input to decide between update and delete, prefer the pattern: initialize → conditionally populate → unconditionally act based on final state.
+
### When Adding Features
- Follow the existing pattern: add setting → add UI → add logic → add template