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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/commands/spend-request/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"',
),
});
12 changes: 8 additions & 4 deletions packages/cli/src/utils/__tests__/line-item-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/utils/line-item-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ export function parseKvString(raw: string): Record<string, string> {
return result;
}

function formatZodError(err: z.ZodError, prefix: string): Error {
function formatZodError(
err: z.ZodError,
prefix: string,
schema: z.ZodObject<z.ZodRawShape>,
): 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}`
Expand All @@ -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;
}
}
Expand All @@ -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;
}
}
6 changes: 6 additions & 0 deletions skills/create-payment-credential/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,15 @@ link-cli spend-request create \
--context "<description>" \
--merchant-name "<name>" \
--merchant-url "<url>" \
--line-item "name:<product>,unit_amount:<cents>,quantity:<n>" \
--total "type:total,display_text:Total,amount:<cents>" \
--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.
Expand Down
Loading