diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index b1086798..eca0b7b9 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -8,7 +8,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, fees_to_json, format_money, make_client, period_label, validate_domain_name, + PRICE_EXCLUDES_TAXES_AND_FEES, api_error, fees_to_json, format_money, make_client, + period_label, validate_domain_name, }; use crate::idn::DomainFormArgs; use crate::next_action::next_action; @@ -48,7 +49,7 @@ fn view_columns() -> Vec { .essential(true), TableColumn::new("firstTermPrice", "First-Term Price").align(Alignment::Right), TableColumn::new("renewalPrice", "Renewal Price").align(Alignment::Right), - TableColumn::new("fees", "Fees").essential(true), + TableColumn::new("fees", "Premium Fees").essential(true), ]), ] } @@ -125,10 +126,15 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("available", "Check whether a domain is available") .with_long( "Check whether a domain can be registered, and at what price. \ - --check-type fast trades accuracy for speed; full is authoritative \ - (the `definitive` field tells you which you got). Lists every \ - registration term the API prices (1 year, 2 years, …), including \ - any first-term promotion.", + --check-type fast trades accuracy for speed; full is \ + authoritative (the `definitive` field tells you which you got). \ + Lists every registration term the API prices, with any \ + promotional discounts applied.\n\ + \n\ + Prices exclude taxes and fees, such as the ICANN fee, which are \ + added when you register. `Premium Fees` lists only the extra \ + purchase cost of a premium domain; it is empty for an ordinary \ + domain.", ) .with_system("domain") .with_tier(Tier::Read) @@ -189,8 +195,11 @@ pub(super) fn command() -> RuntimeCommandSpec { let cmd = CommandResult::new(result); if body.available.unwrap_or(false) { Ok(cmd.with_next_actions(vec![ - next_action("domain quote ", "Price a registration") - .with_param("domain", NextActionParam::value(resolved_domain)), + next_action( + "domain quote ", + format!("Price a registration. {PRICE_EXCLUDES_TAXES_AND_FEES}"), + ) + .with_param("domain", NextActionParam::value(resolved_domain)), ])) } else { Ok(cmd.with_next_actions(vec![ diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index ea056664..cbb77fcb 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -2,13 +2,31 @@ //! client, money formatting, argument helpers, and API-error rendering. Each //! `gddy domain` subcommand lives in its own sibling module and draws from here. -use cli_engine::{CliCoreError, CommandContext, Credential, Result}; +use cli_engine::{CliCoreError, CommandContext, Credential, NextAction, NextActionParam, Result}; use serde_json::json; use crate::environments; +use crate::next_action::next_action; use domains_client::types; +/// Appended to the next-step text under every price a domain command shows. The +/// domains API quotes the registration price only: the ICANN fee and any tax +/// are added when the order is placed, so the total charged is higher. +pub(super) const PRICE_EXCLUDES_TAXES_AND_FEES: &str = "Prices exclude applicable taxes and fees."; + +/// Points at the completed order, which is where the total actually charged +/// (including tax) can be read — the registration and operation records only +/// carry the quoted price. `--wait` because a new order takes a few seconds to +/// become visible. +pub(super) fn order_next_action(order_id: String) -> NextAction { + next_action( + "shopping order get --wait", + "See the total charged, including any taxes and fees", + ) + .with_param("order-id", NextActionParam::value(order_id)) +} + /// The ISO-4217 minor-unit exponent for a currency — how many implied decimal /// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money /// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained @@ -540,6 +558,17 @@ mod tests { } } + #[test] + fn order_next_action_uses_a_structured_param_and_waits_for_the_order() { + let action = order_next_action("2211623318".to_owned()); + assert_eq!(action.command, "gddy shopping order get --wait"); + assert_eq!( + action.params["order-id"].value.as_deref(), + Some("2211623318") + ); + assert!(action.description.contains("taxes and fees")); + } + #[test] fn fees_to_json_renders_type_amount_and_currency() { let fee = types::Fee { diff --git a/rust/src/domain/guides/domain-purchase.md b/rust/src/domain/guides/domain-purchase.md index 1b14db79..53d73773 100644 --- a/rust/src/domain/guides/domain-purchase.md +++ b/rust/src/domain/guides/domain-purchase.md @@ -11,15 +11,11 @@ Registering a domain is a **two-step, quote-then-purchase** flow: 2. `gddy domain purchase --quote-token --agree --confirm` accepts that quote and registers the domain. -A purchase is **paid and not reversible**, so `purchase` has two gates -(`--agree`, `--confirm`) on top of the token. Because the token locks the price -and settings you reviewed, you're charged exactly what the quote showed. - -Registration completes **asynchronously**: `purchase` submits the registration -and waits briefly for the registry, reporting the operation's `status` -(`COMPLETED` when done). The domain then appears in `gddy domain list`. If -`purchase` gives up waiting before reaching a terminal status, it still -succeeded — check on it later (the ID it printed) with: +A purchase is **paid and not reversible**, so `purchase` has two gates (`--agree`, `--confirm`) on top of the token. The quote token locks the registration price and settings you reviewed. + +**The quoted price does not include taxes and fees.** The ICANN fee and any applicable taxes are added when you register, so the total charged is higher than the price shown by `suggest`, `available` and `quote`. Whether tax applies depends on the billing address of your payment method. The `Premium Fees` column is something else: it lists the extra cost of a premium domain and is empty for a non-premium domain. Once the registration completes, `purchase` gives you the order ID, and `gddy shopping order get ` shows the total charged. + +Registration completes **asynchronously**: `purchase` submits the registration and waits briefly for the registry, reporting the operation's `status` (`COMPLETED` when done). The domain then appears in `gddy domain list`. If `purchase` gives up waiting before reaching a terminal status, it will give you an operation ID which you can use to check its progress with: `gddy domain operation status `. ## The flow @@ -52,31 +48,24 @@ succeeded — check on it later (the ID it printed) with: gddy domain purchase --quote-token --agree --confirm ``` -The quote is cached locally (next to your `contacts.toml`), so **run `quote` and -`purchase` on the same machine**, within the token's ~10-minute lifetime. If the -token has expired or isn't found, just re-run `gddy domain quote`. +The quote is cached locally (next to your `contacts.toml`), so **run `quote` and `purchase` on the same machine**, within the token's ~10-minute lifetime. If the token has expired or isn't found, just re-run `gddy domain quote`. ### The three decisions - **The quote token** selects *which* quote you're buying — the exact domain, - settings, and locked price you reviewed. + settings, and registration price you reviewed. - `--agree` is your **consent** to the quote's legal agreements. Run `purchase` without it and it lists the agreements you must accept. - `--confirm` acknowledges that the purchase **charges your account**. Without it, the command stops before buying. -AI assistants: Before using `--agree` or `--confirm`, show the end user the exact price from `domain quote` (including any premium/registration fees) and each required legal agreement's title AND link (from `quote`'s `requiredAgreements`), and obtain their explicit confirmation to proceed. Do not summarize an agreement by name only — the user must be able to click through and read it. Do not infer confirmation from a general request to buy or register a domain. +AI assistants: Before using `--agree` or `--confirm`, show the end user the quoted price from `domain quote` (including any premium fees), tell them that taxes and fees such as the ICANN fee are added on top so the total charged will be higher, and show each required legal agreement's title and URL (from `quote`'s `requiredAgreements`), and obtain their explicit confirmation to proceed. The user must be able to click through and read the agreements. Do not infer confirmation from a general request to buy or register a domain. ## Contacts -A registration has four contact roles: **registrant**, **admin**, **billing**, -and **tech**. If you don't supply them, the API uses your GoDaddy account's -default contacts — which is usually what you want. Contacts are part of the -quote, so configure them *before* you quote. +A registration has four contact roles: **registrant**, **admin**, **billing**, and **tech**. If you don't supply them, the API uses your GoDaddy account's default contacts — which is usually what you want. Contacts are part of the quote, so configure them *before* you quote. -If you register many domains and want to set your own contacts once, save them -in a `contacts.toml` in your `gddy` config directory. The quickest start is to -generate a template and edit it: +If you register many domains and want to set your own contacts once, save them in a `contacts.toml` in your `gddy` config directory. The quickest start is to generate a template and edit it: ``` gddy domain contact init @@ -88,10 +77,7 @@ That writes a starter `contacts.toml` (with every role commented out) to: - macOS: `~/Library/Application Support/gddy/contacts.toml` - Windows: `%APPDATA%\gddy\contacts.toml` -Open it, uncomment the role(s) you want, and fill in the values. Each role is an -optional table: a role you omit falls back to the account default; a role you -uncomment must be **complete** (the required fields below), or the file fails to -load. +Open it, uncomment the role(s) you want, and fill in the values. Each role is an optional table: a role you omit falls back to the account default; a role you uncomment must be **complete** (the required fields below), or the file fails to load. ```toml [registrant] @@ -122,6 +108,5 @@ country = "US" # default for it. ``` -Required fields per role: `name_first`, `name_last`, `email`, `phone`, -`address1`, `city`, `state`, `postal_code`, `country`. Optional fields: -`organization`, `address2`. +Required fields per role: `name_first`, `name_last`, `email`, `phone`, `address1`, `city`, `state`, `postal_code`, `country`. +Optional fields: `organization`, `address2`. diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 41d8df56..fb0f9f5f 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -44,12 +44,13 @@ pub(crate) use common::{api_error, format_api_error, make_client}; /// from `quote` that points at it — so a purchase's price is always relayed to /// the end user before the account is actually charged. See AGENTS.md's /// "Purchase confirmation" section. -pub(crate) const AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS: &str = "AI assistants: Before using --agree or --confirm, show the end user the exact \ - price from `domain quote` (including any fees) and each required legal \ - agreement's title AND link (from `domain quote`'s `requiredAgreements`), and \ - obtain their explicit confirmation to proceed. Do not summarize an agreement \ - by name only — the user must be able to click through and read it. Do not \ - infer confirmation from a general request to buy or register a domain."; +pub(crate) const AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS: &str = "AI assistants: Before using --agree or --confirm, show the end user the \ + quoted price from `domain quote`, including any disclosed fees. Tell them \ + that taxes and fees such as the ICANN fee are added on top, so the total \ + charged will be higher. Also, show each required legal agreement's title and \ + URL, and obtain the user's explicit confirmation to proceed. The user must \ + be able to click through and read the agreements. Do not infer confirmation \ + from a general request to buy or register a domain."; pub fn module() -> Module { Module::new("Domains", |_ctx| { diff --git a/rust/src/domain/operation.rs b/rust/src/domain/operation.rs index f21fdd41..474bfcdd 100644 --- a/rust/src/domain/operation.rs +++ b/rust/src/domain/operation.rs @@ -13,7 +13,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client}; +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client, order_next_action, +}; use crate::idn::DomainFormArgs; use crate::next_action::next_action; use crate::output_schema::output_schema; @@ -123,6 +125,9 @@ fn status_command() -> RuntimeCommandSpec { .with_param("domain", NextActionParam::value(domain)), ); } + if let Some(order_id) = result.get("orderId").and_then(|v| v.as_str()) { + actions.push(order_next_action(order_id.to_owned())); + } } else { actions.push( next_action( diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index c018ee82..f4107e5f 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -7,7 +7,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client_with_cred}; +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client_with_cred, order_next_action, +}; use crate::domain::AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS; use crate::error::GddyError; use crate::idn::DomainFormArgs; @@ -20,10 +22,54 @@ output_schema!(DomainPurchaseResult { "status": "string"; // Present depending on the async operation + the cached quote's receipt. "operationId": "string", optional; + // The order that holds the total actually charged (tax and fees included). + // Absent while registration is still in progress. + "orderId": "string", optional; + // The quoted price from `domain quote`, not the amount charged. "price": "string", optional; "currency": "string", optional; + "note": "string", optional; }); +/// Printed beside `price`, which is the quote's registration price echoed back — +/// the API's registration record carries no total, so the charge itself is only +/// readable on the order. +const QUOTED_PRICE_NOTE: &str = "price is the quoted registration price. Taxes and fees such as the ICANN \ + fee are added to it, so the total charged is higher; see the order for the \ + total."; + +/// Build the result payload. Optional fields are only emitted when present — +/// never JSON null (the schema marks them optional strings, and null leaks into +/// tables). `note` accompanies `price` so the quote's registration price isn't +/// read as the amount charged. +fn purchase_result( + domain: &str, + status: &str, + operation_id: Option, + order_id: Option<&str>, + price: Option<&str>, + currency: Option<&str>, +) -> serde_json::Value { + let mut result = json!({ + "domain": domain, + "status": status, + }); + if let Some(op) = operation_id { + result["operationId"] = json!(op); + } + if let Some(id) = order_id { + result["orderId"] = json!(id); + } + if let Some(p) = price { + result["price"] = json!(p); + result["note"] = json!(QUOTED_PRICE_NOTE); + } + if let Some(c) = currency { + result["currency"] = json!(c); + } + result +} + /// Format a UTC instant as the API's consent timestamp: RFC 3339 with a literal /// trailing `Z` (e.g. `2026-06-30T22:34:43Z`), sub-second digits dropped. fn iso_datetime(now: chrono::DateTime) -> String { @@ -107,8 +153,10 @@ fn purchase_consent_types( String::new() }; return Err(CliCoreError::message(format!( - "registering {domain} for {period} year(s) charges your account and cannot be undone; \ - re-run with --confirm to proceed.{agent_note}" + "registering {domain} for {period} year(s) charges your account and \ + cannot be undone. The quoted price does not include applicable taxes \ + and fees, so the total charged will be higher. Re-run with --confirm \ + to proceed.{agent_note}" ))); } @@ -147,7 +195,10 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_long(format!( "Register a domain by accepting a quote from `gddy domain quote`. \ This charges your GoDaddy account and cannot be undone, so it is \ - gated behind --confirm.\n\ + gated behind --confirm. The quoted price does not include taxes and \ + fees such as the ICANN fee; those are added to the total charged. \ + Once registration completes, `gddy shopping order get ` \ + shows the total.\n\ \n\ Registration settings (period, privacy, nameservers, contacts) are \ fixed at quote time — the quote token locks both those settings and \ @@ -167,7 +218,7 @@ pub(super) fn command() -> RuntimeCommandSpec { )) .with_system("domain") .with_tier(Tier::Destructive) - .with_default_fields("domain,status,operationId,price,currency") + .with_default_fields("domain,status,operationId,orderId,price,currency,note") .with_output_schema::() .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]), |ctx, args: PurchaseArgs| async move { @@ -319,6 +370,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .map(|s| s.to_string()) .unwrap_or_else(|| "SUBMITTED".to_string()); let operation_id = accepted.operation_id.clone(); + let mut order_id = accepted.order_id.clone(); let mut operation_error: Option = None; if let Some(op_id) = operation_id.as_ref() { for _ in 0..20 { @@ -338,6 +390,9 @@ pub(super) fn command() -> RuntimeCommandSpec { status = s.to_string(); } operation_error = op.error; + if let Some(id) = op.result.and_then(|r| r.order_id) { + order_id = Some(id); + } } // A transient poll failure shouldn't fail the purchase — // it already succeeded; report the last-known status. @@ -375,25 +430,21 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } - // Only emit the optional fields when present — never JSON null (the - // schema marks them optional strings, and null leaks into tables). - let mut result = json!({ - "domain": domain, - "status": status, - }); - if let Some(op) = operation_id.as_ref() { - result["operationId"] = json!(op.to_string()); - } - if let Some(p) = price.as_ref() { - result["price"] = json!(p); - } - if let Some(c) = currency.as_ref() { - result["currency"] = json!(c); - } + let mut result = purchase_result( + &domain, + &status, + operation_id.as_ref().map(|op| op.to_string()), + order_id.as_deref(), + price.as_deref(), + currency.as_deref(), + ); let mut actions = vec![ next_action("domain get ", next_action_description(&status)) .with_param("domain", NextActionParam::required()), ]; + if let Some(id) = order_id { + actions.push(order_next_action(id)); + } if still_pending { // `still_pending` implies `operation_id.is_some()`. if let Some(op) = &operation_id { @@ -416,8 +467,9 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use super::{iso_datetime, next_action_description, purchase_consent_types}; + use super::{iso_datetime, next_action_description, purchase_consent_types, purchase_result}; use domains_client::types; + use serde_json::json; #[test] fn purchase_consent_requires_agree_then_confirm() { @@ -438,9 +490,9 @@ mod tests { // Regression: an agent that only named the agreement (no link) still // technically "showed the agreements" — the instruction must be // unambiguous that the link itself is required, not just the title. - assert!(msg.contains("title AND link"), "{msg}"); + assert!(msg.contains("title and URL"), "{msg}"); assert!( - msg.contains("Do not summarize an agreement by name only"), + msg.contains("click through and read the agreements"), "{msg}" ); @@ -449,12 +501,18 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("--confirm"), "{msg}"); assert!(msg.contains("2 year(s)"), "{msg}"); + // The gate that precedes the charge must say the quoted price is not the + // total, even for a human who never sees the agent note. + assert!( + msg.contains("does not include applicable taxes and fees"), + "{msg}" + ); assert!(msg.contains("AI assistants:"), "{msg}"); assert!( - msg.contains("show the end user the exact price from `domain quote`"), + msg.contains("show the end user the quoted price from `domain quote`"), "{msg}" ); - assert!(msg.contains("title AND link"), "{msg}"); + assert!(msg.contains("title and URL"), "{msg}"); let types = purchase_consent_types("example.com", 1, true, true, &titles, &ty, true) .expect("both gates satisfied"); @@ -539,6 +597,37 @@ mod tests { } } + #[test] + fn result_labels_price_as_quoted_and_carries_the_order_id() { + // Regression: `price` is the quote echoed back, not the charge (a test + // purchase quoted 9.19 and was charged 9.39 once the ICANN fee was + // added), so the result must say so and point at the order that holds + // the real total. + let result = purchase_result( + "example.com", + "COMPLETED", + Some("op-1".to_owned()), + Some("2211623318"), + Some("9.19"), + Some("USD"), + ); + assert_eq!(result["orderId"], "2211623318"); + assert_eq!(result["price"], "9.19"); + let note = result["note"].as_str().expect("note accompanies price"); + assert!(note.contains("quoted"), "{note}"); + assert!(note.to_lowercase().contains("taxes and fees"), "{note}"); + } + + #[test] + fn result_omits_order_id_and_note_when_absent() { + // Still in progress and no cached price: no JSON nulls, no stray note. + let result = purchase_result("example.com", "EXECUTING", None, None, None, None); + assert_eq!( + result, + json!({"domain": "example.com", "status": "EXECUTING"}) + ); + } + #[test] fn agreed_at_is_zulu_iso_datetime_not_offset() { use chrono::TimeZone; diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index f876f76d..cf830733 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -196,11 +196,13 @@ fn quote_to_json(quote: &types::RegistrationQuote, request_domain: &str) -> serd fn purchase_next_action(quote_token: String, show_agent_note: bool) -> cli_engine::NextAction { let description = if show_agent_note { format!( - "Register at the quoted price (within ~10 minutes) — requires --agree and --confirm. \ - {AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS}" + "Register at the quoted price (plus applicable taxes and fees) \ + within ~10 minutes — requires --agree and --confirm. \ + {AGENT_PURCHASE_CONFIRMATION_INSTRUCTIONS}" ) } else { - "Register at the quoted price (within ~10 minutes) — requires --agree and --confirm." + "Register at the quoted price (plus applicable taxes and fees) within \ + ~10 minutes — requires --agree and --confirm." .to_owned() }; next_action("domain purchase --quote-token ", description) @@ -253,7 +255,7 @@ fn view_columns() -> Vec { vec![ TableColumn::new("domain", "Domain"), TableColumn::new("available", "Available"), - TableColumn::new("price", "Price").align(Alignment::Right), + TableColumn::new("price", "Price (before taxes and fees)").align(Alignment::Right), TableColumn::new("renewalPrice", "Renewal Price").align(Alignment::Right), TableColumn::new("currency", "Currency"), TableColumn::new("periodLabel", "Period"), @@ -261,7 +263,7 @@ fn view_columns() -> Vec { TableColumn::new("expiresAt", "Expires At"), TableColumn::new("irreversible", "Irreversible"), TableColumn::new("inventory", "Inventory"), - TableColumn::new("fees", "Fees").nested(vec![ + TableColumn::new("fees", "Premium Fees").nested(vec![ TableColumn::new("type", "Type").essential(true), TableColumn::new("amount", "Amount") .align(Alignment::Right) @@ -291,15 +293,16 @@ pub(super) fn command() -> RuntimeCommandSpec { "Price a domain registration and lock a quote", ) .with_long( - "Quote a single-domain registration: returns the locked price, the \ - legal agreements you must accept, the contact/preference settings that \ - would apply, and a single-use quote token (valid ~10 minutes). Quoting \ - is free and changes nothing.\n\ - \n\ - The quote — including the registration settings you pass here \ - (--period/--privacy/--no-renew/--nameserver and your contacts.toml) — is \ - saved locally so `gddy domain purchase --quote-token ` buys the \ - exact thing you reviewed, at the price you were quoted.", + "Quote a single-domain registration: returns the registration price, \ + the legal agreements you must accept, the contact/preference \ + settings that would apply, and a single-use quote token (valid ~10 \ + minutes). Quoting is free and changes nothing.\n\ + \n\ + The price excludes taxes and fees, such as the ICANN fee, which are \ + added when you register — the total charged will be higher. Whether \ + tax applies depends on the billing address of your payment method. \ + After purchasing, `gddy shopping order get ` shows the \ + total charged.", ) .with_system("domain") .with_tier(Tier::Read) @@ -458,7 +461,7 @@ mod tests { assert!( action .description - .contains("show the end user the exact price from `domain quote`") + .contains("show the end user the quoted price from `domain quote`") ); assert!( action @@ -468,11 +471,42 @@ mod tests { // Regression: an agent that only named the agreement (no link) still // technically "showed the agreements" — the instruction must be // unambiguous that the link itself is required, not just the title. - assert!(action.description.contains("title AND link")); + assert!(action.description.contains("title and URL")); assert!( action .description - .contains("Do not summarize an agreement by name only") + .contains("click through and read the agreements") + ); + } + + #[test] + fn purchase_next_action_says_taxes_and_fees_are_extra() { + // Both the human and the agent variants: the quoted price is not the + // total charged. + for show_agent_note in [true, false] { + let action = purchase_next_action("quote-token-1".to_owned(), show_agent_note); + assert!( + action + .description + .contains("Register at the quoted price (plus applicable taxes and fees)"), + "{}", + action.description + ); + assert!( + action + .description + .contains("requires --agree and --confirm") + ); + } + } + + #[test] + fn price_column_says_it_is_before_taxes_and_fees() { + let quote = json!({"domain": "example.com", "available": true, "price": "9.19"}); + let rendered = cli_engine::preview_human_view(quote, &view_columns()); + assert!( + rendered.contains("Price (before taxes and fees): 9.19"), + "{rendered}" ); } @@ -570,7 +604,7 @@ mod tests { let rendered = cli_engine::preview_human_view(quote, &view_columns()); assert!(rendered.contains("Inventory:"), "{rendered}"); assert!(rendered.contains("PREMIUM"), "{rendered}"); - assert!(rendered.contains("Fees:"), "{rendered}"); + assert!(rendered.contains("Premium Fees:"), "{rendered}"); assert!( rendered.contains("ONE_TIME_PREMIUM_DOMAIN_PURCHASE"), "{rendered}" diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index e56fa0e4..4e8cd9cd 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -8,7 +8,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, comma_joined, fees_to_json, format_money, make_client, term_for_period, + PRICE_EXCLUDES_TAXES_AND_FEES, api_error, comma_joined, fees_to_json, format_money, + make_client, term_for_period, }; use crate::idn::DomainFormArgs; use crate::next_action::next_action; @@ -63,10 +64,10 @@ fn view_columns() -> Vec { .align(Alignment::Right) .essential(true), TableColumn::new("renewalPrice1Year", "1yr Renewal").align(Alignment::Right), - TableColumn::new("fees1Year", "1yr Fees"), + TableColumn::new("fees1Year", "1yr Premium Fees"), TableColumn::new("price2Year", "2yr Price").align(Alignment::Right), TableColumn::new("renewalPrice2Year", "2yr Renewal").align(Alignment::Right), - TableColumn::new("fees2Year", "2yr Fees"), + TableColumn::new("fees2Year", "2yr Premium Fees"), TableColumn::new("currency", "Currency").essential(true), TableColumn::new("inventory", "Inventory"), ] @@ -175,7 +176,10 @@ pub(super) fn command() -> RuntimeCommandSpec { "Suggest available domains from a seed word, phrase, or domain. \ Narrow results with --tlds (repeatable), bound the name length \ with --length-min/--length-max, and cap the count with --limit \ - (1-50).", + (1-50).\n\ + \n\ + Prices are indicative and exclude taxes and fees, such as the \ + ICANN fee, which are added when you register.", ) .with_system("domain") .with_tier(Tier::Read) @@ -221,8 +225,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .collect(); Ok( CommandResult::new(json!(suggestions)).with_next_actions(vec![ - next_action("domain available ", "Check a suggested domain") - .with_param("domain", NextActionParam::required()), + next_action( + "domain available ", + format!("Check a suggested domain. {PRICE_EXCLUDES_TAXES_AND_FEES}"), + ) + .with_param("domain", NextActionParam::required()), ]), ) }, @@ -447,7 +454,7 @@ mod tests { .filter(|c| c.field == "fees1Year") .collect(); let rendered = cli_engine::preview_human_view(suggestions, &columns); - assert!(rendered.contains("1YR FEES"), "{rendered}"); + assert!(rendered.contains("1YR PREMIUM FEES"), "{rendered}"); assert!( rendered.contains("ONE_TIME_PREMIUM_DOMAIN_PURCHASE"), "{rendered}"