diff --git a/README.md b/README.md index e6c1c98..3dd823d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,19 @@ # @monnify/mcp-server -[![CI](https://github.com/monnify/monnify-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/monnify/monnify-mcp-server/actions/workflows/ci.yml) +[![CI](https://github.com/monnify/mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/monnify/mcp-server/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@monnify/mcp-server.svg)](https://www.npmjs.com/package/@monnify/mcp-server) -Give your AI assistant the ability to accept payments, verify identities, manage virtual accounts, and query transactions — all through [Monnify](https://monnify.com). +Give your AI assistant the ability to accept payments, verify identities, manage virtual accounts, and query transactions, all through [Monnify](https://monnify.com). -This is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server. Once connected, your AI client can talk directly to the Monnify API using plain language — no custom integration code required. +This is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server. Once connected, your AI client can talk directly to the Monnify API using plain language. No custom integration code required. --- ## What you can do once it's connected -**Payment collection** -> *"Initiate a payment of ₦15,000 for invoice #INV-2024-007 and send me the checkout link"* -> *"Create a dedicated virtual account for customer cust_089 so they can pay anytime by bank transfer"* -> *"Get me the status of transaction TRX-20240601-001"* -> *"Show me all failed transactions from last week"* +A few examples of what you'd actually ask while wiring this into a real feature: -**Identity verification** -> *"Verify that account 0123456789 belongs to John Doe at GTBank"* -> *"Check BVN 22345678901 — does the name and date of birth match?"* -> *"Verify NIN 12345678901 for a customer named Amaka Obi"* - -**Account management** -> *"What transactions came into reserved account ACCT-REF-001 this month?"* -> *"Deallocate the virtual account for the customer who churned last quarter"* -> *"Create a payment invoice for ₦50,000 that expires in 48 hours"* - -**Engineering / integrations** -> *"List all banks and their codes so I can populate a dropdown"* -> *"Get the full details of transaction REF-XYZ for a support ticket"* -> *"Process a partial refund of ₦2,500 on transaction TRX-20240601-009"* +> _"add a reserved account to our wallet feature so customers can top up by bank transfer"_ > _"set up a one-time checkout for a ₦15,000 order and give me the payment link"_ > _"create a ₦50,000 invoice that expires in 48 hours"_ > _"validate this account number before we save it to the customer's payout details"_ > _"refund ₦2,500 on transaction TRX-20240601-009"_ --- @@ -91,7 +74,7 @@ Restart Claude Desktop after saving. You will see "monnify" appear in the tools ### Claude Code (CLI) -**Project-level** — add to `.claude/mcp.json` in your repo (checked in, shared with your team): +**Project-level:** add to `.claude/mcp.json` in your repo (checked in, shared with your team): ```json { @@ -112,7 +95,7 @@ Restart Claude Desktop after saving. You will see "monnify" appear in the tools } ``` -**Global** — add to `~/.claude/claude_desktop_config.json` so it's available in every Claude Code session: +**Global:** add to `~/.claude/claude_desktop_config.json` so it's available in every Claude Code session: ```bash claude mcp add monnify \ @@ -124,6 +107,7 @@ claude mcp add monnify \ ``` Verify it loaded: + ```bash claude mcp list ``` @@ -179,7 +163,7 @@ Add to `~/.gemini/settings.json`: } ``` -Run `gemini` in your terminal — type `@monnify` to confirm the server is available. +Run `gemini` in your terminal, then type `@monnify` to confirm the server is available. --- @@ -232,7 +216,7 @@ Create `.vscode/mcp.json` in your workspace root: } ``` -Open the Copilot Chat panel, switch to **Agent mode**, and the Monnify tools will be available. Commit `.vscode/mcp.json` to share the setup with your team (credentials should come from environment variables — see [using environment variables](#using-environment-variables)). +Open the Copilot Chat panel, switch to **Agent mode**, and the Monnify tools will be available. Commit `.vscode/mcp.json` to share the setup with your team (credentials should come from environment variables; see [using environment variables](#using-environment-variables)). --- @@ -334,16 +318,16 @@ Avoid hardcoding credentials in config files by passing them via environment var All CLI flags have an equivalent environment variable: -| CLI flag | Environment variable | -|-------------------|-----------------------------| -| `--apiKey` | `MONNIFY_API_KEY` | -| `--secretKey` | `MONNIFY_SECRET_KEY` | -| `--contractCode` | `MONNIFY_CONTRACT_CODE` | -| `--env` | `MONNIFY_ENV` | -| `--transport` | `MONNIFY_TRANSPORT` | -| `--port` | `MONNIFY_PORT` | -| `--tools` | `MONNIFY_TOOLS` | -| `--format` | `MONNIFY_RESPONSE_FORMAT` | +| CLI flag | Environment variable | +| ---------------- | ------------------------- | +| `--apiKey` | `MONNIFY_API_KEY` | +| `--secretKey` | `MONNIFY_SECRET_KEY` | +| `--contractCode` | `MONNIFY_CONTRACT_CODE` | +| `--env` | `MONNIFY_ENV` | +| `--transport` | `MONNIFY_TRANSPORT` | +| `--port` | `MONNIFY_PORT` | +| `--tools` | `MONNIFY_TOOLS` | +| `--format` | `MONNIFY_RESPONSE_FORMAT` | --- @@ -351,11 +335,11 @@ All CLI flags have an equivalent environment variable: The server automatically detects which client is connecting and adapts its responses: -| Client | Response format | -|--------|----------------| -| Claude Desktop, Claude Code CLI | Conversational Markdown with status icons, Naira formatting, and human-readable dates | -| Cursor, VS Code Copilot, Windsurf, Zed, Continue.dev | Clean JSON — machine-readable and easy to pipe into other tools | -| ChatGPT, Gemini | Conversational Markdown | +| Client | Response format | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Claude Desktop, Claude Code CLI | Conversational Markdown with status icons, Naira formatting, and human-readable dates | +| Cursor, VS Code Copilot, Windsurf, Zed, Continue.dev | Clean JSON, machine-readable and easy to pipe into other tools | +| ChatGPT, Gemini | Conversational Markdown | **Override the format** with the `--format` flag if the auto-detection doesn't match your workflow: @@ -394,67 +378,96 @@ Expires: 1 Jul 2024, 11:59 PM WAT ## All options -| Flag | Default | Description | -|------------------|-----------|-----------------------------------------------------| -| `--apiKey` | — | Your Monnify API key (required) | -| `--secretKey` | — | Your Monnify secret key (required) | -| `--contractCode` | — | Your Monnify contract code (required) | -| `--env` | `sandbox` | `sandbox` or `production` | -| `--transport` | `stdio` | `stdio` (local clients) or `http` (team deployments)| -| `--port` | `3000` | Port to listen on when using `--transport=http` | -| `--tools` | all | Comma-separated list of tool categories (see below) | -| `--format` | `auto` | `auto`, `markdown`, or `json` | -| `--httpToken` | — | Bearer token to authenticate HTTP `/mcp` requests | +| Flag | Default | Description | +| ---------------- | --------- | ---------------------------------------------------- | +| `--apiKey` | none | Your Monnify API key (required) | +| `--secretKey` | none | Your Monnify secret key (required) | +| `--contractCode` | none | Your Monnify contract code (required) | +| `--env` | `sandbox` | `sandbox` or `production` | +| `--transport` | `stdio` | `stdio` (local clients) or `http` (team deployments) | +| `--port` | `3000` | Port to listen on when using `--transport=http` | +| `--tools` | all | Comma-separated list of tool categories (see below) | +| `--format` | `auto` | `auto`, `markdown`, or `json` | +| `--httpToken` | none | Bearer token to authenticate HTTP `/mcp` requests | --- -## Available tools (25) +## Available tools (29) ### Collections (15) -| Tool | What it does | -|------|--------------| -| `monnify_initiate_payment` | Creates a payment and returns a checkout URL | -| `monnify_reserve_account` | Reserves a virtual bank account for a customer | -| `monnify_get_reserved_account` | Fetches details and status of a reserved account | +| Tool | What it does | +| ------------------------------------------- | ------------------------------------------------- | +| `monnify_initiate_payment` | Creates a payment and returns a checkout URL | +| `monnify_reserve_account` | Reserves a virtual bank account for a customer | +| `monnify_get_reserved_account` | Fetches details and status of a reserved account | | `monnify_get_reserved_account_transactions` | Lists transactions received on a reserved account | -| `monnify_deallocate_reserved_account` | Permanently removes a reserved account | -| `monnify_get_transaction_status` | Checks the status of a transaction by reference | -| `monnify_get_transaction_details` | Fetches full details of a transaction | -| `monnify_get_all_transactions` | Lists transactions with filters and pagination | -| `monnify_create_invoice` | Creates a payment invoice with an expiry date | -| `monnify_process_refund` | Issues a full or partial refund | -| `monnify_pay_with_bank_transfer` | Starts a pay-by-bank-transfer flow | -| `monnify_charge_card` | Charges a card with PAN and CVV | -| `monnify_charge_card_token` | Charges a previously saved card token | -| `monnify_authorise_card_otp` | Submits OTP to complete a card charge | -| `monnify_authorise_card_3ds` | Completes 3DS verification for a card charge | +| `monnify_deallocate_reserved_account` | Permanently removes a reserved account | +| `monnify_get_transaction_status` | Checks the status of a transaction by reference | +| `monnify_get_transaction_details` | Fetches full details of a transaction | +| `monnify_get_all_transactions` | Lists transactions with filters and pagination | +| `monnify_create_invoice` | Creates a payment invoice with an expiry date | +| `monnify_process_refund` | Issues a full or partial refund | +| `monnify_pay_with_bank_transfer` | Starts a pay-by-bank-transfer flow | +| `monnify_charge_card` | Charges a card with PAN and CVV | +| `monnify_charge_card_token` | Charges a previously saved card token | +| `monnify_authorise_card_otp` | Submits OTP to complete a card charge | +| `monnify_authorise_card_3ds` | Completes 3DS verification for a card charge | ### Direct Debit (5) -| Tool | What it does | -|------|--------------| -| `monnify_create_mandate` | Creates a direct debit mandate | -| `monnify_get_mandate_status` | Checks if a mandate is active and ready to debit | -| `monnify_debit_mandate` | Debits an active mandate | -| `monnify_get_mandate_debit_status` | Checks the status of a debit attempt | -| `monnify_cancel_mandate` | Cancels a mandate permanently | +| Tool | What it does | +| ---------------------------------- | ------------------------------------------------ | +| `monnify_create_mandate` | Creates a direct debit mandate | +| `monnify_get_mandate_status` | Checks if a mandate is active and ready to debit | +| `monnify_debit_mandate` | Debits an active mandate | +| `monnify_get_mandate_debit_status` | Checks the status of a debit attempt | +| `monnify_cancel_mandate` | Cancels a mandate permanently | ### Verification (4) -| Tool | What it does | -|------|--------------| -| `monnify_verify_bank_account` | Verifies an account number and returns the account name | -| `monnify_verify_bvn` | Matches a BVN against name, date of birth, and phone number | -| `monnify_verify_bvn_info` | Checks whether all submitted BVN details match holistically | -| `monnify_verify_nin` | Verifies a NIN and returns the associated record | +| Tool | What it does | +| ----------------------------- | ----------------------------------------------------------- | +| `monnify_verify_bank_account` | Verifies an account number and returns the account name | +| `monnify_verify_bvn` | Matches a BVN against name, date of birth, and phone number | +| `monnify_verify_bvn_info` | Checks whether all submitted BVN details match holistically | +| `monnify_verify_nin` | Verifies a NIN and returns the associated record | ### Utilities (1) -| Tool | What it does | -|------|--------------| +| Tool | What it does | +| ----------------------------- | ----------------------------------------- | | `monnify_get_supported_banks` | Lists all supported banks and their codes | +### Sub Accounts (4) + +| Tool | What it does | +| ----------------------------- | ------------------------------------------------------------- | +| `monnify_create_sub_accounts` | Creates one or more sub-accounts for payment splitting | +| `monnify_get_sub_accounts` | Lists all sub-accounts on your integration | +| `monnify_update_sub_account` | Updates a sub-account's bank details, email, or default split | +| `monnify_delete_sub_account` | Permanently deletes a sub-account | + +A sub-account must exist (created via `monnify_create_sub_accounts`, or the Monnify dashboard) before its `subAccountCode` can be used in `incomeSplitConfig` on `monnify_initiate_payment`, `monnify_reserve_account`, `monnify_charge_card_token`, or `monnify_debit_mandate`. + +--- + +## Sandbox limitations + +A few Monnify features are restricted or disabled by default. Knowing these upfront saves time debugging what looks like an integration bug: + +| Feature | Behaviour | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `monnify_verify_bvn`, `monnify_verify_bvn_info`, `monnify_verify_nin` | **Live only.** These identity checks always fail in sandbox, regardless of the BVN/NIN supplied. Test against production (with a real BVN/NIN) or mock the flow in your own tests. | +| `monnify_process_refund` | **Disabled by default**, and only works for transactions originally paid via **ACCOUNT_TRANSFER**. Check `paymentMethod` with `monnify_get_transaction_status` first. Transactions paid by CARD or USSD cannot be refunded through this API at all. Email **integration-support@monnify.com** to request access. | +| Direct Debit (`monnify_create_mandate` and the rest of the mandate lifecycle) | **Disabled by default.** Returns "not permitted" until enabled on your account. Email **integration-support@monnify.com** to request access. | +| Sub Accounts (`monnify_create_sub_accounts` and the rest of the Sub Account tools, plus `incomeSplitConfig`/`subAccountCode` on payment, reserved-account, and mandate tools) | **Disabled by default.** Returns "not permitted" until enabled on your account. Email **integration-support@monnify.com** to request access. A sub-account must also exist before its code can be used in any `incomeSplitConfig`. | +| Wallet balance / disbursement wallet endpoints | Not exposed as a tool in this server; when available on your account, they're also permission-gated the same way. | + +If you hit a "not permitted" response on a feature not listed here, it likely needs enabling the same way. Contact **integration-support@monnify.com** rather than assuming the request is malformed. + +**Date format quirk:** `monnify_create_invoice`'s `expiryDate` must be `YYYY-MM-DD HH:mm:ss` (space-separated), not ISO 8601 (`YYYY-MM-DDTHH:mm:ss`). The tool validates this and will reject an ISO 8601 value with a clear error before it reaches the API. + --- ## Limiting tool access @@ -472,22 +485,22 @@ npx -y @monnify/mcp-server --apiKey=... --secretKey=... --contractCode=... --too npx -y @monnify/mcp-server --apiKey=... --secretKey=... --contractCode=... --tools=collections,verification ``` -Available categories: `collections`, `directDebit`, `verification`, `utilities` +Available categories: `collections`, `directDebit`, `verification`, `utilities`, `subAccounts` --- ## Running as an HTTP server (team deployments) -Deploy a single shared instance and connect multiple clients to it — useful for team environments, CI pipelines, or production AI integrations. +Deploy a single shared instance and connect multiple clients to it. Useful for team environments, CI pipelines, or production AI integrations. -**Step 1 — Generate a bearer token:** +**Step 1: Generate a bearer token** ```bash openssl rand -hex 32 # e.g. a3f8c2d1e4b5a6... ``` -**Step 2 — Start the server:** +**Step 2: Start the server** ```bash npx -y @monnify/mcp-server \ @@ -500,9 +513,9 @@ npx -y @monnify/mcp-server \ --httpToken=YOUR_GENERATED_TOKEN ``` -The server will reject any `/mcp` request that does not carry `Authorization: Bearer YOUR_GENERATED_TOKEN`. If you start without `--httpToken`, it starts unauthenticated and logs a warning — acceptable for local development, not for any internet-facing deployment. +The server will reject any `/mcp` request that does not carry `Authorization: Bearer YOUR_GENERATED_TOKEN`. If you start without `--httpToken`, it starts unauthenticated and logs a warning. That's fine for local development, not for any internet-facing deployment. -**Step 3 — Connect your AI client:** +**Step 3: Connect your AI client** ```bash # Claude Code @@ -511,7 +524,7 @@ claude mcp add monnify --transport http http://localhost:3000/mcp For clients that accept a custom header, set `Authorization: Bearer YOUR_GENERATED_TOKEN` alongside the endpoint URL. -**Step 4 — Put HTTPS in front of it.** Terminate TLS at a reverse proxy (nginx, Caddy, Cloudflare Tunnel) before exposing the server outside your local network. Never send credentials over plain HTTP. +**Step 4: Put HTTPS in front of it.** Terminate TLS at a reverse proxy (nginx, Caddy, Cloudflare Tunnel) before exposing the server outside your local network. Never send credentials over plain HTTP. Health check (no auth required): `GET /health` @@ -552,7 +565,7 @@ Open `http://localhost:5173` in your browser to browse and invoke tools. ## Security - Credentials are passed as CLI args or environment variables and never stored -- All API responses are filtered through a whitelist — internal Monnify fields are stripped before returning data to the AI client +- All API responses are filtered through a whitelist, so internal Monnify fields are stripped before returning data to the AI client - Destructive tools (deallocate, cancel mandate) are clearly labelled in their descriptions so the AI client warns before executing them - Scope tool access with `--tools` to only expose what a given agent needs @@ -560,4 +573,4 @@ Open `http://localhost:5173` in your browser to browse and invoke tools. ## License -MIT — built by the [Monnify](https://monnify.com) team. +MIT. Built by the [Monnify](https://monnify.com) team. diff --git a/openapi/monnify.yaml b/openapi/monnify.yaml index a1d699f..5fe9b17 100644 --- a/openapi/monnify.yaml +++ b/openapi/monnify.yaml @@ -2662,7 +2662,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/disbursements/account/validate: + /api/v2/disbursements/account/validate: get: tags: - Verification APIs diff --git a/package.json b/package.json index 141e489..56d5796 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@monnify/mcp-server", - "version": "1.0.0", - "description": "Production-grade MCP server for Monnify payment APIs — TypeScript, OpenAPI-driven", + "version": "1.0.1", + "description": "MCP server for Monnify payment gateway APIs", "type": "module", "main": "build/index.js", "bin": { @@ -50,6 +50,13 @@ "engines": { "node": ">=20.0.0" }, - "keywords": ["monnify", "mcp", "payments", "nigeria", "fintech", "llm-agents"], + "keywords": [ + "monnify", + "mcp", + "payments", + "nigeria", + "fintech", + "llm-agents" + ], "license": "MIT" } diff --git a/src/cli.ts b/src/cli.ts index dff60b9..a104991 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,7 @@ program ) .option( "--tools ", - "Comma-separated list of tool categories to enable: collections,directDebit,verification,utilities" + "Comma-separated list of tool categories to enable: collections,directDebit,verification,utilities,subAccounts" ) .option( "--format ", diff --git a/src/client/monnifyClient.ts b/src/client/monnifyClient.ts index 2c67bb1..03a5169 100644 --- a/src/client/monnifyClient.ts +++ b/src/client/monnifyClient.ts @@ -156,6 +156,11 @@ export async function apiPatch(path: string, body?: unknown): Promise { return (data as unknown as { responseBody: T }).responseBody; } +export async function apiPut(path: string, body: unknown): Promise { + const { data } = await monnifyClient.put<{ responseBody: T }>(path, body); + return (data as unknown as { responseBody: T }).responseBody; +} + export async function apiDelete(path: string): Promise { const { data } = await monnifyClient.delete<{ responseBody: T }>(path); return (data as unknown as { responseBody: T }).responseBody; diff --git a/src/index.ts b/src/index.ts index 8cb62af..b1da229 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,12 @@ if (isOperationAllowed("directDebit", env())) { await import("./tools/directDebit/getMandateDebitStatus.js"); await import("./tools/directDebit/cancelMandate.js"); } +if (isOperationAllowed("subAccounts", env())) { + await import("./tools/subAccounts/createSubAccounts.js"); + await import("./tools/subAccounts/getSubAccounts.js"); + await import("./tools/subAccounts/updateSubAccount.js"); + await import("./tools/subAccounts/deleteSubAccount.js"); +} export const server = new Server( { name: "monnify-mcp", version: "1.0.0" }, diff --git a/src/schemas/compat.ts b/src/schemas/compat.ts index 561ee7a..b49ea56 100644 --- a/src/schemas/compat.ts +++ b/src/schemas/compat.ts @@ -15,6 +15,8 @@ export const AuthoriseCard3dsBodySchema = s["Authorize3DSCardRequest"]!; export const CreateMandateBodySchema = s["CreateMandateRequest"]!; export const DebitMandateBodySchema = s["DebitMandateRequest"]!; export const VerifyBvnBodySchema = s["BVNVerificationRequest"]!; +export const CreateSubAccountBodySchema = s["CreateSubAccountRequest"]!; +export const UpdateSubAccountBodySchema = s["UpdateSubAccountRequest"]!; export const CancelMandateBodySchema = z .object({ diff --git a/src/schemas/extended/collections.ts b/src/schemas/extended/collections.ts index 1b7620c..1b253b7 100644 --- a/src/schemas/extended/collections.ts +++ b/src/schemas/extended/collections.ts @@ -60,7 +60,9 @@ export const InitiatePaymentInputSchema = InitiatePaymentBodySchema.extend({ subAccountCode: z .string() .optional() - .describe("Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228')."), + .describe( + "Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228'). Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature." + ), feePercentage: z .number() .optional() @@ -164,7 +166,9 @@ export const ReserveAccountInputSchema = ReserveAccountBodySchema.extend({ subAccountCode: z .string() .optional() - .describe("Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228')."), + .describe( + "Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228'). Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature." + ), feePercentage: z .number() .optional() @@ -222,8 +226,12 @@ export const CreateInvoiceInputSchema = CreateInvoiceBodySchema.extend({ .describe("Currency code — currently only NGN is supported."), expiryDate: z .string() + .regex( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, + "expiryDate must be in 'YYYY-MM-DD HH:mm:ss' format (space-separated, not ISO 8601 — e.g. '2024-12-19 10:15:30')" + ) .describe( - "Invoice expiry date in ISO 8601 format (YYYY-MM-DDTHH:mm:ss). After this date the invoice can no longer be paid." + "Invoice expiry date in 'YYYY-MM-DD HH:mm:ss' format (space-separated — NOT ISO 8601, e.g. '2024-12-19 10:15:30'). After this date the invoice can no longer be paid." ), paymentMethods: z .array(z.enum(["CARD", "ACCOUNT_TRANSFER", "USSD", "PHONE_NUMBER"])) @@ -441,7 +449,12 @@ export const ChargeCardTokenInputSchema = z.object({ incomeSplitConfig: z .array( z.object({ - subAccountCode: z.string().optional().describe("Sub-account code to receive the split."), + subAccountCode: z + .string() + .optional() + .describe( + "Sub-account code to receive the split. Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature." + ), feePercentage: z.number().optional().describe("Percentage of the fee borne by this sub-account."), splitPercentage: z.number().optional().describe("Percentage of the amount credited to this sub-account."), splitAmount: z.number().optional().describe("Fixed amount credited to this sub-account per transaction."), diff --git a/src/schemas/extended/directDebit.ts b/src/schemas/extended/directDebit.ts index 6e1be29..e33f7fe 100644 --- a/src/schemas/extended/directDebit.ts +++ b/src/schemas/extended/directDebit.ts @@ -120,7 +120,12 @@ export const DebitMandateInputSchema = DebitMandateBodySchema.extend({ incomeSplitConfig: z .array( z.object({ - subAccountCode: z.string().optional().describe("Sub-account code to receive the split."), + subAccountCode: z + .string() + .optional() + .describe( + "Sub-account code to receive the split. Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature." + ), feePercentage: z.number().optional().describe("Percentage of the fee borne by this sub-account."), splitAmount: z.number().optional().describe("Fixed amount credited to this sub-account per debit."), splitPercentage: z.number().optional().describe("Percentage of the debit amount credited to this sub-account."), diff --git a/src/schemas/extended/subAccounts.ts b/src/schemas/extended/subAccounts.ts new file mode 100644 index 0000000..8ebc6df --- /dev/null +++ b/src/schemas/extended/subAccounts.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { + CreateSubAccountBodySchema, + UpdateSubAccountBodySchema, +} from "../compat.js"; + +const SubAccountItemSchema = CreateSubAccountBodySchema.extend({ + currencyCode: z + .enum(["NGN"]) + .default("NGN") + .describe("Settlement currency — currently only NGN is supported."), + accountNumber: z + .string() + .length(10) + .describe( + "10-digit NUBAN account number that should receive the split." + ), + bankCode: z + .string() + .length(3) + .describe( + "3-digit bank code where accountNumber is domiciled. Call monnify_get_supported_banks to look this up." + ), + email: z + .string() + .email() + .describe("Email address tied to this sub-account — receives settlement notifications."), + defaultSplitPercentage: z + .number() + .min(0) + .max(100) + .describe( + "Default percentage (0-100) of each transaction routed to this sub-account when no per-transaction split is specified." + ), +}); + +export const CreateSubAccountsInputSchema = z.object({ + subAccounts: z + .array(SubAccountItemSchema) + .min(1) + .describe("One or more sub-accounts to create in a single call."), +}); + +export const GetSubAccountsInputSchema = z.object({}); + +export const UpdateSubAccountInputSchema = UpdateSubAccountBodySchema.extend({ + subAccountCode: z + .string() + .min(1) + .describe( + "The sub-account code to update — returned as subAccountCode from monnify_create_sub_accounts or monnify_get_sub_accounts." + ), + currencyCode: z + .enum(["NGN"]) + .default("NGN") + .describe("Settlement currency — currently only NGN is supported."), + accountNumber: z + .string() + .length(10) + .describe("10-digit NUBAN account number that should receive the split."), + bankCode: z + .string() + .length(3) + .describe( + "3-digit bank code where accountNumber is domiciled. Call monnify_get_supported_banks to look this up." + ), + email: z + .string() + .email() + .describe("Email address tied to this sub-account."), + defaultSplitPercentage: z + .number() + .min(0) + .max(100) + .describe( + "Default percentage (0-100) of each transaction routed to this sub-account when no per-transaction split is specified." + ), +}); + +export const DeleteSubAccountInputSchema = z.object({ + subAccountCode: z + .string() + .min(1) + .describe("The sub-account code to permanently delete."), +}); diff --git a/src/security/guards.ts b/src/security/guards.ts index 2252689..e36ce12 100644 --- a/src/security/guards.ts +++ b/src/security/guards.ts @@ -4,7 +4,8 @@ export type OperationCategory = | "collections" | "directDebit" | "verification" - | "utilities"; + | "utilities" + | "subAccounts"; const API_KEY_PATTERN = /^MK_(TEST|PROD)_[A-Za-z0-9]{10}/; diff --git a/src/security/sanitiser.ts b/src/security/sanitiser.ts index 348b2e0..5d2e3c9 100644 --- a/src/security/sanitiser.ts +++ b/src/security/sanitiser.ts @@ -213,6 +213,19 @@ const DEALLOCATE_ACCOUNT_FIELDS = [ "status", ] as const; +// Sub Accounts — whitelisted fields +const SUB_ACCOUNT_FIELDS = [ + "subAccountCode", + "accountNumber", + "accountName", + "currencyCode", + "email", + "bankCode", + "bankName", + "defaultSplitPercentage", + "settlementProfileCode", +] as const; + // Bank list — whitelisted fields const BANK_FIELDS = ["name", "code"] as const; @@ -337,6 +350,18 @@ export function sanitiseNinResponse( return pickFields(raw, NIN_FIELDS); } +export function sanitiseSubAccountResponse( + raw: Record +): Record<(typeof SUB_ACCOUNT_FIELDS)[number], unknown> { + return pickFields(raw, SUB_ACCOUNT_FIELDS); +} + +export function sanitiseSubAccountListResponse( + raw: Array> +): Array> { + return raw.map((account) => pickFields(account, SUB_ACCOUNT_FIELDS)); +} + export function sanitiseBankListResponse( raw: Array> ): Array> { diff --git a/src/tools/collections/processRefund.ts b/src/tools/collections/processRefund.ts index 97fb42a..42d491b 100644 --- a/src/tools/collections/processRefund.ts +++ b/src/tools/collections/processRefund.ts @@ -17,9 +17,9 @@ const definition: Tool = { WHEN TO USE: To reverse a charge at a customer's request, after an order cancellation, or when a duplicate or suspicious charge is detected. Can be a full or partial refund. -PREREQUISITES: The original transaction must have paymentStatus = PAID. Obtain the transactionReference from monnify_get_transaction_status. Verify the destination account with monnify_verify_bank_account first. +PREREQUISITES: The original transaction must have paymentStatus = PAID AND paymentMethod = ACCOUNT_TRANSFER — check both via monnify_get_transaction_status or monnify_get_transaction_details first. Transactions paid by CARD or USSD cannot be refunded through this API at all; the request will be rejected regardless of amount. Verify the destination account with monnify_verify_bank_account first. The Refund API is also disabled by default — a "not permitted" response means it needs to be enabled on your account by emailing integration-support@monnify.com, not that the request was malformed. -SIDE EFFECTS: Initiates a real fund transfer back to the customer. This is a financial operation — confirm the transaction reference and refund amount carefully before proceeding. Using the same refundReference is safe (idempotent — will not double-refund). +SIDE EFFECTS: Initiates a real fund transfer back to the customer's bank account. This is a financial operation — confirm the transaction reference and refund amount carefully before proceeding. Using the same refundReference is safe (idempotent — will not double-refund). MFA NOTE: Not applicable. diff --git a/src/tools/directDebit/createMandate.ts b/src/tools/directDebit/createMandate.ts index 998f1af..f30ab93 100644 --- a/src/tools/directDebit/createMandate.ts +++ b/src/tools/directDebit/createMandate.ts @@ -17,7 +17,7 @@ const definition: Tool = { WHEN TO USE: When setting up recurring billing for a customer — subscriptions, instalments, or utility collection. This is always the first step in the Direct Debit lifecycle. -PREREQUISITES: None. However, verify the customer's bank account with monnify_verify_bank_account first to confirm account details before committing them to a mandate. +PREREQUISITES: Direct Debit is disabled by default — email integration-support@monnify.com to have it enabled on your account before use. Also verify the customer's bank account with monnify_verify_bank_account first to confirm account details before committing them to a mandate. SIDE EFFECTS: Generates a mandateReference and a 30-day authorization link. The mandate status starts as PENDING_AUTHORIZATION — no debiting can occur until the customer clicks the link and authorises via their bank. Monnify automatically routes to TeamApt or NIBSS based on the customer's bank. diff --git a/src/tools/subAccounts/createSubAccounts.ts b/src/tools/subAccounts/createSubAccounts.ts new file mode 100644 index 0000000..71b8373 --- /dev/null +++ b/src/tools/subAccounts/createSubAccounts.ts @@ -0,0 +1,64 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { McpToolResult } from "../../types/mcp.js"; +import { apiPost } from "../../client/monnifyClient.js"; +import { sanitiseSubAccountListResponse } from "../../security/sanitiser.js"; +import { registerTool } from "../registry.js"; +import { MonnifyApiError } from "../../utils/errors.js"; +import { errorResult } from "../../types/mcp.js"; +import { formatSubAccountList } from "../../utils/format.js"; +import { getResponseFormat } from "../../utils/clientContext.js"; +import { CreateSubAccountsInputSchema } from "../../schemas/extended/subAccounts.js"; + +const definition: Tool = { + name: "monnify_create_sub_accounts", + description: `Creates one or more Sub Accounts, used to automatically split payments across multiple bank accounts. + +WHEN TO USE: Before using incomeSplitConfig on monnify_initiate_payment, monnify_reserve_account, monnify_charge_card_token, or monnify_debit_mandate — a subAccountCode must already exist before it can receive a split. Also use for marketplace or multi-vendor setups where each vendor needs their own settlement account. + +PREREQUISITES: Sub Accounts are disabled by default — email integration-support@monnify.com to have this feature enabled on your account before use. + +SIDE EFFECTS: Creates one or more sub-account records tied to real bank accounts on Monnify. Each sub-account receives its own subAccountCode. + +MFA NOTE: Not applicable. + +KEY OUTPUT FIELDS: subAccountCode (use this in incomeSplitConfig), accountNumber, accountName, bankCode, bankName, defaultSplitPercentage.`, + inputSchema: zodToJsonSchema(CreateSubAccountsInputSchema) as Tool["inputSchema"], +}; + +async function handler(args: unknown): Promise { + try { + const parsed = CreateSubAccountsInputSchema.parse(args); + const result = await apiPost>>( + "/api/v1/sub-accounts", + parsed.subAccounts + ); + const sanitised = sanitiseSubAccountListResponse( + Array.isArray(result) ? result : [] + ); + return { + content: [{ type: "text", text: getResponseFormat() === "json" ? JSON.stringify(sanitised, null, 2) : formatSubAccountList(sanitised as Array>, "created") }], + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + content: [ + { + type: "text", + text: `Validation failed:\n${error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`, + }, + ], + isError: true, + }; + } + if (error instanceof MonnifyApiError) { + return { content: [error.toMcpContent()], isError: true }; + } + return errorResult(`monnify_create_sub_accounts failed: ${String(error)}`); + } +} + +registerTool({ definition, handler }); + +export { definition, CreateSubAccountsInputSchema as inputSchema, handler }; diff --git a/src/tools/subAccounts/deleteSubAccount.ts b/src/tools/subAccounts/deleteSubAccount.ts new file mode 100644 index 0000000..38b138d --- /dev/null +++ b/src/tools/subAccounts/deleteSubAccount.ts @@ -0,0 +1,59 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { McpToolResult } from "../../types/mcp.js"; +import { apiDelete } from "../../client/monnifyClient.js"; +import { registerTool } from "../registry.js"; +import { MonnifyApiError } from "../../utils/errors.js"; +import { errorResult } from "../../types/mcp.js"; +import { formatDeleteSubAccount } from "../../utils/format.js"; +import { getResponseFormat } from "../../utils/clientContext.js"; +import { DeleteSubAccountInputSchema } from "../../schemas/extended/subAccounts.js"; + +const definition: Tool = { + name: "monnify_delete_sub_account", + description: `Permanently deletes a Sub Account. + +WHEN TO USE: When a vendor or partner is offboarded and should no longer receive split payments. + +PREREQUISITES: The sub-account must exist. Any incomeSplitConfig still referencing this subAccountCode will fail on future transactions once deleted. + +SIDE EFFECTS: DESTRUCTIVE — permanently removes the sub-account. Cannot be undone. + +MFA NOTE: Not applicable. + +KEY OUTPUT FIELDS: subAccountCode (confirms which sub-account was deleted).`, + inputSchema: zodToJsonSchema(DeleteSubAccountInputSchema) as Tool["inputSchema"], +}; + +async function handler(args: unknown): Promise { + try { + const parsed = DeleteSubAccountInputSchema.parse(args); + await apiDelete( + `/api/v1/sub-accounts/${encodeURIComponent(parsed.subAccountCode)}` + ); + return { + content: [{ type: "text", text: getResponseFormat() === "json" ? JSON.stringify({ subAccountCode: parsed.subAccountCode, deleted: true }, null, 2) : formatDeleteSubAccount(parsed.subAccountCode) }], + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + content: [ + { + type: "text", + text: `Validation failed:\n${error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`, + }, + ], + isError: true, + }; + } + if (error instanceof MonnifyApiError) { + return { content: [error.toMcpContent()], isError: true }; + } + return errorResult(`monnify_delete_sub_account failed: ${String(error)}`); + } +} + +registerTool({ definition, handler }); + +export { definition, DeleteSubAccountInputSchema as inputSchema, handler }; diff --git a/src/tools/subAccounts/getSubAccounts.ts b/src/tools/subAccounts/getSubAccounts.ts new file mode 100644 index 0000000..2f9ee0f --- /dev/null +++ b/src/tools/subAccounts/getSubAccounts.ts @@ -0,0 +1,63 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { McpToolResult } from "../../types/mcp.js"; +import { apiGet } from "../../client/monnifyClient.js"; +import { sanitiseSubAccountListResponse } from "../../security/sanitiser.js"; +import { registerTool } from "../registry.js"; +import { MonnifyApiError } from "../../utils/errors.js"; +import { errorResult } from "../../types/mcp.js"; +import { formatSubAccountList } from "../../utils/format.js"; +import { getResponseFormat } from "../../utils/clientContext.js"; +import { GetSubAccountsInputSchema } from "../../schemas/extended/subAccounts.js"; + +const definition: Tool = { + name: "monnify_get_sub_accounts", + description: `Lists all Sub Accounts created on your integration. + +WHEN TO USE: To look up an existing subAccountCode before configuring an incomeSplitConfig, or to audit which sub-accounts are currently set up. + +PREREQUISITES: Sub Accounts must be enabled on your account — email integration-support@monnify.com if this returns "not permitted". + +SIDE EFFECTS: None. Read-only operation. + +MFA NOTE: Not applicable. + +KEY OUTPUT FIELDS: subAccountCode (use this in incomeSplitConfig), accountNumber, accountName, bankCode, bankName, defaultSplitPercentage.`, + inputSchema: zodToJsonSchema(GetSubAccountsInputSchema) as Tool["inputSchema"], +}; + +async function handler(args: unknown): Promise { + try { + GetSubAccountsInputSchema.parse(args); + const result = await apiGet>>( + "/api/v1/sub-accounts" + ); + const sanitised = sanitiseSubAccountListResponse( + Array.isArray(result) ? result : [] + ); + return { + content: [{ type: "text", text: getResponseFormat() === "json" ? JSON.stringify(sanitised, null, 2) : formatSubAccountList(sanitised as Array>, "found") }], + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + content: [ + { + type: "text", + text: `Validation failed:\n${error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`, + }, + ], + isError: true, + }; + } + if (error instanceof MonnifyApiError) { + return { content: [error.toMcpContent()], isError: true }; + } + return errorResult(`monnify_get_sub_accounts failed: ${String(error)}`); + } +} + +registerTool({ definition, handler }); + +export { definition, GetSubAccountsInputSchema as inputSchema, handler }; diff --git a/src/tools/subAccounts/updateSubAccount.ts b/src/tools/subAccounts/updateSubAccount.ts new file mode 100644 index 0000000..50f59f5 --- /dev/null +++ b/src/tools/subAccounts/updateSubAccount.ts @@ -0,0 +1,62 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { McpToolResult } from "../../types/mcp.js"; +import { apiPut } from "../../client/monnifyClient.js"; +import { sanitiseSubAccountResponse } from "../../security/sanitiser.js"; +import { registerTool } from "../registry.js"; +import { MonnifyApiError } from "../../utils/errors.js"; +import { errorResult } from "../../types/mcp.js"; +import { formatUpdateSubAccount } from "../../utils/format.js"; +import { getResponseFormat } from "../../utils/clientContext.js"; +import { UpdateSubAccountInputSchema } from "../../schemas/extended/subAccounts.js"; + +const definition: Tool = { + name: "monnify_update_sub_account", + description: `Updates the details of an existing Sub Account. + +WHEN TO USE: When a sub-account's linked bank account, email, or default split percentage changes. + +PREREQUISITES: The sub-account must already exist — obtain its subAccountCode from monnify_create_sub_accounts or monnify_get_sub_accounts. Sub Accounts must be enabled on your account — email integration-support@monnify.com if this returns "not permitted". + +SIDE EFFECTS: Overwrites the sub-account's stored bank details, email, and default split percentage. Existing incomeSplitConfig references to this subAccountCode are unaffected — only the destination account and split behaviour change going forward. + +MFA NOTE: Not applicable. + +KEY OUTPUT FIELDS: subAccountCode, accountNumber, accountName, bankCode, bankName, defaultSplitPercentage.`, + inputSchema: zodToJsonSchema(UpdateSubAccountInputSchema) as Tool["inputSchema"], +}; + +async function handler(args: unknown): Promise { + try { + const parsed = UpdateSubAccountInputSchema.parse(args); + const result = await apiPut>( + "/api/v1/sub-accounts", + parsed + ); + const sanitised = sanitiseSubAccountResponse(result); + return { + content: [{ type: "text", text: getResponseFormat() === "json" ? JSON.stringify(sanitised, null, 2) : formatUpdateSubAccount(sanitised as Record) }], + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + content: [ + { + type: "text", + text: `Validation failed:\n${error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`, + }, + ], + isError: true, + }; + } + if (error instanceof MonnifyApiError) { + return { content: [error.toMcpContent()], isError: true }; + } + return errorResult(`monnify_update_sub_account failed: ${String(error)}`); + } +} + +registerTool({ definition, handler }); + +export { definition, UpdateSubAccountInputSchema as inputSchema, handler }; diff --git a/src/tools/verification/verifyBankAccount.ts b/src/tools/verification/verifyBankAccount.ts index 76217cb..17f646d 100644 --- a/src/tools/verification/verifyBankAccount.ts +++ b/src/tools/verification/verifyBankAccount.ts @@ -31,7 +31,7 @@ async function handler(args: unknown): Promise { try { const parsed = VerifyBankAccountInputSchema.parse(args); const result = await apiGet>( - "/api/v1/disbursements/account/validate", + "/api/v2/disbursements/account/validate", { accountNumber: parsed.accountNumber, bankCode: parsed.bankCode, diff --git a/src/tools/verification/verifyBvn.ts b/src/tools/verification/verifyBvn.ts index f207584..b4ae0aa 100644 --- a/src/tools/verification/verifyBvn.ts +++ b/src/tools/verification/verifyBvn.ts @@ -17,7 +17,7 @@ const definition: Tool = { WHEN TO USE: For KYC (Know Your Customer) verification flows, onboarding with identity checks, or before creating a direct debit mandate where BVN validation is required. -PREREQUISITES: None. Requires Monnify Identity Verification API access to be enabled on your account. +PREREQUISITES: None. Requires Monnify Identity Verification API access to be enabled on your account. Live environment only — not available in sandbox, so this call will fail with --env=sandbox regardless of BVN validity. SIDE EFFECTS: None. Read-only — does not store any data. diff --git a/src/utils/format.ts b/src/utils/format.ts index d12a6af..e579f4e 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -398,3 +398,51 @@ export function formatCancelMandate(d: Record): string { `**Status:** ${status}`, ].join("\n"); } + +// ── Sub Accounts ────────────────────────────────────────────────────────────── + +export function formatSubAccountList( + accounts: Array>, + action: "created" | "found" +): string { + if (!accounts.length) { + return action === "created" + ? "No sub accounts were created." + : "No sub accounts found."; + } + const rows = accounts + .map( + (a) => + `| ${a["subAccountCode"] ?? "—"} | ${a["accountName"] ?? "—"} | ${a["accountNumber"] ?? "—"} (${a["bankName"] ?? a["bankCode"] ?? "—"}) | ${a["defaultSplitPercentage"] ?? "—"}% |` + ) + .join("\n"); + const title = action === "created" ? "🏢 **Sub Accounts Created**" : "🏢 **Sub Accounts**"; + return [ + `${title} (${accounts.length})`, + ``, + `| Sub Account Code | Name | Account | Default Split |`, + `|-------------------|------|---------|----------------|`, + rows, + ].join("\n"); +} + +export function formatUpdateSubAccount(d: Record): string { + return [ + `🏢 **Sub Account Updated**`, + ``, + `**Sub Account Code:** ${d["subAccountCode"] ?? "—"}`, + `**Account Name:** ${d["accountName"] ?? "—"}`, + `**Account Number:** ${d["accountNumber"] ?? "—"} (${d["bankName"] ?? d["bankCode"] ?? "—"})`, + `**Default Split Percentage:** ${d["defaultSplitPercentage"] ?? "—"}%`, + ].join("\n"); +} + +export function formatDeleteSubAccount(subAccountCode: string): string { + return [ + `🗑️ **Sub Account Deleted**`, + ``, + `**Sub Account Code:** ${subAccountCode}`, + ``, + `> This sub-account can no longer receive split payments.`, + ].join("\n"); +} diff --git a/tests/security/guards.test.ts b/tests/security/guards.test.ts index a8761b3..41182a5 100644 --- a/tests/security/guards.test.ts +++ b/tests/security/guards.test.ts @@ -185,6 +185,15 @@ describe("isOperationAllowed", () => { expect(isOperationAllowed("directDebit", env)).toBe(true); expect(isOperationAllowed("verification", env)).toBe(true); expect(isOperationAllowed("utilities", env)).toBe(true); + expect(isOperationAllowed("subAccounts", env)).toBe(true); + }); + + it("returns false for 'subAccounts' when MONNIFY_ALLOWED_OPERATIONS=verification,utilities", () => { + const env = makeEnv({ + MONNIFY_ALLOWED_OPERATIONS: "verification,utilities", + }); + + expect(isOperationAllowed("subAccounts", env)).toBe(false); }); it("returns false for 'collections' when MONNIFY_ALLOWED_OPERATIONS=verification,utilities", () => { diff --git a/tests/tools/createInvoice.test.ts b/tests/tools/createInvoice.test.ts index c8622b2..993e063 100644 --- a/tests/tools/createInvoice.test.ts +++ b/tests/tools/createInvoice.test.ts @@ -31,7 +31,7 @@ describe("monnify_create_invoice", () => { customerEmail: "customer@example.com", customerName: "John Doe", currencyCode: "NGN", - expiryDate: "2025-12-31T23:59:59", + expiryDate: "2025-12-31 23:59:59", }; it("returns checkoutUrl and invoiceReference on success", async () => { @@ -62,7 +62,7 @@ describe("monnify_create_invoice", () => { invoiceReference: "INV-001", contractCode: "626843051499", customerEmail: "customer@example.com", - expiryDate: "2025-12-31T23:59:59", + expiryDate: "2025-12-31 23:59:59", }) ); const parsed = JSON.parse(result.content[0]?.text ?? ""); @@ -114,6 +114,20 @@ describe("monnify_create_invoice", () => { expect(apiPost).not.toHaveBeenCalled(); }); + it("fails validation when expiryDate is ISO 8601 instead of space-separated", async () => { + const { apiPost } = await import("../../src/client/monnifyClient.js"); + const { handler } = await import("../../src/tools/collections/createInvoice.js"); + + const result = await handler({ + ...validInput, + expiryDate: "2025-12-31T23:59:59", + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Validation failed"); + expect(apiPost).not.toHaveBeenCalled(); + }); + it("returns MonnifyApiError on duplicate invoiceReference", async () => { const { apiPost } = await import("../../src/client/monnifyClient.js"); const { MonnifyApiError } = await import("../../src/utils/errors.js"); diff --git a/tests/tools/createSubAccounts.test.ts b/tests/tools/createSubAccounts.test.ts new file mode 100644 index 0000000..decafc6 --- /dev/null +++ b/tests/tools/createSubAccounts.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/client/monnifyClient.js", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})); + +vi.mock("../../src/tools/registry.js", () => ({ + registerTool: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("monnify_create_sub_accounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validInput = { + subAccounts: [ + { + currencyCode: "NGN", + accountNumber: "0211319282", + bankCode: "058", + email: "vendor@example.com", + defaultSplitPercentage: 20, + }, + ], + }; + + it("posts the sub-accounts array directly (not wrapped) to /api/v1/sub-accounts", async () => { + const { apiPost } = await import("../../src/client/monnifyClient.js"); + vi.mocked(apiPost).mockResolvedValueOnce([ + { + subAccountCode: "MFY_SUB_811397375865", + accountNumber: "0211319282", + accountName: "ALEMOH DANIEL MOSES", + currencyCode: "NGN", + email: "vendor@example.com", + bankCode: "058", + bankName: "GTBank", + defaultSplitPercentage: 20, + settlementProfileCode: "8717495899", + settlementReportEmails: ["vendor@example.com"], + }, + ]); + + const { handler } = await import("../../src/tools/subAccounts/createSubAccounts.js"); + const result = await handler(validInput); + + expect(result.isError).toBeFalsy(); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/sub-accounts", + validInput.subAccounts + ); + const parsed = JSON.parse(result.content[0]?.text ?? ""); + expect(parsed).toHaveLength(1); + expect(parsed[0].subAccountCode).toBe("MFY_SUB_811397375865"); + expect(parsed[0].settlementReportEmails).toBeUndefined(); + }); + + it("fails validation when subAccounts is empty", async () => { + const { apiPost } = await import("../../src/client/monnifyClient.js"); + const { handler } = await import("../../src/tools/subAccounts/createSubAccounts.js"); + + const result = await handler({ subAccounts: [] }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Validation failed"); + expect(apiPost).not.toHaveBeenCalled(); + }); + + it("fails validation when a required field is missing", async () => { + const { apiPost } = await import("../../src/client/monnifyClient.js"); + const { handler } = await import("../../src/tools/subAccounts/createSubAccounts.js"); + + const { email: _, ...noEmail } = validInput.subAccounts[0]!; + const result = await handler({ subAccounts: [noEmail] }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Validation failed"); + expect(apiPost).not.toHaveBeenCalled(); + }); + + it("returns MonnifyApiError when Sub Accounts feature is not permitted", async () => { + const { apiPost } = await import("../../src/client/monnifyClient.js"); + const { MonnifyApiError } = await import("../../src/utils/errors.js"); + + vi.mocked(apiPost).mockRejectedValueOnce( + new MonnifyApiError("99", "Not permitted for this account", 403) + ); + + const { handler } = await import("../../src/tools/subAccounts/createSubAccounts.js"); + const result = await handler(validInput); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Not permitted for this account"); + }); +}); diff --git a/tests/tools/deleteSubAccount.test.ts b/tests/tools/deleteSubAccount.test.ts new file mode 100644 index 0000000..b4b8f65 --- /dev/null +++ b/tests/tools/deleteSubAccount.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/client/monnifyClient.js", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), +})); + +vi.mock("../../src/tools/registry.js", () => ({ + registerTool: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const SUB_ACCOUNT_CODE = "MFY_SUB_811397375865"; + +describe("monnify_delete_sub_account", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends DELETE to the path-param endpoint and confirms deletion", async () => { + const { apiDelete } = await import("../../src/client/monnifyClient.js"); + vi.mocked(apiDelete).mockResolvedValueOnce(undefined); + + const { handler } = await import("../../src/tools/subAccounts/deleteSubAccount.js"); + const result = await handler({ subAccountCode: SUB_ACCOUNT_CODE }); + + expect(result.isError).toBeFalsy(); + expect(apiDelete).toHaveBeenCalledWith( + `/api/v1/sub-accounts/${SUB_ACCOUNT_CODE}` + ); + const parsed = JSON.parse(result.content[0]?.text ?? ""); + expect(parsed).toEqual({ subAccountCode: SUB_ACCOUNT_CODE, deleted: true }); + }); + + it("fails validation when subAccountCode is missing", async () => { + const { apiDelete } = await import("../../src/client/monnifyClient.js"); + const { handler } = await import("../../src/tools/subAccounts/deleteSubAccount.js"); + + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Validation failed"); + expect(apiDelete).not.toHaveBeenCalled(); + }); + + it("returns MonnifyApiError when the sub account does not exist", async () => { + const { apiDelete } = await import("../../src/client/monnifyClient.js"); + const { MonnifyApiError } = await import("../../src/utils/errors.js"); + + vi.mocked(apiDelete).mockRejectedValueOnce( + new MonnifyApiError("99", "Sub account not found", 404) + ); + + const { handler } = await import("../../src/tools/subAccounts/deleteSubAccount.js"); + const result = await handler({ subAccountCode: SUB_ACCOUNT_CODE }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Sub account not found"); + }); +}); diff --git a/tests/tools/getSubAccounts.test.ts b/tests/tools/getSubAccounts.test.ts new file mode 100644 index 0000000..cf59a01 --- /dev/null +++ b/tests/tools/getSubAccounts.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/client/monnifyClient.js", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})); + +vi.mock("../../src/tools/registry.js", () => ({ + registerTool: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe("monnify_get_sub_accounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the list of sub accounts", async () => { + const { apiGet } = await import("../../src/client/monnifyClient.js"); + vi.mocked(apiGet).mockResolvedValueOnce([ + { + subAccountCode: "MFY_SUB_811397375865", + accountNumber: "0211319282", + accountName: "ALEMOH DANIEL MOSES", + bankCode: "058", + bankName: "GTBank", + defaultSplitPercentage: 20, + }, + ]); + + const { handler } = await import("../../src/tools/subAccounts/getSubAccounts.js"); + const result = await handler({}); + + expect(result.isError).toBeFalsy(); + expect(apiGet).toHaveBeenCalledWith("/api/v1/sub-accounts"); + const parsed = JSON.parse(result.content[0]?.text ?? ""); + expect(parsed).toHaveLength(1); + expect(parsed[0].subAccountCode).toBe("MFY_SUB_811397375865"); + }); + + it("returns an empty array when no sub accounts exist", async () => { + const { apiGet } = await import("../../src/client/monnifyClient.js"); + vi.mocked(apiGet).mockResolvedValueOnce([]); + + const { handler } = await import("../../src/tools/subAccounts/getSubAccounts.js"); + const result = await handler({}); + + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(result.content[0]?.text ?? ""); + expect(parsed).toEqual([]); + }); + + it("returns MonnifyApiError on API failure", async () => { + const { apiGet } = await import("../../src/client/monnifyClient.js"); + const { MonnifyApiError } = await import("../../src/utils/errors.js"); + + vi.mocked(apiGet).mockRejectedValueOnce( + new MonnifyApiError("99", "Not permitted for this account", 403) + ); + + const { handler } = await import("../../src/tools/subAccounts/getSubAccounts.js"); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Not permitted for this account"); + }); +}); diff --git a/tests/tools/updateSubAccount.test.ts b/tests/tools/updateSubAccount.test.ts new file mode 100644 index 0000000..9f021df --- /dev/null +++ b/tests/tools/updateSubAccount.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/client/monnifyClient.js", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), +})); + +vi.mock("../../src/tools/registry.js", () => ({ + registerTool: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe("monnify_update_sub_account", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validInput = { + subAccountCode: "MFY_SUB_811397375865", + currencyCode: "NGN", + accountNumber: "0211319282", + bankCode: "058", + email: "vendor@example.com", + defaultSplitPercentage: 25, + }; + + it("sends PUT to /api/v1/sub-accounts with the full update payload", async () => { + const { apiPut } = await import("../../src/client/monnifyClient.js"); + vi.mocked(apiPut).mockResolvedValueOnce({ + subAccountCode: "MFY_SUB_811397375865", + accountNumber: "0211319282", + accountName: "ALEMOH DANIEL MOSES", + bankCode: "058", + bankName: "GTBank", + defaultSplitPercentage: 25, + }); + + const { handler } = await import("../../src/tools/subAccounts/updateSubAccount.js"); + const result = await handler(validInput); + + expect(result.isError).toBeFalsy(); + expect(apiPut).toHaveBeenCalledWith( + "/api/v1/sub-accounts", + expect.objectContaining({ subAccountCode: "MFY_SUB_811397375865", defaultSplitPercentage: 25 }) + ); + const parsed = JSON.parse(result.content[0]?.text ?? ""); + expect(parsed.defaultSplitPercentage).toBe(25); + }); + + it("fails validation when subAccountCode is missing", async () => { + const { apiPut } = await import("../../src/client/monnifyClient.js"); + const { handler } = await import("../../src/tools/subAccounts/updateSubAccount.js"); + + const { subAccountCode: _, ...noCode } = validInput; + const result = await handler(noCode); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Validation failed"); + expect(apiPut).not.toHaveBeenCalled(); + }); + + it("returns MonnifyApiError when the sub account does not exist", async () => { + const { apiPut } = await import("../../src/client/monnifyClient.js"); + const { MonnifyApiError } = await import("../../src/utils/errors.js"); + + vi.mocked(apiPut).mockRejectedValueOnce( + new MonnifyApiError("99", "Sub account not found", 404) + ); + + const { handler } = await import("../../src/tools/subAccounts/updateSubAccount.js"); + const result = await handler(validInput); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Sub account not found"); + }); +}); diff --git a/tests/tools/verifyBankAccount.test.ts b/tests/tools/verifyBankAccount.test.ts index 89970c0..619aeec 100644 --- a/tests/tools/verifyBankAccount.test.ts +++ b/tests/tools/verifyBankAccount.test.ts @@ -42,6 +42,10 @@ describe("monnify_verify_bank_account", () => { }); expect(result.isError).toBeFalsy(); + expect(apiGet).toHaveBeenCalledWith( + "/api/v2/disbursements/account/validate", + { accountNumber: "0123456789", bankCode: "058" } + ); const text = result.content[0]?.text ?? ""; const parsed = JSON.parse(text); expect(parsed.accountName).toBe("JOHN DOE");