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
102 changes: 102 additions & 0 deletions apps/web/lib/chat/budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { budgetAmounts, parseBudgetAmount } from "./budget";
import { budgetFor, parsePrice } from "./payment-choice";

describe("budgetAmounts", () => {
it("formats each option with the currency's decimals", () => {
expect(budgetAmounts("USD").map((m) => m.value)).toEqual(["20.00", "50.00", "100.00"]);
expect(budgetAmounts("KWD").map((m) => m.value)).toEqual(["20.000", "50.000", "100.000"]);
expect(budgetAmounts("CLP", { value: "10000", currency: "CLP" })).toEqual([
{ value: "10000", currency: "CLP" },
]);
});
});

describe("parseBudgetAmount", () => {
it("emits a zero-decimal currency without decimals", () => {
expect(parseBudgetAmount("10000", "CLP")).toEqual({
amount: { value: "10000", currency: "CLP" },
});
expect(parseBudgetAmount("500", "JPY")).toEqual({
amount: { value: "500", currency: "JPY" },
});
});

it("emits a three-decimal currency with three decimals", () => {
expect(parseBudgetAmount("1.25", "KWD")).toEqual({
amount: { value: "1.250", currency: "KWD" },
});
});

it("rejects decimals the currency does not allow", () => {
expect(parseBudgetAmount("10000.5", "CLP")).toHaveProperty("problem");
expect(parseBudgetAmount("10.005", "USD")).toHaveProperty("problem");
});

it("accepts zero-padded decimals the currency does not show", () => {
expect(parseBudgetAmount("10000.00", "CLP")).toEqual({
amount: { value: "10000", currency: "CLP" },
});
});

it("reads a comma followed by three digits as a thousands separator", () => {
expect(parseBudgetAmount("10,000", "KWD")).toEqual({
amount: { value: "10000.000", currency: "KWD" },
});
expect(parseBudgetAmount("1,234", "USD")).toEqual({
amount: { value: "1234.00", currency: "USD" },
});
});

it("reads a comma followed by fewer digits as a decimal point", () => {
expect(parseBudgetAmount("10,50", "USD")).toEqual({
amount: { value: "10.50", currency: "USD" },
});
});

it("keeps USD at two decimals", () => {
expect(parseBudgetAmount("75", "USD")).toEqual({
amount: { value: "75.00", currency: "USD" },
});
});
});

describe("parsePrice", () => {
it("parses catalog prices with up to four decimals", () => {
expect(parsePrice("35.99 USD")).toEqual({ amount: "35.99", currency: "USD" });
expect(parsePrice("1.250 KWD")).toEqual({ amount: "1.250", currency: "KWD" });
expect(parsePrice("1.2345 CLF")).toEqual({ amount: "1.2345", currency: "CLF" });
expect(parsePrice("1.23456 CLF")).toBeUndefined();
});
});

describe("budgetFor", () => {
it("pads the price into the currency's decimals", () => {
expect(budgetFor({ amount: "8000", currency: "CLP" })).toEqual({
value: "10000",
currency: "CLP",
});
expect(budgetFor({ amount: "100", currency: "USD" })).toEqual({
value: "125.00",
currency: "USD",
});
});

it("formats a three-decimal price and budget", () => {
expect(budgetFor({ amount: "1.250", currency: "KWD" })).toEqual({
value: "12.000",
currency: "KWD",
});
});

it("falls back to two decimals for a currency code it does not know", () => {
expect(budgetFor({ amount: "100", currency: "ZZZ" })).toEqual({
value: "125.00",
currency: "ZZZ",
});
expect(budgetAmounts("ZZZ").map((m) => m.value)).toEqual(["20.00", "50.00", "100.00"]);
expect(parseBudgetAmount("75", "ZZZ")).toEqual({
amount: { value: "75.00", currency: "ZZZ" },
});
});
});
28 changes: 22 additions & 6 deletions apps/web/lib/chat/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* data and arithmetic, so the tool schema on the server and the picker in
* the chat share one source.
*/
import { formatAmount } from "@agent-commerce/ui";
import { currencyFractionDigits, formatAmount, significantDecimals } from "@agent-commerce/core";
import type { Money } from "./payment-choice";

/** How long a budget can last, in days, as the picker offers it. */
Expand Down Expand Up @@ -34,7 +34,11 @@ export function budgetAmounts(currency: string, floor?: Money): Money[] {
const min = floor ? Number.parseFloat(floor.value) : 0;
const fits = STEPS.filter((v) => v >= min).slice(0, 3);
const values = fits.length ? fits : [Math.ceil(min)];
return values.map((v) => ({ value: v.toFixed(2), currency: currency.toUpperCase() }));
const decimals = currencyFractionDigits(currency, 2);
return values.map((v) => ({
value: v.toFixed(decimals),
currency: currency.toUpperCase(),
}));
}

/**
Expand Down Expand Up @@ -62,15 +66,27 @@ export function parseBudgetAmount(
currency: string,
floor?: Money,
): { amount: Money } | { problem: string } {
const cleaned = text.replace(/[^\d.,]/g, "").replace(",", ".");
// A comma followed by exactly three digits is a thousands separator
// ("10,000"), not a decimal mark — reading it as a point would turn ten
// thousand into ten in a 3-decimal currency.
const cleaned = text
.replace(/[^\d.,]/g, "")
.replace(/,(?=\d{3}(?:\D|$))/g, "")
.replace(",", ".");
const value = Number.parseFloat(cleaned);
if (!/^\d+(\.\d{1,2})?$/.test(cleaned) || !(value > 0)) {
const decimals = currencyFractionDigits(currency, 2);
if (!/^\d+(\.\d+)?$/.test(cleaned) || !(value > 0)) {
return { problem: "Enter an amount, such as 75." };
}
if (significantDecimals(cleaned) > decimals) {
return { problem: `${currency.toUpperCase()} takes at most ${decimals} decimals.` };
}
if (floor && value < Number.parseFloat(floor.value)) {
return { problem: `Enter at least ${formatAmount(floor.value, floor.currency)}, so it covers this purchase.` };
return {
problem: `Enter at least ${formatAmount(floor.value, floor.currency)}, so it covers this purchase.`,
};
}
return { amount: { value: value.toFixed(2), currency: currency.toUpperCase() } };
return { amount: { value: value.toFixed(decimals), currency: currency.toUpperCase() } };
}

/** What the agent asks over the picker. */
Expand Down
11 changes: 8 additions & 3 deletions apps/web/lib/chat/payment-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* their Shop account when the store asks.
* - other: any other way the store takes, in the user's words.
*/
import { currencyFractionDigits } from "@agent-commerce/core";

export const PAYMENT_CHOICE_METHODS = ["agent_card", "card", "shop_pay", "other"] as const;
export type PaymentChoiceMethod = (typeof PAYMENT_CHOICE_METHODS)[number];

Expand All @@ -30,11 +32,14 @@ export function budgetFor(price: { amount: string; currency: string }): Money |
const amount = Number.parseFloat(price.amount);
if (!Number.isFinite(amount) || amount <= 0) return undefined;
const value = Math.ceil(amount + Math.max(amount * 0.25, 10));
return { value: value.toFixed(2), currency: price.currency.toUpperCase() };
const decimals = currencyFractionDigits(price.currency, 2);
return { value: value.toFixed(decimals), currency: price.currency.toUpperCase() };
}

/** "35.99 USD", as the catalog writes a price, as an amount and a currency. */
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
export function parsePrice(text: string | undefined): { amount: string; currency: string } | undefined {
const match = text?.trim().match(/^(\d+(?:\.\d{1,2})?)\s+([A-Za-z]{3})$/);
export function parsePrice(
text: string | undefined,
): { amount: string; currency: string } | undefined {
const match = text?.trim().match(/^(\d+(?:\.\d{1,4})?)\s+([A-Za-z]{3})$/);
return match ? { amount: match[1]!, currency: match[2]!.toUpperCase() } : undefined;
}
21 changes: 18 additions & 3 deletions apps/web/lib/chat/shopify-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { currencyFractionDigits } from "@agent-commerce/core";
import { serverEnv } from "@/lib/env";

/**
Expand Down Expand Up @@ -73,18 +74,21 @@ interface CatalogProduct {

export async function searchProducts(opts: {
query: string;
/** Most the buyer wants to pay, in major units of the currency. */
/** Most the buyer wants to pay, in major units of `currency`. */
maxPrice?: number;
/** The currency `maxPrice` is stated in; the filter takes minor units. */
currency?: string;
/** ISO 3166-1 alpha-2. Default US. */
shipsTo?: string;
limit?: number;
}): Promise<FoundProduct[]> {
const factor = 10 ** currencyFractionDigits(opts.currency ?? "USD", 2);
return call("search_catalog", {
query: opts.query,
filters: {
available: true,
ships_to: { country: (opts.shipsTo ?? "US").toUpperCase() },
...(opts.maxPrice ? { price: { max: Math.round(opts.maxPrice * 100) } } : {}),
...(opts.maxPrice ? { price: { max: Math.round(opts.maxPrice * factor) } } : {}),
},
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
pagination: { limit: Math.min(Math.max(opts.limit ?? 5, 1), 10) },
});
Expand Down Expand Up @@ -142,7 +146,7 @@ function toFound(p: CatalogProduct): FoundProduct | undefined {
about.length > 280 ? `${about.slice(0, 277).replace(/\s+\S*$/, "")}…` : about,
}
: {}),
...(money ? { price: `${(money.amount / 100).toFixed(2)} ${money.currency}` } : {}),
...(money ? { price: catalogPrice(money) } : {}),
store: cheapest.seller?.name ?? hostOf(cheapest.url),
url: cleanUrl(cheapest.url),
...(p.rating?.value
Expand All @@ -165,6 +169,17 @@ function toFound(p: CatalogProduct): FoundProduct | undefined {
};
}

/**
* The catalog states prices in minor units (the search filter takes cents
* too), so "599" is "$5.99" and "1000" in JPY is "¥1000". The divisor is the
* currency's own factor, not always 100.
*/
function catalogPrice(money: Money): string {
// An unknown code still gets shown, in the shape most currencies use.
const decimals = currencyFractionDigits(money.currency, 2);
return `${(money.amount / 10 ** decimals).toFixed(decimals)} ${money.currency}`;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}

/**
* A card-sized picture. Shopify's CDN resizes on `width`, and store pictures
* can be huge: IQBAR's main one is a 12 MB PNG, 200 KB at this size.
Expand Down
21 changes: 14 additions & 7 deletions apps/web/lib/chat/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ type ChatTools = Record<ToolNameFor<"chat">, unknown>;
const amountSchema = z.object({
value: z
.string()
.regex(/^\d+(\.\d{1,2})?$/, 'Decimal string like "50.00"')
.describe('Decimal string, e.g. "50.00".'),
.regex(/^\d+(\.\d{1,4})?$/, 'Decimal string like "50.00"')
.describe(
'Decimal string with the currency\'s decimals, e.g. "50.00" USD, "500" JPY, "1.250" KWD.',
),
currency: z.string().length(3).describe(PARAM_DOCS.currency),
});

Expand Down Expand Up @@ -415,7 +417,6 @@ function budgetPlanFor(messages: ModelMessage[]): { agentCardId?: string } | und
return plan;
}


/**
* How the user chose to pay for the store a checkout starts at, as the chat
* settled it: the newest payment choice for that store, and what happened
Expand Down Expand Up @@ -603,7 +604,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st
.object({
amount: z
.string()
.regex(/^\d+(\.\d{1,2})?$/)
.regex(/^\d+(\.\d{1,4})?$/)
.describe('Decimal string, e.g. "35.99".'),
currency: z.string().length(3).describe(PARAM_DOCS.currency),
})
Expand All @@ -626,7 +627,12 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st
inputSchema: z.object({
category: z.string().min(1).max(60).describe(paramDoc("await_budget", "category")),
amount: amountSchema.optional().describe(paramDoc("await_budget", "amount")),
purchase: z.string().min(1).max(80).optional().describe(paramDoc("await_budget", "purchase")),
purchase: z
.string()
.min(1)
.max(80)
.optional()
.describe(paramDoc("await_budget", "purchase")),
}),
outputSchema: budgetOutcomeSchema,
}),
Expand Down Expand Up @@ -717,7 +723,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st
.object({
amount: z
.string()
.regex(/^\d+(\.\d{1,2})?$/)
.regex(/^\d+(\.\d{1,4})?$/)
.describe('Decimal string, e.g. "50.00".'),
currency: z.string().length(3).describe(PARAM_DOCS.currency),
})
Expand Down Expand Up @@ -879,6 +885,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st
.positive()
.optional()
.describe(paramDoc("search_products", "maxPrice")),
currency: z.string().length(3).optional().describe(paramDoc("search_products", "currency")),
shipsTo: z.string().length(2).optional().describe(paramDoc("search_products", "shipsTo")),
}),
execute: (input) =>
Expand Down Expand Up @@ -947,7 +954,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st
label: z.string().min(1).max(60),
amount: z
.string()
.regex(/^\d+(\.\d{1,2})?$/)
.regex(/^\d+(\.\d{1,4})?$/)
.optional()
.describe(paramDoc("show_receipt", "itemAmount")),
}),
Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test": "vitest run",
"typecheck": "next typegen && tsc --noEmit",
"clean": "rm -rf .next .turbo",
"db:generate": "drizzle-kit generate",
Expand Down Expand Up @@ -57,6 +58,7 @@
"eslint-config-next": "16.3.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^5.0.1"
}
}
4 changes: 2 additions & 2 deletions packages/cli/src/commands/agent-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function buildRequestBody(
ctx: Pick<CliContext, "env" | "hostname">,
): CreateAgentCardRequestBody {
const body: CreateAgentCardRequestBody = {
amount: { value: toDecimalString(opts.amount), currency: opts.currency.toUpperCase() },
amount: { value: toDecimalString(opts.amount, opts.currency), currency: opts.currency.toUpperCase() },
description: opts.description,
expiresInHours: opts.expiresInHours,
requester: opts.requester ?? detectRequester(ctx.env, ctx.hostname()),
Expand Down Expand Up @@ -197,7 +197,7 @@ export function registerAgentCardCommands(program: Command, ctx: CliContext): vo
const body: MintCredentialBody = { format: "card" };
if (opts.amount !== undefined)
body.amount = {
value: toDecimalString(opts.amount),
value: toDecimalString(opts.amount, opts.currency),
currency: opts.currency.toUpperCase(),
};
const merchant = merchantFromOptions(opts);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ interface AnswerOptions extends WaitOptions {
export function buildCheckoutBody(opts: CreateOptions): CreateCheckoutBody {
const body: CreateCheckoutBody = {
startUrl: opts.url,
maxCost: { amount: toDecimalString(opts.maxCost), currency: opts.currency.toUpperCase() },
maxCost: { amount: toDecimalString(opts.maxCost, opts.currency), currency: opts.currency.toUpperCase() },
};
if (opts.agentCard) body.agentCardId = opts.agentCard;
const task = opts.task ?? opts.request;
Expand Down
Loading
Loading