From a8cb63c18a39a8e8b06022671e7efc96a684a0d4 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 16:12:41 +0200 Subject: [PATCH 01/13] refactor(business): cleanup --- src/app.ts | 2 - src/controller/business.controller.ts | 124 ------------------ src/controller/payments.controller.ts | 4 +- src/services/tiers.service.ts | 38 +----- src/services/users.service.ts | 82 ------------ .../controller/business.controller.test.ts | 72 ---------- tests/src/services/users.service.test.ts | 70 +--------- 7 files changed, 6 insertions(+), 386 deletions(-) delete mode 100644 src/controller/business.controller.ts delete mode 100644 tests/src/controller/business.controller.test.ts diff --git a/src/app.ts b/src/app.ts index e21dad5f..cfd0d229 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,6 @@ import Fastify, { FastifyInstance } from 'fastify'; import { AppConfig } from './config'; import { paymentsController } from './controller/payments.controller'; import { objectStorageController } from './controller/object-storage.controller'; -import { businessController } from './controller/business.controller'; import { productsController } from './controller/products.controller'; import { checkoutController } from './controller/checkout.controller'; import { customerController } from './controller/customer.controller'; @@ -65,7 +64,6 @@ export async function buildApp({ paymentsController(paymentService, usersService, config, cacheService, licenseCodesService, tiersService), ); fastify.register(objectStorageController(paymentService), { prefix: '/object-storage' }); - fastify.register(businessController(paymentService, usersService, tiersService, config), { prefix: '/business' }); fastify.register(productsController(productsService, cacheService, config), { prefix: '/products', }); diff --git a/src/controller/business.controller.ts b/src/controller/business.controller.ts deleted file mode 100644 index 94f54476..00000000 --- a/src/controller/business.controller.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { FastifyInstance } from 'fastify'; -import { AppConfig } from '../config'; -import { - IncompatibleSubscriptionTypesError, - InvalidSeatNumberError, - NotFoundSubscriptionError, - UpdateWorkspaceError, - UserNotFoundError, -} from '../errors/PaymentErrors'; -import { UsersService } from '../services/users.service'; -import { assertUser } from '../utils/assertUser'; -import Stripe from 'stripe'; -import { TiersService } from '../services/tiers.service'; -import { Service } from '../core/users/Tier'; -import { setupAuth } from '../plugins/auth'; -import { PaymentService } from '../services/payment.service'; - -export function businessController( - paymentService: PaymentService, - usersService: UsersService, - tiersService: TiersService, - config: AppConfig, -) { - return async function (fastify: FastifyInstance) { - await setupAuth(fastify, { secret: config.JWT_SECRET }); - - fastify.patch<{ Body: { workspaceId: string; subscriptionId: string; workspaceUpdatedSeats: number } }>( - '/subscription', - { - schema: { - body: { - type: 'object', - properties: { - workspaceId: { type: 'string' }, - subscriptionId: { type: 'string' }, - workspaceUpdatedSeats: { type: 'number' }, - }, - required: ['workspaceId', 'subscriptionId', 'workspaceUpdatedSeats'], - }, - }, - }, - async (req, res): Promise => { - const { workspaceId, subscriptionId, workspaceUpdatedSeats } = req.body; - const user = await assertUser(req, res, usersService); - - if (!user) throw new UserNotFoundError('User does not exist'); - - try { - const activeSubscription = await paymentService.getSubscriptionById(subscriptionId); - if (activeSubscription.status !== 'active') { - throw new NotFoundSubscriptionError('Subscription not found'); - } - const productItem = activeSubscription.items.data[0]; - const maxSpaceBytes = productItem?.price.metadata.maxSpaceBytes as string; - - const { minimumSeats, maximumSeats } = await paymentService.getBusinessSubscriptionSeats( - productItem?.price.id as string, - ); - - if (minimumSeats && maximumSeats) { - if (workspaceUpdatedSeats > parseInt(maximumSeats)) { - throw new InvalidSeatNumberError('The new price does not allow the current amount of seats'); - } - - if (workspaceUpdatedSeats < parseInt(minimumSeats)) { - throw new InvalidSeatNumberError('The new price does not allow the current amount of seats'); - } - - if (workspaceUpdatedSeats === productItem?.quantity) { - throw new InvalidSeatNumberError('The workspace already has these seats'); - } - } - - await usersService.isWorkspaceUpgradeAllowed( - user.uuid, - workspaceId, - Number(maxSpaceBytes), - workspaceUpdatedSeats, - ); - - const updatedSub = await paymentService.updateBusinessSub({ - customerId: user.customerId, - priceId: productItem?.price.id as string, - seats: workspaceUpdatedSeats, - additionalOptions: { - proration_behavior: 'create_prorations', - }, - }); - - const price = updatedSub.items.data[0]?.price; - const productId = typeof price?.product === 'string' ? price.product : price?.product.id; - const tier = await tiersService.getTierProductsByProductsId(productId, 'subscription'); - - await usersService.updateWorkspace({ - ownerId: user.uuid, - tierId: tier.featuresPerService[Service.Drive].foreignTierId, - maxSpaceBytes: Number(maxSpaceBytes), - seats: workspaceUpdatedSeats, - }); - - return res.status(200).send(updatedSub); - } catch (err) { - const error = err as Error; - req.log.error(`[WORKSPACES/ERROR]: Error trying to update seats: ${error.stack ?? error.message}`); - if ( - error instanceof InvalidSeatNumberError || - error instanceof IncompatibleSubscriptionTypesError || - error instanceof NotFoundSubscriptionError || - error instanceof UpdateWorkspaceError || - error instanceof UserNotFoundError - ) { - return res.status(400).send({ - message: error.message, - }); - } - - return res.status(500).send({ - message: 'Internal Server Error', - }); - } - }, - ); - }; -} diff --git a/src/controller/payments.controller.ts b/src/controller/payments.controller.ts index b5674de6..71db6772 100644 --- a/src/controller/payments.controller.ts +++ b/src/controller/payments.controller.ts @@ -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'] }; }; }; }; diff --git a/src/services/tiers.service.ts b/src/services/tiers.service.ts index 982e82a6..bc484dfe 100644 --- a/src/services/tiers.service.ts +++ b/src/services/tiers.service.ts @@ -6,8 +6,9 @@ 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 axios from 'axios'; import { Customer } from '../infrastructure/domain/entities/customer'; +import { BadRequestError } from '../errors/Errors'; export class TierNotFoundError extends Error { constructor(message: string) { @@ -139,40 +140,7 @@ export class TiersService { 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; diff --git a/src/services/users.service.ts b/src/services/users.service.ts index c7f8c393..a0eed97b 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -142,88 +142,6 @@ export class UsersService { return uniqueCodes; } - async initializeWorkspace( - ownerId: string, - payload: { newStorageBytes: number; seats: number; tierId: string; address?: string; phoneNumber?: string }, - ): Promise { - 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, - ); - } - - async isWorkspaceUpgradeAllowed( - ownerId: string, - workspaceId: string, - maxSpaceBytes: number, - seats: number, - ): Promise { - const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); - const requestConfig: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - }; - - return this.axios.post( - `${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces/${workspaceId}/storage/upgrade-check`, - { - ownerId, - maxSpaceBytes: maxSpaceBytes * seats, - numberOfSeats: seats, - }, - requestConfig, - ); - } - - async updateWorkspace({ - ownerId, - tierId, - maxSpaceBytes, - seats, - }: { - ownerId: string; - tierId: string; - maxSpaceBytes: number; - seats: number; - }): Promise { - const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); - const requestConfig: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - }; - - return this.axios.patch( - `${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`, - { - ownerId, - maxSpaceBytes: maxSpaceBytes * seats, - numberOfSeats: seats, - tierId, - }, - requestConfig, - ); - } - async destroyWorkspace(ownerId: string): Promise { const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); const requestConfig: AxiosRequestConfig = { diff --git a/tests/src/controller/business.controller.test.ts b/tests/src/controller/business.controller.test.ts deleted file mode 100644 index 4649c6ec..00000000 --- a/tests/src/controller/business.controller.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { FastifyInstance } from 'fastify'; -import { closeServerAndDatabase, initializeServerAndDatabase } from '../utils/initializeServer'; -import { getCreatedSubscription, getUser, getValidAuthToken, newTier } from '../fixtures'; -import { UsersService } from '../../../src/services/users.service'; -import { PaymentService } from '../../../src/services/payment.service'; -import Stripe from 'stripe'; -import { TiersService } from '../../../src/services/tiers.service'; -import { Service } from '../../../src/core/users/Tier'; - -let app: FastifyInstance; - -beforeAll(async () => { - app = await initializeServerAndDatabase(); -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -afterAll(async () => { - await closeServerAndDatabase(); -}); - -describe('Testing business endpoints', () => { - describe('Updating business subscription', () => { - test('When the business is updated, then the Stripe subscription is updated and the workspace in Drive too', async () => { - const mockedUser = getUser(); - const mockedTier = newTier(); - const mockedSubscription = getCreatedSubscription(); - const mockedUserToken = getValidAuthToken(mockedUser.uuid); - const mockedMaxSpaceBytes = mockedSubscription.items.data[0].price.metadata.maxSpaceBytes; - jest.spyOn(UsersService.prototype, 'findUserByUuid').mockResolvedValue(mockedUser); - jest - .spyOn(PaymentService.prototype, 'getSubscriptionById') - .mockResolvedValueOnce(mockedSubscription as Stripe.Response); - jest.spyOn(PaymentService.prototype, 'getBusinessSubscriptionSeats').mockResolvedValue({ - minimumSeats: '3', - maximumSeats: '10', - }); - jest.spyOn(UsersService.prototype, 'isWorkspaceUpgradeAllowed').mockResolvedValue(true); - jest - .spyOn(PaymentService.prototype, 'updateBusinessSub') - .mockResolvedValueOnce(mockedSubscription as Stripe.Response); - jest.spyOn(TiersService.prototype, 'getTierProductsByProductsId').mockResolvedValue(mockedTier); - const updateWorkspaceSpy = jest.spyOn(UsersService.prototype, 'updateWorkspace').mockResolvedValue(); - - const response = await app.inject({ - path: `/business/subscription`, - method: 'PATCH', - body: { - workspaceId: 'workspace_id', - subscriptionId: mockedSubscription.id, - workspaceUpdatedSeats: 4, - }, - headers: { - Authorization: `Bearer ${mockedUserToken}`, - }, - }); - - const responseBody = response.json(); - - expect(response.statusCode).toBe(200); - expect(responseBody).toStrictEqual(mockedSubscription); - expect(updateWorkspaceSpy).toHaveBeenCalledWith({ - ownerId: mockedUser.uuid, - tierId: mockedTier.featuresPerService[Service.Drive].foreignTierId, - maxSpaceBytes: Number(mockedMaxSpaceBytes), - seats: 4, - }); - }); - }); -}); diff --git a/tests/src/services/users.service.test.ts b/tests/src/services/users.service.test.ts index 8dccd833..77d7dbcd 100644 --- a/tests/src/services/users.service.test.ts +++ b/tests/src/services/users.service.test.ts @@ -4,7 +4,7 @@ import Stripe from 'stripe'; import { ExtendedSubscription } from '../../../src/types/stripe'; import config from '../../../src/config'; import { FREE_PLAN_BYTES_SPACE } from '../../../src/constants'; -import { getActiveSubscriptions, getCoupon, getCustomer, getUser, newTier, voidPromise } from '../fixtures'; +import { getActiveSubscriptions, getCoupon, getUser, newTier, voidPromise } from '../fixtures'; import { createTestServices } from '../helpers/services-factory'; import { Service } from '../../../src/core/users/Tier'; import { UserNotFoundError } from '../../../src/errors/PaymentErrors'; @@ -117,74 +117,6 @@ describe('UsersService tests', () => { }); }); - describe('Workspaces', () => { - test('When initializing the workspace, then the workspace is initialized using the correct params', async () => { - const userWithEmail = { ...getUser(), email: 'test@internxt.com' }; - const tier = newTier(); - const mockedCustomer = getCustomer(); - const amountOfSeats = 5; - - const axiosPostSpy = jest.spyOn(axios, 'post').mockResolvedValue({} as any); - - await usersService.initializeWorkspace(userWithEmail.uuid, { - newStorageBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat, - seats: amountOfSeats, - address: mockedCustomer.address?.line1 ?? undefined, - phoneNumber: mockedCustomer.phone ?? undefined, - tierId: tier.featuresPerService[Service.Drive].foreignTierId, - }); - - expect(axiosPostSpy).toHaveBeenCalledWith( - `${process.env.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`, - { - ownerId: userWithEmail.uuid, - maxSpaceBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat * amountOfSeats, - numberOfSeats: amountOfSeats, - address: mockedCustomer.address?.line1 ?? undefined, - phoneNumber: mockedCustomer.phone ?? undefined, - tierId: tier.featuresPerService[Service.Drive].foreignTierId, - }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer undefined', - }, - }, - ); - }); - - test('When updating the workspace, then the workspace is updated using the correct params', async () => { - const userWithEmail = { ...getUser(), email: 'test@internxt.com' }; - const tier = newTier(); - const amountOfSeats = 5; - - const axiosPostSpy = jest.spyOn(axios, 'patch').mockResolvedValue({} as any); - - await usersService.updateWorkspace({ - ownerId: userWithEmail.uuid, - maxSpaceBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat, - seats: amountOfSeats, - tierId: tier.featuresPerService[Service.Drive].foreignTierId, - }); - - expect(axiosPostSpy).toHaveBeenCalledWith( - `${process.env.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`, - { - ownerId: userWithEmail.uuid, - maxSpaceBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat * amountOfSeats, - numberOfSeats: amountOfSeats, - tierId: tier.featuresPerService[Service.Drive].foreignTierId, - }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer undefined', - }, - }, - ); - }); - }); - describe('Cancel user subscription', () => { describe('Cancel the user Individual subscription', () => { it('When the customer wants to cancel the individual subscription, then the Stripe plan is cancelled and the storage is restored', async () => { From b5a4e2651a27a8eaadf6ab723c51508886c48ee3 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 16:20:53 +0200 Subject: [PATCH 02/13] fix: restore business controller --- src/app.ts | 2 + src/controller/business.controller.ts | 124 ++++++++++++++++++ src/services/users.service.ts | 58 ++++++++ .../controller/business.controller.test.ts | 72 ++++++++++ 4 files changed, 256 insertions(+) create mode 100644 src/controller/business.controller.ts create mode 100644 tests/src/controller/business.controller.test.ts diff --git a/src/app.ts b/src/app.ts index cfd0d229..e21dad5f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import Fastify, { FastifyInstance } from 'fastify'; import { AppConfig } from './config'; import { paymentsController } from './controller/payments.controller'; import { objectStorageController } from './controller/object-storage.controller'; +import { businessController } from './controller/business.controller'; import { productsController } from './controller/products.controller'; import { checkoutController } from './controller/checkout.controller'; import { customerController } from './controller/customer.controller'; @@ -64,6 +65,7 @@ export async function buildApp({ paymentsController(paymentService, usersService, config, cacheService, licenseCodesService, tiersService), ); fastify.register(objectStorageController(paymentService), { prefix: '/object-storage' }); + fastify.register(businessController(paymentService, usersService, tiersService, config), { prefix: '/business' }); fastify.register(productsController(productsService, cacheService, config), { prefix: '/products', }); diff --git a/src/controller/business.controller.ts b/src/controller/business.controller.ts new file mode 100644 index 00000000..94f54476 --- /dev/null +++ b/src/controller/business.controller.ts @@ -0,0 +1,124 @@ +import { FastifyInstance } from 'fastify'; +import { AppConfig } from '../config'; +import { + IncompatibleSubscriptionTypesError, + InvalidSeatNumberError, + NotFoundSubscriptionError, + UpdateWorkspaceError, + UserNotFoundError, +} from '../errors/PaymentErrors'; +import { UsersService } from '../services/users.service'; +import { assertUser } from '../utils/assertUser'; +import Stripe from 'stripe'; +import { TiersService } from '../services/tiers.service'; +import { Service } from '../core/users/Tier'; +import { setupAuth } from '../plugins/auth'; +import { PaymentService } from '../services/payment.service'; + +export function businessController( + paymentService: PaymentService, + usersService: UsersService, + tiersService: TiersService, + config: AppConfig, +) { + return async function (fastify: FastifyInstance) { + await setupAuth(fastify, { secret: config.JWT_SECRET }); + + fastify.patch<{ Body: { workspaceId: string; subscriptionId: string; workspaceUpdatedSeats: number } }>( + '/subscription', + { + schema: { + body: { + type: 'object', + properties: { + workspaceId: { type: 'string' }, + subscriptionId: { type: 'string' }, + workspaceUpdatedSeats: { type: 'number' }, + }, + required: ['workspaceId', 'subscriptionId', 'workspaceUpdatedSeats'], + }, + }, + }, + async (req, res): Promise => { + const { workspaceId, subscriptionId, workspaceUpdatedSeats } = req.body; + const user = await assertUser(req, res, usersService); + + if (!user) throw new UserNotFoundError('User does not exist'); + + try { + const activeSubscription = await paymentService.getSubscriptionById(subscriptionId); + if (activeSubscription.status !== 'active') { + throw new NotFoundSubscriptionError('Subscription not found'); + } + const productItem = activeSubscription.items.data[0]; + const maxSpaceBytes = productItem?.price.metadata.maxSpaceBytes as string; + + const { minimumSeats, maximumSeats } = await paymentService.getBusinessSubscriptionSeats( + productItem?.price.id as string, + ); + + if (minimumSeats && maximumSeats) { + if (workspaceUpdatedSeats > parseInt(maximumSeats)) { + throw new InvalidSeatNumberError('The new price does not allow the current amount of seats'); + } + + if (workspaceUpdatedSeats < parseInt(minimumSeats)) { + throw new InvalidSeatNumberError('The new price does not allow the current amount of seats'); + } + + if (workspaceUpdatedSeats === productItem?.quantity) { + throw new InvalidSeatNumberError('The workspace already has these seats'); + } + } + + await usersService.isWorkspaceUpgradeAllowed( + user.uuid, + workspaceId, + Number(maxSpaceBytes), + workspaceUpdatedSeats, + ); + + const updatedSub = await paymentService.updateBusinessSub({ + customerId: user.customerId, + priceId: productItem?.price.id as string, + seats: workspaceUpdatedSeats, + additionalOptions: { + proration_behavior: 'create_prorations', + }, + }); + + const price = updatedSub.items.data[0]?.price; + const productId = typeof price?.product === 'string' ? price.product : price?.product.id; + const tier = await tiersService.getTierProductsByProductsId(productId, 'subscription'); + + await usersService.updateWorkspace({ + ownerId: user.uuid, + tierId: tier.featuresPerService[Service.Drive].foreignTierId, + maxSpaceBytes: Number(maxSpaceBytes), + seats: workspaceUpdatedSeats, + }); + + return res.status(200).send(updatedSub); + } catch (err) { + const error = err as Error; + req.log.error(`[WORKSPACES/ERROR]: Error trying to update seats: ${error.stack ?? error.message}`); + if ( + error instanceof InvalidSeatNumberError || + error instanceof IncompatibleSubscriptionTypesError || + error instanceof NotFoundSubscriptionError || + error instanceof UpdateWorkspaceError || + error instanceof UserNotFoundError + ) { + return res.status(400).send({ + message: error.message, + }); + } + + return res.status(500).send({ + message: 'Internal Server Error', + }); + } + }, + ); + }; +} diff --git a/src/services/users.service.ts b/src/services/users.service.ts index a0eed97b..ec5a8c60 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -142,6 +142,64 @@ export class UsersService { return uniqueCodes; } + // !DEPRECATED + async isWorkspaceUpgradeAllowed( + ownerId: string, + workspaceId: string, + maxSpaceBytes: number, + seats: number, + ): Promise { + const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); + const requestConfig: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + return this.axios.post( + `${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces/${workspaceId}/storage/upgrade-check`, + { + ownerId, + maxSpaceBytes: maxSpaceBytes * seats, + numberOfSeats: seats, + }, + requestConfig, + ); + } + + // !DEPRECATED + async updateWorkspace({ + ownerId, + tierId, + maxSpaceBytes, + seats, + }: { + ownerId: string; + tierId: string; + maxSpaceBytes: number; + seats: number; + }): Promise { + const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); + const requestConfig: AxiosRequestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + return this.axios.patch( + `${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`, + { + ownerId, + maxSpaceBytes: maxSpaceBytes * seats, + numberOfSeats: seats, + tierId, + }, + requestConfig, + ); + } + async destroyWorkspace(ownerId: string): Promise { const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); const requestConfig: AxiosRequestConfig = { diff --git a/tests/src/controller/business.controller.test.ts b/tests/src/controller/business.controller.test.ts new file mode 100644 index 00000000..4649c6ec --- /dev/null +++ b/tests/src/controller/business.controller.test.ts @@ -0,0 +1,72 @@ +import { FastifyInstance } from 'fastify'; +import { closeServerAndDatabase, initializeServerAndDatabase } from '../utils/initializeServer'; +import { getCreatedSubscription, getUser, getValidAuthToken, newTier } from '../fixtures'; +import { UsersService } from '../../../src/services/users.service'; +import { PaymentService } from '../../../src/services/payment.service'; +import Stripe from 'stripe'; +import { TiersService } from '../../../src/services/tiers.service'; +import { Service } from '../../../src/core/users/Tier'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await initializeServerAndDatabase(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterAll(async () => { + await closeServerAndDatabase(); +}); + +describe('Testing business endpoints', () => { + describe('Updating business subscription', () => { + test('When the business is updated, then the Stripe subscription is updated and the workspace in Drive too', async () => { + const mockedUser = getUser(); + const mockedTier = newTier(); + const mockedSubscription = getCreatedSubscription(); + const mockedUserToken = getValidAuthToken(mockedUser.uuid); + const mockedMaxSpaceBytes = mockedSubscription.items.data[0].price.metadata.maxSpaceBytes; + jest.spyOn(UsersService.prototype, 'findUserByUuid').mockResolvedValue(mockedUser); + jest + .spyOn(PaymentService.prototype, 'getSubscriptionById') + .mockResolvedValueOnce(mockedSubscription as Stripe.Response); + jest.spyOn(PaymentService.prototype, 'getBusinessSubscriptionSeats').mockResolvedValue({ + minimumSeats: '3', + maximumSeats: '10', + }); + jest.spyOn(UsersService.prototype, 'isWorkspaceUpgradeAllowed').mockResolvedValue(true); + jest + .spyOn(PaymentService.prototype, 'updateBusinessSub') + .mockResolvedValueOnce(mockedSubscription as Stripe.Response); + jest.spyOn(TiersService.prototype, 'getTierProductsByProductsId').mockResolvedValue(mockedTier); + const updateWorkspaceSpy = jest.spyOn(UsersService.prototype, 'updateWorkspace').mockResolvedValue(); + + const response = await app.inject({ + path: `/business/subscription`, + method: 'PATCH', + body: { + workspaceId: 'workspace_id', + subscriptionId: mockedSubscription.id, + workspaceUpdatedSeats: 4, + }, + headers: { + Authorization: `Bearer ${mockedUserToken}`, + }, + }); + + const responseBody = response.json(); + + expect(response.statusCode).toBe(200); + expect(responseBody).toStrictEqual(mockedSubscription); + expect(updateWorkspaceSpy).toHaveBeenCalledWith({ + ownerId: mockedUser.uuid, + tierId: mockedTier.featuresPerService[Service.Drive].foreignTierId, + maxSpaceBytes: Number(mockedMaxSpaceBytes), + seats: 4, + }); + }); + }); +}); From 52fe04ffc7945291545fcb9679422da7f6f496ed Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 6 May 2026 12:21:31 +0200 Subject: [PATCH 03/13] refactor(b2b): remove useless code related to b2b --- src/controller/checkout.controller.ts | 6 +--- src/services/payment.service.ts | 30 ++------------------ src/types/subscription.ts | 2 -- tests/src/services/payment.service.test.ts | 32 ---------------------- 4 files changed, 3 insertions(+), 67 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 08b70806..64ae33d6 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -162,15 +162,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); @@ -196,7 +193,6 @@ export function checkoutController(usersService: UsersService, paymentsService: customerId, priceId, currency, - seatsForBusinessSubscription: quantity ?? 1, promoCodeId, additionalOptions: { automatic_tax: { diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 21a4d545..9916f013 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -112,7 +112,6 @@ export class PaymentService { async createSubscription({ customerId, priceId, - seatsForBusinessSubscription = 1, currency, promoCodeId, companyName, @@ -123,7 +122,6 @@ export class PaymentService { }: { customerId: string; priceId: string; - seatsForBusinessSubscription?: number; currency?: string; promoCodeId?: Stripe.SubscriptionCreateParams['promotion_code']; companyName?: string; @@ -139,21 +137,6 @@ export class PaymentService { expand: ['product'], }); 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'); - } - } await this.checkIfUserAlreadyHasASubscription(customerId, product); @@ -167,7 +150,7 @@ export class PaymentService { items: [ { price: priceId, - quantity: seats, + quantity: 1, }, ], discounts: [ @@ -1029,14 +1012,6 @@ export class PaymentService { let businessSeats; const { currency_options, recurring, metadata, type, product } = selectedPrice; - const isBusinessPrice = metadata?.type === 'business'; - - if (isBusinessPrice) { - businessSeats = { - minimumSeats: Number(metadata.minimumSeats), - maximumSeats: Number(metadata.maximumSeats), - }; - } return { id: priceId, @@ -1045,9 +1020,8 @@ export class PaymentService { bytes: 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, + type: UserType.Individual, product: product as string, - ...businessSeats, }; } diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 9e479f2c..9f8ae763 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -46,8 +46,6 @@ export interface SubscriptionCreated { export type RequestedPlanData = DisplayPrice & { decimalAmount: number; - minimumSeats?: number; - maximumSeats?: number; type?: UserType; }; diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index b94d3e2f..ba4d8ec1 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -718,38 +718,6 @@ describe('Payments Service tests', () => { expect(price).toStrictEqual(priceResponse); }); - - it('When the price exists and belongs to a business product, then the price is returned with minimum and maximum seats', async () => { - const businessSeats = { - minimumSeats: 1, - maximumSeats: 3, - }; - const mockedPrice = getPrice({ - metadata: { - type: 'business', - maxSpaceBytes: '123456789', - minimumSeats: businessSeats.minimumSeats.toString(), - maximumSeats: businessSeats.maximumSeats.toString(), - }, - }); - const validPriceId = mockedPrice.id; - const priceResponse = { - id: validPriceId, - currency: mockedPrice.currency, - amount: mockedPrice.currency_options![mockedPrice.currency].unit_amount as number, - bytes: parseInt(mockedPrice.metadata?.maxSpaceBytes), - interval: mockedPrice.type === 'one_time' ? 'lifetime' : mockedPrice.recurring?.interval, - decimalAmount: (mockedPrice.currency_options![mockedPrice.currency].unit_amount as number) / 100, - product: mockedPrice.product as string, - type: UserType.Business, - ...businessSeats, - }; - jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrice]); - - const price = await paymentService.getPriceById(validPriceId); - - expect(price).toStrictEqual(priceResponse); - }); }); describe('Get tax for a price', () => { From 1b8476a27a17721395c95f162ba63179867dba06 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 12:48:52 +0200 Subject: [PATCH 04/13] fix: throw error when user attempts to purchase a b2b plan --- src/controller/checkout.controller.ts | 5 +++++ src/services/payment.service.ts | 4 ++-- src/services/tiers.service.ts | 5 ----- src/webhooks/events/invoices/InvoiceCompletedHandler.ts | 9 +-------- .../events/invoices/InvoiceCompletedHandler.test.ts | 9 --------- 5 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 64ae33d6..d0e66317 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -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) { @@ -189,6 +190,10 @@ 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 not available'); + const subscriptionAttempt = await paymentsService.createSubscription({ customerId, priceId, diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 9916f013..df213b70 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1010,8 +1010,8 @@ export class PaymentService { throw new NotFoundError('The requested price does not exist'); } - let businessSeats; const { currency_options, recurring, metadata, type, product } = selectedPrice; + const isBusiness = metadata.type === 'business'; return { id: priceId, @@ -1020,7 +1020,7 @@ export class PaymentService { bytes: parseInt(metadata?.maxSpaceBytes), interval: type === 'one_time' ? 'lifetime' : recurring?.interval, decimalAmount: (currency_options![currency].unit_amount as number) / 100, - type: UserType.Individual, + type: isBusiness ? UserType.Business : UserType.Individual, product: product as string, }; } diff --git a/src/services/tiers.service.ts b/src/services/tiers.service.ts index bc484dfe..d0a5abe9 100644 --- a/src/services/tiers.service.ts +++ b/src/services/tiers.service.ts @@ -4,10 +4,8 @@ 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 from 'axios'; -import { Customer } from '../infrastructure/domain/entities/customer'; import { BadRequestError } from '../errors/Errors'; export class TierNotFoundError extends Error { @@ -131,10 +129,7 @@ export class TiersService { async applyDriveFeatures( userWithEmail: { email: string; uuid: User['uuid'] }, - customer: Customer, - subscriptionSeats: Stripe.InvoiceLineItem['quantity'], tier: Tier, - log: FastifyBaseLogger, customMaxSpaceBytes?: number, ): Promise { const features = tier.featuresPerService[Service.Drive]; diff --git a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts index 060a1a03..6bd2e9fc 100644 --- a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts +++ b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts @@ -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}`, { diff --git a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index 6b6b959d..f816948f 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -430,10 +430,7 @@ describe('Testing the handler when an invoice is completed', () => { ...mockedUser, email: mockedCustomer.email as string, }, - Customer.toDomain(mockedCustomer), - totalQuantity, mockedLifetimeTier, - expect.anything(), lifetimeMockedMaxSpaceBytes, ); expect(applyVpnFeaturesSpy).toHaveBeenCalledWith( @@ -480,10 +477,7 @@ describe('Testing the handler when an invoice is completed', () => { ...mockedUser, email: mockedCustomer.email as string, }, - Customer.toDomain(mockedCustomer), - totalQuantity, mockedTier, - expect.anything(), undefined, ); expect(applyVpnFeaturesSpy).toHaveBeenCalledWith( @@ -527,10 +521,7 @@ describe('Testing the handler when an invoice is completed', () => { ...mockedUser, email: mockedCustomer.email as string, }, - Customer.toDomain(mockedCustomer), - totalQuantity, mockedTier, - expect.anything(), undefined, ); expect(applyVpnFeaturesSpy).toHaveBeenCalledWith( From dfbb31119e3c25c67203d781ce5c311a9fc7f693 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 12:52:45 +0200 Subject: [PATCH 05/13] refactor: remove useless casts --- src/controller/business.controller.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controller/business.controller.ts b/src/controller/business.controller.ts index 94f54476..99da3d33 100644 --- a/src/controller/business.controller.ts +++ b/src/controller/business.controller.ts @@ -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'); } @@ -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', From 7f653f11ee53ef489a33971d0a7c52064de30942 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 13:02:16 +0200 Subject: [PATCH 06/13] test: add coverage for business plan error --- .../controller/checkout.controller.test.ts | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index c293cf16..ddd9d230 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -1,10 +1,10 @@ import { FastifyInstance } from 'fastify'; import { - getCreatedSubscription, getCreateSubscriptionResponse, getCryptoCurrency, getCustomer, getInvoice, + getPrice, getRawCryptoInvoiceResponse, getTaxes, getUser, @@ -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'); @@ -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); @@ -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, @@ -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); From 9c359550c1a1fa3a812acf51476827d1dc885daa Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 13:55:09 +0200 Subject: [PATCH 07/13] tests: add coverage when applying Drive features --- tests/src/services/tiers.service.test.ts | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/src/services/tiers.service.test.ts b/tests/src/services/tiers.service.test.ts index e554d104..ddcac985 100644 --- a/tests/src/services/tiers.service.test.ts +++ b/tests/src/services/tiers.service.test.ts @@ -4,6 +4,7 @@ import { Service } from '../../../src/core/users/Tier'; import { UserTier } from '../../../src/core/users/MongoDBUsersTiersRepository'; import { FREE_PLAN_BYTES_SPACE } from '../../../src/constants'; import { createTestServices } from '../helpers/services-factory'; +import { BadRequestError } from '../../../src/errors/Errors'; describe('TiersService tests', () => { const { usersTiersRepository, tiersRepository, tiersService, usersService, storageService } = createTestServices(); @@ -164,6 +165,55 @@ describe('TiersService tests', () => { }); }); + describe('Apply the Drive features', () => { + test('When the user wants to apply an individual plan, then the Drive features are applied', async () => { + const mockedUser = getUser(); + const mockedTier = newTier(); + + const changeStorageSpy = jest.spyOn(storageService, 'updateUserStorageAndTier').mockResolvedValue(); + + await tiersService.applyDriveFeatures( + { + email: 'example@internxt.com', + uuid: mockedUser.uuid, + }, + mockedTier, + mockedTier.featuresPerService.drive.maxSpaceBytes, + ); + + expect(changeStorageSpy).toHaveBeenCalledWith( + mockedUser.uuid, + mockedTier.featuresPerService.drive.maxSpaceBytes, + mockedTier.featuresPerService.drive.foreignTierId, + ); + }); + + test('When the user wants to apply a business plan, then an error indicating that it is not possible is thrown', async () => { + const mockedUser = getUser(); + const mockedTier = newTier({ + featuresPerService: { + drive: { + workspaces: { + enabled: true, + }, + maxSpaceBytes: 100, + }, + } as any, + }); + + await expect( + tiersService.applyDriveFeatures( + { + email: 'example@internxt.com', + uuid: mockedUser.uuid, + }, + mockedTier, + mockedTier.featuresPerService.drive.maxSpaceBytes, + ), + ).rejects.toThrow(BadRequestError); + }); + }); + describe('Remove the tier the user canceled or requested a refund', () => { it('When removing the tier, then fails if the tier is not found', async () => { const mockedUser = getUser(); From 0b849ede6e07e8335c50ca0dd98ab1ed340b7f34 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 14:13:34 +0200 Subject: [PATCH 08/13] fix: throw error if it is a business plan --- src/controller/checkout.controller.ts | 7 +++++ src/services/payment.service.ts | 5 ++-- tests/src/services/users.service.test.ts | 33 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index d0e66317..57a83dd8 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -287,6 +287,9 @@ export function checkoutController(usersService: UsersService, paymentsService: } const price = await paymentsService.getPriceById(priceId); + const isBusiness = price.type === UserType.Business; + + if (isBusiness) throw new BadRequestError('Business plans are not available'); if (price.interval !== 'lifetime') { throw new BadRequestError('Only lifetime plans are supported'); @@ -363,6 +366,10 @@ export function checkoutController(usersService: UsersService, paymentsService: const user = await usersService.findUserByUuid(userUuid).catch(() => null); const price = await paymentsService.getPriceById(priceId, currency); + const isBusiness = price.type === UserType.Business; + + if (isBusiness) throw new BadRequestError('Business plans are not available'); + let amount = price.amount; if (promoCodeName) { diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index df213b70..3e31945a 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1011,16 +1011,15 @@ export class PaymentService { } const { currency_options, recurring, metadata, type, product } = selectedPrice; - const isBusiness = metadata.type === 'business'; return { 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: isBusiness ? UserType.Business : UserType.Individual, + type: UserType.Individual, product: product as string, }; } diff --git a/tests/src/services/users.service.test.ts b/tests/src/services/users.service.test.ts index 77d7dbcd..9281dbb5 100644 --- a/tests/src/services/users.service.test.ts +++ b/tests/src/services/users.service.test.ts @@ -117,6 +117,39 @@ describe('UsersService tests', () => { }); }); + describe('Workspaces', () => { + test('When updating the workspace, then the workspace is updated using the correct params', async () => { + const userWithEmail = { ...getUser(), email: 'test@internxt.com' }; + const tier = newTier(); + const amountOfSeats = 5; + + const axiosPostSpy = jest.spyOn(axios, 'patch').mockResolvedValue({} as any); + + await usersService.updateWorkspace({ + ownerId: userWithEmail.uuid, + maxSpaceBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat, + seats: amountOfSeats, + tierId: tier.featuresPerService[Service.Drive].foreignTierId, + }); + + expect(axiosPostSpy).toHaveBeenCalledWith( + `${process.env.DRIVE_NEW_GATEWAY_URL}/gateway/workspaces`, + { + ownerId: userWithEmail.uuid, + maxSpaceBytes: tier.featuresPerService[Service.Drive].workspaces.maxSpaceBytesPerSeat * amountOfSeats, + numberOfSeats: amountOfSeats, + tierId: tier.featuresPerService[Service.Drive].foreignTierId, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer undefined', + }, + }, + ); + }); + }); + describe('Cancel user subscription', () => { describe('Cancel the user Individual subscription', () => { it('When the customer wants to cancel the individual subscription, then the Stripe plan is cancelled and the storage is restored', async () => { From 979f1fa48bc64c0536a27b4a0ab5f0fe8a240322 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 14:31:52 +0200 Subject: [PATCH 09/13] test: coverage for business errors --- src/controller/checkout.controller.ts | 3 --- src/services/payment.service.ts | 3 ++- .../controller/checkout.controller.test.ts | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 57a83dd8..e52783c3 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -287,9 +287,6 @@ export function checkoutController(usersService: UsersService, paymentsService: } const price = await paymentsService.getPriceById(priceId); - const isBusiness = price.type === UserType.Business; - - if (isBusiness) throw new BadRequestError('Business plans are not available'); if (price.interval !== 'lifetime') { throw new BadRequestError('Only lifetime plans are supported'); diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 3e31945a..b46366de 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1011,6 +1011,7 @@ export class PaymentService { } const { currency_options, recurring, metadata, type, product } = selectedPrice; + const isBusiness = metadata.type === UserType.Business; return { id: priceId, @@ -1019,7 +1020,7 @@ export class PaymentService { bytes: Number.parseInt(metadata?.maxSpaceBytes), interval: type === 'one_time' ? 'lifetime' : recurring?.interval, decimalAmount: (currency_options![currency].unit_amount as number) / 100, - type: UserType.Individual, + type: isBusiness ? UserType.Business : UserType.Individual, product: product as string, }; } diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index ddd9d230..2cb40102 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -860,6 +860,27 @@ describe('Checkout controller', () => { }); }); + test('When the user wants to get a business price by its ID, then an error indicating so is thrown', async () => { + const mockedPrice = priceById({ + bytes: 123456789, + interval: 'year', + type: UserType.Business, + }); + + jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + + const response = await app.inject({ + path: `/checkout/price-by-id?priceId=${mockedPrice.id}&userAddress=123.12.12.12`, + query: { + priceId: mockedPrice.id, + userAddress: '123.12.12.12', + }, + method: 'GET', + }); + + expect(response.statusCode).toBe(400); + }); + describe('Handling promo codes', () => { test('When the user provides a promo code with amount off, then the price is returned with the discount applied', async () => { const mockedPrice = priceById({ From a098e8aa87cfac16261faf6bb9840714190535ea Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 15:44:42 +0200 Subject: [PATCH 10/13] fix: remove error when fetching a b2b plan --- src/controller/checkout.controller.ts | 3 --- .../controller/checkout.controller.test.ts | 21 ------------------- 2 files changed, 24 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index e52783c3..6ce338a2 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -363,9 +363,6 @@ export function checkoutController(usersService: UsersService, paymentsService: const user = await usersService.findUserByUuid(userUuid).catch(() => null); const price = await paymentsService.getPriceById(priceId, currency); - const isBusiness = price.type === UserType.Business; - - if (isBusiness) throw new BadRequestError('Business plans are not available'); let amount = price.amount; diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 2cb40102..ddd9d230 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -860,27 +860,6 @@ describe('Checkout controller', () => { }); }); - test('When the user wants to get a business price by its ID, then an error indicating so is thrown', async () => { - const mockedPrice = priceById({ - bytes: 123456789, - interval: 'year', - type: UserType.Business, - }); - - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); - - const response = await app.inject({ - path: `/checkout/price-by-id?priceId=${mockedPrice.id}&userAddress=123.12.12.12`, - query: { - priceId: mockedPrice.id, - userAddress: '123.12.12.12', - }, - method: 'GET', - }); - - expect(response.statusCode).toBe(400); - }); - describe('Handling promo codes', () => { test('When the user provides a promo code with amount off, then the price is returned with the discount applied', async () => { const mockedPrice = priceById({ From ccbf0e6bed7b1f962e153b8aac6fc26ac9cf3f1a Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 16:52:59 +0200 Subject: [PATCH 11/13] fix: prevent b2b sub creation --- src/services/payment.service.ts | 3 +++ tests/src/services/payment.service.test.ts | 23 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index b46366de..07dab600 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -137,6 +137,9 @@ export class PaymentService { expand: ['product'], }); const product = price.product as Stripe.Product; + const isBusinessProduct = !!product.metadata.type && product.metadata.type === UserType.Business; + + if (isBusinessProduct) throw new BadRequestError('Business plan is not available'); await this.checkIfUserAlreadyHasASubscription(customerId, product); diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index ba4d8ec1..10c54dfc 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -87,6 +87,29 @@ describe('Payments Service tests', () => { }); expect(subscription).toEqual(mockedSubscriptionResponse); }); + + it('When trying to create a business subscription, then an error is thrown', async () => { + const mockedCreateSubscription = getCreatedSubscription(); + const mockedPrice = getPrice({ + product: { + metadata: { + type: 'business', + }, + } as any, + }); + + jest.spyOn(stripe.prices, 'retrieve').mockResolvedValue(mockedPrice as any); + + await expect( + paymentService.createSubscription({ + customerId: mockedCreateSubscription.customer as string, + priceId: mockedCreateSubscription.items.data[0].price.id, + promoCodeId: ( + (mockedCreateSubscription.discounts[0] as Stripe.Discount)?.promotion_code as Stripe.PromotionCode + ).code, + }), + ).rejects.toThrow(BadRequestError); + }); }); describe('Get a price given its ID', () => { From c815e9eb0d1c0a2fb996736a6416570da2c434f6 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 16:56:32 +0200 Subject: [PATCH 12/13] refactor: undo changes when fetching a price --- src/services/payment.service.ts | 13 +++++++-- tests/src/services/payment.service.test.ts | 32 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 07dab600..744ab36b 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1013,8 +1013,16 @@ export class PaymentService { throw new NotFoundError('The requested price does not exist'); } + let businessSeats; const { currency_options, recurring, metadata, type, product } = selectedPrice; - const isBusiness = metadata.type === UserType.Business; + const isBusinessPrice = metadata?.type === 'business'; + + if (isBusinessPrice) { + businessSeats = { + minimumSeats: Number(metadata.minimumSeats), + maximumSeats: Number(metadata.maximumSeats), + }; + } return { id: priceId, @@ -1023,8 +1031,9 @@ export class PaymentService { bytes: Number.parseInt(metadata?.maxSpaceBytes), interval: type === 'one_time' ? 'lifetime' : recurring?.interval, decimalAmount: (currency_options![currency].unit_amount as number) / 100, - type: isBusiness ? UserType.Business : UserType.Individual, + type: isBusinessPrice ? UserType.Business : UserType.Individual, product: product as string, + ...businessSeats, }; } diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index 10c54dfc..2d52cdb9 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -718,6 +718,38 @@ describe('Payments Service tests', () => { await expect(paymentService.getPriceById(invalidPriceId)).rejects.toThrow(NotFoundError); }); + it('When the price exists and belongs to a business product, then the price is returned with minimum and maximum seats', async () => { + const businessSeats = { + minimumSeats: 1, + maximumSeats: 3, + }; + const mockedPrice = getPrice({ + metadata: { + type: 'business', + maxSpaceBytes: '123456789', + minimumSeats: businessSeats.minimumSeats.toString(), + maximumSeats: businessSeats.maximumSeats.toString(), + }, + }); + const validPriceId = mockedPrice.id; + const priceResponse = { + id: validPriceId, + currency: mockedPrice.currency, + amount: mockedPrice.currency_options![mockedPrice.currency].unit_amount as number, + bytes: parseInt(mockedPrice.metadata?.maxSpaceBytes), + interval: mockedPrice.type === 'one_time' ? 'lifetime' : mockedPrice.recurring?.interval, + decimalAmount: (mockedPrice.currency_options![mockedPrice.currency].unit_amount as number) / 100, + product: mockedPrice.product as string, + type: UserType.Business, + ...businessSeats, + }; + jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrice]); + + const price = await paymentService.getPriceById(validPriceId); + + expect(price).toStrictEqual(priceResponse); + }); + it('When the price exists, then the correct price object is returned', async () => { const mockedPrice = getPrice({ metadata: { From e8babf487ebfd4c4f09efd7cd95cdb66e2bd23f6 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 7 May 2026 20:25:56 +0200 Subject: [PATCH 13/13] feat: improve business plan error --- src/controller/checkout.controller.ts | 2 +- src/services/payment.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 6ce338a2..c6e2c9b1 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -192,7 +192,7 @@ export function checkoutController(usersService: UsersService, paymentsService: const price = await paymentsService.getPriceById(priceId); - if (price.type === UserType.Business) throw new BadRequestError('Business plan is not available'); + if (price.type === UserType.Business) throw new BadRequestError('Business plan is no longer available'); const subscriptionAttempt = await paymentsService.createSubscription({ customerId, diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 744ab36b..c2f70ad5 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -139,7 +139,7 @@ export class PaymentService { const product = price.product as Stripe.Product; const isBusinessProduct = !!product.metadata.type && product.metadata.type === UserType.Business; - if (isBusinessProduct) throw new BadRequestError('Business plan is not available'); + if (isBusinessProduct) throw new BadRequestError('Business plan is no longer available'); await this.checkIfUserAlreadyHasASubscription(customerId, product);