diff --git a/.changeset/country-checkout-localization.md b/.changeset/country-checkout-localization.md new file mode 100644 index 0000000..ccd4b3a --- /dev/null +++ b/.changeset/country-checkout-localization.md @@ -0,0 +1,5 @@ +--- +'@shopify/shop-cli': patch +--- + +Add `--country` to `checkout create`, setting `checkout.context.address_country` so the merchant resolves presentment currency to the buyer's country. Falls back to the stored `config set-country` preference (but never a default country); stdin context wins; the saved address is not overridden. diff --git a/skill/SKILL.md b/skill/SKILL.md index 7235c1f..0b6e7c0 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -67,8 +67,8 @@ shop catalog get-product gid://shopify/p/abc --select Color=Black --select Size= ### Checkout ```bash -# create from a variant -printf '{"email":"buyer@example.com"}' | shop checkout create --shop-domain example.myshopify.com --variant-id 123 --quantity 1 --checkout-stdin +# create from a variant (--country localizes presentment currency) +printf '{"email":"buyer@example.com"}' | shop checkout create --shop-domain example.myshopify.com --variant-id 123 --quantity 1 --country GB --checkout-stdin # create from an existing cart printf '{"cart_id":"cart_123","line_items":[]}' | shop checkout create --shop-domain example.myshopify.com --checkout-stdin printf '{"fulfillment":{"methods":[]}}' | shop checkout update --shop-domain example.myshopify.com --checkout-id CHECKOUT_ID --checkout-stdin @@ -164,6 +164,7 @@ When the item is visual (clothing, shoes, accessories, furniture, decor, art) ** **Reading the `checkout create` / `update` response:** - Inspect `status`, `email`, addresses, `continue_url`, and `payment.instruments`. - If the buyer's saved shipping details are missing, collect them and pass via `checkout create`/`update`. +- Pass `--country ` on `checkout create` to localize presentment currency; without it the merchant may present a foreign currency. It does not override the saved address. - **Warnings:** display every `messages[]` entry with type `warning` (e.g. `final_sale`, `prop65`, `age_restricted`) before completing. Show `presentation: "disclosure"` warnings verbatim — never omit or summarize them. Never complete a purchase without surfacing these. Then take one of two paths: diff --git a/skill/references/direct-api.md b/skill/references/direct-api.md index 5baff98..0f70d99 100644 --- a/skill/references/direct-api.md +++ b/skill/references/direct-api.md @@ -110,6 +110,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id }, "checkout": { "cart_id": "", + "context": { "address_country": "US" }, "line_items": [ { "quantity": 1, @@ -142,6 +143,8 @@ Create with line items, or pass a checkout body that already contains a `cart_id } ``` +`context.address_country` (ISO2) localizes presentment currency to the buyer's country; without it the merchant infers it from the request geo-IP. It does not override the saved address. + If response status is `ready_for_complete` and includes a Shop Pay payment token, complete after clear purchase intent. If no payment token is present, present the UCP `continue_url` as a Finish in Shop link. **If the buyer has a delegated budget (see Payment Budget) but the checkout still returns no payment instruments, the merchant does not accept Shop Pay** — hand off `continue_url` or suggest another store; do not re-prompt the user to set up a budget (they already have one). The checkout response may include a `messages[]` array. You MUST display every `warning` message's `content` to the user (e.g. `final_sale`, `prop65`, `age_restricted`) before completing. Show `presentation: "disclosure"` warnings verbatim and do not omit or summarize them away. Never complete a purchase without surfacing these messages. diff --git a/src/cli.ts b/src/cli.ts index 5a033f8..6928ff3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -287,6 +287,7 @@ export function createProgram(deps: CliDependencies = {}): Command { quantity: options.quantity, checkout, buyerIp: options.buyerIp, + country: explicitCountry(program), }) return annotateShopPayAvailability(client, result) }) @@ -400,18 +401,23 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro } } +function explicitCountry(program: Command): string | undefined { + return program.getOptionValueSource('country') === 'cli' + ? program.optsWithGlobals().country + : undefined +} + function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClient { const globals = program.optsWithGlobals() const store = resolveStore(deps, globals) // Only treat --country as an explicit override when it was actually passed on the CLI; // otherwise leave it undefined so the client falls back to the stored preference, then // DEFAULT_COUNTRY. The default value of the option must never override a stored country. - const explicitCountry = program.getOptionValueSource('country') === 'cli' ? globals.country : undefined return new ShopCatalogClient({ fetch: deps.fetch, store, profileUrl: globals.profileUrl, - country: explicitCountry, + country: explicitCountry(program), }) } diff --git a/src/shop-client.ts b/src/shop-client.ts index 26243b2..8d9ae26 100644 --- a/src/shop-client.ts +++ b/src/shop-client.ts @@ -99,6 +99,7 @@ export interface CheckoutCreateInput { quantity?: number checkout?: JsonObject buyerIp?: string + country?: string } export interface CheckoutUpdateInput { @@ -186,6 +187,11 @@ export class ShopCatalogClient { }, ] } + const country = (input.country ?? this.explicitCountry ?? (await getCountry(this.options.store, ''))) || undefined + if (country) { + const piped = isPlainObject(checkout.context) ? checkout.context : {} + checkout.context = { address_country: country, ...piped } + } return unwrapMcpResult(await this.callShopMcp(shopDomain, 'create_checkout', { checkout }, token, buyerIp)) } diff --git a/tests/checkout-orders.test.ts b/tests/checkout-orders.test.ts index 8a2086b..f918452 100644 --- a/tests/checkout-orders.test.ts +++ b/tests/checkout-orders.test.ts @@ -3,6 +3,7 @@ import { expect, fn } from './harness.js' import { ACCESS_TOKEN_ACCOUNT, + COUNTRY_ACCOUNT, DEVICE_ID_ACCOUNT, REFRESH_TOKEN_ACCOUNT, } from '../src/constants.js' @@ -66,6 +67,61 @@ describe('checkout and orders', () => { }) }) + it('sets checkout.context.address_country from country, letting stdin context win', async () => { + const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) + const bodies: unknown[] = [] + const fetchMock = createFetchMock(async (url, init) => { + if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' }) + if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' }) + if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' }) + bodies.push(await readJsonBody(init)) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } }) + }) + const client = new ShopCatalogClient({ fetch: fetchMock, store }) + + await client.createCheckout({ shopDomain: 'example.myshopify.com', variantId: '123', country: 'GB' }) + await client.createCheckout({ + shopDomain: 'example.myshopify.com', + variantId: '123', + country: 'GB', + checkout: { context: { address_country: 'US' } }, + }) + + expect(bodies[0]).toMatchObject({ + params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'GB' } } } }, + }) + expect(bodies[1]).toMatchObject({ + params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'US' } } } }, + }) + }) + + it('falls back to the stored country preference, but never to a default country', async () => { + const bodies: { params: { arguments: { checkout: { context?: unknown } } } }[] = [] + const fetchMock = createFetchMock(async (url, init) => { + if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' }) + if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' }) + if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' }) + bodies.push((await readJsonBody(init)) as (typeof bodies)[number]) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } }) + }) + + const stored = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access', [COUNTRY_ACCOUNT]: 'GB' }) + await new ShopCatalogClient({ fetch: fetchMock, store: stored }).createCheckout({ + shopDomain: 'example.myshopify.com', + variantId: '123', + }) + const none = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) + await new ShopCatalogClient({ fetch: fetchMock, store: none }).createCheckout({ + shopDomain: 'example.myshopify.com', + variantId: '123', + }) + + expect(bodies[0]).toMatchObject({ + params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'GB' } } } }, + }) + expect(bodies[1].params.arguments.checkout.context).toBeUndefined() + }) + it('unwraps the MCP envelope and returns the checkout payload, not the raw frame', async () => { const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) const checkout = { id: 'gid://shopify/Checkout/abc', status: 'ready_for_complete', currency: 'GBP' } @@ -464,6 +520,27 @@ describe('checkout and orders', () => { expect(names).toEqual(['create_checkout', 'create_checkout', 'update_checkout', 'complete_checkout']) }) + it('wires --country into checkout.context.address_country via the CLI', async () => { + const { createProgram } = await import('../src/cli.js') + const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) + let body: { params: { name: string; arguments: { checkout: { context?: { address_country?: string } } } } } | undefined + const fetchMock = createFetchMock(async (url, init) => { + if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' }) + if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' }) + if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' }) + const parsed = (await readJsonBody(init)) as typeof body + if (parsed?.params?.name === 'create_checkout') body = parsed + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } }) + }) + const base = { fetch: fetchMock, store, stdout: { write: fn() }, stderr: { write: fn() }, exit: (() => undefined) as never } + + await createProgram(base).parseAsync([ + 'node', 'shop', 'checkout', 'create', '--shop-domain', 'example.myshopify.com', '--variant-id', '123', '--country', 'GB', + ]) + + expect(body?.params.arguments.checkout.context).toEqual({ address_country: 'GB' }) + }) + it('surfaces UCP checkout messages (final_sale, prop65) above the raw JSON on create', async () => { const { createProgram } = await import('../src/cli.js') const stdout = { write: fn() }