From 94804a411f8ab564e508a32f30591beead2689e9 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 10:53:29 +0200 Subject: [PATCH 1/3] feat(price): add prices logic to stripe adapter --- src/infrastructure/adapters/stripe.adapter.ts | 84 +++++++- src/infrastructure/domain/entities/price.ts | 64 ++++++ tests/src/fixtures.ts | 18 ++ .../adapters/stripe.adapter.test.ts | 193 +++++++++++++++++- 4 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 src/infrastructure/domain/entities/price.ts diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 2983ef42..a4b61380 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -6,15 +6,14 @@ import { Customer, CreateCustomerParams, UpdateCustomerParams } from '../domain/ import envVariablesConfig from '../../config'; import { PaymentMethod } from '../domain/entities/paymentMethod'; +import { UserType } from '../../core/users/User'; +import { Price, PriceInterval } from '../domain/entities/price'; + export class StripePaymentsAdapter implements PaymentsAdapter { - private readonly provider: Stripe = new Stripe(envVariablesConfig.STRIPE_SECRET_KEY, { + readonly provider: Stripe = new Stripe(envVariablesConfig.STRIPE_SECRET_KEY, { apiVersion: '2025-02-24.acacia', }); - getInstance(): Stripe { - return this.provider; - } - async createCustomer(params: Partial): Promise { const stripeCustomer = await this.provider.customers.create(this.toStripeCustomerParams(params)); @@ -55,6 +54,56 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return PaymentMethod.toDomain(paymentMethods); } + async getPrices(currency: string = 'eur'): Promise { + const prices = await this.provider.prices.search({ + query: `metadata["show"]:"1" active:"true" currency:"${currency}"`, + expand: ['data.currency_options', 'data.product'], + limit: 100, + }); + + return prices.data.map((price) => { + const businessSeats = this.getBusinessSeats(price); + + 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), + 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, + type: price.metadata.type === 'business' ? UserType.Business : UserType.Individual, + ...businessSeats, + }); + }); + } + + async getPriceById(priceId: Price['id'], currency: string = 'eur'): Promise { + const price = await this.provider.prices.retrieve(priceId, { + expand: ['currency_options', 'product'], + }); + + const isBusinessPlan = price.metadata?.type === 'business'; + + const businessSeats = isBusinessPlan ? this.getBusinessSeats(price) : undefined; + + 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), + 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, + type: isBusinessPlan ? UserType.Business : UserType.Individual, + ...businessSeats, + }); + } + private toStripeCustomerParams(params: Partial): Stripe.CustomerCreateParams { return { ...(params.name && { name: params.name }), @@ -73,6 +122,31 @@ export class StripePaymentsAdapter implements PaymentsAdapter { ...(params.metadata && { metadata: params.metadata }), }; } + + private hasAnnualCommitment(price: Stripe.Price): boolean { + return price?.metadata.annualCommitment === 'true'; + } + + private getInterval(interval: Stripe.Price.Recurring.Interval): PriceInterval { + switch (interval) { + case 'year': + return 'year'; + case 'month': + return 'month'; + default: + return 'lifetime'; + } + } + + private getBusinessSeats(price: Stripe.Price): { + minimumSeats: number | undefined; + maximumSeats: number | undefined; + } { + return { + minimumSeats: price.metadata.minimumSeats ? Number.parseInt(price.metadata.minimumSeats) : undefined, + maximumSeats: price.metadata.maximumSeats ? Number.parseInt(price.metadata.maximumSeats) : undefined, + }; + } } export const stripePaymentsAdapter = new StripePaymentsAdapter(); diff --git a/src/infrastructure/domain/entities/price.ts b/src/infrastructure/domain/entities/price.ts new file mode 100644 index 00000000..527a97f2 --- /dev/null +++ b/src/infrastructure/domain/entities/price.ts @@ -0,0 +1,64 @@ +import { UserType } from '../../../core/users/User'; + +export type PriceInterval = 'year' | 'month' | 'lifetime'; + +export interface PriceAttributes { + id: string; + productId: string; + bytes: number; + interval: PriceInterval; + commitmentPlan: boolean; + recurring: boolean; + amount: number; + currency: string; + decimalAmount: number; + type: UserType; + minimumSeats?: number; + maximumSeats?: number; +} + +export class Price implements PriceAttributes { + id: string; + productId: string; + bytes: number; + interval: PriceInterval; + commitmentPlan: boolean; + recurring: boolean; + amount: number; + currency: string; + decimalAmount: number; + type: UserType; + minimumSeats?: number; + maximumSeats?: number; + + private constructor(attributes: PriceAttributes) { + this.id = attributes.id; + this.productId = attributes.productId; + this.bytes = attributes.bytes; + this.interval = attributes.interval; + this.commitmentPlan = attributes.commitmentPlan; + this.amount = attributes.amount; + this.currency = attributes.currency; + this.decimalAmount = attributes.decimalAmount; + this.recurring = attributes.recurring; + this.type = attributes.type; + this.minimumSeats = attributes.minimumSeats; + this.maximumSeats = attributes.maximumSeats; + } + + static toDomain(attributes: PriceAttributes): Price { + return new Price(attributes); + } + + public isBusinessPlan(): boolean { + return this.type === UserType.Business; + } + + public isCommitmentPlan(): boolean { + return this.commitmentPlan; + } + + public isRecurring(): boolean { + return this.recurring; + } +} diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index dbfcc5fe..355a8132 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -1,4 +1,5 @@ import jwt from 'jsonwebtoken'; +import { Price, PriceAttributes } from '../../src/infrastructure/domain/entities/price'; import { randomUUID } from 'node:crypto'; import { FastifyBaseLogger } from 'fastify'; import { Chance } from 'chance'; @@ -218,6 +219,23 @@ export const getPromoCode = (params?: DeepPartial): Stripe }; }; +export const getPriceEntity = (params?: Partial): Price => { + const mockedStripePrice = getPrice(); + return Price.toDomain({ + id: mockedStripePrice.id, + productId: mockedStripePrice.product as string, + bytes: 1099511627776, + interval: 'year', + commitmentPlan: false, + recurring: true, + amount: mockedStripePrice.unit_amount as number, + currency: mockedStripePrice.currency, + decimalAmount: (mockedStripePrice.unit_amount as number) / 100, + type: UserType.Individual, + ...params, + }); +}; + export const priceById = ({ bytes, interval, diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index fa9c8998..b7fdbf85 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -1,9 +1,12 @@ -import { getCustomer, getPaymentMethod } from '../../fixtures'; +import { getCustomer, getPaymentMethod, getPrice } from '../../fixtures'; import { stripePaymentsAdapter } from '../../../../src/infrastructure/adapters/stripe.adapter'; import Stripe from 'stripe'; import { Customer } from '../../../../src/infrastructure/domain/entities/customer'; import { UserNotFoundError } from '../../../../src/errors/PaymentErrors'; import { PaymentMethod } from '../../../../src/infrastructure/domain/entities/paymentMethod'; +import { Price } from '../../../../src/infrastructure/domain/entities/price'; +import { UserType } from '../../../../src/core/users/User'; +import { PRODUCT_BASE } from '../../fixtures/stripe-base.generated'; describe('Stripe Adapter', () => { describe('Create customer', () => { @@ -11,7 +14,7 @@ describe('Stripe Adapter', () => { const mockedCustomer = getCustomer(); jest - .spyOn(stripePaymentsAdapter.getInstance().customers, 'create') + .spyOn(stripePaymentsAdapter.provider.customers, 'create') .mockResolvedValue(mockedCustomer as Stripe.Response); const metadata = { referralCode: 'ABC123' }; @@ -39,7 +42,7 @@ describe('Stripe Adapter', () => { const mockedCustomer = getCustomer(); jest - .spyOn(stripePaymentsAdapter.getInstance().customers, 'update') + .spyOn(stripePaymentsAdapter.provider.customers, 'update') .mockResolvedValue(mockedCustomer as Stripe.Response); const updatedCustomer = await stripePaymentsAdapter.updateCustomer(mockedCustomer.id, { @@ -82,7 +85,7 @@ describe('Stripe Adapter', () => { }); const updateSpy = jest - .spyOn(stripePaymentsAdapter.getInstance().customers, 'update') + .spyOn(stripePaymentsAdapter.provider.customers, 'update') .mockResolvedValue(updatedCustomer as Stripe.Response); const result = await stripePaymentsAdapter.updateCustomer(initialCustomer.id, { @@ -114,7 +117,7 @@ describe('Stripe Adapter', () => { const mockedCustomer = getCustomer(); jest - .spyOn(stripePaymentsAdapter.getInstance().customers, 'retrieve') + .spyOn(stripePaymentsAdapter.provider.customers, 'retrieve') .mockResolvedValue(mockedCustomer as Stripe.Response); const customer = await stripePaymentsAdapter.getCustomer(mockedCustomer.id); @@ -128,7 +131,7 @@ describe('Stripe Adapter', () => { }; const mockedError = new UserNotFoundError(); - jest.spyOn(stripePaymentsAdapter.getInstance().customers, 'retrieve').mockResolvedValue(mockedCustomer as any); + jest.spyOn(stripePaymentsAdapter.provider.customers, 'retrieve').mockResolvedValue(mockedCustomer as any); await expect(stripePaymentsAdapter.getCustomer('')).rejects.toThrow(mockedError); }); @@ -138,7 +141,7 @@ describe('Stripe Adapter', () => { test('When searching a customer, then the customer is returned', async () => { const mockedCustomer = getCustomer(); - jest.spyOn(stripePaymentsAdapter.getInstance().customers, 'search').mockResolvedValue({ + jest.spyOn(stripePaymentsAdapter.provider.customers, 'search').mockResolvedValue({ data: [mockedCustomer], } as any); @@ -151,7 +154,7 @@ describe('Stripe Adapter', () => { const mockedError = new UserNotFoundError(); const mockedCustomer = getCustomer(); - jest.spyOn(stripePaymentsAdapter.getInstance().customers, 'search').mockResolvedValue({ + jest.spyOn(stripePaymentsAdapter.provider.customers, 'search').mockResolvedValue({ data: [], total_count: 0, } as any); @@ -165,7 +168,7 @@ describe('Stripe Adapter', () => { const mockedPaymentMethod = getPaymentMethod(); jest - .spyOn(stripePaymentsAdapter.getInstance().paymentMethods, 'retrieve') + .spyOn(stripePaymentsAdapter.provider.paymentMethods, 'retrieve') .mockResolvedValue(mockedPaymentMethod as Stripe.Response); const paymentMethod = await stripePaymentsAdapter.retrievePaymentMethod(mockedPaymentMethod.id); @@ -173,4 +176,176 @@ describe('Stripe Adapter', () => { expect(paymentMethod).toStrictEqual(PaymentMethod.toDomain(mockedPaymentMethod)); }); }); + + 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' }, + recurring: { + interval: 'year', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_test' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 999, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '999' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'search').mockResolvedValue({ + data: [stripePrice], + } as any); + + const prices = await stripePaymentsAdapter.getPrices('eur'); + + expect(prices).toHaveLength(1); + expect(prices[0]).toStrictEqual( + Price.toDomain({ + id: stripePrice.id, + productId: 'prod_test', + bytes: 107374182400, + interval: 'year', + commitmentPlan: false, + recurring: true, + amount: 999, + currency: stripePrice.currency, + decimalAmount: 9.99, + type: UserType.Individual, + }), + ); + }); + + test('When getting all available prices for a business plan, then the seat limits are included', async () => { + const stripePrice = getPrice({ + metadata: { + show: '1', + bytes: '107374182400', + type: 'business', + annualCommitment: 'false', + minimumSeats: '1', + maximumSeats: '10', + }, + recurring: { + interval: 'month', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_business' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 2000, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '2000' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'search').mockResolvedValue({ + data: [stripePrice], + } as any); + + const prices = await stripePaymentsAdapter.getPrices('eur'); + + expect(prices[0].type).toBe(UserType.Business); + expect(prices[0].minimumSeats).toBe(1); + expect(prices[0].maximumSeats).toBe(10); + }); + }); + + 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' }, + recurring: { + interval: 'year', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_test' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 999, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '999' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'retrieve').mockResolvedValue(stripePrice as any); + + const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); + + expect(price).toStrictEqual( + Price.toDomain({ + id: stripePrice.id, + productId: 'prod_test', + bytes: 107374182400, + interval: 'year', + commitmentPlan: false, + recurring: true, + amount: 999, + currency: stripePrice.currency, + decimalAmount: 9.99, + type: UserType.Individual, + }), + ); + }); + + test('When getting a business price by its ID, then the seat limits are included', async () => { + const stripePrice = getPrice({ + metadata: { + bytes: '107374182400', + type: 'business', + annualCommitment: 'false', + minimumSeats: '1', + maximumSeats: '10', + }, + recurring: { + interval: 'year', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_business' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 5000, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '5000' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'retrieve').mockResolvedValue(stripePrice as any); + + const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); + + expect(price.type).toBe(UserType.Business); + expect(price.minimumSeats).toBe(1); + expect(price.maximumSeats).toBe(10); + }); + + test('When the price has an annual commitment, then the price is returned indicating that', async () => { + const stripePrice = getPrice({ + metadata: { bytes: '107374182400', type: 'individual', annualCommitment: 'true' }, + recurring: { + interval: 'year', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_test' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 999, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '999' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'retrieve').mockResolvedValue(stripePrice as any); + + const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); + + expect(price.commitmentPlan).toBe(true); + }); + }); }); From 087ca83b23fc65fbbfa63c3d6a70bea24ccda3b7 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 11:06:13 +0200 Subject: [PATCH 2/3] fix: use the function to check if it is a commitment --- tests/src/infrastructure/adapters/stripe.adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index b7fdbf85..0c8ae8bf 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -345,7 +345,7 @@ describe('Stripe Adapter', () => { const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); - expect(price.commitmentPlan).toBe(true); + expect(price.isCommitmentPlan).toBeTruthy(); }); }); }); From c17d4258c1909f9fb34c17bda435fc6cbe80b2d6 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 12 May 2026 11:14:41 +0200 Subject: [PATCH 3/3] test: add coverage to business plans and lifetime plans --- src/infrastructure/adapters/stripe.adapter.ts | 4 +- .../adapters/stripe.adapter.test.ts | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index a4b61380..0b011756 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -93,7 +93,7 @@ export class StripePaymentsAdapter implements PaymentsAdapter { id: price.id, productId: (price.product as Stripe.Product).id, bytes: Number.parseInt(price.metadata.bytes), - interval: this.getInterval(price.recurring!.interval), + interval: this.getInterval(price.recurring?.interval), commitmentPlan: this.hasAnnualCommitment(price), recurring: price.type === 'recurring', amount: price.currency_options![currency].unit_amount as number, @@ -127,7 +127,7 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return price?.metadata.annualCommitment === 'true'; } - private getInterval(interval: Stripe.Price.Recurring.Interval): PriceInterval { + private getInterval(interval?: Stripe.Price.Recurring.Interval): PriceInterval { switch (interval) { case 'year': return 'year'; diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index 0c8ae8bf..a443412a 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -347,5 +347,47 @@ describe('Stripe Adapter', () => { expect(price.isCommitmentPlan).toBeTruthy(); }); + + test('When the price is a business plan, then the price is returned indicating that', async () => { + const stripePrice = getPrice({ + metadata: { bytes: '107374182400', type: 'business' }, + recurring: { + interval: 'year', + interval_count: 1, + aggregate_usage: null, + meter: null, + usage_type: 'licensed', + trial_period_days: null, + }, + product: { ...PRODUCT_BASE, id: 'prod_test' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 999, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '999' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'retrieve').mockResolvedValue(stripePrice as any); + + const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); + + expect(price.isBusinessPlan).toBeTruthy(); + }); + + test('When the price is a one-time payment, then the interval is lifetime', async () => { + const stripePrice = getPrice({ + metadata: { bytes: '107374182400', type: 'individual' }, + recurring: null, + type: 'one_time', + product: { ...PRODUCT_BASE, id: 'prod_test' } as Stripe.Product, + currency_options: { + eur: { unit_amount: 999, tax_behavior: 'exclusive', custom_unit_amount: null, unit_amount_decimal: '999' }, + }, + }); + + jest.spyOn(stripePaymentsAdapter.provider.prices, 'retrieve').mockResolvedValue(stripePrice as any); + + const price = await stripePaymentsAdapter.getPriceById(stripePrice.id, 'eur'); + + expect(price.interval).toBe('lifetime'); + }); }); });