Skip to content

Commit 246e371

Browse files
authored
Merge pull request #38 from Shopify/add-country-checkout-localization
Add --country to checkout create for presentment currency localization
2 parents 739548a + db77df1 commit 246e371

6 files changed

Lines changed: 102 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/shop-cli': patch
3+
---
4+
5+
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.

skill/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ shop catalog get-product gid://shopify/p/abc --select Color=Black --select Size=
6767

6868
### Checkout
6969
```bash
70-
# create from a variant
71-
printf '{"email":"buyer@example.com"}' | shop checkout create --shop-domain example.myshopify.com --variant-id 123 --quantity 1 --checkout-stdin
70+
# create from a variant (--country localizes presentment currency)
71+
printf '{"email":"buyer@example.com"}' | shop checkout create --shop-domain example.myshopify.com --variant-id 123 --quantity 1 --country GB --checkout-stdin
7272
# create from an existing cart
7373
printf '{"cart_id":"cart_123","line_items":[]}' | shop checkout create --shop-domain example.myshopify.com --checkout-stdin
7474
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) **
164164
**Reading the `checkout create` / `update` response:**
165165
- Inspect `status`, `email`, addresses, `continue_url`, and `payment.instruments`.
166166
- If the buyer's saved shipping details are missing, collect them and pass via `checkout create`/`update`.
167+
- 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.
167168
- **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.
168169

169170
Then take one of two paths:

skill/references/direct-api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id
110110
},
111111
"checkout": {
112112
"cart_id": "<optional_cart_id>",
113+
"context": { "address_country": "US" },
113114
"line_items": [
114115
{
115116
"quantity": 1,
@@ -142,6 +143,8 @@ Create with line items, or pass a checkout body that already contains a `cart_id
142143
}
143144
```
144145

146+
`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.
147+
145148
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).
146149

147150
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.

src/cli.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export function createProgram(deps: CliDependencies = {}): Command {
287287
quantity: options.quantity,
288288
checkout,
289289
buyerIp: options.buyerIp,
290+
country: explicitCountry(program),
290291
})
291292
return annotateShopPayAvailability(client, result)
292293
})
@@ -400,18 +401,23 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro
400401
}
401402
}
402403

404+
function explicitCountry(program: Command): string | undefined {
405+
return program.getOptionValueSource('country') === 'cli'
406+
? program.optsWithGlobals<GlobalOptions>().country
407+
: undefined
408+
}
409+
403410
function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClient {
404411
const globals = program.optsWithGlobals<GlobalOptions>()
405412
const store = resolveStore(deps, globals)
406413
// Only treat --country as an explicit override when it was actually passed on the CLI;
407414
// otherwise leave it undefined so the client falls back to the stored preference, then
408415
// DEFAULT_COUNTRY. The default value of the option must never override a stored country.
409-
const explicitCountry = program.getOptionValueSource('country') === 'cli' ? globals.country : undefined
410416
return new ShopCatalogClient({
411417
fetch: deps.fetch,
412418
store,
413419
profileUrl: globals.profileUrl,
414-
country: explicitCountry,
420+
country: explicitCountry(program),
415421
})
416422
}
417423

src/shop-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface CheckoutCreateInput {
9999
quantity?: number
100100
checkout?: JsonObject
101101
buyerIp?: string
102+
country?: string
102103
}
103104

104105
export interface CheckoutUpdateInput {
@@ -186,6 +187,11 @@ export class ShopCatalogClient {
186187
},
187188
]
188189
}
190+
const country = (input.country ?? this.explicitCountry ?? (await getCountry(this.options.store, ''))) || undefined
191+
if (country) {
192+
const piped = isPlainObject(checkout.context) ? checkout.context : {}
193+
checkout.context = { address_country: country, ...piped }
194+
}
189195

190196
return unwrapMcpResult(await this.callShopMcp(shopDomain, 'create_checkout', { checkout }, token, buyerIp))
191197
}

tests/checkout-orders.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { expect, fn } from './harness.js'
33

44
import {
55
ACCESS_TOKEN_ACCOUNT,
6+
COUNTRY_ACCOUNT,
67
DEVICE_ID_ACCOUNT,
78
REFRESH_TOKEN_ACCOUNT,
89
} from '../src/constants.js'
@@ -66,6 +67,61 @@ describe('checkout and orders', () => {
6667
})
6768
})
6869

70+
it('sets checkout.context.address_country from country, letting stdin context win', async () => {
71+
const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' })
72+
const bodies: unknown[] = []
73+
const fetchMock = createFetchMock(async (url, init) => {
74+
if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' })
75+
if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' })
76+
if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' })
77+
bodies.push(await readJsonBody(init))
78+
return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } })
79+
})
80+
const client = new ShopCatalogClient({ fetch: fetchMock, store })
81+
82+
await client.createCheckout({ shopDomain: 'example.myshopify.com', variantId: '123', country: 'GB' })
83+
await client.createCheckout({
84+
shopDomain: 'example.myshopify.com',
85+
variantId: '123',
86+
country: 'GB',
87+
checkout: { context: { address_country: 'US' } },
88+
})
89+
90+
expect(bodies[0]).toMatchObject({
91+
params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'GB' } } } },
92+
})
93+
expect(bodies[1]).toMatchObject({
94+
params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'US' } } } },
95+
})
96+
})
97+
98+
it('falls back to the stored country preference, but never to a default country', async () => {
99+
const bodies: { params: { arguments: { checkout: { context?: unknown } } } }[] = []
100+
const fetchMock = createFetchMock(async (url, init) => {
101+
if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' })
102+
if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' })
103+
if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' })
104+
bodies.push((await readJsonBody(init)) as (typeof bodies)[number])
105+
return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } })
106+
})
107+
108+
const stored = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access', [COUNTRY_ACCOUNT]: 'GB' })
109+
await new ShopCatalogClient({ fetch: fetchMock, store: stored }).createCheckout({
110+
shopDomain: 'example.myshopify.com',
111+
variantId: '123',
112+
})
113+
const none = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' })
114+
await new ShopCatalogClient({ fetch: fetchMock, store: none }).createCheckout({
115+
shopDomain: 'example.myshopify.com',
116+
variantId: '123',
117+
})
118+
119+
expect(bodies[0]).toMatchObject({
120+
params: { name: 'create_checkout', arguments: { checkout: { context: { address_country: 'GB' } } } },
121+
})
122+
expect(bodies[1].params.arguments.checkout.context).toBeUndefined()
123+
})
124+
69125
it('unwraps the MCP envelope and returns the checkout payload, not the raw frame', async () => {
70126
const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' })
71127
const checkout = { id: 'gid://shopify/Checkout/abc', status: 'ready_for_complete', currency: 'GBP' }
@@ -464,6 +520,27 @@ describe('checkout and orders', () => {
464520
expect(names).toEqual(['create_checkout', 'create_checkout', 'update_checkout', 'complete_checkout'])
465521
})
466522

523+
it('wires --country into checkout.context.address_country via the CLI', async () => {
524+
const { createProgram } = await import('../src/cli.js')
525+
const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' })
526+
let body: { params: { name: string; arguments: { checkout: { context?: { address_country?: string } } } } } | undefined
527+
const fetchMock = createFetchMock(async (url, init) => {
528+
if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' })
529+
if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' })
530+
if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' })
531+
const parsed = (await readJsonBody(init)) as typeof body
532+
if (parsed?.params?.name === 'create_checkout') body = parsed
533+
return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } })
534+
})
535+
const base = { fetch: fetchMock, store, stdout: { write: fn() }, stderr: { write: fn() }, exit: (() => undefined) as never }
536+
537+
await createProgram(base).parseAsync([
538+
'node', 'shop', 'checkout', 'create', '--shop-domain', 'example.myshopify.com', '--variant-id', '123', '--country', 'GB',
539+
])
540+
541+
expect(body?.params.arguments.checkout.context).toEqual({ address_country: 'GB' })
542+
})
543+
467544
it('surfaces UCP checkout messages (final_sale, prop65) above the raw JSON on create', async () => {
468545
const { createProgram } = await import('../src/cli.js')
469546
const stdout = { write: fn() }

0 commit comments

Comments
 (0)