Skip to content
Merged
10 changes: 5 additions & 5 deletions src/controller/business.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,18 @@ export function businessController(
throw new NotFoundSubscriptionError('Subscription not found');
}
const productItem = activeSubscription.items.data[0];
const maxSpaceBytes = productItem?.price.metadata.maxSpaceBytes as string;
const maxSpaceBytes = productItem?.price.metadata.maxSpaceBytes;

const { minimumSeats, maximumSeats } = await paymentService.getBusinessSubscriptionSeats(
productItem?.price.id as string,
productItem?.price.id,
);

if (minimumSeats && maximumSeats) {
if (workspaceUpdatedSeats > parseInt(maximumSeats)) {
if (workspaceUpdatedSeats > Number.parseInt(maximumSeats)) {
throw new InvalidSeatNumberError('The new price does not allow the current amount of seats');
}

if (workspaceUpdatedSeats < parseInt(minimumSeats)) {
if (workspaceUpdatedSeats < Number.parseInt(minimumSeats)) {
throw new InvalidSeatNumberError('The new price does not allow the current amount of seats');
}

Expand All @@ -80,7 +80,7 @@ export function businessController(

const updatedSub = await paymentService.updateBusinessSub({
customerId: user.customerId,
priceId: productItem?.price.id as string,
priceId: productItem?.price.id,
seats: workspaceUpdatedSeats,
additionalOptions: {
proration_behavior: 'create_prorations',
Expand Down
12 changes: 7 additions & 5 deletions src/controller/checkout.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { signUserToken } from '../utils/signUserToken';
import { verifyRecaptcha } from '../utils/verifyRecaptcha';
import { setupAuth } from '../plugins/auth';
import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter';
import { UserType } from '../core/users/User';

export function checkoutController(usersService: UsersService, paymentsService: PaymentService) {
return async function (fastify: FastifyInstance) {
Expand Down Expand Up @@ -162,15 +163,12 @@ export function checkoutController(usersService: UsersService, paymentsService:
promoCodeId: {
type: 'string',
},
quantity: {
type: 'number',
},
},
},
},
},
async (req, res) => {
const { customerId, priceId, currency, promoCodeId, quantity, captchaToken, token } = req.body;
const { customerId, priceId, currency, promoCodeId, captchaToken, token } = req.body;
let tokenCustomerId;

const verifiedCaptcha = await verifyRecaptcha(captchaToken);
Expand All @@ -192,11 +190,14 @@ export function checkoutController(usersService: UsersService, paymentsService:
throw new ForbiddenError();
}

const price = await paymentsService.getPriceById(priceId);

if (price.type === UserType.Business) throw new BadRequestError('Business plan is no longer available');

const subscriptionAttempt = await paymentsService.createSubscription({
customerId,
priceId,
currency,
seatsForBusinessSubscription: quantity ?? 1,
promoCodeId,
additionalOptions: {
automatic_tax: {
Expand Down Expand Up @@ -362,6 +363,7 @@ export function checkoutController(usersService: UsersService, paymentsService:
const user = await usersService.findUserByUuid(userUuid).catch(() => null);

const price = await paymentsService.getPriceById(priceId, currency);

let amount = price.amount;

if (promoCodeName) {
Expand Down
4 changes: 2 additions & 2 deletions src/controller/payments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,13 @@ export function paymentsController(
}

fastify.get<{
Querystring: { currency?: string; userType?: 'individual' | 'business' };
Querystring: { currency?: string; userType?: 'individual' };
schema: {
querystring: {
type: 'object';
properties: {
currency: { type: 'string' };
userType: { type: 'string'; enum: ['individual', 'business'] };
userType: { type: 'string'; enum: ['individual'] };
};
};
};
Expand Down
20 changes: 3 additions & 17 deletions src/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ export class PaymentService {
async createSubscription({
customerId,
priceId,
seatsForBusinessSubscription = 1,
currency,
promoCodeId,
companyName,
Expand All @@ -123,7 +122,6 @@ export class PaymentService {
}: {
customerId: string;
priceId: string;
seatsForBusinessSubscription?: number;
currency?: string;
promoCodeId?: Stripe.SubscriptionCreateParams['promotion_code'];
companyName?: string;
Expand All @@ -140,20 +138,8 @@ export class PaymentService {
});
const product = price.product as Stripe.Product;
const isBusinessProduct = !!product.metadata.type && product.metadata.type === UserType.Business;
const isObjStorageProduct = !!product.metadata.type && product.metadata.type === UserType.ObjectStorage;
const seats = isObjStorageProduct ? undefined : seatsForBusinessSubscription;
const minimumSeats = price.metadata.minimumSeats ?? 1;
const maximumSeats = price.metadata.maximumSeats ?? 1;

if (isBusinessProduct && minimumSeats && maximumSeats) {
if (seatsForBusinessSubscription > parseInt(maximumSeats)) {
throw new InvalidSeatNumberError('The new price does not allow the current amount of seats');
}

if (seatsForBusinessSubscription < parseInt(minimumSeats)) {
throw new InvalidSeatNumberError('The new price does not allow the current amount of seats');
}
}
if (isBusinessProduct) throw new BadRequestError('Business plan is no longer available');

await this.checkIfUserAlreadyHasASubscription(customerId, product);

Expand All @@ -167,7 +153,7 @@ export class PaymentService {
items: [
{
price: priceId,
quantity: seats,
quantity: 1,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because now we only accept individual plans (1 product at once).

},
],
discounts: [
Expand Down Expand Up @@ -1042,7 +1028,7 @@ export class PaymentService {
id: priceId,
currency,
amount: currency_options![currency].unit_amount as number,
bytes: parseInt(metadata?.maxSpaceBytes),
bytes: Number.parseInt(metadata?.maxSpaceBytes),
interval: type === 'one_time' ? 'lifetime' : recurring?.interval,
decimalAmount: (currency_options![currency].unit_amount as number) / 100,
type: isBusinessPrice ? UserType.Business : UserType.Individual,
Expand Down
43 changes: 3 additions & 40 deletions src/services/tiers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { UsersService } from './users.service';
import { StorageService } from './storage.service';
import { Service, Tier } from '../core/users/Tier';
import { UsersTiersRepository } from '../core/users/MongoDBUsersTiersRepository';
import Stripe from 'stripe';
import { FastifyBaseLogger } from 'fastify';
import axios, { isAxiosError } from 'axios';
import { Customer } from '../infrastructure/domain/entities/customer';
import axios from 'axios';
import { BadRequestError } from '../errors/Errors';

export class TierNotFoundError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -130,49 +129,13 @@ export class TiersService {

async applyDriveFeatures(
userWithEmail: { email: string; uuid: User['uuid'] },
customer: Customer,
subscriptionSeats: Stripe.InvoiceLineItem['quantity'],
tier: Tier,
log: FastifyBaseLogger,
customMaxSpaceBytes?: number,
): Promise<void> {
const features = tier.featuresPerService[Service.Drive];

if (features.workspaces.enabled) {
if (!subscriptionSeats || subscriptionSeats < features.workspaces.minimumSeats)
throw new NoSubscriptionSeatsProvidedError('The amount of seats is not allowed for this type of subscription');

const maxSpaceBytes = features.workspaces.maxSpaceBytesPerSeat;
const address = customer.address?.line1 ?? undefined;
const phoneNumber = customer.phone ?? undefined;
const driveTierId = tier.featuresPerService[Service.Drive].foreignTierId;

try {
await this.usersService.updateWorkspace({
ownerId: userWithEmail.uuid,
maxSpaceBytes: Number(maxSpaceBytes),
seats: subscriptionSeats,
tierId: driveTierId,
});
log.info(`[DRIVE/WORKSPACES]: The workspace for user ${userWithEmail.uuid} has been updated`);
} catch (err) {
if (isAxiosError(err) && err.response?.status === 404) {
log.info(
`[DRIVE/WORKSPACES]: User with customer Id: ${customer.id} - uuid: ${userWithEmail.uuid} - email: ${customer.email} does not have a workspace. Creating a new one...`,
);
await this.usersService.initializeWorkspace(userWithEmail.uuid, {
newStorageBytes: Number(maxSpaceBytes),
seats: subscriptionSeats,
tierId: driveTierId,
address,
phoneNumber,
});
} else {
throw err;
}
}

return;
throw new BadRequestError('Workspaces feature is not available anymore');
}

const maxSpaceBytes = customMaxSpaceBytes ?? features.maxSpaceBytes;
Expand Down
28 changes: 2 additions & 26 deletions src/services/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,32 +142,7 @@ export class UsersService {
return uniqueCodes;
}

async initializeWorkspace(
ownerId: string,
payload: { newStorageBytes: number; seats: number; tierId: string; address?: string; phoneNumber?: string },
): Promise<void> {
const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET);
const params: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
};

await this.axios.post(
`${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`,
{
ownerId,
tierId: payload.tierId,
maxSpaceBytes: payload.newStorageBytes * payload.seats,
address: payload.address,
numberOfSeats: payload.seats,
phoneNumber: payload.phoneNumber,
},
params,
);
}

// !DEPRECATED
async isWorkspaceUpgradeAllowed(
ownerId: string,
workspaceId: string,
Expand All @@ -193,6 +168,7 @@ export class UsersService {
);
}

// !DEPRECATED
async updateWorkspace({
ownerId,
tierId,
Expand Down
2 changes: 0 additions & 2 deletions src/types/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ export interface SubscriptionCreated {

export type RequestedPlanData = DisplayPrice & {
decimalAmount: number;
minimumSeats?: number;
maximumSeats?: number;
type?: UserType;
};

Expand Down
9 changes: 1 addition & 8 deletions src/webhooks/events/invoices/InvoiceCompletedHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,14 +339,7 @@ export class InvoiceCompletedHandler {

// Apply Drive features
try {
await this.tiersService.applyDriveFeatures(
user,
customer,
totalQuantity,
tierToApply,
this.logger,
lifetimeMaxSpaceBytesToApply,
);
await this.tiersService.applyDriveFeatures(user, tierToApply, lifetimeMaxSpaceBytesToApply);
Logger.info(`Drive features applied for user ${user.uuid} with customerId ${customer.id}`);
} catch (error) {
Logger.error(`Failed to apply drive features for user ${user.uuid} with customerId ${customer.id}`, {
Expand Down
44 changes: 40 additions & 4 deletions tests/src/controller/checkout.controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { FastifyInstance } from 'fastify';
import {
getCreatedSubscription,
getCreateSubscriptionResponse,
getCryptoCurrency,
getCustomer,
getInvoice,
getPrice,
getRawCryptoInvoiceResponse,
getTaxes,
getUser,
Expand All @@ -24,6 +24,7 @@ import { Bit2MeService } from '../../../src/services/bit2me.service';
import * as verifyRecaptcha from '../../../src/utils/verifyRecaptcha';
import { StripePaymentsAdapter } from '../../../src/infrastructure/adapters/stripe.adapter';
import { Customer } from '../../../src/infrastructure/domain/entities/customer';
import { UserType } from '../../../src/core/users/User';

jest.mock('../../../src/utils/fetchUserStorage');

Expand Down Expand Up @@ -280,13 +281,16 @@ describe('Checkout controller', () => {
describe('Creating a subscription', () => {
test('When the user wants to create a subscription, test is created successfully', async () => {
const mockedUser = getUser();
const mockedSubscription = getCreatedSubscription();
const mockedPrice = getPrice();
const mockedSubscriptionResponse = getCreateSubscriptionResponse();
const mockedCaptchaToken = 'captcha_token';

const authToken = getValidAuthToken(mockedUser.uuid);
const userToken = getValidUserToken({ customerId: mockedUser.customerId });

jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue({
type: UserType.Individual,
} as any);
jest.spyOn(PaymentService.prototype, 'createSubscription').mockResolvedValue(mockedSubscriptionResponse);
jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true);

Expand All @@ -295,8 +299,8 @@ describe('Checkout controller', () => {
method: 'POST',
body: {
customerId: mockedUser.customerId,
priceId: mockedSubscription.items.data[0].price.id,
currency: mockedSubscription.items.data[0].price.currency,
priceId: mockedPrice.id,
currency: mockedPrice.currency,
quantity: 1,
token: userToken,
captchaToken: mockedCaptchaToken,
Expand All @@ -313,6 +317,38 @@ describe('Checkout controller', () => {
});

describe('Handling errors', () => {
test('When the subscription is a business plan, then an error indicating so is thrown', async () => {
const mockedUser = getUser();
const mockedPrice = getPrice();
const mockedCaptchaToken = 'captcha_token';

const authToken = getValidAuthToken(mockedUser.uuid);
const userToken = getValidUserToken({ customerId: mockedUser.customerId });

jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue({
type: UserType.Business,
} as any);
jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true);

const response = await app.inject({
path: '/checkout/subscription',
method: 'POST',
body: {
customerId: mockedUser.customerId,
priceId: mockedPrice.id,
currency: mockedPrice.currency,
quantity: 1,
token: userToken,
captchaToken: mockedCaptchaToken,
},
headers: {
authorization: `Bearer ${authToken}`,
},
});

expect(response.statusCode).toBe(400);
});

test('When the id of the price is not present in the body, then an error indicating so is thrown', async () => {
const mockedUser = getUser();
const authToken = getValidAuthToken(mockedUser.uuid);
Expand Down
Loading
Loading