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
33 changes: 20 additions & 13 deletions rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,23 @@ export class CheckoutService {
// Fulfillment Logic (Mock)
if (checkout.fulfillment?.methods) {
for (const method of checkout.fulfillment.methods) {
// fulfillment.md, "Business Response Behavior": with the default
// supports_multi_group: false the response MUST carry one group per
// method. Options are quoted per destination below, so a method the
// merchant cannot quote for — a non-shipping type, or a destination it
// does not serve — still reports its line items with no options rather
// than no group at all.
if (!method.groups || method.groups.length === 0) {
method.groups = [
{
id: `group_${uuidv4()}`,
// Required by fulfillment_group.json, so never left undefined.
line_item_ids: method.line_item_ids ?? [],
options: [],
},
];
}

if (
method.type === "shipping" &&
method.selected_destination_id &&
Expand Down Expand Up @@ -421,19 +438,9 @@ export class CheckoutService {
}

// Assign options to groups
if (!method.groups || method.groups.length === 0) {
method.groups = [
{
id: `group_${uuidv4()}`,
line_item_ids: method.line_item_ids,
options,
},
];
} else {
// Update all groups with available options
for (const group of method.groups) {
group.options = options;
}
// Update all groups with available options
for (const group of method.groups) {
group.options = options;
}

// Calculate total from selected option
Expand Down
49 changes: 49 additions & 0 deletions rest/nodejs/test/fulfillment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,52 @@ test("empty fulfillment methods array blocks completion", async () => {
assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST");
assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i);
});

// fulfillment.md "Business Response Behavior" requires one group per method
// under the default supports_multi_group: false. Only shipping methods with a
// resolvable destination used to get one, so every other method came back with
// groups: [] — which then satisfied the completion gate via
// [].every(...) === true, letting a checkout complete with nothing selected.
//
// This mock merchant quotes options for shipping only, so a pickup method it
// cannot serve reports a group with no options and stays uncompletable. That
// is the point: refusing is correct, and silently completing was the bug.
// Quoting real pickup options would be a new merchant capability, not a fix.
test("a method the merchant cannot quote options for still gets one group", async () => {
const app = buildApp();
const created = await app.request("/checkout-sessions", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
currency: "USD",
line_items: LINE_ITEMS,
payment: {},
buyer: KNOWN_BUYER,
fulfillment: {
methods: [{ type: "pickup", selected_destination_id: "addr_1" }],
},
}),
});
assert.equal(created.status, 201);
const checkout = (await created.json()) as Checkout;
const groups = checkout.fulfillment?.methods?.[0]?.groups;
assert.equal(groups?.length, 1, "one group per method");
assert.deepEqual(
groups?.[0].options,
[],
"no options the merchant can quote"
);

// With no option selectable, completion must still be refused.
const res = await app.request(`/checkout-sessions/${checkout.id}/complete`, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(SUCCESS_PAYMENT),
});
assert.equal(res.status, 400);
const body = (await res.json()) as {
messages?: Array<{ code?: string; content?: string }>;
};
assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST");
assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i);
});
Loading