Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/country-checkout-localization.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <ISO2>` 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:
Expand Down
3 changes: 3 additions & 0 deletions skill/references/direct-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id
},
"checkout": {
"cart_id": "<optional_cart_id>",
"context": { "address_country": "US" },
"line_items": [
{
"quantity": 1,
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export function createProgram(deps: CliDependencies = {}): Command {
quantity: options.quantity,
checkout,
buyerIp: options.buyerIp,
country: explicitCountry(program),
})
return annotateShopPayAvailability(client, result)
})
Expand Down Expand Up @@ -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<GlobalOptions>().country
: undefined
}

function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClient {
const globals = program.optsWithGlobals<GlobalOptions>()
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),
})
}

Expand Down
6 changes: 6 additions & 0 deletions src/shop-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface CheckoutCreateInput {
quantity?: number
checkout?: JsonObject
buyerIp?: string
country?: string
}

export interface CheckoutUpdateInput {
Expand Down Expand Up @@ -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))
}
Expand Down
77 changes: 77 additions & 0 deletions tests/checkout-orders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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() }
Expand Down
Loading