diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
index 88f5d9f..a33c345 100644
--- a/licensing-service/src/api/buy_page.rs
+++ b/licensing-service/src/api/buy_page.rs
@@ -1133,7 +1133,15 @@ fn render_tier_picker(
.map(|c| crate::api::purchase::compute_discount(&c.kind, c.amount, base_price_units))
.unwrap_or(0);
let post_discount_units = (base_price_units - discount_units).max(0);
- let (price_fmt, price_unit) = if product.price_currency == "SAT" {
+ // Render free tiers as "Free" rather than "0 sats" / "0.00
+ // USD". Matches the price card below the tier picker, which
+ // already swaps the price + unit to "FREE" via the JS path.
+ // Cadence suffix is suppressed below as well, since a
+ // "Free / yr" suffix would be incoherent.
+ let is_free = post_discount_units == 0;
+ let (price_fmt, price_unit) = if is_free {
+ ("Free".to_string(), String::new())
+ } else if product.price_currency == "SAT" {
(format_thousands(post_discount_units), "sats".to_string())
} else {
let cents = post_discount_units;
@@ -1395,6 +1403,12 @@ fn render_tier_picker(
"
{}{}{}{}
",
dur_html, recurring_meta, trial_banner, trial_meta
);
+ // Suppress the cadence suffix on free tiers so the price
+ // doesn't render as "Free /yr" or similar. The "Free"
+ // string alone is enough; the recurring_meta line below
+ // (e.g. "Renews annually") still surfaces the cadence for
+ // recurring tiers that aren't free.
+ let cadence_suffix = if is_free { "" } else { cadence_suffix };
format!(
r#"
"#,
classes = classes,
diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts
index 2e10c54..9972203 100644
--- a/startos/versions/v0.2.0.ts
+++ b/startos/versions/v0.2.0.ts
@@ -58,6 +58,8 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
+ '0.2.0:35 — **Free tiers render as "Free" on the buy page tier card.** Previously the server rendered a 0-priced tier as `0 sats` (or `0.00 USD`) in the tier-card price headline, even though the price card BELOW the picker already swapped to `FREE` via the JS path. Inconsistency fixed at the server-render layer: when post-discount price is 0, the tier card renders the headline as `Free` with no unit suffix and no cadence (`Free /yr` would be incoherent). Recurring-meta line ("Renews annually") still surfaces beneath for recurring tiers that happen to be free, so the cadence is still visible — just not stuffed into the headline. Cosmetic; no API or schema change.',
+ '',
'0.2.0:34 — **Buy page: featured discount pre-populates the code field and shows "Launch special applied" on load.** Previously the launch-special discount auto-applied silently at payment time but the discount-code input was empty, leaving buyers unsure whether they needed to type anything. Now: when a tier has an active featured code, the input renders pre-filled with the code (e.g. `LAUNCH`) and the green "Launch special applied" status badge shows on load. The price card has always rendered the slashed-original + discounted-current price; this change just makes the form match. Tier switches clear an auto-populated code so a code that doesn\'t apply to the new tier doesn\'t linger; buyer-typed codes are untouched. UI / JS only; no API or schema change.',
'',
'0.2.0:33 — **Drop a long-standing unused-variable warning.** Removed `let invoice_id_safe = html_escape(&invoice_id);` in `src/api/mod.rs` — the value was computed but never referenced anywhere in the thank-you-page template (the HTML uses `invoice_id_json` for the inline JS, and the on-screen invoice id renders from JS via that JSON variable). One-line cleanup; `cargo check` is now warning-free. No behavior change.',
@@ -493,7 +495,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
- version: '0.2.0:34',
+ version: '0.2.0:35',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under