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', diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 08b70806..c6e2c9b1 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) { @@ -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); @@ -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: { @@ -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) { 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/payment.service.ts b/src/services/payment.service.ts index 21a4d545..c2f70ad5 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; @@ -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); @@ -167,7 +153,7 @@ export class PaymentService { items: [ { price: priceId, - quantity: seats, + quantity: 1, }, ], discounts: [ @@ -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, diff --git a/src/services/tiers.service.ts b/src/services/tiers.service.ts index 982e82a6..d0a5abe9 100644 --- a/src/services/tiers.service.ts +++ b/src/services/tiers.service.ts @@ -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) { @@ -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 { 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..ec5a8c60 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -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 { - 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, @@ -193,6 +168,7 @@ export class UsersService { ); } + // !DEPRECATED async updateWorkspace({ ownerId, tierId, 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/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/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); diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index b94d3e2f..2d52cdb9 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', () => { @@ -695,10 +718,17 @@ describe('Payments Service tests', () => { await expect(paymentService.getPriceById(invalidPriceId)).rejects.toThrow(NotFoundError); }); - it('When the price exists, then the correct price object is returned', async () => { + 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; @@ -710,7 +740,8 @@ describe('Payments Service tests', () => { 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.Individual, + type: UserType.Business, + ...businessSeats, }; jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrice]); @@ -719,17 +750,10 @@ 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, - }; + it('When the price exists, then the correct price object is returned', async () => { const mockedPrice = getPrice({ metadata: { - type: 'business', maxSpaceBytes: '123456789', - minimumSeats: businessSeats.minimumSeats.toString(), - maximumSeats: businessSeats.maximumSeats.toString(), }, }); const validPriceId = mockedPrice.id; @@ -741,8 +765,7 @@ describe('Payments Service tests', () => { 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, + type: UserType.Individual, }; jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrice]); 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(); diff --git a/tests/src/services/users.service.test.ts b/tests/src/services/users.service.test.ts index 8dccd833..9281dbb5 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'; @@ -118,41 +118,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(); 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(