Language pricing and API contract

Every language tab in both product editors, including the default language, supports two modes:

  • Automatic conversion uses base products.price and global locale_currency_bindings. The base compare-at price uses metaData.original_price (with compare_at_price compatibility).
  • Custom price selects a currency and sets a selling price plus an optional compare-at price. A blank compare-at price hides it; a configured value must exceed the selling price. Switching back to automatic removes that language's override.

Configuration uses existing metadata, without a schema change or migration:

{
  "price": 10,
  "metaData": {
    "original_price": 20,
    "locale_prices": {
      "zh": { "currency": "CNY", "price": 99, "original_price": 199 },
      "en": { "currency": "USD", "price": 19 }
    }
  }
}

POST /api/admin/products and PUT /api/admin/products/:id accept metaData.locale_prices. Keys normalize to lowercase with hyphens (zh-HK becomes zh-hk); duplicate normalized keys are rejected. Overrides match the exact language only: an unconfigured zh-HK uses automatic conversion rather than a zh product override. Delete an entry or pass {} to restore automatic mode. Null entries, missing/blank/negative/non-finite prices, unsupported currencies, invalid currency precision and invalid compare-at prices return HTTP 400. Zero is an explicit free price.

Currency options use the base currency and currencies with configured rates. Custom prices still require real rates for accounting: the base currency uses 1; other currencies use the matching current-language rate, or the sole consistent configured rate for that currency. Missing or ambiguous rates block saving/checkout rather than implying 1:1.

GET /api/products and GET /api/products/:slug retain base price and return pricing metadata. Product lists, details, plan cards and Product SEO Offers use shared rules to resolve the current language; product caches are language-independent.

POST /api/orders/checkout uses locale to select the price; client amounts cannot override the server quote. Volume and promo discounts multiply against the selected selling price, rounded in the selling currency. Custom payment amount divided by the frozen exchange rate produces the accounting amount. currencySnapshot stores language, payment currency/amount, rate, base amounts, unitPrice, optional originalPrice, and source (product-locale-price for custom prices). Language/configuration changes never reprice existing orders; pending orders are reused only when snapshots match. Subscription renewal retains the original purchased order's payment snapshot.

Top-up entitlements remain independent: configured recharge_amount multiplies by quantity; without it, custom language pricing retains the credit amount derived from the original base price, quantity and discounts. The server freezes credit amounts separately on the order.

Rollback: remove overrides to restore automatic conversion. Before reverting code, stop accepting new configuration and remove overrides. Preserve existing order amounts and snapshots. Payment currency restrictions remain enforced by the workspace and initiation API; unsupported currencies cannot initiate payment.