Skip to content
Open
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/fresh-catalog-products.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@godaddy/commerce-server': patch
---

Load active catalog products and SKUs in merchant-defined variant order.
6 changes: 6 additions & 0 deletions .changeset/quiet-tax-totals.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/commerce-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ 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.
- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent or malformed so configuration discovery cannot prevent checkout creation. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration.

See [Checkout configuration](docs/checkout-configuration.md) for the temporary build-time synchronization flow and its eventual-consistency limitation.

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.

Expand Down
62 changes: 62 additions & 0 deletions packages/commerce-server/docs/checkout-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Checkout configuration

## Goal

Storefronts should respond to a merchant enabling or disabling shipping, taxes, or discounts without requiring the builder agent to edit application code. This document records the current design, its temporary limitation, and the intended long-term solution.

## Decisions

- The cart remains a draft order. We will not create a checkout session when the cart is created or maintain a lazy session while the shopper edits it.
- The cart does not calculate or estimate shipping, taxes, or discounts. It displays **Subtotal** and one message: “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so an application can hide it easily.
- A checkout session is created only when the shopper selects **Checkout**. The Checkout API and the enabled commerce providers perform the actual calculations.
- Checkout capability flags are server-only. They must not be exposed by `/api/commerce/config` or supplied by browser code.
- We will defer live capability discovery in this library because the Checkout API is expected to discover enabled providers through App Registry. Until that work ships, configuration can be refreshed during an initial build or a later rebuild/redeploy.

This keeps the cart simple, avoids a second source of pricing logic, and lets shipping rates, tax rules, and discounts remain authoritative in their respective services.

## Source of truth

App Registry is the source of truth for whether the store has a provider enabled for each capability:

| Capability | App Registry action |
| --- | --- |
| Shipping | `commerce.shipping-rates.calculate` |
| Taxes | `commerce.taxes.calculate` |
| Discounts | `commerce.price-adjustment.apply` |

App Registry indicates that a capable GPA is enabled; the shipping, tax, and discount services still own their settings, rules, and calculations. The general Settings API is not a consolidated source for these three enablement states.

Commerce Admin already exposes this lookup to builders through the `commerce_checkout_configuration_get` MCP tool. Given a `storeId` and `channelId`, it validates their binding, queries enabled App Registry actions, and returns checkout flags such as `enableShipping`, `enableTaxCollection`, and `enablePromotionCodes`, plus shipping-origin readiness.

## Temporary build-time flow

Until Checkout API performs App Registry discovery itself:

1. During every initial build and rebuild/redeploy, the builder calls `commerce_checkout_configuration_get`.
2. The deployment writes the three returned feature flags to the server-only `GODADDY_CHECKOUT_CONFIGURATION` value; it does not copy the full MCP response or generate conditional application code.
3. When Checkout is selected, `commerce-server` reads that value and explicitly configures the new checkout session.
4. The enabled providers calculate live rates and adjustments during checkout.

For example:

```json
{
"enablePromotionCodes": true,
"enableTaxCollection": true,
"enableShipping": true
}
```

Restoring the server reader alone does not discover merchant settings: the builder/deployment integration must populate this value. Shipping-origin address details do not need to be copied into application code; hosted checkout can use store configuration.

The accepted stopgap is eventually consistent: a provider enabled or disabled after deployment is reflected on the next rebuild, not immediately. This is acceptable only while the Checkout API change is pending.

If the MCP lookup fails, the deployment does not receive the expected flags, or the server value is malformed, checkout falls back to all optional capabilities disabled. In particular, it sends `enableShipping: false` rather than preventing checkout creation. Builders should surface the lookup problem during the build, but it must not make the deployed checkout unusable.

## Important compatibility detail

The current checkout request builder applies explicit defaults of `false` for shipping, shipping-address collection, and tax collection. Removing `GODADDY_CHECKOUT_CONFIGURATION` without changing that behavior disables those features and is a regression. Also, omitting fields is not a complete substitute: Checkout API currently defaults shipping to enabled, but does not similarly enable address collection, taxes, or promotion codes.

## Long-term flow

Checkout API should query App Registry when a session is created and automatically enable shipping, taxes, and discounts for the store’s active GPAs. Once that is available and verified, this repository can remove the static checkout configuration and its build-time synchronization. The cart and draft-order lifecycle do not need to change.
26 changes: 16 additions & 10 deletions packages/commerce-server/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,20 @@ describe('Commerce runtime configuration', () => {
});
});

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');
},
);
it.each([
'{bad json',
'null',
'{"enableShipping":true}',
'{"enablePromotionCodes":false,"enableTaxCollection":false,"enableShipping":true,"shipping":{"originAddressConfigured":true}}',
])('defaults malformed checkout configuration to disabled capabilities: %s', (raw): void => {
expect(
createRuntimeCommerceConfiguration({
environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw },
}).readCheckout(),
).toEqual({
enablePromotionCodes: false,
enableTaxCollection: false,
enableShipping: false,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 17 additions & 7 deletions packages/commerce-server/src/create-checkout-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ describe('createCheckoutSession', () => {
},
);

it('disables optional checkout capabilities when configuration falls back to defaults', async (): Promise<void> => {
await createCheckoutSession(cart, configuration);
expect(mockGqlRequest.mock.calls[0]?.[0].variables.input).toMatchObject({
enablePromotionCodes: false,
enableTaxCollection: false,
enableShipping: false,
enableShippingAddressCollection: false,
});
});

it.each(flows)('uses only host-owned attribution for %s checkout', async (_name, params): Promise<void> => {
config = { ...config, sourceApp: 'merchant-site', owner: 'merchant-orders' };
mockGqlRequest.mockResolvedValue(response({ sourceApp: 'merchant-site' }));
Expand Down Expand Up @@ -143,13 +153,6 @@ describe('createCheckoutSession', () => {
},
);

it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise<void> => {
mockGqlRequest.mockResolvedValue(response({ paymentMethods: null }));
await expect(createCheckoutSession(params, configuration)).rejects.toThrow(
'Checkout session did not configure payment methods.',
);
});

it.each([flows[1], flows[2]])(
'uses the store shipping configuration for %s checkout',
async (_name, params): Promise<void> => {
Expand Down Expand Up @@ -234,6 +237,13 @@ describe('createCheckoutSession', () => {
);
});

it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise<void> => {
mockGqlRequest.mockResolvedValue(response({ paymentMethods: null }));
await expect(createCheckoutSession(params, configuration)).rejects.toThrow(
'Checkout session did not configure payment methods.',
);
});

it('uses the configured currency for non-catalog pricing', async (): Promise<void> => {
config.currencyCode = 'GBP';
await createCheckoutSession(
Expand Down
4 changes: 2 additions & 2 deletions packages/commerce-server/src/lib/commerce/cart-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { createHash } from 'node:crypto';
import type { Request, Response } from 'express';
import type { CommerceConfig } from './config';

type CartBinding = Pick<CommerceConfig, 'apiBaseUrl' | 'storeId' | 'channelId'>;
type CartBinding = Pick<CommerceConfig, 'apiBaseUrl' | 'storeId' | 'channelId' | 'currencyCode'>;

/** 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);
}
Expand Down
30 changes: 18 additions & 12 deletions packages/commerce-server/src/lib/commerce/checkout-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,34 @@ export function parseCommerceCheckoutConfiguration(raw: string | undefined): Com
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be valid JSON.', {
cause: error,
});
} catch {
return DEFAULT_CHECKOUT_CONFIGURATION;
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be a JSON object.');
return DEFAULT_CHECKOUT_CONFIGURATION;
}

const candidate = value as Record<string, unknown>;
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.`);
return DEFAULT_CHECKOUT_CONFIGURATION;
}
}

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.');
const shipping = candidate.shipping as Record<string, unknown> | undefined;
if (shipping !== undefined) {
if (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) {
return DEFAULT_CHECKOUT_CONFIGURATION;
}
const hasOriginAddress =
shipping.originAddress !== null &&
typeof shipping.originAddress === 'object' &&
!Array.isArray(shipping.originAddress);
const hasFulfillmentLocationId =
typeof shipping.fulfillmentLocationId === 'string' && shipping.fulfillmentLocationId.trim() !== '';
if (hasOriginAddress === hasFulfillmentLocationId) {
return DEFAULT_CHECKOUT_CONFIGURATION;
}
}

return {
Expand Down
20 changes: 19 additions & 1 deletion packages/commerce-server/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,7 +37,7 @@ 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,
Expand Down Expand Up @@ -65,6 +66,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<void> => {
Expand Down Expand Up @@ -100,9 +105,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<void> => {
const res: ReturnType<typeof response> = 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<void> => {
Expand Down
12 changes: 8 additions & 4 deletions packages/commerce-server/src/server/api/commerce/config/GET.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,11 +49,11 @@ const skuGroupsQuery = `
description
htmlDescription
type
priceRange {
priceRange(status: { eq: "ACTIVE" }) {
min
max
}
compareAtPriceRange {
compareAtPriceRange(status: { eq: "ACTIVE" }) {
min
max
}
Expand All @@ -58,15 +65,15 @@ const skuGroupsQuery = `
}
}
}
attributes {
attributes(first: 50, orderBy: { position: ASC }) {
edges {
node {
id
name
label
description
htmlDescription
values(first: 50) {
values(first: 50, orderBy: { position: ASC }) {
edges {
node {
id
Expand All @@ -78,7 +85,7 @@ const skuGroupsQuery = `
}
}
}
skus(first: 2) {
skus(first: 2, status: { eq: "ACTIVE" }) {
pageInfo { hasNextPage }
totalCount
edges {
Expand Down
Loading
Loading