diff --git a/README.md b/README.md index d038160..8804721 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Currently, with this SDK you can: - Suppressions management - Account access management - Permissions management + - Billing usage management - List accounts you have access to @@ -178,6 +179,7 @@ Refer to the [`examples`](examples) folder for the source code of this and other - [List User & Invite account accesses](examples/general/account-accesses.ts) - [Remove account access](examples/general/accounts.ts) - [Permissions](examples/general/permissions.ts) +- [Billing usage](examples/general/billing.ts) ## Development diff --git a/examples/general/billing.ts b/examples/general/billing.ts new file mode 100644 index 0000000..3ebb8c8 --- /dev/null +++ b/examples/general/billing.ts @@ -0,0 +1,20 @@ +import { MailtrapClient } from "mailtrap" + +const TOKEN = ""; +const TEST_INBOX_ID = "" +const ACCOUNT_ID = "" + +const client = new MailtrapClient({ token: TOKEN, testInboxId: TEST_INBOX_ID, accountId: ACCOUNT_ID }); + +const billingClient = client.general.billing + +const testBillingCycleUsage = async () => { + try { + const result = await billingClient.getCurrentBillingCycleUsage() + console.log("Billing cycle usage:", JSON.stringify(result, null, 2)) + } catch (error) { + console.error(error) + } +} + +testBillingCycleUsage() diff --git a/src/__tests__/lib/api/General.test.ts b/src/__tests__/lib/api/General.test.ts index 99cbe79..74cb18f 100644 --- a/src/__tests__/lib/api/General.test.ts +++ b/src/__tests__/lib/api/General.test.ts @@ -13,11 +13,13 @@ describe("lib/api/General: ", () => { expect(generalAPI).toHaveProperty("accountAccesses"); expect(generalAPI).toHaveProperty("accounts"); expect(generalAPI).toHaveProperty("permissions"); + expect(generalAPI).toHaveProperty("billing"); }); it("lazily instantiates account-specific APIs via getters when accountId is provided.", () => { expect(generalAPI.accountAccesses).toBeDefined(); expect(generalAPI.permissions).toBeDefined(); + expect(generalAPI.billing).toBeDefined(); expect(generalAPI.accounts).toBeDefined(); }); }); @@ -56,6 +58,15 @@ describe("lib/api/General: ", () => { "Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance." ); }); + + it("throws error when accessing billing without accountId.", () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + generalAPI.billing; + }).toThrow( + "Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance." + ); + }); }); describe("account discovery functionality: ", () => { @@ -78,10 +89,14 @@ describe("lib/api/General: ", () => { it("maintains existing API surface for account-specific operations.", () => { expect(generalAPI.accountAccesses).toBeDefined(); expect(generalAPI.permissions).toBeDefined(); + expect(generalAPI.billing).toBeDefined(); expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe( "function" ); expect(typeof generalAPI.permissions.getResources).toBe("function"); + expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe( + "function" + ); }); }); @@ -109,10 +124,14 @@ describe("lib/api/General: ", () => { expect(generalAPI.accounts).toBeDefined(); expect(generalAPI.accountAccesses).toBeDefined(); expect(generalAPI.permissions).toBeDefined(); + expect(generalAPI.billing).toBeDefined(); expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe( "function" ); expect(typeof generalAPI.permissions.getResources).toBe("function"); + expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe( + "function" + ); }); }); }); diff --git a/src/__tests__/lib/api/resources/Billing.test.ts b/src/__tests__/lib/api/resources/Billing.test.ts new file mode 100644 index 0000000..28b763a --- /dev/null +++ b/src/__tests__/lib/api/resources/Billing.test.ts @@ -0,0 +1,155 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import Billing from "../../../../lib/api/resources/Billing"; +import handleSendingError from "../../../../lib/axios-logger"; +import MailtrapError from "../../../../lib/MailtrapError"; + +import CONFIG from "../../../../config"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +describe("lib/api/resources/Billing: ", () => { + let mock: AxiosMockAdapter; + const accountId = 100; + const billingAPI = new Billing(axios, accountId); + const responseData = { + billing: { + cycle_start: "2024-01-01T00:00:00Z", + cycle_end: "2024-01-31T23:59:59Z", + }, + sending: { + plan: { + name: "Pro", + }, + usage: { + sent_messages_count: { + current: 1000, + limit: 10000, + }, + }, + }, + testing: { + plan: { + name: "Pro", + }, + usage: { + sent_messages_count: { + current: 500, + limit: 5000, + }, + forwarded_messages_count: { + current: 200, + limit: 2000, + }, + }, + }, + }; + + describe("class Billing(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(billingAPI).toHaveProperty("getCurrentBillingCycleUsage"); + }); + }); + }); + + beforeAll(() => { + /** + * Init Axios interceptors for handling response.data, errors. + */ + axios.interceptors.response.use( + (response) => response.data, + handleSendingError + ); + mock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("getCurrentBillingCycleUsage(): ", () => { + it("successfully gets billing cycle usage.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, responseData); + const result = await billingAPI.getCurrentBillingCycleUsage(); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error when accountId is invalid.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`; + const expectedErrorMessage = "Account not found"; + const statusCode = 404; + + expect.assertions(3); + + mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage }); + + try { + await billingAPI.getCurrentBillingCycleUsage(); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + // Verify status code is accessible via cause property + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + + it("fails with error when billing endpoint returns 403 Forbidden.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`; + const expectedErrorMessage = "Access denied"; + const statusCode = 403; + + expect.assertions(3); + + mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage }); + + try { + await billingAPI.getCurrentBillingCycleUsage(); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + // Verify status code is accessible via cause property + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + + it("fails with error when no error body is provided.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`; + const expectedErrorMessage = "Request failed with status code 500"; + const statusCode = 500; + + expect.assertions(3); + + mock.onGet(endpoint).reply(statusCode); + + try { + await billingAPI.getCurrentBillingCycleUsage(); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + // Verify status code is accessible via cause property + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + }); +}); diff --git a/src/lib/api/General.ts b/src/lib/api/General.ts index 0c0df64..2ac485d 100644 --- a/src/lib/api/General.ts +++ b/src/lib/api/General.ts @@ -2,6 +2,7 @@ import { AxiosInstance } from "axios"; import AccountAccessesApi from "./resources/AccountAccesses"; import AccountsApi from "./resources/Accounts"; +import BillingApi from "./resources/Billing"; import PermissionsApi from "./resources/Permissions"; export default class GeneralAPI { @@ -15,6 +16,8 @@ export default class GeneralAPI { private permissionsInstance: PermissionsApi | null = null; + private billingInstance: BillingApi | null = null; + constructor(client: AxiosInstance, accountId?: number) { this.client = client; this.accountId = accountId ?? null; @@ -30,6 +33,9 @@ export default class GeneralAPI { } } + /** + * Checks if the account ID is present. + */ private checkAccountIdPresence(): number { if (this.accountId === null) { throw new Error( @@ -39,6 +45,9 @@ export default class GeneralAPI { return this.accountId; } + /** + * Singleton getter for Account Accesses API. + */ public get accountAccesses(): AccountAccessesApi { if (this.accountAccessesInstance === null) { const accountId = this.checkAccountIdPresence(); @@ -51,6 +60,9 @@ export default class GeneralAPI { return this.accountAccessesInstance; } + /** + * Singleton getter for Permissions API. + */ public get permissions(): PermissionsApi { if (this.permissionsInstance === null) { const accountId = this.checkAccountIdPresence(); @@ -59,4 +71,16 @@ export default class GeneralAPI { return this.permissionsInstance; } + + /** + * Singleton getter for Billing API. + */ + public get billing(): BillingApi { + if (this.billingInstance === null) { + const accountId = this.checkAccountIdPresence(); + this.billingInstance = new BillingApi(this.client, accountId); + } + + return this.billingInstance; + } } diff --git a/src/lib/api/resources/Billing.ts b/src/lib/api/resources/Billing.ts new file mode 100644 index 0000000..a96a2d8 --- /dev/null +++ b/src/lib/api/resources/Billing.ts @@ -0,0 +1,28 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; + +import { BillingCycleUsage } from "../../../types/api/billing"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +export default class BillingApi { + private client: AxiosInstance; + + private billingURL: string; + + constructor(client: AxiosInstance, accountId: number) { + this.client = client; + this.billingURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`; + } + + /** + * Get billing usage for the account. + */ + public async getCurrentBillingCycleUsage() { + const url = this.billingURL; + + return this.client.get(url); + } +} diff --git a/src/types/api/billing.ts b/src/types/api/billing.ts new file mode 100644 index 0000000..e3e7f01 --- /dev/null +++ b/src/types/api/billing.ts @@ -0,0 +1,32 @@ +export type BillingCycleUsage = { + billing: { + cycle_start: string; + cycle_end: string; + }; + sending: { + plan: { + name: string; + }; + usage: { + sent_messages_count: { + current: number; + limit: number; + }; + }; + }; + testing: { + plan: { + name: string; + }; + usage: { + sent_messages_count: { + current: number; + limit: number; + }; + forwarded_messages_count: { + current: number; + limit: number; + }; + }; + }; +};