From d25364b224282b06ea09deba5d86bed8e4521693 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 15:42:06 +0200 Subject: [PATCH 1/5] refactor(prices): get prices from stripe payment adapter --- src/controller/checkout.controller.ts | 13 ++-- src/controller/payments.controller.ts | 14 +++- src/infrastructure/adapters/stripe.adapter.ts | 6 +- src/infrastructure/domain/entities/price.ts | 25 ++++++- src/services/payment.service.ts | 63 +---------------- .../controller/checkout.controller.test.ts | 61 ++++++++--------- tests/src/services/payment.service.test.ts | 67 ------------------- 7 files changed, 74 insertions(+), 175 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index c6e2c9b1..cd74f1a8 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -13,7 +13,6 @@ 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) { @@ -190,9 +189,9 @@ export function checkoutController(usersService: UsersService, paymentsService: throw new ForbiddenError(); } - const price = await paymentsService.getPriceById(priceId); + const price = await stripePaymentsAdapter.getPriceById(priceId); - if (price.type === UserType.Business) throw new BadRequestError('Business plan is no longer available'); + if (price.isBusinessPlan()) throw new BadRequestError('Business plan is no longer available'); const subscriptionAttempt = await paymentsService.createSubscription({ customerId, @@ -286,7 +285,7 @@ export function checkoutController(usersService: UsersService, paymentsService: throw new ForbiddenError(); } - const price = await paymentsService.getPriceById(priceId); + const price = await stripePaymentsAdapter.getPriceById(priceId); if (price.interval !== 'lifetime') { throw new BadRequestError('Only lifetime plans are supported'); @@ -362,12 +361,12 @@ export function checkoutController(usersService: UsersService, paymentsService: const userUuid = req.user?.payload?.uuid; const user = await usersService.findUserByUuid(userUuid).catch(() => null); - const price = await paymentsService.getPriceById(priceId, currency); + const price = await stripePaymentsAdapter.getPriceById(priceId, currency); let amount = price.amount; if (promoCodeName) { - const couponCode = await paymentsService.getPromoCodeByName(price.product, promoCodeName); + const couponCode = await paymentsService.getPromoCodeByName(price.productId, promoCodeName); if (couponCode.amountOff) { amount = Math.max(0, price.amount - couponCode.amountOff); } else if (couponCode.percentOff) { @@ -393,7 +392,7 @@ export function checkoutController(usersService: UsersService, paymentsService: const amountTotal = taxForPrice?.amount_total ?? price.amount; return res.status(200).send({ - price, + price: price.toJSON(), taxes: { tax: taxAmount, decimalTax: taxAmount / 100, diff --git a/src/controller/payments.controller.ts b/src/controller/payments.controller.ts index 71db6772..406f938f 100644 --- a/src/controller/payments.controller.ts +++ b/src/controller/payments.controller.ts @@ -425,7 +425,6 @@ export function paymentsController( }, async (req, rep) => { const { currency } = req.query; - const userType = (req.query.userType as UserType) || UserType.Individual; const { currencyValue, isError, errorMessage } = checkCurrency(currency); @@ -433,7 +432,18 @@ export function paymentsController( return rep.status(400).send({ message: errorMessage }); } - return paymentService.getPrices(currencyValue, userType); + const prices = await stripePaymentsAdapter.getPrices(currencyValue); + + const mappedPrices = prices.map((price) => ({ + id: price.id, + productId: price.productId, + currency: price.currency, + amount: price.amount, + bytes: price.bytes, + interval: price?.interval, + })); + + return mappedPrices; }, ); diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 0b011756..06b730a2 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -67,8 +67,8 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return Price.toDomain({ id: price.id, productId: (price.product as Stripe.Product).id, - bytes: Number.parseInt(price.metadata.bytes), - interval: this.getInterval(price.recurring!.interval), + bytes: Number.parseInt(price.metadata.maxSpaceBytes), + interval: this.getInterval(price.recurring?.interval), commitmentPlan: this.hasAnnualCommitment(price), recurring: price.type === 'recurring', amount: price.currency_options![currency].unit_amount as number, @@ -92,7 +92,7 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return Price.toDomain({ id: price.id, productId: (price.product as Stripe.Product).id, - bytes: Number.parseInt(price.metadata.bytes), + bytes: Number.parseInt(price.metadata.maxSpaceBytes), interval: this.getInterval(price.recurring?.interval), commitmentPlan: this.hasAnnualCommitment(price), recurring: price.type === 'recurring', diff --git a/src/infrastructure/domain/entities/price.ts b/src/infrastructure/domain/entities/price.ts index 527a97f2..338289b6 100644 --- a/src/infrastructure/domain/entities/price.ts +++ b/src/infrastructure/domain/entities/price.ts @@ -42,8 +42,7 @@ export class Price implements PriceAttributes { this.decimalAmount = attributes.decimalAmount; this.recurring = attributes.recurring; this.type = attributes.type; - this.minimumSeats = attributes.minimumSeats; - this.maximumSeats = attributes.maximumSeats; + this.buildBusinessSeats(attributes.minimumSeats, attributes.maximumSeats); } static toDomain(attributes: PriceAttributes): Price { @@ -61,4 +60,26 @@ export class Price implements PriceAttributes { public isRecurring(): boolean { return this.recurring; } + + public toJSON(): PriceAttributes { + return { + id: this.id, + productId: this.productId, + bytes: this.bytes, + interval: this.interval, + commitmentPlan: this.commitmentPlan, + recurring: this.recurring, + amount: this.amount, + currency: this.currency, + decimalAmount: this.decimalAmount, + type: this.type, + ...(this.minimumSeats !== undefined && { minimumSeats: this.minimumSeats }), + ...(this.maximumSeats !== undefined && { maximumSeats: this.maximumSeats }), + }; + } + + private buildBusinessSeats(minimumSeats?: number, maximumSeats?: number) { + if (minimumSeats !== undefined) this.minimumSeats = minimumSeats; + if (maximumSeats !== undefined) this.maximumSeats = maximumSeats; + } } diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 2243548c..f985fc4e 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -35,7 +35,7 @@ import { CustomerSource, Customer as StripeCustomer, } from '../types/stripe'; -import { PaymentIntent, PromotionCode, PriceByIdResponse } from '../types/payment'; +import { PaymentIntent, PromotionCode } from '../types/payment'; import { RenewalPeriod, PlanSubscription, SubscriptionCreated } from '../types/subscription'; import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; @@ -937,67 +937,6 @@ export class PaymentService { }); } - async getPricesRaw(currency?: string, expandProduct = false): Promise { - const currencyValue = currency ?? 'eur'; - - const expandOptions = ['data.currency_options']; - - if (expandProduct) { - expandOptions.push('data.product'); - } - - //!TODO: add metadata["show"]:"1" in query param - const res = await this.provider.prices.search({ - query: `active:"true" currency:"${currencyValue}"`, - expand: expandOptions, - limit: 100, - }); - - return res.data.filter( - (price) => - price.metadata.maxSpaceBytes && price.currency_options && price.currency_options[currencyValue].unit_amount, - ); - } - - /** - * Returns the requested price if exists - * @param priceId - The id of the requested price - * @param currency - The currency of the requested price - * @returns - The selected price if it exists and it is active - */ - async getPriceById(priceId: string, currency = 'eur'): Promise { - const availablePrices = await this.getPricesRaw(currency); - - const selectedPrice = availablePrices.find((price) => price.id === priceId && price.active); - - if (!selectedPrice) { - throw new NotFoundError('The requested price does not exist'); - } - - 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, - currency, - amount: currency_options![currency].unit_amount as number, - 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, - product: product as string, - ...businessSeats, - }; - } - /** * Returns the tax for a given price * @param priceId - The Id of the price that we want to calculate the tax for diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index ddd9d230..6643d42a 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -5,13 +5,13 @@ import { getCustomer, getInvoice, getPrice, + getPriceEntity, getRawCryptoInvoiceResponse, getTaxes, getUser, getValidAuthToken, getValidUserToken, mockCalculateTaxFor, - priceById, } from '../fixtures'; import { closeServerAndDatabase, initializeServerAndDatabase } from '../utils/initializeServer'; import { UsersService } from '../../../src/services/users.service'; @@ -288,7 +288,7 @@ describe('Checkout controller', () => { const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue({ + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue({ type: UserType.Individual, } as any); jest.spyOn(PaymentService.prototype, 'createSubscription').mockResolvedValue(mockedSubscriptionResponse); @@ -325,7 +325,7 @@ describe('Checkout controller', () => { const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue({ + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue({ type: UserType.Business, } as any); jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); @@ -492,8 +492,7 @@ describe('Checkout controller', () => { test('When the user wants to pay a one time plan, then an invoice is created and the client secret is returned', async () => { const mockedUser = getUser(); const mockedInvoice = getInvoice(); - const mockedPrice = priceById({ - bytes: 123456789, + const mockedPrice = getPriceEntity({ interval: 'lifetime', }); const authToken = getValidAuthToken(mockedUser.uuid); @@ -505,7 +504,7 @@ describe('Checkout controller', () => { } as const; const mockedCaptchaToken = 'captcha_token'; - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); (fetchUserStorage as jest.Mock).mockResolvedValue({ canExpand: true, }); @@ -536,7 +535,7 @@ describe('Checkout controller', () => { test('when the user want to pay a one time plan using crypto currencies, then an invoice is created and the specific payload containing the QR Link is returned', async () => { const mockedUser = getUser(); const mockedInvoice = getInvoice(); - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'lifetime', }); @@ -557,7 +556,7 @@ describe('Checkout controller', () => { token: getValidUserToken({ invoiceId: 'invoice_id' }), } as const; - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); (fetchUserStorage as jest.Mock).mockResolvedValue({ canExpand: true, }); @@ -588,13 +587,13 @@ describe('Checkout controller', () => { test('When the user wants to pay a subscription plan creating an invoice, then an error indicating so is thrown', async () => { const mockedUser = getUser(); - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'year', }); const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); const response = await app.inject({ path: '/checkout/payment-intent', @@ -616,14 +615,14 @@ describe('Checkout controller', () => { test('When the user already has the max storage allowed, then an error indicating so is thrown', async () => { const mockedUser = getUser(); const mockedInvoice = getInvoice(); - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'lifetime', }); const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); (fetchUserStorage as jest.Mock).mockResolvedValue({ canExpand: false, }); @@ -826,13 +825,13 @@ describe('Checkout controller', () => { describe('Get Price by its ID', () => { test('When the user wants to get a price by its ID, then the price is returned with its taxes', async () => { - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'year', }); const taxes = mockCalculateTaxFor(mockedPrice.amount); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); jest .spyOn(PaymentService.prototype, 'calculateTax') .mockResolvedValueOnce(taxes as unknown as Stripe.Tax.Calculation); @@ -850,7 +849,7 @@ describe('Checkout controller', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ - price: mockedPrice, + price: mockedPrice.toJSON(), taxes: { tax: taxes.tax_amount_exclusive, decimalTax: taxes.tax_amount_exclusive / 100, @@ -862,7 +861,7 @@ describe('Checkout controller', () => { 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({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'year', }); @@ -875,7 +874,7 @@ describe('Checkout controller', () => { const discountedAmount = mockedPrice.amount - promoCode.amountOff; const taxes = mockCalculateTaxFor(discountedAmount); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); jest.spyOn(PaymentService.prototype, 'getPromoCodeByName').mockResolvedValue(promoCode); jest .spyOn(PaymentService.prototype, 'calculateTax') @@ -895,7 +894,7 @@ describe('Checkout controller', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ - price: mockedPrice, + price: mockedPrice.toJSON(), taxes: { tax: taxes.tax_amount_exclusive, decimalTax: taxes.tax_amount_exclusive / 100, @@ -906,7 +905,7 @@ describe('Checkout controller', () => { }); test('When the user provides a promo code with percent off, then the price is returned with the discount applied', async () => { - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'year', }); @@ -920,7 +919,7 @@ describe('Checkout controller', () => { const discountedAmount = mockedPrice.amount - discount; const taxes = mockCalculateTaxFor(discountedAmount); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); jest.spyOn(PaymentService.prototype, 'getPromoCodeByName').mockResolvedValue(promoCode); jest .spyOn(PaymentService.prototype, 'calculateTax') @@ -940,7 +939,7 @@ describe('Checkout controller', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ - price: mockedPrice, + price: mockedPrice.toJSON(), taxes: { tax: taxes.tax_amount_exclusive, decimalTax: taxes.tax_amount_exclusive / 100, @@ -951,14 +950,12 @@ describe('Checkout controller', () => { }); test('When the user provides a promo code with a discount that is more than the product price, then the price should be 0 instead of a negative price', async () => { - const mockedPrice = { - ...priceById({ - bytes: 123456789, - interval: 'year', - }), + const mockedPrice = getPriceEntity({ + bytes: 123456789, + interval: 'year', amount: 14000, decimalAmount: 140, - }; + }); const promoCode = { promoCodeName: 'promo_code_name', amountOff: 15000, @@ -969,7 +966,7 @@ describe('Checkout controller', () => { const discountedAmount = 0; const taxes = mockCalculateTaxFor(discountedAmount); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); jest.spyOn(PaymentService.prototype, 'getPromoCodeByName').mockResolvedValue(promoCode); jest .spyOn(PaymentService.prototype, 'calculateTax') @@ -989,7 +986,7 @@ describe('Checkout controller', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ - price: mockedPrice, + price: mockedPrice.toJSON(), taxes: { tax: taxes.tax_amount_exclusive, decimalTax: taxes.tax_amount_exclusive / 100, @@ -1013,13 +1010,13 @@ describe('Checkout controller', () => { describe('User address, country and postal code are not provided', () => { test('When any of user location params are provided, then the price is returned with taxes to 0', async () => { - const mockedPrice = priceById({ + const mockedPrice = getPriceEntity({ bytes: 123456789, interval: 'year', }); const mockedTaxes = getTaxes(); - jest.spyOn(PaymentService.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); jest.spyOn(PaymentService.prototype, 'calculateTax').mockResolvedValue(mockedTaxes); const response = await app.inject({ @@ -1034,7 +1031,7 @@ describe('Checkout controller', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ - price: mockedPrice, + price: mockedPrice.toJSON(), taxes: { tax: 0, decimalTax: 0, diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index 2d52cdb9..7ef06abd 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -708,73 +708,6 @@ describe('Payments Service tests', () => { }); }); - describe('Fetch a price by its ID', () => { - it('When the price does not exist, an error indicating so is thrown', async () => { - const mockedPrices = getPrice(); - const invalidPriceId = 'invalid_price_id'; - - jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrices]); - - 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: { - maxSpaceBytes: '123456789', - }, - }); - 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.Individual, - }; - jest.spyOn(paymentService, 'getPricesRaw').mockResolvedValue([mockedPrice]); - - const price = await paymentService.getPriceById(validPriceId); - - expect(price).toStrictEqual(priceResponse); - }); - }); - describe('Get tax for a price', () => { it('When the params are correct, then a tax object is returned for the requested price', async () => { const mockedPrice = getPrice(); From 3fd8104fc09ac0eaedc3dc4e04103499118b71df Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 15:53:32 +0200 Subject: [PATCH 2/5] fix: correct usage of Price for tests --- tests/src/controller/checkout.controller.test.ts | 12 ++++++------ .../infrastructure/adapters/stripe.adapter.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 6643d42a..ee90ddc7 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -288,9 +288,9 @@ describe('Checkout controller', () => { const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue({ - type: UserType.Individual, - } as any); + jest + .spyOn(StripePaymentsAdapter.prototype, 'getPriceById') + .mockResolvedValue(getPriceEntity({ type: UserType.Individual })); jest.spyOn(PaymentService.prototype, 'createSubscription').mockResolvedValue(mockedSubscriptionResponse); jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); @@ -325,9 +325,9 @@ describe('Checkout controller', () => { const authToken = getValidAuthToken(mockedUser.uuid); const userToken = getValidUserToken({ customerId: mockedUser.customerId }); - jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue({ - type: UserType.Business, - } as any); + jest + .spyOn(StripePaymentsAdapter.prototype, 'getPriceById') + .mockResolvedValue(getPriceEntity({ type: UserType.Business })); jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); const response = await app.inject({ diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index a443412a..a360cd90 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -180,7 +180,7 @@ describe('Stripe Adapter', () => { describe('Get prices', () => { test('When getting all available prices, then all prices are returned with the correct data', async () => { const stripePrice = getPrice({ - metadata: { show: '1', bytes: '107374182400', type: 'individual', annualCommitment: 'false' }, + metadata: { show: '1', maxSpaceBytes: '107374182400', type: 'individual', annualCommitment: 'false' }, recurring: { interval: 'year', interval_count: 1, @@ -206,7 +206,7 @@ describe('Stripe Adapter', () => { Price.toDomain({ id: stripePrice.id, productId: 'prod_test', - bytes: 107374182400, + bytes: Number(stripePrice.metadata.maxSpaceBytes), interval: 'year', commitmentPlan: false, recurring: true, @@ -257,7 +257,7 @@ describe('Stripe Adapter', () => { describe('Get price by ID', () => { test('When getting a price by its ID, then the price is returned with the correct data', async () => { const stripePrice = getPrice({ - metadata: { bytes: '107374182400', type: 'individual', annualCommitment: 'false' }, + metadata: { maxSpaceBytes: '107374182400', type: 'individual', annualCommitment: 'false' }, recurring: { interval: 'year', interval_count: 1, @@ -280,7 +280,7 @@ describe('Stripe Adapter', () => { Price.toDomain({ id: stripePrice.id, productId: 'prod_test', - bytes: 107374182400, + bytes: Number(stripePrice.metadata.maxSpaceBytes), interval: 'year', commitmentPlan: false, recurring: true, From 963d1c9ab160f750675f22d6544df8d9bd249db8 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 16:05:28 +0200 Subject: [PATCH 3/5] refactor(price): remove useless functions --- src/infrastructure/adapters/stripe.adapter.ts | 9 ++-- src/services/payment.service.ts | 43 +------------------ tests/src/services/payment.service.test.ts | 33 +++++--------- 3 files changed, 18 insertions(+), 67 deletions(-) diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 06b730a2..e0f55273 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -56,13 +56,14 @@ export class StripePaymentsAdapter implements PaymentsAdapter { async getPrices(currency: string = 'eur'): Promise { const prices = await this.provider.prices.search({ - query: `metadata["show"]:"1" active:"true" currency:"${currency}"`, + query: `metadata["show"]:"1" active:"true" currency:"eur"`, expand: ['data.currency_options', 'data.product'], limit: 100, }); return prices.data.map((price) => { const businessSeats = this.getBusinessSeats(price); + const currencyOptions = price.currency_options![currency] ?? price.currency_options!['eur']; return Price.toDomain({ id: price.id, @@ -71,9 +72,9 @@ export class StripePaymentsAdapter implements PaymentsAdapter { interval: this.getInterval(price.recurring?.interval), commitmentPlan: this.hasAnnualCommitment(price), recurring: price.type === 'recurring', - amount: price.currency_options![currency].unit_amount as number, - currency: price.currency, - decimalAmount: (price.currency_options![currency].unit_amount as number) / 100, + amount: currencyOptions.unit_amount as number, + currency, + decimalAmount: (currencyOptions.unit_amount as number) / 100, type: price.metadata.type === 'business' ? UserType.Business : UserType.Individual, ...businessSeats, }); diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index f985fc4e..96dede97 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1,7 +1,6 @@ import Stripe from 'stripe'; import dayjs from 'dayjs'; -import { DisplayPrice } from '../core/users/DisplayPrice'; import { ProductsRepository } from '../core/users/ProductsRepository'; import { UserSubscription, UserType } from '../core/users/User'; import { Bit2MeService } from './bit2me.service'; @@ -190,10 +189,6 @@ export class PaymentService { } } - async getPrice(priceId: string): Promise { - return this.provider.prices.retrieve(priceId); - } - /** * Creates an invoice to purchase a one time plan. * @@ -261,8 +256,8 @@ export class PaymentService { throw new BadRequestError('Invoice item does not have a price.'); } - const price = await this.getPrice(invoiceItem.pricing?.price_details?.price); - const isLifetime = price.type === 'one_time'; + const price = await stripePaymentsAdapter.getPriceById(invoiceItem.pricing?.price_details?.price); + const isLifetime = price.interval === 'lifetime'; if (isLifetime && isCryptoCurrency(currency)) { const normalizedCurrencyForBit2Me = normalizeForBit2Me(currency); @@ -903,40 +898,6 @@ export class PaymentService { }; } - async getPrices(currency?: string, userType: UserType = UserType.Individual): Promise { - const currencyValue = currency ?? 'eur'; - - const res = await this.provider.prices.search({ - query: `metadata["show"]:"1" active:"true" currency:"${currencyValue}"`, - expand: ['data.currency_options', 'data.product'], - limit: 100, - }); - - return res.data - .filter((price) => { - const priceProductType = ((price.product as Stripe.Product).metadata.type as UserType) || UserType.Individual; - return ( - price.metadata.maxSpaceBytes && - price.currency_options && - price.currency_options[currencyValue].unit_amount && - priceProductType === userType - ); - }) - .map((price) => { - const hasAnnualCommitment = this.hasAnnualCommitment(price); - const recurringInterval = hasAnnualCommitment ? 'year' : (price.recurring?.interval as 'year' | 'month'); - - return { - id: price.id, - productId: (price.product as Stripe.Product).id, - currency: currencyValue, - amount: price.currency_options![currencyValue].unit_amount as number, - bytes: parseInt(price.metadata.maxSpaceBytes), - interval: price.type === 'one_time' ? 'lifetime' : recurringInterval, - }; - }); - } - /** * Returns the tax for a given price * @param priceId - The Id of the price that we want to calculate the tax for diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index 7ef06abd..9292625a 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -15,6 +15,7 @@ import { getPaymentIntentResponse, getPaymentMethod, getPrice, + getPriceEntity, getPrices, getProduct, getPromoCode, @@ -112,18 +113,6 @@ describe('Payments Service tests', () => { }); }); - describe('Get a price given its ID', () => { - it('When the price exists, then it is returned', async () => { - const mockedPrice = getPrice(); - const priceSpy = jest.spyOn(paymentService, 'getPrice').mockResolvedValue(mockedPrice); - - const price = await paymentService.getPrice(mockedPrice.id); - - expect(priceSpy).toHaveBeenCalledWith(mockedPrice.id); - expect(price).toEqual(mockedPrice); - }); - }); - describe('Creating an invoice', () => { test('When trying to create an invoice with the correct params, then it is successfully created', async () => { const mockedPaymentIntent = getPaymentIntentResponse({ type: 'fiat' }); @@ -187,7 +176,7 @@ describe('Payments Service tests', () => { client_secret: mockedPaymentIntent.clientSecret as string, }, }); - const mockedPrice = getPrice({ + const mockedPrice = getPriceEntity({ id: 'mockedPriceId', }); @@ -197,7 +186,7 @@ describe('Payments Service tests', () => { jest .spyOn(stripeNewVersion.invoiceItems, 'create') .mockResolvedValueOnce(mockedInvoice.lines.data[0] as unknown as Stripe.Response); - jest.spyOn(paymentService, 'getPrice').mockResolvedValueOnce(mockedPrice); + jest.spyOn(stripePaymentsAdapter, 'getPriceById').mockResolvedValueOnce(mockedPrice); jest .spyOn(stripeNewVersion.invoices, 'finalizeInvoice') .mockResolvedValueOnce(mockedInvoice as unknown as Stripe.Response); @@ -237,7 +226,7 @@ describe('Payments Service tests', () => { ], }, }); - const mockedPrice = getPrice({ + const mockedPrice = getPriceEntity({ id: 'mockedPriceId', }); @@ -254,7 +243,7 @@ describe('Payments Service tests', () => { jest .spyOn(stripeNewVersion.invoiceItems, 'create') .mockResolvedValueOnce(mockedInvoice.lines.data[0] as unknown as Stripe.Response); - jest.spyOn(paymentService, 'getPrice').mockResolvedValueOnce(mockedPrice); + jest.spyOn(stripePaymentsAdapter, 'getPriceById').mockResolvedValueOnce(mockedPrice); jest .spyOn(stripeNewVersion.invoices, 'finalizeInvoice') .mockResolvedValueOnce(mockedInvoice as unknown as Stripe.Response); @@ -281,8 +270,8 @@ describe('Payments Service tests', () => { }); const mockedCustomerEmail = mockedCustomer.email as string; const mockedCustomerId = mockedCustomer.id as string; - const mockedPrice = getPrice({ - type: 'one_time', + const mockedPrice = getPriceEntity({ + interval: 'lifetime', }); const mockedPriceId = mockedPrice.id as string; const mockInvoiceId = 'in_test_456'; @@ -319,7 +308,7 @@ describe('Payments Service tests', () => { }, }); - jest.spyOn(paymentService, 'getPrice').mockResolvedValue(mockedPrice); + jest.spyOn(stripePaymentsAdapter, 'getPriceById').mockResolvedValue(mockedPrice); jest.spyOn(stripePaymentsAdapter, 'getCustomer').mockRejectedValue(new BadRequestError()); jest .spyOn(stripeNewVersion.invoices, 'create') @@ -344,8 +333,8 @@ describe('Payments Service tests', () => { const mockedCustomer = getCustomer(); const mockedCustomerEmail = mockedCustomer.email as string; const mockedCustomerId = mockedCustomer.id as string; - const mockedPrice = getPrice({ - type: 'one_time', + const mockedPrice = getPriceEntity({ + interval: 'lifetime', }); const mockedPriceId = mockedPrice.id as string; const mockInvoiceId = 'in_test_456'; @@ -411,7 +400,7 @@ describe('Payments Service tests', () => { jest .spyOn(stripeNewVersion.invoices, 'finalizeInvoice') .mockResolvedValueOnce(mockedInvoice as unknown as Stripe.Response); - jest.spyOn(paymentService, 'getPrice').mockResolvedValue(mockedPrice); + jest.spyOn(stripePaymentsAdapter, 'getPriceById').mockResolvedValue(mockedPrice); const createCryptoInvoiceSpy = jest .spyOn(bit2MeService, 'createCryptoInvoice') .mockResolvedValueOnce(mockedParsedCreatedInvoiceResponse); From a9f6dfd2dd645726be502617411c4499cbef84ea Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 17:14:31 +0200 Subject: [PATCH 4/5] test: add coverage to GET prices EP --- .../controller/payments.controller.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/src/controller/payments.controller.test.ts b/tests/src/controller/payments.controller.test.ts index 078424bd..cc190f8b 100644 --- a/tests/src/controller/payments.controller.test.ts +++ b/tests/src/controller/payments.controller.test.ts @@ -5,6 +5,7 @@ import { getCustomer, getLicenseCode, getPaymentIntent, + getPriceEntity, getUniqueCodes, getUser, getValidAuthToken, @@ -544,4 +545,30 @@ describe('Payment controller e2e tests', () => { }); }); }); + + describe('Get prices', () => { + test('When fetching the available prices, then they are returned with the necessary data', async () => { + const mockedPriceEntity = getPriceEntity(); + const expectedResponse = { + id: mockedPriceEntity.id, + currency: mockedPriceEntity.currency, + amount: mockedPriceEntity.amount, + bytes: mockedPriceEntity.bytes, + interval: mockedPriceEntity.interval, + productId: mockedPriceEntity.productId, + }; + + jest.spyOn(StripePaymentsAdapter.prototype, 'getPrices').mockResolvedValue([mockedPriceEntity]); + + const response = await app.inject({ + method: 'GET', + path: '/prices', + }); + + const responseBody = response.json(); + + expect(response.statusCode).toBe(200); + expect(responseBody).toStrictEqual([expectedResponse]); + }); + }); }); From 09fd49c3e47809073f3ea79db152da68d71baf43 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 13 May 2026 13:53:26 +0200 Subject: [PATCH 5/5] fix: return only available products and correct currency --- src/infrastructure/adapters/stripe.adapter.ts | 38 ++++++++++--------- .../adapters/stripe.adapter.test.ts | 2 +- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index e0f55273..dc5cde0e 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -61,24 +61,26 @@ export class StripePaymentsAdapter implements PaymentsAdapter { limit: 100, }); - return prices.data.map((price) => { - const businessSeats = this.getBusinessSeats(price); - const currencyOptions = price.currency_options![currency] ?? price.currency_options!['eur']; - - return Price.toDomain({ - id: price.id, - productId: (price.product as Stripe.Product).id, - bytes: Number.parseInt(price.metadata.maxSpaceBytes), - interval: this.getInterval(price.recurring?.interval), - commitmentPlan: this.hasAnnualCommitment(price), - recurring: price.type === 'recurring', - amount: currencyOptions.unit_amount as number, - currency, - decimalAmount: (currencyOptions.unit_amount as number) / 100, - type: price.metadata.type === 'business' ? UserType.Business : UserType.Individual, - ...businessSeats, + return prices.data + .filter((price) => price.metadata.maxSpaceBytes && price.currency_options) + .map((price) => { + const businessSeats = this.getBusinessSeats(price); + const currencyOptions = price.currency_options![currency] ?? price.currency_options!['eur']; + + return Price.toDomain({ + id: price.id, + productId: (price.product as Stripe.Product).id, + bytes: Number.parseInt(price.metadata.maxSpaceBytes), + interval: this.getInterval(price.recurring?.interval), + commitmentPlan: this.hasAnnualCommitment(price), + recurring: price.type === 'recurring', + amount: currencyOptions.unit_amount as number, + currency, + decimalAmount: (currencyOptions.unit_amount as number) / 100, + type: price.metadata.type === 'business' ? UserType.Business : UserType.Individual, + ...businessSeats, + }); }); - }); } async getPriceById(priceId: Price['id'], currency: string = 'eur'): Promise { @@ -98,7 +100,7 @@ export class StripePaymentsAdapter implements PaymentsAdapter { commitmentPlan: this.hasAnnualCommitment(price), recurring: price.type === 'recurring', amount: price.currency_options![currency].unit_amount as number, - currency: price.currency, + currency, decimalAmount: (price.currency_options![currency].unit_amount as number) / 100, type: isBusinessPlan ? UserType.Business : UserType.Individual, ...businessSeats, diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index a360cd90..bfac369e 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -222,7 +222,7 @@ describe('Stripe Adapter', () => { const stripePrice = getPrice({ metadata: { show: '1', - bytes: '107374182400', + maxSpaceBytes: '107374182400', type: 'business', annualCommitment: 'false', minimumSeats: '1',