diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 2983ef42..0b011756 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..a443412a 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,218 @@ 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.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'); + }); + }); });