chore: add BillingService.ts test coverage

This commit is contained in:
Federico Martín Alconada Verzini
2023-11-13 17:19:32 +00:00
parent 2b2f9a74b6
commit d3bb9f26e9
11 changed files with 3594 additions and 1365 deletions

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@ node_modules
.idea
# testing
/coverage
**/coverage
# production
/build

7
Common/Utils/Errors.ts Normal file
View File

@@ -0,0 +1,7 @@
export default {
API: {
CLIENT_SECRET_MISSING:
'client_secret not returned by payment provider.',
INVOICE_NOT_GENERATED: 'Invoice not generated.',
},
};

View File

@@ -15,6 +15,8 @@ import SubscriptionStatus, {
import BaseService from './BaseService';
import Email from 'Common/Types/Email';
import Dictionary from 'Common/Types/Dictionary';
import Errors from '../Utils/Errors';
import APIErrors from 'Common/Utils/Errors';
export type SubscriptionItem = Stripe.SubscriptionItem;
@@ -54,10 +56,9 @@ export class BillingService extends BaseService {
}): Promise<string> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
const customer: Stripe.Response<Stripe.Customer> =
await this.stripe.customers.create({
name: data.name,
@@ -66,7 +67,6 @@ export class BillingService extends BaseService {
id: data.id.toString(),
},
});
return customer.id;
}
@@ -76,7 +76,7 @@ export class BillingService extends BaseService {
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -86,7 +86,7 @@ export class BillingService extends BaseService {
public async deleteCustomer(id: string): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -191,16 +191,16 @@ export class BillingService extends BaseService {
}> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
let trialDate: Date | null = null;
if (typeof data.trial === Typeof.Boolean) {
trialDate = OneUptimeDate.getSomeDaysAfter(
data.plan.getTrialPeriod()
);
trialDate = data.trial
? OneUptimeDate.getSomeDaysAfter(data.plan.getTrialPeriod())
: null;
}
if (data.trial instanceof Date) {
@@ -260,7 +260,7 @@ export class BillingService extends BaseService {
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -268,9 +268,10 @@ export class BillingService extends BaseService {
await this.stripe.subscriptions.retrieve(subscriptionId);
if (!subscription) {
throw new BadDataException('Subscription not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_NOT_FOUND
);
}
if (subscription.status === 'canceled') {
// subscription is canceled.
return;
@@ -280,7 +281,9 @@ export class BillingService extends BaseService {
subscription.items.data[0]?.id;
if (!subscriptionItemId) {
throw new BadDataException('Subscription Item not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_ITEM_NOT_FOUND
);
}
await this.stripe.subscriptionItems.update(subscriptionItemId, {
@@ -295,7 +298,7 @@ export class BillingService extends BaseService {
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -305,7 +308,9 @@ export class BillingService extends BaseService {
);
if (!subscription) {
throw new BadDataException('Subscription not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_NOT_FOUND
);
}
// check if this pricing exists
@@ -324,7 +329,9 @@ export class BillingService extends BaseService {
})?.id;
if (!subscriptionItemId) {
throw new BadDataException('Subscription Item not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_ITEM_NOT_FOUND
);
}
// use stripe usage based api to update the quantity.
@@ -357,7 +364,7 @@ export class BillingService extends BaseService {
public async isPromoCodeValid(promoCode: string): Promise<boolean> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
try {
@@ -365,13 +372,16 @@ export class BillingService extends BaseService {
await this.stripe.coupons.retrieve(promoCode);
if (!promoCodeResponse) {
throw new BadDataException('Promo code not found');
throw new BadDataException(
Errors.BillingService.PROMO_CODE_NOT_FOUND
);
}
return promoCodeResponse.valid;
} catch (err) {
throw new BadDataException(
(err as Error).message || 'Invalid promo code'
(err as Error).message ||
Errors.BillingService.PROMO_CODE_INVALID
);
}
}
@@ -383,7 +393,7 @@ export class BillingService extends BaseService {
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -391,7 +401,9 @@ export class BillingService extends BaseService {
await this.stripe.subscriptions.retrieve(subscriptionId);
if (!subscription) {
throw new BadDataException('Subscription not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_NOT_FOUND
);
}
if (subscription.status === 'canceled') {
@@ -418,7 +430,7 @@ export class BillingService extends BaseService {
): Promise<Array<SubscriptionItem>> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -426,7 +438,9 @@ export class BillingService extends BaseService {
await this.stripe.subscriptions.retrieve(subscriptionId);
if (!subscription) {
throw new BadDataException('Subscription not found');
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_NOT_FOUND
);
}
return subscription.items.data;
@@ -450,10 +464,10 @@ export class BillingService extends BaseService {
logger.info(data);
if (!this.isBillingEnabled()) {
logger.info('Billing not enabled');
logger.info(Errors.BillingService.BILLING_NOT_ENABLED);
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -464,8 +478,10 @@ export class BillingService extends BaseService {
logger.info(subscription);
if (!subscription) {
logger.info('Subscription not found');
throw new BadDataException('Subscription not found');
logger.info(Errors.BillingService.SUBSCRIPTION_NOT_FOUND);
throw new BadDataException(
Errors.BillingService.SUBSCRIPTION_NOT_FOUND
);
}
logger.info('Subscription status');
@@ -481,7 +497,7 @@ export class BillingService extends BaseService {
logger.info('No payment methods');
throw new BadDataException(
'No payment methods added. Please add your card to this project to change your plan'
Errors.BillingService.NO_PAYMENTS_METHODS
);
}
@@ -536,7 +552,7 @@ export class BillingService extends BaseService {
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -545,7 +561,7 @@ export class BillingService extends BaseService {
if (paymentMethods.length === 1) {
throw new BadDataException(
"There's only one payment method associated with this account. It cannot be deleted. To delete this payment method please add more payment methods to your account."
Errors.BillingService.MIN_REQUIRED_PAYMENT_METHOD_NOT_MET
);
}
@@ -576,7 +592,7 @@ export class BillingService extends BaseService {
): Promise<Array<PaymentMethod>> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
const paymentMethods: Array<PaymentMethod> = [];
@@ -671,9 +687,7 @@ export class BillingService extends BaseService {
});
if (!setupIntent.client_secret) {
throw new APIException(
'client_secret not returned by payment provider.'
);
throw new APIException(APIErrors.API.CLIENT_SECRET_MISSING);
}
return setupIntent.client_secret;
@@ -682,7 +696,7 @@ export class BillingService extends BaseService {
public async cancelSubscription(subscriptionId: string): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
try {
@@ -706,7 +720,7 @@ export class BillingService extends BaseService {
): Promise<Stripe.Subscription> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
Errors.BillingService.BILLING_NOT_ENABLED
);
}
@@ -748,7 +762,7 @@ export class BillingService extends BaseService {
});
if (!invoice || !invoice.id) {
throw new APIException('Invoice not generated.');
throw new APIException(APIErrors.API.INVOICE_NOT_GENERATED);
}
await this.stripe.invoiceItems.create({
@@ -787,7 +801,7 @@ export class BillingService extends BaseService {
if (paymentMethods.length === 0) {
throw new BadDataException(
'Payment Method not added. Please go to Project Settings > Billing and add a payment method.'
Errors.BillingService.NO_PAYMENTS_METHODS_2
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
import { Stripe } from 'stripe';
import { faker } from '@faker-js/faker';
import Email from 'Common/Types/Email';
import ObjectID from 'Common/Types/ObjectID';
import SubscriptionPlan from 'Common/Types/Billing/SubscriptionPlan';
import { BillingService } from '../../../Services/BillingService';
import {
CustomerData,
Subscription,
MeteredSubscription,
ChangePlan,
CouponData,
} from '../../TestingUtils/Services/Types';
/// @dev consider modifyfing the EnvirontmentConfig to use functions instead of constants so that we can mock them
const mockIsBillingEnabled: Function = (value: boolean): BillingService => {
jest.resetModules();
jest.doMock('../../../EnvironmentConfig', () => {
return {
IsBillingEnabled: value,
};
});
const { BillingService } = require('../../../Services/BillingService');
return new BillingService();
};
const getStripeCustomer: Function = (id?: string): Stripe.Customer => {
id = id || faker.datatype.uuid();
return {
id,
object: 'customer',
balance: faker.datatype.number(),
created: 1,
default_source: null,
description: null,
email: null,
invoice_settings: {
custom_fields: null,
default_payment_method: null,
footer: null,
rendering_options: null,
},
livemode: true,
metadata: {},
shipping: null,
};
};
const getStripeSubscription: Function = (): Stripe.Subscription => {
return {
id: faker.datatype.uuid(),
items: {
data: [
{
id: faker.datatype.uuid(),
// @ts-ignore
price: { id: faker.datatype.uuid() },
},
],
},
status: 'active',
customer: getStripeCustomer(),
};
};
const getSubscriptionPlanData: Function = (): SubscriptionPlan => {
return new SubscriptionPlan(
faker.datatype.uuid(), // monthlyPlanId
faker.datatype.uuid(), // yearlyPlanId
faker.commerce.productName(), // name
faker.datatype.number(), // monthlySubscriptionAmountInUSD
faker.datatype.number({ min: 1, max: 100 }), // yearlySubscriptionAmountInUSD
faker.datatype.number({ min: 1, max: 100 }), // order
faker.datatype.number({ min: 1, max: 100 }) // trial period days
);
};
const getStripeInvoice: Function = (): Stripe.Invoice => {
// @ts-ignore
return {
id: faker.datatype.uuid(),
amount_due: faker.datatype.number(),
currency: 'usd',
customer: faker.datatype.uuid(),
subscription: faker.datatype.uuid(),
status: 'paid',
};
};
const getCustomerData: Function = (id?: ObjectID): CustomerData => {
return {
id: id || new ObjectID('customer_id'),
name: 'John Doe',
email: new Email('test@example.com'),
};
};
const getSubscriptionData: Function = (id?: ObjectID): Subscription => {
return {
projectId: id || new ObjectID('project_id'),
customerId: 'cust_123',
serverMeteredPlans: [],
trialDate: new Date(),
};
};
const getMeteredSubscription: Function = (
subscriptionPlan: SubscriptionPlan,
id?: ObjectID
): MeteredSubscription => {
return {
projectId: id || new ObjectID('project_id'),
customerId: 'cust_123',
serverMeteredPlans: [],
plan: subscriptionPlan,
quantity: 1,
isYearly: false,
trial: true,
};
};
const getChangePlanData: Function = (
subscriptionPlan: SubscriptionPlan,
id?: ObjectID
): ChangePlan => {
return {
projectId: id || new ObjectID('project_id'),
subscriptionId: 'sub_123',
meteredSubscriptionId: 'sub_456',
serverMeteredPlans: [],
newPlan: subscriptionPlan,
quantity: 1,
isYearly: false,
};
};
const getCouponData: Function = (): CouponData => {
return {
name: 'TESTCOUPON',
metadata: { description: 'Test coupon' },
percentOff: 10,
durationInMonths: 3,
maxRedemptions: 100,
};
};
export {
mockIsBillingEnabled,
getStripeCustomer,
getStripeSubscription,
getSubscriptionPlanData,
getCustomerData,
getSubscriptionData,
getMeteredSubscription,
getChangePlanData,
getCouponData,
getStripeInvoice,
};

View File

@@ -0,0 +1,57 @@
import Email from 'Common/Types/Email';
import ObjectID from 'Common/Types/ObjectID';
import ServerMeteredPlan from '../../../Types/Billing/MeteredPlan/ServerMeteredPlan';
import SubscriptionPlan from 'Common/Types/Billing/SubscriptionPlan';
import { PaymentMethod } from '../../../Services/BillingService';
import Dictionary from 'Common/Types/Dictionary';
export type CustomerData = {
id: ObjectID;
name: string;
email: Email;
};
export type CouponData = {
name: string;
metadata?: Dictionary<string> | undefined;
percentOff: number;
durationInMonths: number;
maxRedemptions: number;
};
export type Subscription = {
projectId: ObjectID;
customerId: string;
serverMeteredPlans: Array<typeof ServerMeteredPlan>;
promoCode?: string;
defaultPaymentMethodId?: string;
trialDate: Date;
};
export type MeteredSubscription = {
projectId: ObjectID;
customerId: string;
serverMeteredPlans: Array<typeof ServerMeteredPlan>;
plan: SubscriptionPlan;
quantity: number;
isYearly: boolean;
trial: boolean | Date | undefined;
defaultPaymentMethodId?: string | undefined;
promoCode?: string | undefined;
};
export type ChangePlan = {
projectId: ObjectID;
subscriptionId: string;
meteredSubscriptionId: string;
serverMeteredPlans: Array<typeof ServerMeteredPlan>;
newPlan: SubscriptionPlan;
quantity: number;
isYearly: boolean;
endTrialAt?: Date | undefined;
};
export type PaymentMethodsResponse = {
data: PaymentMethod[];
defaultPaymentMethodId?: string | undefined;
};

View File

@@ -0,0 +1,16 @@
import * as mock from 'jest-mock-extended';
let mockStripe: jest.Mocked<Stripe>;
jest.mock('stripe', () => {
mockStripe = mock.mockDeep<Stripe>();
return jest.fn(() => {
return mockStripe;
});
});
// import libraries to mock (we do it here because of hoisting)
import Stripe from 'stripe';
// return the mocked library and the library itself
export { mockStripe, Stripe };

View File

@@ -0,0 +1,16 @@
export default {
BillingService: {
BILLING_NOT_ENABLED: 'Billing is not enabled for this server.',
SUBSCRIPTION_ITEM_NOT_FOUND: 'Subscription item not found.',
SUBSCRIPTION_NOT_FOUND: 'Subscription not found.',
NO_PAYMENTS_METHODS:
'No payment methods added. Please add your card to this project to change your plan',
/// @dev consider consolidating the above and below error messages
NO_PAYMENTS_METHODS_2:
'Payment Method not added. Please go to Project Settings > Billing and add a payment method.',
MIN_REQUIRED_PAYMENT_METHOD_NOT_MET:
"There's only one payment method associated with this account. It cannot be deleted. To delete this payment method please add more payment methods to your account.",
PROMO_CODE_NOT_FOUND: 'Promo code not found',
PROMO_CODE_INVALID: 'Invalid promo code',
},
};

View File

@@ -12,8 +12,11 @@
".(ts|tsx)": "ts-jest"
},
"testEnvironment": "node",
"collectCoverage": true,
"coverageReporters": ["text"],
"collectCoverage": false,
"transformIgnorePatterns": [
"/node_modules/(?!Common).+\\.js$"
],
"coverageReporters": ["text", "lcov"],
"testRegex": "./Tests/(.*).test.ts",
"collectCoverageFrom": ["./**/*.(tsx||ts)"],
"coverageThreshold": {

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": {
"compile": "tsc",
"test": "jest --detectOpenHandles",
"coverage": "jest --detectOpenHandles --coverage",
"debug:test": "cd .. && export $(grep -v '^#' config.env | xargs) && cd CommonServer && node --inspect node_modules/.bin/jest --runInBand ./Tests --detectOpenHandles"
},
"author": "",
@@ -59,6 +60,7 @@
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "^17.0.22",
"jest": "^27.5.1",
"ts-jest": "^27.1.4"
"ts-jest": "^27.1.4",
"jest-mock-extended": "^3.0.5"
}
}