diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6dffb0..c6438b6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -20,6 +20,7 @@ memberstack-cli/ │ │ ├── custom-fields.ts # Custom field listing │ │ ├── members.ts # Member CRUD, search, pagination │ │ ├── plans.ts # Plan CRUD, ordering, redirects, permissions +│ │ ├── prices.ts # Price management (create, update, activate, deactivate, delete) │ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops │ │ ├── skills.ts # Agent skill add/remove (wraps npx skills) │ │ ├── tables.ts # Data table CRUD, describe @@ -43,6 +44,7 @@ memberstack-cli/ │ │ ├── custom-fields.test.ts │ │ ├── members.test.ts │ │ ├── plans.test.ts +│ │ ├── prices.test.ts │ │ ├── records.test.ts │ │ ├── skills.test.ts │ │ ├── tables.test.ts diff --git a/README.md b/README.md index bebf07f..02b5790 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ memberstack skills add memberstack-cli | `apps` | View, create, update, delete, and restore apps | | `members` | List, create, update, delete, import/export, bulk ops | | `plans` | List, create, update, delete, and reorder plans | +| `prices` | Create, update, activate, deactivate, and delete prices | | `tables` | List, create, update, delete, and describe schema | | `records` | CRUD, query, import/export, bulk ops | | `custom-fields` | List, create, update, and delete custom fields | diff --git a/src/commands/prices.ts b/src/commands/prices.ts new file mode 100644 index 0000000..9353db7 --- /dev/null +++ b/src/commands/prices.ts @@ -0,0 +1,343 @@ +import { Command, Option } from "commander"; +import yoctoSpinner from "yocto-spinner"; +import { graphqlRequest } from "../lib/graphql-client.js"; +import { printError, printRecord, printSuccess } from "../lib/utils.js"; + +interface Price { + active: boolean; + amount: number; + currency: string; + id: string; + memberCount: number | null; + name: string | null; + status: string; + type: string; +} + +const PRICE_FIELDS = ` + id + name + status + active + amount + type + currency + memberCount +`; + +const PRICE_TYPES = ["SUBSCRIPTION", "ONETIME"]; +const INTERVAL_TYPES = ["YEARLY", "MONTHLY", "WEEKLY"]; +const EXPIRATION_INTERVALS = ["MONTHS", "DAYS"]; + +const parseNumber = (value: string): number => { + const num = Number(value); + if (Number.isNaN(num)) { + throw new Error(`Invalid number: ${value}`); + } + return num; +}; + +export const pricesCommand = new Command("prices") + .usage(" [options]") + .description("Manage prices for plans"); + +pricesCommand + .command("create") + .description("Create a price for a plan") + .requiredOption("--plan-id ", "Plan ID to add the price to") + .requiredOption("--name ", "Price name") + .requiredOption("--amount ", "Price amount", parseNumber) + .addOption( + new Option("--type ", "Price type") + .choices(PRICE_TYPES) + .makeOptionMandatory() + ) + .option("--currency ", "Currency code (e.g. USD, EUR, GBP)", "usd") + .addOption( + new Option("--interval-type ", "Billing interval").choices( + INTERVAL_TYPES + ) + ) + .option( + "--interval-count ", + "Number of intervals between billings", + parseNumber + ) + .option("--setup-fee-amount ", "Setup fee amount", parseNumber) + .option("--setup-fee-name ", "Setup fee name") + .option("--setup-fee-enabled", "Enable setup fee") + .option("--free-trial-enabled", "Enable free trial") + .option("--free-trial-requires-card", "Require card for free trial") + .option( + "--free-trial-days ", + "Free trial duration in days", + parseNumber + ) + .option("--expiration-count ", "Expiration count", parseNumber) + .addOption( + new Option( + "--expiration-interval ", + "Expiration interval" + ).choices(EXPIRATION_INTERVALS) + ) + .option("--cancel-at-period-end", "Cancel at period end") + .action(async (opts) => { + const spinner = yoctoSpinner({ text: "Creating price..." }).start(); + try { + const input: Record = { + planId: opts.planId, + name: opts.name, + amount: opts.amount, + type: opts.type, + currency: opts.currency, + }; + if (opts.intervalType) { + input.intervalType = opts.intervalType; + } + if (opts.intervalCount !== undefined) { + input.intervalCount = opts.intervalCount; + } + if (opts.setupFeeAmount !== undefined) { + input.setupFeeAmount = opts.setupFeeAmount; + } + if (opts.setupFeeName) { + input.setupFeeName = opts.setupFeeName; + } + if (opts.setupFeeEnabled) { + input.setupFeeEnabled = true; + } + if (opts.freeTrialEnabled) { + input.freeTrialEnabled = true; + } + if (opts.freeTrialRequiresCard) { + input.freeTrialRequiresCard = true; + } + if (opts.freeTrialDays !== undefined) { + input.freeTrialDays = opts.freeTrialDays; + } + if (opts.expirationCount !== undefined) { + input.expirationCount = opts.expirationCount; + } + if (opts.expirationInterval) { + input.expirationInterval = opts.expirationInterval; + } + if (opts.cancelAtPeriodEnd) { + input.cancelAtPeriodEnd = true; + } + + const result = await graphqlRequest<{ createPrice: Price }>({ + query: `mutation($input: CreatePriceInput!) { + createPrice(input: $input) { + ${PRICE_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess("Price created successfully."); + printRecord(result.createPrice); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +pricesCommand + .command("update") + .description("Update a price") + .argument("", "Price ID to update") + .option("--name ", "Price name") + .option("--amount ", "Price amount", parseNumber) + .addOption(new Option("--type ", "Price type").choices(PRICE_TYPES)) + .option("--currency ", "Currency code") + .addOption( + new Option("--interval-type ", "Billing interval").choices( + INTERVAL_TYPES + ) + ) + .option( + "--interval-count ", + "Number of intervals between billings", + parseNumber + ) + .option("--setup-fee-amount ", "Setup fee amount", parseNumber) + .option("--setup-fee-name ", "Setup fee name") + .option("--setup-fee-enabled", "Enable setup fee") + .option("--no-setup-fee-enabled", "Disable setup fee") + .option("--free-trial-enabled", "Enable free trial") + .option("--no-free-trial-enabled", "Disable free trial") + .option("--free-trial-requires-card", "Require card for free trial") + .option( + "--free-trial-days ", + "Free trial duration in days", + parseNumber + ) + .option("--expiration-count ", "Expiration count", parseNumber) + .addOption( + new Option( + "--expiration-interval ", + "Expiration interval" + ).choices(EXPIRATION_INTERVALS) + ) + .option("--cancel-at-period-end", "Cancel at period end") + .option("--no-cancel-at-period-end", "Do not cancel at period end") + .action(async (priceId: string, opts) => { + const input: Record = { priceId }; + if (opts.name) { + input.name = opts.name; + } + if (opts.amount !== undefined) { + input.amount = opts.amount; + } + if (opts.type) { + input.type = opts.type; + } + if (opts.currency) { + input.currency = opts.currency; + } + if (opts.intervalType) { + input.intervalType = opts.intervalType; + } + if (opts.intervalCount !== undefined) { + input.intervalCount = opts.intervalCount; + } + if (opts.setupFeeAmount !== undefined) { + input.setupFeeAmount = opts.setupFeeAmount; + } + if (opts.setupFeeName) { + input.setupFeeName = opts.setupFeeName; + } + if (opts.setupFeeEnabled !== undefined) { + input.setupFeeEnabled = opts.setupFeeEnabled; + } + if (opts.freeTrialEnabled !== undefined) { + input.freeTrialEnabled = opts.freeTrialEnabled; + } + if (opts.freeTrialRequiresCard) { + input.freeTrialRequiresCard = true; + } + if (opts.freeTrialDays !== undefined) { + input.freeTrialDays = opts.freeTrialDays; + } + if (opts.expirationCount !== undefined) { + input.expirationCount = opts.expirationCount; + } + if (opts.expirationInterval) { + input.expirationInterval = opts.expirationInterval; + } + if (opts.cancelAtPeriodEnd !== undefined) { + input.cancelAtPeriodEnd = opts.cancelAtPeriodEnd; + } + + if (Object.keys(input).length <= 1) { + printError( + "No update options provided. Use --help to see available options." + ); + process.exitCode = 1; + return; + } + + const spinner = yoctoSpinner({ text: "Updating price..." }).start(); + try { + const result = await graphqlRequest<{ updatePrice: Price }>({ + query: `mutation($input: UpdatePriceInput!) { + updatePrice(input: $input) { + ${PRICE_FIELDS} + } +}`, + variables: { input }, + }); + spinner.stop(); + printSuccess("Price updated successfully."); + printRecord(result.updatePrice); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +pricesCommand + .command("activate") + .description("Reactivate a price") + .argument("", "Price ID to reactivate") + .action(async (priceId: string) => { + const spinner = yoctoSpinner({ text: "Reactivating price..." }).start(); + try { + const result = await graphqlRequest<{ reactivatePrice: Price }>({ + query: `mutation($input: ReactivatePriceInput!) { + reactivatePrice(input: $input) { + ${PRICE_FIELDS} + } +}`, + variables: { input: { priceId } }, + }); + spinner.stop(); + printSuccess("Price reactivated."); + printRecord(result.reactivatePrice); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +pricesCommand + .command("deactivate") + .description("Deactivate a price") + .argument("", "Price ID to deactivate") + .action(async (priceId: string) => { + const spinner = yoctoSpinner({ text: "Deactivating price..." }).start(); + try { + const result = await graphqlRequest<{ deactivatePrice: Price }>({ + query: `mutation($input: DeactivatePriceInput!) { + deactivatePrice(input: $input) { + ${PRICE_FIELDS} + } +}`, + variables: { input: { priceId } }, + }); + spinner.stop(); + printSuccess("Price deactivated."); + printRecord(result.deactivatePrice); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); + +pricesCommand + .command("delete") + .description("Delete a price") + .argument("", "Price ID to delete") + .action(async (priceId: string) => { + const spinner = yoctoSpinner({ text: "Deleting price..." }).start(); + try { + const result = await graphqlRequest<{ deletePrice: Price }>({ + query: `mutation($input: DeletePriceInput!) { + deletePrice(input: $input) { + ${PRICE_FIELDS} + } +}`, + variables: { input: { priceId } }, + }); + spinner.stop(); + printSuccess(`Price "${result.deletePrice.id}" deleted.`); + } catch (error) { + spinner.stop(); + printError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exitCode = 1; + } + }); diff --git a/src/index.ts b/src/index.ts index d591778..c45d176 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { authCommand } from "./commands/auth.js"; import { customFieldsCommand } from "./commands/custom-fields.js"; import { membersCommand } from "./commands/members.js"; import { plansCommand } from "./commands/plans.js"; +import { pricesCommand } from "./commands/prices.js"; import { recordsCommand } from "./commands/records.js"; import { skillsCommand } from "./commands/skills.js"; import { tablesCommand } from "./commands/tables.js"; @@ -60,6 +61,7 @@ program.addCommand(authCommand); program.addCommand(whoamiCommand); program.addCommand(membersCommand); program.addCommand(plansCommand); +program.addCommand(pricesCommand); program.addCommand(tablesCommand); program.addCommand(recordsCommand); program.addCommand(customFieldsCommand); diff --git a/tests/commands/prices.test.ts b/tests/commands/prices.test.ts new file mode 100644 index 0000000..bdd132a --- /dev/null +++ b/tests/commands/prices.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMockSpinner, runCommand } from "./helpers.js"; + +vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() })); +vi.mock("../../src/lib/program.js", () => ({ + program: { opts: () => ({}) }, +})); + +const graphqlRequest = vi.fn(); +vi.mock("../../src/lib/graphql-client.js", () => ({ + graphqlRequest: (...args: unknown[]) => graphqlRequest(...args), +})); + +const { pricesCommand } = await import("../../src/commands/prices.js"); + +const mockPrice = { + id: "prc_1", + name: "Monthly", + status: "ACTIVE", + active: true, + amount: 9.99, + type: "SUBSCRIPTION", + currency: "usd", + memberCount: 0, +}; + +describe("prices", () => { + it("create sends required fields", async () => { + graphqlRequest.mockResolvedValueOnce({ createPrice: mockPrice }); + + await runCommand(pricesCommand, [ + "create", + "--plan-id", + "pln_1", + "--name", + "Monthly", + "--amount", + "9.99", + "--type", + "SUBSCRIPTION", + "--currency", + "usd", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.planId).toBe("pln_1"); + expect(call.variables.input.name).toBe("Monthly"); + expect(call.variables.input.amount).toBe(9.99); + expect(call.variables.input.type).toBe("SUBSCRIPTION"); + expect(call.variables.input.currency).toBe("usd"); + }); + + it("create sends optional fields", async () => { + graphqlRequest.mockResolvedValueOnce({ createPrice: mockPrice }); + + await runCommand(pricesCommand, [ + "create", + "--plan-id", + "pln_1", + "--name", + "Monthly", + "--amount", + "9.99", + "--type", + "SUBSCRIPTION", + "--interval-type", + "MONTHLY", + "--interval-count", + "1", + "--free-trial-enabled", + "--free-trial-days", + "14", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.intervalType).toBe("MONTHLY"); + expect(call.variables.input.intervalCount).toBe(1); + expect(call.variables.input.freeTrialEnabled).toBe(true); + expect(call.variables.input.freeTrialDays).toBe(14); + }); + + it("update sends priceId and fields", async () => { + graphqlRequest.mockResolvedValueOnce({ updatePrice: mockPrice }); + + await runCommand(pricesCommand, [ + "update", + "prc_1", + "--name", + "Updated", + "--amount", + "19.99", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.priceId).toBe("prc_1"); + expect(call.variables.input.name).toBe("Updated"); + expect(call.variables.input.amount).toBe(19.99); + }); + + it("update rejects with no options", async () => { + const original = process.exitCode; + await runCommand(pricesCommand, ["update", "prc_1"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("activate sends priceId", async () => { + graphqlRequest.mockResolvedValueOnce({ reactivatePrice: mockPrice }); + + await runCommand(pricesCommand, ["activate", "prc_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { priceId: "prc_1" } }, + }) + ); + }); + + it("deactivate sends priceId", async () => { + graphqlRequest.mockResolvedValueOnce({ deactivatePrice: mockPrice }); + + await runCommand(pricesCommand, ["deactivate", "prc_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { priceId: "prc_1" } }, + }) + ); + }); + + it("delete sends priceId", async () => { + graphqlRequest.mockResolvedValueOnce({ deletePrice: mockPrice }); + + await runCommand(pricesCommand, ["delete", "prc_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: { priceId: "prc_1" } }, + }) + ); + }); + + it("handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Stripe error")); + + const original = process.exitCode; + await runCommand(pricesCommand, [ + "create", + "--plan-id", + "pln_1", + "--name", + "Test", + "--amount", + "5", + "--type", + "ONETIME", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +});