From 62dd6ed25f2cde029fb1dfc074916ae81acf83cf Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 25 Sep 2026 09:59:10 -0700 Subject: [PATCH 1/4] Support catalog product variants --- .changeset/fresh-catalog-products.md | 5 +++ packages/commerce-server/src/router.test.ts | 13 ++++++ .../src/server/api/commerce/products/GET.ts | 19 +++++--- .../server/api/commerce/products/[id]/GET.ts | 10 ++--- .../src/storefront.test.tsx | 44 +++++++++++++++++++ 5 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 .changeset/fresh-catalog-products.md diff --git a/.changeset/fresh-catalog-products.md b/.changeset/fresh-catalog-products.md new file mode 100644 index 00000000..a50d074e --- /dev/null +++ b/.changeset/fresh-catalog-products.md @@ -0,0 +1,5 @@ +--- +'@godaddy/commerce-server': patch +--- + +Load active catalog products and SKUs in merchant-defined variant order. diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index 3bb8bd54..edcf1204 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -100,9 +100,22 @@ describe('Commerce scoped routes', () => { expect(query).toContain('prices(first: 10)'); expect(query).toContain('inventoryCounts'); expect(query).toContain('pageInfo { hasNextPage }'); + expect(query).toContain('attributes(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('values(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('status: { eq: "ACTIVE" }'); expect(res.json).toHaveBeenCalledWith({ skuGroup: { id: 'product' } }); }); + it('loads only active catalog products and active card SKUs', async (): Promise => { + const res: ReturnType = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({ skuGroups: { edges: [] } }); + await readProducts({ query: {} } as unknown as Request, res as unknown as Response); + const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? ''; + expect(query).toContain('status: { eq: "ACTIVE" }'); + expect(query).toContain('skus(first: 2, status: { eq: "ACTIVE" })'); + expect(query).toContain('priceRange(status: { eq: "ACTIVE" })'); + }); + it.each([readProducts, readProduct, readSku])( 'keeps catalog queries within the upstream depth limit of 10', async (handler: typeof readProducts): Promise => { diff --git a/packages/commerce-server/src/server/api/commerce/products/GET.ts b/packages/commerce-server/src/server/api/commerce/products/GET.ts index aea3f87d..e2ccfb7b 100644 --- a/packages/commerce-server/src/server/api/commerce/products/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -32,7 +32,14 @@ import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; // Nested SKU price money fields would exceed the catalog API's depth limit of 10. const skuGroupsQuery = ` query SkuGroups($first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) { - skuGroups(first: $first, after: $after, id: $id, listId: $listId, label: $label) { + skuGroups( + first: $first + after: $after + id: $id + listId: $listId + label: $label + status: { eq: "ACTIVE" } + ) { edges { cursor node { @@ -42,11 +49,11 @@ const skuGroupsQuery = ` description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -58,7 +65,7 @@ const skuGroupsQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -66,7 +73,7 @@ const skuGroupsQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -78,7 +85,7 @@ const skuGroupsQuery = ` } } } - skus(first: 2) { + skus(first: 2, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts index 1fb13b99..5e96325a 100644 --- a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts @@ -34,11 +34,11 @@ const skuGroupQuery = ` description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -50,7 +50,7 @@ const skuGroupQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -58,7 +58,7 @@ const skuGroupQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -70,7 +70,7 @@ const skuGroupQuery = ` } } } - skus(attributeValues: { has: $attributeValues }, first: $first) { + skus(attributeValues: { has: $attributeValues }, first: $first, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index b14210e0..90091f3c 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -331,6 +331,50 @@ const group: SKUGroup = { skus: { totalCount: 2, edges: [] }, }; describe('catalog and product selection', () => { + it('purchases a one-SKU product without requiring variant configuration', async () => { + const simpleProduct: SKUGroup = { + id: 'mug', + label: 'Studio mug', + description: 'A ceramic mug.', + priceRange: { min: 2400, max: 2400 }, + attributes: { edges: [], totalCount: 0 }, + skus: { + totalCount: 1, + pageInfo: { hasNextPage: false }, + edges: [ + { + node: { + id: 'mug-sku', + prices: { edges: [{ node: { value: { value: 2400, currencyCode: 'USD' } } }] }, + inventoryCounts: { edges: [{ node: { type: 'AVAILABLE', quantity: 8 } }] }, + }, + }, + ], + }, + }; + const api = mockApi((path, init) => { + if (path.endsWith('/config')) return response(configuration); + if (init?.method === 'POST') return response({ cart: cart() }, 201); + return response({ skuGroup: simpleProduct }); + }); + const view = mount( + + } /> + , + '/products/mug', + ); + await connected(view); + expect(await screen.findByTestId('product-price')).toHaveTextContent('$24.00'); + const add = screen.getByRole('button', { name: 'Add to cart' }); + expect(add).toBeEnabled(); + await userEvent.click(add); + await waitFor(() => expect(api.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true)); + const request = api.mock.calls.find((call) => call[1]?.method === 'POST'); + expect(JSON.parse(String(request?.[1]?.body))).toEqual({ + lineItems: [{ skuId: 'mug-sku', name: 'Studio mug', quantity: 1 }], + }); + }); + it('waits for verified attribute names and blocks sold-out variants', async () => { const api = mockApi((path) => { if (path.endsWith('/config')) return response(configuration); From db96fa8b6b9b63e326557896fbb8d86d9cde3b59 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 25 Sep 2026 10:05:34 -0700 Subject: [PATCH 2/4] Show checkout adjustment notice --- .changeset/quiet-tax-totals.md | 6 ++ packages/commerce-server/README.md | 3 +- packages/commerce-server/src/config.test.ts | 33 ------- .../src/configuration-integration.test.ts | 5 +- .../src/create-checkout-session.test.ts | 88 ------------------- packages/commerce-server/src/index.ts | 5 -- .../src/lib/commerce/cart-scope.ts | 4 +- .../src/lib/commerce/checkout-config.ts | 56 ------------ .../src/lib/commerce/config.ts | 10 +-- .../lib/commerce/create-checkout-session.ts | 28 ------ packages/commerce-server/src/router.test.ts | 12 +-- .../src/server/api/commerce/config/GET.ts | 12 ++- packages/commerce-storefront/README.md | 2 +- .../commerce-storefront/docs/server-api.md | 6 +- packages/commerce-storefront/src/cart.tsx | 31 ++----- .../src/storefront.test.tsx | 30 ++++++- 16 files changed, 70 insertions(+), 261 deletions(-) create mode 100644 .changeset/quiet-tax-totals.md delete mode 100644 packages/commerce-server/src/lib/commerce/checkout-config.ts diff --git a/.changeset/quiet-tax-totals.md b/.changeset/quiet-tax-totals.md new file mode 100644 index 00000000..4a2e5162 --- /dev/null +++ b/.changeset/quiet-tax-totals.md @@ -0,0 +1,6 @@ +--- +'@godaddy/commerce-storefront': patch +'@godaddy/commerce-server': patch +--- + +Show the draft-order subtotal and explain that shipping, taxes, and discounts are calculated at checkout. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index c1741082..ac2d86e2 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -22,7 +22,6 @@ The default configuration reads these **server-only environment variables** on e - `GODADDY_OAUTH_CLIENT_ID` and `GODADDY_OAUTH_CLIENT_SECRET` - `GODADDY_STORE_ID` and `GODADDY_CHANNEL_ID` - `GODADDY_CURRENCY_CODE` -- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. The API origin defaults to `https://api.godaddy.com`. The package does not load files, provision merchants, or assign application attribution. Hosts own these concerns and any readiness checks or retries before invoking Commerce. @@ -40,7 +39,7 @@ const configuration = createRuntimeCommerceConfiguration({ `apiBaseUrl` controls catalog, order, and OAuth requests; checkout uses the corresponding `checkout.commerce.` subdomain. There is no built-in list of alternate environments. `sourceApp` and `owner` are optional host-owned attribution values: checkout uses both, while draft orders use `owner`. Supply values required by your Commerce integration; the package omits them by default. -Hosts with their own configuration service can implement `CommerceConfiguration` directly. `read()` returns `clientId`, `clientSecret`, `storeId`, `channelId`, `currencyCode`, `apiBaseUrl`, and optional attribution. `readCheckout()` returns the checkout flags and optional shipping settings. Return validated, ready-to-use settings from one consistent binding. Both functions run on the server; credentials must never reach browser code. +Hosts with their own configuration service can implement `CommerceConfiguration` directly. `read()` returns `clientId`, `clientSecret`, `storeId`, `channelId`, `currencyCode`, `apiBaseUrl`, and optional attribution. Return a validated, ready-to-use binding. This function runs on the server; credentials must never reach browser code. ## Routers and helpers diff --git a/packages/commerce-server/src/config.test.ts b/packages/commerce-server/src/config.test.ts index 17b5db12..53aa2973 100644 --- a/packages/commerce-server/src/config.test.ts +++ b/packages/commerce-server/src/config.test.ts @@ -74,37 +74,4 @@ describe('Commerce runtime configuration', () => { delete values[key]; expect(() => readCommerceConfig({ environment: values })).toThrow(`${key} is missing`); }); - - it('reads checkout flags and API shipping options', (): void => { - const values = environment(); - const configuration = createRuntimeCommerceConfiguration({ environment: values }); - expect(configuration.readCheckout()).toEqual({ - enablePromotionCodes: false, - enableTaxCollection: false, - enableShipping: false, - }); - values.GODADDY_CHECKOUT_CONFIGURATION = JSON.stringify({ - enablePromotionCodes: true, - enableTaxCollection: false, - enableShipping: true, - shipping: { fulfillmentLocationId: 'location-1' }, - }); - expect(configuration.readCheckout()).toEqual({ - enablePromotionCodes: true, - enableTaxCollection: false, - enableShipping: true, - shipping: { fulfillmentLocationId: 'location-1' }, - }); - }); - - it.each(['{bad json', 'null', '{"enableShipping":true}'])( - 'rejects malformed checkout configuration: %s', - (raw): void => { - expect(() => - createRuntimeCommerceConfiguration({ - environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, - }).readCheckout(), - ).toThrow('Commerce config: GODADDY_CHECKOUT_CONFIGURATION'); - }, - ); }); diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index ff9ec77e..14d2d2d5 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -91,7 +91,10 @@ it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( const base = `http://127.0.0.1:${address.port}/api/commerce`; const configResponse = await clientFetch(`${base}/config`); const publicConfig = await configResponse.json(); - expect(publicConfig).toEqual({ cartScope: expect.any(String), currencyCode: 'USD' }); + expect(publicConfig).toEqual({ + cartScope: expect.any(String), + currencyCode: 'USD', + }); const headers = { 'Content-Type': 'application/json', 'X-Commerce-Scope': publicConfig.cartScope }; expect((await clientFetch(`${base}/products`, { headers })).status).toBe(200); expect( diff --git a/packages/commerce-server/src/create-checkout-session.test.ts b/packages/commerce-server/src/create-checkout-session.test.ts index 82cb754a..90e438e6 100644 --- a/packages/commerce-server/src/create-checkout-session.test.ts +++ b/packages/commerce-server/src/create-checkout-session.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CommerceCheckoutConfiguration } from './lib/commerce/checkout-config'; import type { CheckoutSessionResult, CreateCheckoutSessionResult } from './lib/commerce/checkout-subgraph'; import type { CommerceConfig, CommerceConfiguration } from './lib/commerce/config'; import { @@ -26,10 +25,8 @@ const flows = [ const nonCatalog = flows[0][1]; const cart = flows[1][1]; let config: CommerceConfig; -let checkout: CommerceCheckoutConfiguration; const configuration: CommerceConfiguration = { read: vi.fn(() => config), - readCheckout: () => checkout, }; function response(overrides: Partial = {}): CreateCheckoutSessionResult { @@ -57,7 +54,6 @@ beforeEach((): void => { apiBaseUrl: 'https://api.godaddy.com', currencyCode: 'USD', }; - checkout = { enablePromotionCodes: false, enableTaxCollection: false, enableShipping: false }; vi.mocked(configuration.read).mockImplementation(() => config); mockGetOAuthAccessToken.mockResolvedValue({ access_token: 'access-token', @@ -150,90 +146,6 @@ describe('createCheckoutSession', () => { ); }); - it.each([flows[1], flows[2]])( - 'uses the store shipping configuration for %s checkout', - async (_name, params): Promise => { - checkout = { enablePromotionCodes: true, enableTaxCollection: true, enableShipping: true }; - mockGqlRequest.mockResolvedValue( - response({ - enablePromotionCodes: true, - enableTaxCollection: true, - enableShipping: true, - enableShippingAddressCollection: true, - }), - ); - await createCheckoutSession(params, configuration); - const input = mockGqlRequest.mock.calls[0]?.[0].variables.input; - expect(input).toMatchObject({ - enablePromotionCodes: true, - enableTaxCollection: true, - enableShipping: true, - enableShippingAddressCollection: true, - }); - expect(input).not.toHaveProperty('shipping'); - }, - ); - - it.each([ - { - originAddress: { - addressLine1: '123 Main St', - adminArea1: 'AZ', - adminArea2: 'Tempe', - postalCode: '85281', - countryCode: 'US', - }, - }, - { fulfillmentLocationId: 'location-1' }, - ])('passes explicit API shipping options from the host: %j', async (shipping): Promise => { - checkout = { ...checkout, enableShipping: true, shipping }; - mockGqlRequest.mockResolvedValue( - response({ enableShipping: true, enableShippingAddressCollection: true }), - ); - await createCheckoutSession(cart, configuration); - expect(mockGqlRequest.mock.calls[0]?.[0].variables.input.shipping).toEqual(shipping); - }); - - it('does not apply shipping or promotion codes to non-catalog checkout', async (): Promise => { - checkout = { - enablePromotionCodes: true, - enableTaxCollection: true, - enableShipping: true, - shipping: { fulfillmentLocationId: 'location-1' }, - }; - mockGqlRequest.mockResolvedValue(response({ enableTaxCollection: true })); - await createCheckoutSession(nonCatalog, configuration); - const input = mockGqlRequest.mock.calls[0]?.[0].variables.input; - expect(input).toMatchObject({ - enableTaxCollection: true, - enableShipping: false, - enableShippingAddressCollection: false, - }); - expect(input).not.toHaveProperty('enablePromotionCodes'); - expect(input).not.toHaveProperty('shipping'); - }); - - it.each([ - 'enablePromotionCodes', - 'enableTaxCollection', - 'enableShipping', - 'enableShippingAddressCollection', - ] as const)('rejects sessions that omit configured %s', async (field): Promise => { - checkout = { enablePromotionCodes: true, enableTaxCollection: true, enableShipping: true }; - mockGqlRequest.mockResolvedValue( - response({ - enablePromotionCodes: true, - enableTaxCollection: true, - enableShipping: true, - enableShippingAddressCollection: true, - [field]: false, - }), - ); - await expect(createCheckoutSession(cart, configuration)).rejects.toThrow( - 'Checkout session did not enable configured', - ); - }); - it('uses the configured currency for non-catalog pricing', async (): Promise => { config.currencyCode = 'GBP'; await createCheckoutSession( diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts index fd69b46b..fc716b58 100644 --- a/packages/commerce-server/src/index.ts +++ b/packages/commerce-server/src/index.ts @@ -1,8 +1,3 @@ -export { - type CommerceCheckoutConfiguration, - type CommerceCheckoutShippingConfiguration, - parseCommerceCheckoutConfiguration, -} from './lib/commerce/checkout-config'; export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls'; export { type CommerceConfig, diff --git a/packages/commerce-server/src/lib/commerce/cart-scope.ts b/packages/commerce-server/src/lib/commerce/cart-scope.ts index 8854944a..46c5382a 100644 --- a/packages/commerce-server/src/lib/commerce/cart-scope.ts +++ b/packages/commerce-server/src/lib/commerce/cart-scope.ts @@ -3,12 +3,12 @@ import { createHash } from 'node:crypto'; import type { Request, Response } from 'express'; import type { CommerceConfig } from './config'; -type CartBinding = Pick; +type CartBinding = Pick; /** Public cache/storage scope, not an authorization credential. */ export function getCommerceCartScope(config: CartBinding): string { return createHash('sha256') - .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId])) + .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId, config.currencyCode])) .digest('hex') .slice(0, 32); } diff --git a/packages/commerce-server/src/lib/commerce/checkout-config.ts b/packages/commerce-server/src/lib/commerce/checkout-config.ts deleted file mode 100644 index 7cae4047..00000000 --- a/packages/commerce-server/src/lib/commerce/checkout-config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** Shipping options accepted by the hosted checkout API. Omit to use the store's configuration. */ -export interface CommerceCheckoutShippingConfiguration { - readonly originAddress?: Readonly>; - readonly fulfillmentLocationId?: string; -} - -export interface CommerceCheckoutConfiguration { - readonly enablePromotionCodes: boolean; - readonly enableTaxCollection: boolean; - readonly enableShipping: boolean; - readonly shipping?: CommerceCheckoutShippingConfiguration; -} - -const DEFAULT_CHECKOUT_CONFIGURATION: CommerceCheckoutConfiguration = { - enablePromotionCodes: false, - enableTaxCollection: false, - enableShipping: false, -}; - -export function parseCommerceCheckoutConfiguration(raw: string | undefined): CommerceCheckoutConfiguration { - if (!raw?.trim()) return DEFAULT_CHECKOUT_CONFIGURATION; - - let value: unknown; - try { - value = JSON.parse(raw); - } catch (error) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be valid JSON.', { - cause: error, - }); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be a JSON object.'); - } - - const candidate = value as Record; - for (const key of ['enablePromotionCodes', 'enableTaxCollection', 'enableShipping'] as const) { - if (typeof candidate[key] !== 'boolean') { - throw new Error(`Commerce config: GODADDY_CHECKOUT_CONFIGURATION.${key} must be boolean.`); - } - } - - const shipping = candidate.shipping; - if ( - shipping !== undefined && - (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) - ) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION.shipping must be an object.'); - } - - return { - enablePromotionCodes: candidate.enablePromotionCodes as boolean, - enableTaxCollection: candidate.enableTaxCollection as boolean, - enableShipping: candidate.enableShipping as boolean, - ...(shipping ? { shipping: shipping as CommerceCheckoutShippingConfiguration } : {}), - }; -} diff --git a/packages/commerce-server/src/lib/commerce/config.ts b/packages/commerce-server/src/lib/commerce/config.ts index 19381526..e51fe7f1 100644 --- a/packages/commerce-server/src/lib/commerce/config.ts +++ b/packages/commerce-server/src/lib/commerce/config.ts @@ -1,6 +1,5 @@ /** Server-only Commerce configuration. Hosts own secrets and deployment-specific loading. */ import type { Response } from 'express'; -import { type CommerceCheckoutConfiguration, parseCommerceCheckoutConfiguration } from './checkout-config'; const DEFAULT_API_BASE_URL = 'https://api.godaddy.com'; @@ -24,11 +23,10 @@ export interface CommerceConfig { export interface CommerceConfiguration { read(): CommerceConfig; - readCheckout(): CommerceCheckoutConfiguration; } export interface RuntimeCommerceConfigurationOptions { - /** Server environment containing credentials, store/channel IDs, currency, and checkout flags. */ + /** Server environment containing credentials, store/channel IDs, and currency. */ environment?: NodeJS.ProcessEnv; /** Explicit server-controlled API origin override. Defaults to production. */ apiBaseUrl?: string; @@ -84,8 +82,6 @@ export function createRuntimeCommerceConfiguration( ): CommerceConfiguration { return { read: (): CommerceConfig => readCommerceConfig(options), - readCheckout: (): CommerceCheckoutConfiguration => - parseCommerceCheckoutConfiguration((options.environment ?? process.env).GODADDY_CHECKOUT_CONFIGURATION), }; } @@ -95,9 +91,7 @@ export function commerceConfigurationForResponse(res: Response): CommerceConfigu configuration && typeof configuration === 'object' && 'read' in configuration && - typeof configuration.read === 'function' && - 'readCheckout' in configuration && - typeof configuration.readCheckout === 'function' + typeof configuration.read === 'function' ) { return configuration as CommerceConfiguration; } diff --git a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts index a57e8bbc..a3d44f48 100644 --- a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts +++ b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts @@ -156,10 +156,6 @@ const createCheckoutSessionMutation = ` } `; -function promotionCodesEnabled(configuration: object): boolean { - return 'enablePromotionCodes' in configuration && configuration.enablePromotionCodes === true; -} - export async function createCheckoutSession( params: CreateCheckoutSessionParams, configuration: CommerceConfiguration = createRuntimeCommerceConfiguration(), @@ -185,9 +181,6 @@ export async function createCheckoutSession( owner, currencyCode: configCurrencyCode, } = configuration.read(); - const checkoutConfiguration = configuration.readCheckout(); - const enablePromotionCodes: boolean = promotionCodesEnabled(checkoutConfiguration); - const catalogShippingEnabled: boolean = lineItemData === undefined && checkoutConfiguration.enableShipping; const checkoutOAuthScope: string = 'commerce.product:read'; const token = await getOAuthAccessToken({ clientId, @@ -199,12 +192,7 @@ export async function createCheckoutSession( const attribution = { sourceApp, owner }; const catalogCheckoutOverrides = { ...attribution, - enablePromotionCodes, - enableTaxCollection: checkoutConfiguration.enableTaxCollection, - enableShipping: checkoutConfiguration.enableShipping, - enableShippingAddressCollection: checkoutConfiguration.enableShipping, paymentMethods: DEFAULT_CHECKOUT_PAYMENT_METHODS, - shipping: catalogShippingEnabled ? checkoutConfiguration.shipping : undefined, }; const resolvedLineItemData: typeof lineItemData = @@ -241,7 +229,6 @@ export async function createCheckoutSession( }, { ...attribution, - enableTaxCollection: checkoutConfiguration.enableTaxCollection, paymentMethods: DEFAULT_CHECKOUT_PAYMENT_METHODS, }, ) @@ -280,21 +267,6 @@ export async function createCheckoutSession( ); } - const expectedShipping = lineItemData === undefined && checkoutConfiguration.enableShipping; - const expectedPromotionCodes: boolean = lineItemData === undefined && enablePromotionCodes; - if (expectedPromotionCodes && session.enablePromotionCodes !== true) { - throw new Error('Checkout session did not enable configured promotion codes'); - } - if (checkoutConfiguration.enableTaxCollection && session.enableTaxCollection !== true) { - throw new Error('Checkout session did not enable configured tax collection'); - } - if ( - expectedShipping && - (session.enableShipping !== true || session.enableShippingAddressCollection !== true) - ) { - throw new Error('Checkout session did not enable configured shipping and address collection'); - } - if (!session.paymentMethods?.card?.processor) { throw new Error('Checkout session did not configure payment methods.'); } diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index edcf1204..c9406eaa 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express'; import express from 'express'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getCommerceCartScope } from './lib/commerce/cart-scope'; +import type { CommerceConfiguration } from './lib/commerce/config'; import { createCheckoutSession } from './lib/commerce/create-checkout-session'; import { GraphQLErrorWithCodes, gqlRequest } from './lib/commerce/gql'; import { getCartOrderQuery } from './lib/commerce/order-subgraph'; @@ -36,13 +37,8 @@ const binding = { clientId: 'client-1', clientSecret: 'server-only-secret', }; -const configuration = { +const configuration: CommerceConfiguration = { read: (): typeof binding => binding, - readCheckout: (): { enablePromotionCodes: false; enableTaxCollection: false; enableShipping: false } => ({ - enablePromotionCodes: false, - enableTaxCollection: false, - enableShipping: false, - }), }; function response() { @@ -65,6 +61,10 @@ describe('Commerce scoped routes', () => { expect(getCartOrderQuery).not.toMatch(/\bstatuses\s*\{/); }); + it('uses currency as part of the persisted cart binding', (): void => { + expect(getCommerceCartScope(binding)).not.toBe(getCommerceCartScope({ ...binding, currencyCode: 'GBP' })); + }); + it.each([readCart, addItem, updateItem, deleteItem, applyDiscount, readProduct, readSku])( 'rejects array route IDs before an upstream request', async (handler): Promise => { diff --git a/packages/commerce-server/src/server/api/commerce/config/GET.ts b/packages/commerce-server/src/server/api/commerce/config/GET.ts index 7deb364a..3169634f 100644 --- a/packages/commerce-server/src/server/api/commerce/config/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/config/GET.ts @@ -1,17 +1,21 @@ /** * GET /api/commerce/config - * Public { cartScope, currencyCode } for the shared CommerceProvider. + * Public binding configuration for the shared CommerceProvider. * Store/channel IDs and credentials stay server-side. Do not cache across bindings. */ import type { Request, Response } from 'express'; import { getCommerceCartScope } from '@/lib/commerce/cart-scope'; -import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; export default async function handler(_req: Request, res: Response): Promise { res.setHeader('Cache-Control', 'no-store'); try { - const config: CommerceConfig = readCommerceConfigForResponse(res); - res.json({ cartScope: getCommerceCartScope(config), currencyCode: config.currencyCode }); + const configuration = commerceConfigurationForResponse(res); + const config: CommerceConfig = configuration.read(); + res.json({ + cartScope: getCommerceCartScope(config), + currencyCode: config.currencyCode, + }); } catch (cause: unknown) { res.status(503).json({ error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 1156dfe9..475b8b99 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -58,7 +58,7 @@ Use the providers your application already has; do not create another router or Provide root-relative paths without a trailing slash. Enable `checkoutSuccessPath` only after your server supports checkout and validates merchant readiness. Mount a corresponding return page. A redirect back from checkout is **not proof of payment**; that page must obtain authoritative payment status from your server. This package does not provide a payment receipt page or merchant onboarding. -`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store/channel binding changes. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. +`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store, channel, or currency binding changes. The draft-order cart displays its subtotal and explains that shipping, taxes, and discounts are calculated at checkout. The explanation has the stable `commerce-cart-checkout-adjustments-note` class so applications can hide it when needed. The cart does not create a checkout session or calculate adjustments. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. A connection failure leaves the surrounding application and its state mounted. Catalog and product surfaces show the connection error and retry action. Custom integrations can render `CommerceStatus` or inspect `useCommerce().connection`. diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 9b73a489..5675eead 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -11,13 +11,13 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati - Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically. - Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. - Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds. -- Resolve prices, taxes, discounts and availability on the server. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. +- Resolve prices and availability on the server. The cart does not calculate shipping, taxes, or discounts; it explains that they are calculated at checkout. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. ## Endpoints | Method and path | Input | Successful JSON response | | --- | --- | --- | -| `GET /config` | None | `{ cartScope: string, currencyCode: string }` | +| `GET /config` | None | `{ cartScope, currencyCode }` | | `GET /products?first=6&after=` | Optional opaque cursor | `{ skuGroups: Connection }` | | `GET /products/:id` | URI-encoded product ID | `{ skuGroup: SKUGroup \| null }` | | `GET /products/:id?attributeValues=blue&attributeValues=large` | Repeated selected attribute **names**, not IDs | `{ skuGroup: SKUGroup \| null }` with matching SKU connection | @@ -33,7 +33,7 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati ## Configuration -`cartScope` is a nonempty opaque identifier for the effective store/channel binding. It is not a secret. Rotate it when the binding changes so a saved cart cannot cross stores. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. +`cartScope` is a nonempty opaque identifier for the effective store/channel/currency binding. It is not a secret. Rotate it when that binding changes so a saved cart cannot cross stores or currencies. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. The cart shows the draft-order subtotal and the message “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so a host can hide it without changing the component. The browser rechecks configuration on window focus when stale. Return current server configuration rather than a browser-selected store. Persisted IDs use the package-specific key documented in the README; migration from another application's storage keys belongs to that application's integration. diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 592557de..3b6fa554 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -263,31 +263,16 @@ export function CartDrawer(): ReactElement { {items.length > 0 && (
- {( - [ - ['Subtotal', cart?.totals?.subTotal, summary.subtotal], - ['Shipping', cart?.totals?.shippingTotal, summary.shipping], - ['Tax', cart?.totals?.taxTotal, summary.taxes], - ] as const - ).map( - ([label, amount, value]) => - typeof amount?.value === 'number' && ( -
-
{label}
-
{money(value, amount.currencyCode ?? currency)}
-
- ), + {typeof cart?.totals?.subTotal?.value === 'number' && ( +
+
Subtotal
+
{money(summary.subtotal, cart.totals.subTotal.currencyCode ?? currency)}
+
)} -
-
Total
-
- {typeof cart?.totals?.total?.value === 'number' - ? money(summary.total, currency) - : 'Unavailable'} -
-
-

Shipping and taxes may change at checkout.

+

+ Shipping, taxes, and discounts are calculated at checkout. +

{config.checkoutSuccessPath ? (