diff --git a/README.md b/README.md index 3782779..75db893 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,23 @@ The `--request-approval` flag triggers a push notification (or email) to the use Users can easily approve requests with the [Link app](https://link.com/download). +#### Line items and totals + +`--line-item` and `--total` use repeatable `key:value` format. + +**`--line-item` keys:** `name` (required), `quantity`, `unit_amount`, `description`, `sku`, `url`, `image_url`, `product_url` + +```bash +--line-item "name:Running Shoes,unit_amount:12000,quantity:1,description:Trail runners" +``` + +**`--total` keys:** `type` (required), `display_text` (required), `amount` (required) + +```bash +--total "type:subtotal,display_text:Subtotal,amount:12000" \ +--total "type:total,display_text:Total,amount:12000" +``` + #### Credential types By default, a spend request provisions a virtual card. For merchants that support the [Machine Payments Protocol](https://mpp.dev) (HTTP 402) and the Stripe payment method, you can instead include `--credential-type "shared_payment_token"`. diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index e8709cc..2013944 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -42,11 +42,15 @@ export const createOptions = z.object({ lineItem: z .array(z.union([z.string(), z.record(z.string(), z.unknown())])) .default([]) - .describe('Line item (repeatable, key:value format)'), + .describe( + 'Line item (repeatable, key:value format). Keys: name (required), quantity, unit_amount, description, sku, url, image_url, product_url. Example: "name:Shoes,unit_amount:5000,quantity:2"', + ), total: z .array(z.union([z.string(), z.record(z.string(), z.unknown())])) .default([]) - .describe('Total (repeatable, key:value format)'), + .describe( + 'Total (repeatable, key:value format). Keys: type (required), display_text (required), amount (required). Example: "type:total,display_text:Total,amount:5000"', + ), requestApproval: z .boolean() .default(true) @@ -94,9 +98,13 @@ export const updateOptions = z.object({ lineItem: z .array(z.union([z.string(), z.record(z.string(), z.unknown())])) .default([]) - .describe('Line item (repeatable, key:value format)'), + .describe( + 'Line item (repeatable, key:value format). Keys: name (required), quantity, unit_amount, description, sku, url, image_url, product_url. Example: "name:Shoes,unit_amount:5000,quantity:2"', + ), total: z .array(z.union([z.string(), z.record(z.string(), z.unknown())])) .default([]) - .describe('Total (repeatable, key:value format)'), + .describe( + 'Total (repeatable, key:value format). Keys: type (required), display_text (required), amount (required). Example: "type:total,display_text:Total,amount:5000"', + ), }); diff --git a/packages/cli/src/utils/__tests__/line-item-parser.test.ts b/packages/cli/src/utils/__tests__/line-item-parser.test.ts index f1f3111..2149308 100644 --- a/packages/cli/src/utils/__tests__/line-item-parser.test.ts +++ b/packages/cli/src/utils/__tests__/line-item-parser.test.ts @@ -44,8 +44,10 @@ describe('parseLineItemFlag', () => { ); }); - it('throws on unknown keys', () => { - expect(() => parseLineItemFlag('name:Shoes,color:red')).toThrow('color'); + it('throws on unknown keys with allowed keys listed', () => { + expect(() => parseLineItemFlag('name:Shoes,color:red')).toThrow( + 'unrecognized key "color". Allowed keys: name, url, image_url, description, sku, quantity, unit_amount, product_url', + ); }); it('throws on fields missing colon separator', () => { @@ -87,10 +89,12 @@ describe('parseTotalFlag', () => { ).toThrow('amount'); }); - it('throws on unknown keys', () => { + it('throws on unknown keys with allowed keys listed', () => { expect(() => parseTotalFlag('type:total,display_text:Total,amount:5000,extra:value'), - ).toThrow('extra'); + ).toThrow( + 'unrecognized key "extra". Allowed keys: type, display_text, amount', + ); }); it('throws on fields missing colon separator', () => { diff --git a/packages/cli/src/utils/line-item-parser.ts b/packages/cli/src/utils/line-item-parser.ts index 96ba0d7..4f261ce 100644 --- a/packages/cli/src/utils/line-item-parser.ts +++ b/packages/cli/src/utils/line-item-parser.ts @@ -34,8 +34,17 @@ export function parseKvString(raw: string): Record { return result; } -function formatZodError(err: z.ZodError, prefix: string): Error { +function formatZodError( + err: z.ZodError, + prefix: string, + schema: z.ZodObject, +): Error { + const allowed = Object.keys(schema.shape); const messages = err.issues.map((issue) => { + if (issue.code === 'unrecognized_keys') { + const keys = issue.keys.map((k) => `"${k}"`).join(', '); + return `${prefix}: unrecognized key ${keys}. Allowed keys: ${allowed.join(', ')}`; + } const key = issue.path[0]?.toString(); return key ? `${prefix} ${key}: ${issue.message}` @@ -49,7 +58,8 @@ export function parseLineItemFlag(raw: string): LineItem { try { return LineItemSchema.parse(obj) as LineItem; } catch (err) { - if (err instanceof z.ZodError) throw formatZodError(err, 'Line item'); + if (err instanceof z.ZodError) + throw formatZodError(err, 'Line item', LineItemSchema); throw err; } } @@ -59,7 +69,8 @@ export function parseTotalFlag(raw: string): Total { try { return TotalSchema.parse(obj) as Total; } catch (err) { - if (err instanceof z.ZodError) throw formatZodError(err, 'Total'); + if (err instanceof z.ZodError) + throw formatZodError(err, 'Total', TotalSchema); throw err; } } diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 3b870fb..8298cca 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -146,9 +146,15 @@ link-cli spend-request create \ --context "" \ --merchant-name "" \ --merchant-url "" \ + --line-item "name:,unit_amount:,quantity:" \ + --total "type:total,display_text:Total,amount:" \ --format json ``` +**`--line-item` keys:** `name` (required), `quantity`, `unit_amount`, `description`, `sku`, `url`, `image_url`, `product_url`. Repeatable for multiple items. + +**`--total` keys:** `type` (required), `display_text` (required), `amount` (required). Repeatable (e.g. subtotal + shipping + total). + After creating or requesting approval for a spend request, run the returned `_next.command` to poll for the terminal status. Do not proceed to payment while the request is still `created` or `pending_approval`. If polling exits with `POLLING_TIMEOUT`, keep waiting or ask the user whether to continue polling. If they deny, ask for clarification what to do next. Recommend the user approves with the [Link app](https://link.com/download). Show the download URL.