Skip to content
Open
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
8 changes: 5 additions & 3 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,16 @@ grid cards get <cardId>
# Issue a virtual card
grid cards create \
--cardholder-id <customerId> \
--funding-sources "InternalAccount:1,InternalAccount:2"
--funding-sources "InternalAccount:1,InternalAccount:2" \
--max-spend-per-transaction 5000

# Freeze / unfreeze / close, or replace funding sources
# Freeze / unfreeze / close, replace funding sources, or change the spending limit
grid cards update <cardId> --state FROZEN
grid cards update <cardId> --state ACTIVE
grid cards update <cardId> --state CLOSED
grid cards update <cardId> --funding-sources "InternalAccount:3"
grid cards update <cardId> --max-spend-per-transaction 10000
grid cards update <cardId> --clear-max-spend-per-transaction

# Reveal card details — prints a short-lived panEmbedUrl to render in an iframe.
# Do not store or log it.
Expand Down Expand Up @@ -355,7 +358,6 @@ grid auth delegated-keys list --account-id <id>
grid auth delegated-keys get <delegatedKeyId>
grid auth delegated-keys create \
--card-id <cardId> --internal-account-id <id> --nickname "Card key" \
--spending-limit USD:5000 --spending-limit EUR:4000 \
--wallet-signature <stamp> --request-id <id>
grid auth delegated-keys revoke <delegatedKeyId> # no signature needed

Expand Down
30 changes: 1 addition & 29 deletions cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command, InvalidArgumentError } from "commander";
import { Command } from "commander";
import { GridClient } from "../client";
import { outputResponse, formatError, output } from "../output";
import { GlobalOptions } from "../index";
Expand Down Expand Up @@ -36,28 +36,6 @@ interface AuthSession {
expiresAt: string;
}

// Collects a repeated "CURRENCY:amount" flag into spending-limit objects.
// Amounts are integers in the smallest currency unit; a malformed or non-integer
// value is rejected up front rather than silently sent as null.
function collectSpendingLimit(
value: string,
previous: Array<{ currencyCode: string; maxPerTransaction: number }> = []
): Array<{ currencyCode: string; maxPerTransaction: number }> {
const match = /^([A-Z0-9]{3,16}):(\d+)$/.exec(value);
if (!match) {
throw new InvalidArgumentError(
`expected CURRENCY:amount with an uppercase code and an integer amount, e.g. USD:5000 (got "${value}")`
);
}
const maxPerTransaction = Number(match[2]);
if (!Number.isSafeInteger(maxPerTransaction) || maxPerTransaction < 1) {
throw new InvalidArgumentError(
`spending limit amount must be a positive integer within the safe range (got "${value}")`
);
}
return [...previous, { currencyCode: match[1], maxPerTransaction }];
}

export function registerAuthCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
Expand Down Expand Up @@ -269,11 +247,6 @@ export function registerAuthCommand(
.requiredOption("--card-id <id>", "Card ID")
.requiredOption("--internal-account-id <id>", "Embedded Wallet internal account ID")
.requiredOption("--nickname <name>", "Human-readable label for the key")
.option(
"--spending-limit <CUR:amount>",
"Per-transaction limit, e.g. USD:5000 (repeatable)",
collectSpendingLimit
)
).action(async (options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
Expand All @@ -285,7 +258,6 @@ export function registerAuthCommand(
internalAccountId: options.internalAccountId,
nickname: options.nickname,
};
if (options.spendingLimit) body.spendingLimits = options.spendingLimit;

const response = await client.post<DelegatedKey>(
"/auth/delegated-keys",
Expand Down
82 changes: 76 additions & 6 deletions cli/src/commands/cards.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command } from "commander";
import { Command, InvalidArgumentError } from "commander";
import { GridClient, PaginatedResponse } from "../client";
import { outputResponse, formatError, output } from "../output";
import { GlobalOptions } from "../index";
Expand All @@ -13,6 +13,7 @@ interface Card {
form: "VIRTUAL";
last4?: string;
fundingSources: string[];
maxSpendPerTransaction: number | null;
currency?: string;
createdAt: string;
updatedAt: string;
Expand All @@ -23,6 +24,21 @@ interface CardRevealResponse {
expiresAt: string;
}

function parseMaxSpendPerTransaction(value: string): number {
if (!/^\d+$/.test(value)) {
throw new InvalidArgumentError(
`--max-spend-per-transaction must be a positive integer (got "${value}")`
);
}
const amount = Number(value);
if (!Number.isSafeInteger(amount) || amount < 1) {
throw new InvalidArgumentError(
`--max-spend-per-transaction must be a positive integer within the safe range (got "${value}")`
);
}
return amount;
}

export function registerCardsCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
Expand Down Expand Up @@ -84,6 +100,11 @@ export function registerCardsCommand(
.requiredOption("--funding-sources <list>", "Comma-separated internal account IDs, in priority order")
.option("--form <form>", "Card form (VIRTUAL)", "VIRTUAL")
.option("--platform-card-id <id>", "Your platform's identifier for the card")
.option(
"--max-spend-per-transaction <amount>",
"Maximum amount per transaction in the card currency's smallest unit",
parseMaxSpendPerTransaction
)
.action(async (options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
Expand All @@ -102,6 +123,9 @@ export function registerCardsCommand(
fundingSources,
};
if (options.platformCardId) body.platformCardId = options.platformCardId;
if (options.maxSpendPerTransaction !== undefined) {
body.maxSpendPerTransaction = options.maxSpendPerTransaction;
}

const response = await client.post<Card>("/cards", body);
outputResponse(response);
Expand All @@ -110,9 +134,20 @@ export function registerCardsCommand(
addSignedOptions(
cardsCmd
.command("update <cardId>")
.description("Update a card (freeze/unfreeze, replace funding sources, or close)")
.description(
"Update a card (freeze/unfreeze, replace funding sources, set a spending limit, or close)"
)
.option("--state <state>", "Target state: ACTIVE, FROZEN, or CLOSED")
.option("--funding-sources <list>", "Comma-separated internal account IDs (fully replaces the binding)")
.option(
"--max-spend-per-transaction <amount>",
"Set the maximum amount per transaction in the card currency's smallest unit",
parseMaxSpendPerTransaction
)
.option(
"--clear-max-spend-per-transaction",
"Remove the per-transaction spending limit"
)
).action(async (cardId: string, options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
Expand All @@ -126,8 +161,17 @@ export function registerCardsCommand(
}

const fundingSources = parseList(options.fundingSources);
if (!options.state && options.fundingSources === undefined) {
output(formatError("Provide --state and/or --funding-sources"));
if (
!options.state &&
options.fundingSources === undefined &&
options.maxSpendPerTransaction === undefined &&
!options.clearMaxSpendPerTransaction
) {
output(
formatError(
"Provide --state, --funding-sources, --max-spend-per-transaction, and/or --clear-max-spend-per-transaction"
)
);
process.exitCode = 1;
return;
}
Expand All @@ -138,15 +182,41 @@ export function registerCardsCommand(
process.exitCode = 1;
return;
}
if (options.state === "CLOSED" && options.fundingSources !== undefined) {
output(formatError("--state CLOSED cannot be combined with --funding-sources"));
if (
options.maxSpendPerTransaction !== undefined &&
options.clearMaxSpendPerTransaction
) {
output(
formatError(
"--max-spend-per-transaction cannot be combined with --clear-max-spend-per-transaction"
)
);
process.exitCode = 1;
return;
}
if (
options.state === "CLOSED" &&
(options.fundingSources !== undefined ||
options.maxSpendPerTransaction !== undefined ||
options.clearMaxSpendPerTransaction)
) {
output(
formatError(
"--state CLOSED cannot be combined with funding-source or spending-limit changes"
)
);
process.exitCode = 1;
return;
}

const body: Record<string, unknown> = {};
if (options.state) body.state = options.state;
if (fundingSources) body.fundingSources = fundingSources;
if (options.maxSpendPerTransaction !== undefined) {
body.maxSpendPerTransaction = options.maxSpendPerTransaction;
} else if (options.clearMaxSpendPerTransaction) {
body.maxSpendPerTransaction = null;
}

const response = await client.patch<Card>(
`/cards/${cardId}`,
Expand Down
28 changes: 1 addition & 27 deletions cli/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe("auth delegated-keys", () => {
expect(calls).toBe(0);
});

it("create builds the body with parsed spending limits", async () => {
it("create builds the delegated-key request body", async () => {
const { request } = await runCli([
"auth",
"delegated-keys",
Expand All @@ -129,19 +129,11 @@ describe("auth delegated-keys", () => {
"InternalAccount:1",
"--nickname",
"Card key",
"--spending-limit",
"USD:5000",
"--spending-limit",
"EUR:4000",
]);
expect(request?.body).toMatchObject({
cardId: "Card:1",
internalAccountId: "InternalAccount:1",
nickname: "Card key",
spendingLimits: [
{ currencyCode: "USD", maxPerTransaction: 5000 },
{ currencyCode: "EUR", maxPerTransaction: 4000 },
],
});
});

Expand All @@ -156,24 +148,6 @@ describe("auth delegated-keys", () => {
expect(request?.method).toBe("DELETE");
expect(request?.path).toBe("/grid/v1/auth/delegated-keys/DelegatedKey:1");
});

it("rejects a malformed spending limit before sending", async () => {
await expect(
runCli([
"auth",
"delegated-keys",
"create",
"--card-id",
"Card:1",
"--internal-account-id",
"InternalAccount:1",
"--nickname",
"Card key",
"--spending-limit",
"USD5000",
])
).rejects.toThrow();
});
});

describe("auth sessions", () => {
Expand Down
78 changes: 78 additions & 0 deletions cli/test/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ describe("cards create", () => {
fundingSources: ["InternalAccount:1", "InternalAccount:2"],
});
});

it("sets a per-transaction spending limit", async () => {
const { request } = await runCli([
"cards",
"create",
"--cardholder-id",
"Customer:abc",
"--funding-sources",
"InternalAccount:1",
"--max-spend-per-transaction",
"5000",
]);

expect(request?.body).toMatchObject({ maxSpendPerTransaction: 5000 });
});

it("rejects a non-positive spending limit", async () => {
await expect(
runCli([
"cards",
"create",
"--cardholder-id",
"Customer:abc",
"--funding-sources",
"InternalAccount:1",
"--max-spend-per-transaction",
"0",
])
).rejects.toThrow();
});
});

describe("cards update", () => {
Expand Down Expand Up @@ -73,6 +103,29 @@ describe("cards update", () => {
expect(request?.headers["Request-Id"]).toBe("req-1");
});

it("sets a per-transaction spending limit", async () => {
const { request } = await runCli([
"cards",
"update",
"Card:1",
"--max-spend-per-transaction",
"7500",
]);

expect(request?.body).toEqual({ maxSpendPerTransaction: 7500 });
});

it("clears a per-transaction spending limit", async () => {
const { request } = await runCli([
"cards",
"update",
"Card:1",
"--clear-max-spend-per-transaction",
]);

expect(request?.body).toEqual({ maxSpendPerTransaction: null });
});

it("rejects an update with no state or funding sources", async () => {
const { calls } = await runCli(["cards", "update", "Card:1"]);
expect(calls).toBe(0);
Expand Down Expand Up @@ -125,6 +178,31 @@ describe("cards update", () => {
]);
expect(calls).toBe(0);
});

it("rejects setting and clearing the spending limit together", async () => {
const { calls } = await runCli([
"cards",
"update",
"Card:1",
"--max-spend-per-transaction",
"5000",
"--clear-max-spend-per-transaction",
]);
expect(calls).toBe(0);
});

it("rejects CLOSED combined with a spending-limit change", async () => {
const { calls } = await runCli([
"cards",
"update",
"Card:1",
"--state",
"CLOSED",
"--max-spend-per-transaction",
"5000",
]);
expect(calls).toBe(0);
});
});

describe("cards reveal", () => {
Expand Down
Loading
Loading