@agent-cards/commerce-sdk 0.18.13 → 0.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,7 @@ import { type BuyToolModelView } from './model-view.js';
44
44
  import { type MessagePace, type MessageStream } from './messages.js';
45
45
  import { type ShopperMessage } from './shopper-message.js';
46
46
  import { type BuyStoreWait } from './store-wait.js';
47
+ import { type ShopperLocation } from './shopper-location.js';
47
48
  /**
48
49
  * Where the purchase stands:
49
50
  * - `needs_input`: Agentcard asks the shopper something, or shows a cart.
@@ -159,7 +160,7 @@ export interface BuyToolStore {
159
160
  linkSent: boolean;
160
161
  }
161
162
  /** A product the last search found. */
162
- /** What taking a product does: `reserve` a table, `book` a flight, room, class or ticket, `buy` anything else. */
163
+ /** What taking a product does: `reserve` a table, `book` a flight, room, rental car, class, ticket or event, `buy` anything else. */
163
164
  export type BuyToolProductAction = 'buy' | 'reserve' | 'book';
164
165
  /** The action a product of `merchant` takes (BuyToolProductAction). */
165
166
  export declare function productAction(merchant: string | null | undefined): BuyToolProductAction;
@@ -347,6 +348,8 @@ export interface BuyToolOptions extends BuyStoreWait {
347
348
  sendLink?: (link: BuyToolLink) => void | Promise<void>;
348
349
  /** Where the orders go, when you already hold the shopper's address. The model never sets it. */
349
350
  deliveryAddress?: BuyDeliveryAddress;
351
+ /** Where the shopper buys from, as your app knows it: sent on every request, so Agentcard knows their country before their first message. */
352
+ shopperLocation?: ShopperLocation;
350
353
  /** With `accessToken`: where Agentcard answers. Default `https://api.agentcard.sh`. */
351
354
  baseUrl?: string;
352
355
  /** With `accessToken`: the fetch to call Agentcard with. Default: the global fetch. */
@@ -828,9 +831,4 @@ export declare function handedNote(answer: string): string;
828
831
  export declare function nextStepPrompt(message: ShopperMessage): string;
829
832
  /** Shared with the merchant monitor so link eligibility uses the tool's product mapping. */
830
833
  export declare function productsOf(turn: BuyTurn): BuyToolProduct[];
831
- /**
832
- * A buy tool for one shopper. With a company client, name the shopper with
833
- * `externalUserId`; with a shopper's own `accessToken`, the token names them.
834
- * Throws a TypeError for options that cannot work, before any call.
835
- */
836
834
  export declare function buyTool(options: BuyToolOptions): BuyTool;
package/dist/buy-tool.js CHANGED
@@ -9,12 +9,15 @@ import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep,
9
9
  import { isStale, messageInChat } from './shopper-message.js';
10
10
  import { actionLinkId, isActionLink } from './action-links.js';
11
11
  import { checkStoreWait, storeWaitFields, withLateCards } from './store-wait.js';
12
+ import { shopperLocationOf } from './shopper-location.js';
12
13
  import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
14
+ /** The merchants whose products are booked: a seat, a room, a car or an event, never an item shipped. */
15
+ const BOOKED = new Set(['flights', 'hotels', 'cars', 'tickets', 'classes', 'gomry']);
13
16
  /** The action a product of `merchant` takes (BuyToolProductAction). */
14
17
  export function productAction(merchant) {
15
18
  if (merchant === 'reservations')
16
19
  return 'reserve';
17
- return merchant === 'flights' || merchant === 'hotels' || merchant === 'tickets' || merchant === 'classes' ? 'book' : 'buy';
20
+ return merchant && BOOKED.has(merchant) ? 'book' : 'buy';
18
21
  }
19
22
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
20
23
  /**
@@ -741,6 +744,13 @@ function endTurn(state) {
741
744
  * `externalUserId`; with a shopper's own `accessToken`, the token names them.
742
745
  * Throws a TypeError for options that cannot work, before any call.
743
746
  */
747
+ /**
748
+ * The carts as an app shows them, in order: two lists read the same only when they show the same carts at the same price.
749
+ * The hash alone is not enough, since a store pricing a cart again changes `pricing` and its total under one hash.
750
+ */
751
+ function cartsKey(carts) {
752
+ return JSON.stringify(carts.map((c) => [c.hash, c.totalCents, c.pricing === true, c.totalIsEstimate, c.items.map((i) => [i.line_id, i.qty, i.lineTotalCents])]));
753
+ }
744
754
  export function buyTool(options) {
745
755
  const o = options ?? {};
746
756
  if ((o.agentcard === undefined) === (o.accessToken === undefined)) {
@@ -761,6 +771,7 @@ export function buyTool(options) {
761
771
  throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
762
772
  }
763
773
  const threadId = o.threadId?.trim();
774
+ const shopperLocation = shopperLocationOf(o.shopperLocation);
764
775
  checkStoreWait(o);
765
776
  /** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
766
777
  const inThisThread = (message) => messageInChat(message, threadId);
@@ -899,6 +910,9 @@ export function buyTool(options) {
899
910
  let declinedNoCart = false;
900
911
  /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
901
912
  let lastConversationId = null;
913
+ /** Calls started, in order, and the newest one whose carts onCart heard: an older call's answer never replaces a newer call's carts. */
914
+ let callsStarted = 0;
915
+ let cartsFromCall = 0;
902
916
  let lastCarts = [];
903
917
  /** The cart the last answer showed: the cart a confirm sent now confirms. */
904
918
  let lastShownCart = null;
@@ -1663,11 +1677,19 @@ export function buyTool(options) {
1663
1677
  * from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
1664
1678
  */
1665
1679
  const answerIn = async (raw, explicit) => {
1680
+ const callNo = ++callsStarted;
1666
1681
  const startedAt = Date.now();
1667
1682
  // A step link held for the message that introduces it goes out before this call hands over any of its own.
1668
1683
  explicit?.release?.();
1669
1684
  const withdrawalsAtStart = withdrawals;
1670
- const input = parseBuyToolInput(raw);
1685
+ const parsedInput = parseBuyToolInput(raw);
1686
+ // A tapped turn's first call that names no product and no store picks the tapped card (TurnState.tapped).
1687
+ const tappedId = explicit?.tapped ?? null;
1688
+ if (explicit)
1689
+ explicit.tapped = null;
1690
+ const input = tappedId && !parsedInput.confirm && !parsedInput.new_order && !parsedInput.product_id && !parsedInput.store_id
1691
+ ? { ...parsedInput, product_id: tappedId }
1692
+ : parsedInput;
1671
1693
  // With carts at more than one store waiting, a new order would drop every one of them: "cancel the
1672
1694
  // reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
1673
1695
  // The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
@@ -2103,6 +2125,11 @@ export function buyTool(options) {
2103
2125
  ...(t.onCart
2104
2126
  ? {
2105
2127
  onCart: async ({ cart, carts, conversationId }) => {
2128
+ // A newer call already handed on its carts: this older stream's are stale, however late they come.
2129
+ if (callNo < cartsFromCall)
2130
+ return;
2131
+ t.cartsHeard = cartsKey(carts);
2132
+ cartsFromCall = callNo;
2106
2133
  try {
2107
2134
  await t.onCart(cart, { carts, conversationId });
2108
2135
  }
@@ -2120,6 +2147,8 @@ export function buyTool(options) {
2120
2147
  // Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
2121
2148
  if (threadId)
2122
2149
  sent = { ...sent, thread_id: threadId };
2150
+ if (shopperLocation)
2151
+ sent = { ...sent, shopper_location: shopperLocation };
2123
2152
  // Every request names how long the reply waits for stores, and what happens to a store that answers after it.
2124
2153
  sent = { ...sent, ...storeWaitFields(t.storeWait ?? {}, o) };
2125
2154
  if (plan && 'local' in plan) {
@@ -2281,6 +2310,26 @@ export function buyTool(options) {
2281
2310
  if (turn) {
2282
2311
  lastCartCount = result.carts.length;
2283
2312
  lastCarts = result.carts;
2313
+ // The answer's carts are the conversation's as they stand, an empty list included: onCart hears them when the stream
2314
+ // sent none or older ones, so an app never keeps showing a cart Agentcard no longer holds (a stream sends a cart only
2315
+ // when a tool changed it, and a call with stream: false sends none).
2316
+ // An answer the stream lost before Agentcard sent it (`turn_timeout`, or another error_code) holds no carts of its own,
2317
+ // so the app keeps the carts it last heard rather than hiding one Agentcard may still hold.
2318
+ const conversationId = result.conversation_id ?? lastConversationId;
2319
+ // A newer call that already handed on carts holds the fresher ones, so this older answer's carts stay unheard.
2320
+ const current = t.onCart && conversationId && !result.error_code && callNo >= cartsFromCall;
2321
+ // A current answer is the newest word on the carts even when they did not change, so an older call stays stale after it.
2322
+ if (current)
2323
+ cartsFromCall = callNo;
2324
+ if (current && cartsKey(result.carts) !== t.cartsHeard) {
2325
+ t.cartsHeard = cartsKey(result.carts);
2326
+ try {
2327
+ await t.onCart(result.cart ?? null, { carts: result.carts, conversationId: conversationId });
2328
+ }
2329
+ catch {
2330
+ // The app's display failing never fails the call.
2331
+ }
2332
+ }
2284
2333
  declinedNoCart = result.status === 'declined' && result.carts.length === 0;
2285
2334
  }
2286
2335
  if (result.conversation_id)
@@ -2619,6 +2668,7 @@ export function buyTool(options) {
2619
2668
  const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
2620
2669
  // A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
2621
2670
  const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
2671
+ state.tapped = tappedId;
2622
2672
  // A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
2623
2673
  // straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
2624
2674
  const direct = !directOn || running > 0
package/dist/client.d.ts CHANGED
@@ -16,6 +16,7 @@ import { type BuyFulfilledBy } from './fulfilled-by.js';
16
16
  import { type BuyCardSize } from './card-size.js';
17
17
  import { type AgentcardListener, type ListenOptions } from './listen.js';
18
18
  import { type ShopperMessage } from './shopper-message.js';
19
+ import type { BuyShopperLocation } from './shopper-location.js';
19
20
  /** The fetch the client calls: Node 18 and later have one built in. */
20
21
  export type FetchLike = (url: string, init: {
21
22
  method: string;
@@ -363,6 +364,8 @@ export interface BuyRequest {
363
364
  };
364
365
  payment_source?: 'vault';
365
366
  delivery_address?: BuyDeliveryAddress;
367
+ /** Where the shopper buys from, as your app knows it. `buyTool({ shopperLocation })` sends it on every request. */
368
+ shopper_location?: BuyShopperLocation;
366
369
  external_user_id?: string;
367
370
  user_id?: string;
368
371
  /** `false` asks for no line that says what Agentcard is looking for (`onAck`): the shopper already read one. */
@@ -432,6 +435,11 @@ export interface BuyCartLine {
432
435
  ref: string | null;
433
436
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
434
437
  url: string | null;
438
+ /**
439
+ * The id `removeCartLine` takes to take this line out of its cart, at any store. Null from an older API, and for a line
440
+ * Agentcard recorded before it captured the store's product id.
441
+ */
442
+ line_id: string | null;
435
443
  /** Who sells or books the line, where that is not the store the cart names (`via StubHub`); absent when Agentcard named none. */
436
444
  fulfilled_by?: BuyFulfilledBy;
437
445
  }
@@ -852,6 +860,14 @@ export declare class AgentcardClient {
852
860
  buy(body: BuyRequest, stream?: BuyStreamHandlers): Promise<BuyTurn>;
853
861
  /** One read of a conversation: whether a turn is running, the last checkout, the orders it placed, the carts on the table. */
854
862
  conversation(conversationId: string, who?: Shopper): Promise<BuyConversation>;
863
+ /**
864
+ * Takes one line out of a cart the conversation shows, by the line's `line_id`, at any store, and answers the
865
+ * conversation as `conversation()` reads it, with the carts as they stand after the remove. A cart left with no line
866
+ * leaves `carts`. Throws `AgentcardApiError` with `code` `line_not_removable` and the store's reason when the store
867
+ * cannot take that line out, `line_not_found` for a line the conversation does not show, and `turn_in_progress` while a
868
+ * turn of the conversation still runs; the cart is then as it was.
869
+ */
870
+ removeCartLine(conversationId: string, lineId: string, who?: Shopper): Promise<BuyConversation>;
855
871
  /**
856
872
  * The Vault link a shopper opens to connect to your company and add a card.
857
873
  * Agentcard returns the link it already opened for this shopper while that
package/dist/client.js CHANGED
@@ -252,6 +252,7 @@ function cartOf(v) {
252
252
  product_id: str(i.product_id),
253
253
  ref: str(i.ref),
254
254
  url: pageUrl(i.url),
255
+ line_id: str(i.line_id),
255
256
  ...fulfilledByField(i),
256
257
  };
257
258
  }) : [],
@@ -865,6 +866,22 @@ export class AgentcardClient {
865
866
  : who.userId ? `?user_id=${encodeURIComponent(who.userId)}` : '';
866
867
  return conversationOf(await this.call('GET', `/buy/conversations/${encodeURIComponent(conversationId)}${query}`, undefined, CALL_TIMEOUT_MS));
867
868
  }
869
+ /**
870
+ * Takes one line out of a cart the conversation shows, by the line's `line_id`, at any store, and answers the
871
+ * conversation as `conversation()` reads it, with the carts as they stand after the remove. A cart left with no line
872
+ * leaves `carts`. Throws `AgentcardApiError` with `code` `line_not_removable` and the store's reason when the store
873
+ * cannot take that line out, `line_not_found` for a line the conversation does not show, and `turn_in_progress` while a
874
+ * turn of the conversation still runs; the cart is then as it was.
875
+ */
876
+ async removeCartLine(conversationId, lineId, who = {}) {
877
+ if (!str(conversationId))
878
+ throw new TypeError('Pass the conversation_id a purchase answer named.');
879
+ if (!str(lineId))
880
+ throw new TypeError('Pass the line_id of a cart line the conversation shows.');
881
+ this.checkShopper(who);
882
+ const shopper = who.externalUserId ? { external_user_id: who.externalUserId } : who.userId ? { user_id: who.userId } : {};
883
+ return conversationOf(await this.call('POST', `/buy/conversations/${encodeURIComponent(conversationId)}/cart/remove`, { line_id: lineId, ...shopper }, CALL_TIMEOUT_MS));
884
+ }
868
885
  /**
869
886
  * The Vault link a shopper opens to connect to your company and add a card.
870
887
  * Agentcard returns the link it already opened for this shopper while that
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, type AgentcardSignatureReason, type AgentcardEvent, type VerifyOptions, } from './verify.js';
2
+ export { shopperLocationOf, type ShopperLocation, type ShopperShipTo, type BuyShopperLocation } from './shopper-location.js';
2
3
  export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, type PurchaseFacts, } from './message.js';
3
4
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type NextStepKind, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
5
  export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { verifyAgentcardWebhook, AgentcardSignatureError, } from './verify.js';
2
+ export { shopperLocationOf } from './shopper-location.js';
2
3
  export { readPurchaseMessage, } from './message.js';
3
4
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, } from './shopper-message.js';
4
5
  export { ORDER_STEPS, updateOrder, arrivalText, } from './order.js';
package/dist/message.d.ts CHANGED
@@ -32,6 +32,12 @@ export interface PurchaseFacts {
32
32
  merchantName?: string;
33
33
  /** The total in the currency's minor unit: 2306 is $23.06. */
34
34
  totalCents?: number;
35
+ /**
36
+ * On an order whose store sets the final amount at checkout (a retail order): the estimate the shopper approved, in
37
+ * place of `totalCents` until the store confirms the charge, and the most the store may charge.
38
+ */
39
+ estimatedTotalCents?: number;
40
+ chargeUpToCents?: number;
35
41
  /** The three-letter currency code. */
36
42
  currency?: string;
37
43
  cardBrand?: string;
package/dist/message.js CHANGED
@@ -21,6 +21,8 @@ const STRING_FACTS = [
21
21
  ];
22
22
  const NUMBER_FACTS = [
23
23
  ['total_cents', 'totalCents'],
24
+ ['estimated_total_cents', 'estimatedTotalCents'],
25
+ ['charge_up_to_cents', 'chargeUpToCents'],
24
26
  ['nudge_number', 'nudgeNumber'],
25
27
  ];
26
28
  function readFacts(raw) {
@@ -246,11 +246,11 @@ export declare function pickId(product: Pick<BuyToolProduct, 'id' | 'ref'>): str
246
246
  export declare function cardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url'>): string | null;
247
247
  /**
248
248
  * The key of the card a product gets, or null when it gets none: a product with a page at its store,
249
- * or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
249
+ * or a flight option, a place to book or any other booking (a hotel, a rental car, an event), which can have no page, with an id a pick takes. A flight's card
250
250
  * is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
251
251
  * and a place's card is where the shopper reads whether it has room.
252
252
  */
253
- export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight' | 'availability'>): string | null;
253
+ export declare function productCardKey(product: Pick<BuyToolProduct, 'id' | 'ref' | 'url' | 'flight' | 'availability'> & Partial<Pick<BuyToolProduct, 'action'>>): string | null;
254
254
  /**
255
255
  * The carts' grand totals, one per currency, summed from each cart's `totalCents`, which already holds its tip, fees and
256
256
  * tax: a display string is never parsed. Carts in two currencies are never added together.
@@ -184,14 +184,16 @@ export function cardKey(product) {
184
184
  }
185
185
  /**
186
186
  * The key of the card a product gets, or null when it gets none: a product with a page at its store,
187
- * or a flight option or a place to book, which can have no page, with an id a pick takes. A flight's card
187
+ * or a flight option, a place to book or any other booking (a hotel, a rental car, an event), which can have no page, with an id a pick takes. A flight's card
188
188
  * is how the shopper taps the flight they want (2026-10-20: three flights listed in text, and no card to tap),
189
189
  * and a place's card is where the shopper reads whether it has room.
190
190
  */
191
191
  export function productCardKey(product) {
192
192
  if (typeof product.url === 'string' && product.url !== '')
193
193
  return cardKey(product);
194
- return product.flight || product.availability ? pickId(product) : null;
194
+ // A booking the shopper picks by its id, such as a hotel, a rental car or an event, has no page either: on 2026-11-04 a trip's
195
+ // hotels and cars reached chat.opencommerce.sh as groups of ids with no card to draw.
196
+ return product.flight || product.availability || product.action === 'book' || product.action === 'reserve' ? pickId(product) : null;
195
197
  }
196
198
  /** True when `store`, the domain an answer's `cart_building` names, is the store `cart` is at. */
197
199
  function buildingAt(cart, store) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where your shopper buys from, as your app knows it: `buyTool({ shopperLocation })` sends it on every request as
3
+ * `shopper_location`, so Agentcard knows the shopper's country before their first message. Each part is optional, and
4
+ * Agentcard trusts them in this order: what the shopper says, then `country` or `shipTo`, then addresses Agentcard already
5
+ * holds for them, their identity check and their phone, and last `ipCountry` and `locale`. With none of it, the shopper reads
6
+ * as being in the United States.
7
+ */
8
+ /** A delivery address your app holds for the shopper. Agentcard reads its `country`, or a US ZIP or Canadian postal code. */
9
+ export interface ShopperShipTo {
10
+ street?: string;
11
+ city?: string;
12
+ state?: string;
13
+ zip?: string;
14
+ /** ISO 3166-1 alpha-2, such as AU. */
15
+ country?: string;
16
+ }
17
+ export interface ShopperLocation {
18
+ /** The country your app holds for the shopper, ISO 3166-1 alpha-2, such as AU. */
19
+ country?: string;
20
+ /** The shopper's default delivery address in your app. */
21
+ shipTo?: ShopperShipTo;
22
+ /** The country of the shopper's own request to your app, as your server or CDN reads it from their IP address. */
23
+ ipCountry?: string;
24
+ /** The shopper's browser language, such as en-AU. */
25
+ locale?: string;
26
+ }
27
+ /** `shopper_location` as `POST /buy` takes it. */
28
+ export interface BuyShopperLocation {
29
+ country?: string;
30
+ ship_to?: ShopperShipTo;
31
+ ip_country?: string;
32
+ locale?: string;
33
+ }
34
+ /** The location as `POST /buy` takes it, or undefined when it holds nothing. Throws a TypeError on a country that is not two letters. */
35
+ export declare function shopperLocationOf(location: ShopperLocation | undefined): BuyShopperLocation | undefined;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Where your shopper buys from, as your app knows it: `buyTool({ shopperLocation })` sends it on every request as
3
+ * `shopper_location`, so Agentcard knows the shopper's country before their first message. Each part is optional, and
4
+ * Agentcard trusts them in this order: what the shopper says, then `country` or `shipTo`, then addresses Agentcard already
5
+ * holds for them, their identity check and their phone, and last `ipCountry` and `locale`. With none of it, the shopper reads
6
+ * as being in the United States.
7
+ */
8
+ const code = (value, name) => {
9
+ if (value === undefined)
10
+ return undefined;
11
+ if (typeof value !== 'string' || !/^[A-Za-z]{2}$/.test(value.trim())) {
12
+ throw new TypeError(`Pass ${name} as a two-letter country code, such as AU, or leave it out.`);
13
+ }
14
+ return value.trim().toUpperCase();
15
+ };
16
+ /** The location as `POST /buy` takes it, or undefined when it holds nothing. Throws a TypeError on a country that is not two letters. */
17
+ export function shopperLocationOf(location) {
18
+ if (location === undefined)
19
+ return undefined;
20
+ if (location === null || typeof location !== 'object')
21
+ throw new TypeError('Pass shopperLocation as an object, or leave it out.');
22
+ const country = code(location.country, 'shopperLocation.country');
23
+ for (const part of ['street', 'city', 'state', 'zip']) {
24
+ const value = location.shipTo?.[part];
25
+ if (value !== undefined && (typeof value !== 'string' || value.length > 200)) {
26
+ throw new TypeError(`Pass shopperLocation.shipTo.${part} as text of at most 200 characters, or leave it out.`);
27
+ }
28
+ }
29
+ const shipTo = location.shipTo ? { ...location.shipTo, country: code(location.shipTo.country, 'shopperLocation.shipTo.country') } : undefined;
30
+ if (shipTo && shipTo.country === undefined)
31
+ delete shipTo.country;
32
+ const ipCountry = code(location.ipCountry, 'shopperLocation.ipCountry');
33
+ const locale = location.locale?.trim() || undefined;
34
+ if (locale && locale.length > 35)
35
+ throw new TypeError('Pass shopperLocation.locale as a language tag, such as en-AU, or leave it out.');
36
+ const wire = {
37
+ ...(country ? { country } : {}),
38
+ ...(shipTo ? { ship_to: shipTo } : {}),
39
+ ...(ipCountry ? { ip_country: ipCountry } : {}),
40
+ ...(locale ? { locale } : {}),
41
+ };
42
+ return Object.keys(wire).length > 0 ? wire : undefined;
43
+ }
@@ -6,6 +6,7 @@
6
6
  import { isRecord } from './verify.js';
7
7
  import { readPurchaseMessage } from './message.js';
8
8
  import { orderFrom, orderStepOf } from './order.js';
9
+ import { money } from './model-view.js';
9
10
  /**
10
11
  * The event types that carry a message for a shopper. Subscribe your endpoint to each by name:
11
12
  * a catch-all `*` alone does not turn `purchase.message` on.
@@ -103,6 +104,12 @@ function readOrderConfirmed(event) {
103
104
  const { data, store, base } = read;
104
105
  const retailerOrderId = text(data.retailer_order_id, 80);
105
106
  const deliveryWindow = text(data.delivery_window, 80);
107
+ // What the card was captured for, now that the store has set tax and shipping: the one amount a retail order's messages
108
+ // name as charged, since the placed message named only the estimate and the ceiling. A confirmation that carries the
109
+ // store's total and no capture names the total as the store's, never as a charge.
110
+ const currency = text(data.currency, 8) ?? 'usd';
111
+ const charged = money(count(data.charged_cents), currency);
112
+ const total = charged ? null : money(count(data.final_total_cents), currency);
106
113
  const facts = present({
107
114
  merchantName: store ?? undefined,
108
115
  storeName: text(data.store_name, 64),
@@ -122,8 +129,9 @@ function readOrderConfirmed(event) {
122
129
  ...base,
123
130
  kind: 'order_confirmed',
124
131
  advances: false,
125
- text: `${who}${retailerOrderId ? `, order number ${retailerOrderId}` : ''}.${deliveryWindow ? ` Expected delivery: ${deliveryWindow}.` : ''}`,
132
+ text: `${who}${retailerOrderId ? `, order number ${retailerOrderId}` : ''}.${charged ? ` Charged ${charged}.` : total ? ` Total ${total}.` : ''}${deliveryWindow ? ` Expected delivery: ${deliveryWindow}.` : ''}`,
126
133
  note: `[event: the store accepted this user's order${store ? ` at ${store}` : ''} on its side, outside this conversation.` +
134
+ `${charged ? ` The store charged ${charged}, its final amount with tax and shipping.` : total ? ` The store's total is ${total}, with tax and shipping; the charge is not confirmed yet.` : ''}` +
127
135
  `${retailerOrderId ? ` The store's order number is ${retailerOrderId}.` : ''}` +
128
136
  `${deliveryWindow ? ` The store quotes delivery ${deliveryWindow}.` : ''} ${SYSTEM_LINE}]`,
129
137
  facts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.18.13",
3
+ "version": "0.18.15",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
5
  "homepage": "https://opencommerce.sh",
6
6
  "type": "module",