Skip to content

Commit c66d455

Browse files
Merge pull request #103 from mailtrap/billing-api
Billing api
2 parents 0fa2b59 + 796b710 commit c66d455

File tree

7 files changed

+280
-0
lines changed

7 files changed

+280
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Currently, with this SDK you can:
4141
- Suppressions management
4242
- Account access management
4343
- Permissions management
44+
- Billing usage management
4445
- List accounts you have access to
4546

4647

@@ -178,6 +179,7 @@ Refer to the [`examples`](examples) folder for the source code of this and other
178179
- [List User & Invite account accesses](examples/general/account-accesses.ts)
179180
- [Remove account access](examples/general/accounts.ts)
180181
- [Permissions](examples/general/permissions.ts)
182+
- [Billing usage](examples/general/billing.ts)
181183

182184
## Development
183185

examples/general/billing.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MailtrapClient } from "mailtrap"
2+
3+
const TOKEN = "<YOUR-TOKEN-HERE>";
4+
const TEST_INBOX_ID = "<YOUR-TEST-INBOX-ID-HERE>"
5+
const ACCOUNT_ID = "<YOUR-ACCOUNT-ID-HERE>"
6+
7+
const client = new MailtrapClient({ token: TOKEN, testInboxId: TEST_INBOX_ID, accountId: ACCOUNT_ID });
8+
9+
const billingClient = client.general.billing
10+
11+
const testBillingCycleUsage = async () => {
12+
try {
13+
const result = await billingClient.getCurrentBillingCycleUsage()
14+
console.log("Billing cycle usage:", JSON.stringify(result, null, 2))
15+
} catch (error) {
16+
console.error(error)
17+
}
18+
}
19+
20+
testBillingCycleUsage()

src/__tests__/lib/api/General.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ describe("lib/api/General: ", () => {
1313
expect(generalAPI).toHaveProperty("accountAccesses");
1414
expect(generalAPI).toHaveProperty("accounts");
1515
expect(generalAPI).toHaveProperty("permissions");
16+
expect(generalAPI).toHaveProperty("billing");
1617
});
1718

1819
it("lazily instantiates account-specific APIs via getters when accountId is provided.", () => {
1920
expect(generalAPI.accountAccesses).toBeDefined();
2021
expect(generalAPI.permissions).toBeDefined();
22+
expect(generalAPI.billing).toBeDefined();
2123
expect(generalAPI.accounts).toBeDefined();
2224
});
2325
});
@@ -56,6 +58,15 @@ describe("lib/api/General: ", () => {
5658
"Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance."
5759
);
5860
});
61+
62+
it("throws error when accessing billing without accountId.", () => {
63+
expect(() => {
64+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
65+
generalAPI.billing;
66+
}).toThrow(
67+
"Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance."
68+
);
69+
});
5970
});
6071

6172
describe("account discovery functionality: ", () => {
@@ -78,10 +89,14 @@ describe("lib/api/General: ", () => {
7889
it("maintains existing API surface for account-specific operations.", () => {
7990
expect(generalAPI.accountAccesses).toBeDefined();
8091
expect(generalAPI.permissions).toBeDefined();
92+
expect(generalAPI.billing).toBeDefined();
8193
expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe(
8294
"function"
8395
);
8496
expect(typeof generalAPI.permissions.getResources).toBe("function");
97+
expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe(
98+
"function"
99+
);
85100
});
86101
});
87102

@@ -109,10 +124,14 @@ describe("lib/api/General: ", () => {
109124
expect(generalAPI.accounts).toBeDefined();
110125
expect(generalAPI.accountAccesses).toBeDefined();
111126
expect(generalAPI.permissions).toBeDefined();
127+
expect(generalAPI.billing).toBeDefined();
112128
expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe(
113129
"function"
114130
);
115131
expect(typeof generalAPI.permissions.getResources).toBe("function");
132+
expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe(
133+
"function"
134+
);
116135
});
117136
});
118137
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import axios from "axios";
2+
import AxiosMockAdapter from "axios-mock-adapter";
3+
4+
import Billing from "../../../../lib/api/resources/Billing";
5+
import handleSendingError from "../../../../lib/axios-logger";
6+
import MailtrapError from "../../../../lib/MailtrapError";
7+
8+
import CONFIG from "../../../../config";
9+
10+
const { CLIENT_SETTINGS } = CONFIG;
11+
const { GENERAL_ENDPOINT } = CLIENT_SETTINGS;
12+
13+
describe("lib/api/resources/Billing: ", () => {
14+
let mock: AxiosMockAdapter;
15+
const accountId = 100;
16+
const billingAPI = new Billing(axios, accountId);
17+
const responseData = {
18+
billing: {
19+
cycle_start: "2024-01-01T00:00:00Z",
20+
cycle_end: "2024-01-31T23:59:59Z",
21+
},
22+
sending: {
23+
plan: {
24+
name: "Pro",
25+
},
26+
usage: {
27+
sent_messages_count: {
28+
current: 1000,
29+
limit: 10000,
30+
},
31+
},
32+
},
33+
testing: {
34+
plan: {
35+
name: "Pro",
36+
},
37+
usage: {
38+
sent_messages_count: {
39+
current: 500,
40+
limit: 5000,
41+
},
42+
forwarded_messages_count: {
43+
current: 200,
44+
limit: 2000,
45+
},
46+
},
47+
},
48+
};
49+
50+
describe("class Billing(): ", () => {
51+
describe("init: ", () => {
52+
it("initializes with all necessary params.", () => {
53+
expect(billingAPI).toHaveProperty("getCurrentBillingCycleUsage");
54+
});
55+
});
56+
});
57+
58+
beforeAll(() => {
59+
/**
60+
* Init Axios interceptors for handling response.data, errors.
61+
*/
62+
axios.interceptors.response.use(
63+
(response) => response.data,
64+
handleSendingError
65+
);
66+
mock = new AxiosMockAdapter(axios);
67+
});
68+
69+
afterEach(() => {
70+
mock.reset();
71+
});
72+
73+
describe("getCurrentBillingCycleUsage(): ", () => {
74+
it("successfully gets billing cycle usage.", async () => {
75+
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
76+
77+
expect.assertions(2);
78+
79+
mock.onGet(endpoint).reply(200, responseData);
80+
const result = await billingAPI.getCurrentBillingCycleUsage();
81+
82+
expect(mock.history.get[0].url).toEqual(endpoint);
83+
expect(result).toEqual(responseData);
84+
});
85+
86+
it("fails with error when accountId is invalid.", async () => {
87+
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
88+
const expectedErrorMessage = "Account not found";
89+
const statusCode = 404;
90+
91+
expect.assertions(3);
92+
93+
mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage });
94+
95+
try {
96+
await billingAPI.getCurrentBillingCycleUsage();
97+
} catch (error) {
98+
expect(error).toBeInstanceOf(MailtrapError);
99+
100+
if (error instanceof MailtrapError) {
101+
expect(error.message).toEqual(expectedErrorMessage);
102+
// Verify status code is accessible via cause property
103+
// @ts-expect-error ES5 types don't know about cause property
104+
expect(error.cause?.response?.status).toEqual(statusCode);
105+
}
106+
}
107+
});
108+
109+
it("fails with error when billing endpoint returns 403 Forbidden.", async () => {
110+
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
111+
const expectedErrorMessage = "Access denied";
112+
const statusCode = 403;
113+
114+
expect.assertions(3);
115+
116+
mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage });
117+
118+
try {
119+
await billingAPI.getCurrentBillingCycleUsage();
120+
} catch (error) {
121+
expect(error).toBeInstanceOf(MailtrapError);
122+
123+
if (error instanceof MailtrapError) {
124+
expect(error.message).toEqual(expectedErrorMessage);
125+
// Verify status code is accessible via cause property
126+
// @ts-expect-error ES5 types don't know about cause property
127+
expect(error.cause?.response?.status).toEqual(statusCode);
128+
}
129+
}
130+
});
131+
132+
it("fails with error when no error body is provided.", async () => {
133+
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
134+
const expectedErrorMessage = "Request failed with status code 500";
135+
const statusCode = 500;
136+
137+
expect.assertions(3);
138+
139+
mock.onGet(endpoint).reply(statusCode);
140+
141+
try {
142+
await billingAPI.getCurrentBillingCycleUsage();
143+
} catch (error) {
144+
expect(error).toBeInstanceOf(MailtrapError);
145+
146+
if (error instanceof MailtrapError) {
147+
expect(error.message).toEqual(expectedErrorMessage);
148+
// Verify status code is accessible via cause property
149+
// @ts-expect-error ES5 types don't know about cause property
150+
expect(error.cause?.response?.status).toEqual(statusCode);
151+
}
152+
}
153+
});
154+
});
155+
});

src/lib/api/General.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AxiosInstance } from "axios";
22

33
import AccountAccessesApi from "./resources/AccountAccesses";
44
import AccountsApi from "./resources/Accounts";
5+
import BillingApi from "./resources/Billing";
56
import PermissionsApi from "./resources/Permissions";
67

78
export default class GeneralAPI {
@@ -15,6 +16,8 @@ export default class GeneralAPI {
1516

1617
private permissionsInstance: PermissionsApi | null = null;
1718

19+
private billingInstance: BillingApi | null = null;
20+
1821
constructor(client: AxiosInstance, accountId?: number) {
1922
this.client = client;
2023
this.accountId = accountId ?? null;
@@ -30,6 +33,9 @@ export default class GeneralAPI {
3033
}
3134
}
3235

36+
/**
37+
* Checks if the account ID is present.
38+
*/
3339
private checkAccountIdPresence(): number {
3440
if (this.accountId === null) {
3541
throw new Error(
@@ -39,6 +45,9 @@ export default class GeneralAPI {
3945
return this.accountId;
4046
}
4147

48+
/**
49+
* Singleton getter for Account Accesses API.
50+
*/
4251
public get accountAccesses(): AccountAccessesApi {
4352
if (this.accountAccessesInstance === null) {
4453
const accountId = this.checkAccountIdPresence();
@@ -51,6 +60,9 @@ export default class GeneralAPI {
5160
return this.accountAccessesInstance;
5261
}
5362

63+
/**
64+
* Singleton getter for Permissions API.
65+
*/
5466
public get permissions(): PermissionsApi {
5567
if (this.permissionsInstance === null) {
5668
const accountId = this.checkAccountIdPresence();
@@ -59,4 +71,16 @@ export default class GeneralAPI {
5971

6072
return this.permissionsInstance;
6173
}
74+
75+
/**
76+
* Singleton getter for Billing API.
77+
*/
78+
public get billing(): BillingApi {
79+
if (this.billingInstance === null) {
80+
const accountId = this.checkAccountIdPresence();
81+
this.billingInstance = new BillingApi(this.client, accountId);
82+
}
83+
84+
return this.billingInstance;
85+
}
6286
}

src/lib/api/resources/Billing.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { AxiosInstance } from "axios";
2+
3+
import CONFIG from "../../../config";
4+
5+
import { BillingCycleUsage } from "../../../types/api/billing";
6+
7+
const { CLIENT_SETTINGS } = CONFIG;
8+
const { GENERAL_ENDPOINT } = CLIENT_SETTINGS;
9+
10+
export default class BillingApi {
11+
private client: AxiosInstance;
12+
13+
private billingURL: string;
14+
15+
constructor(client: AxiosInstance, accountId: number) {
16+
this.client = client;
17+
this.billingURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
18+
}
19+
20+
/**
21+
* Get billing usage for the account.
22+
*/
23+
public async getCurrentBillingCycleUsage() {
24+
const url = this.billingURL;
25+
26+
return this.client.get<BillingCycleUsage, BillingCycleUsage>(url);
27+
}
28+
}

src/types/api/billing.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export type BillingCycleUsage = {
2+
billing: {
3+
cycle_start: string;
4+
cycle_end: string;
5+
};
6+
sending: {
7+
plan: {
8+
name: string;
9+
};
10+
usage: {
11+
sent_messages_count: {
12+
current: number;
13+
limit: number;
14+
};
15+
};
16+
};
17+
testing: {
18+
plan: {
19+
name: string;
20+
};
21+
usage: {
22+
sent_messages_count: {
23+
current: number;
24+
limit: number;
25+
};
26+
forwarded_messages_count: {
27+
current: number;
28+
limit: number;
29+
};
30+
};
31+
};
32+
};

0 commit comments

Comments
 (0)