From a04932cfcd4600ddb5a83fdfb4232ed5008aefab Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 09:08:57 +0200 Subject: [PATCH 1/4] feat(plans): add monthly commitment plans logic --- src/services/bit2me.service.ts | 4 +- src/services/klaviyo.service.ts | 11 +- src/services/objectStorage.service.ts | 4 +- src/services/payment.service.ts | 63 +++++++++- src/services/storage.service.ts | 4 +- src/services/users.service.ts | 4 +- src/types/subscription.ts | 5 + tests/src/fixtures.ts | 3 + tests/src/services/payment.service.test.ts | 137 +++++++++++++++++++++ 9 files changed, 218 insertions(+), 17 deletions(-) diff --git a/src/services/bit2me.service.ts b/src/services/bit2me.service.ts index 50575c9d..82247dfb 100644 --- a/src/services/bit2me.service.ts +++ b/src/services/bit2me.service.ts @@ -1,4 +1,4 @@ -import { Axios, AxiosError, AxiosRequestConfig } from 'axios'; +import { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; import jwt from 'jsonwebtoken'; import { AppConfig } from '../config'; import { createHmac } from 'crypto'; @@ -20,7 +20,7 @@ import { export class Bit2MeService { constructor( private readonly config: AppConfig, - private readonly axios: Axios, + private readonly axios: AxiosInstance, private readonly secretKey = config.CRYPTO_PAYMENTS_PROCESSOR_SECRET_KEY, private readonly apiKey = config.CRYPTO_PAYMENTS_PROCESSOR_API_KEY, private readonly apiUrl = config.CRYPTO_PAYMENTS_PROCESSOR_API_URL, diff --git a/src/services/klaviyo.service.ts b/src/services/klaviyo.service.ts index fcf79118..a72bef08 100644 --- a/src/services/klaviyo.service.ts +++ b/src/services/klaviyo.service.ts @@ -1,6 +1,5 @@ import axios from 'axios'; import Logger from '../Logger'; -import { BadRequestError } from '../errors/Errors'; import config from '../config'; export enum KlaviyoEvent { @@ -12,14 +11,14 @@ interface KlaviyoEventOptions { eventName: KlaviyoEvent; } - export class KlaviyoTrackingService { - private readonly apiKey: string; - private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly baseUrl?: string; constructor() { if (!config.KLAVIYO_API_KEY) { - throw new BadRequestError("Klaviyo API Key is required."); + Logger.warn('Klaviyo API key is not set'); + return; } this.apiKey = config.KLAVIYO_API_KEY; @@ -74,4 +73,4 @@ export class KlaviyoTrackingService { } } -export const klaviyoService = new KlaviyoTrackingService(); \ No newline at end of file +export const klaviyoService = new KlaviyoTrackingService(); diff --git a/src/services/objectStorage.service.ts b/src/services/objectStorage.service.ts index 07461515..48c7e199 100644 --- a/src/services/objectStorage.service.ts +++ b/src/services/objectStorage.service.ts @@ -1,6 +1,6 @@ import { PaymentService } from './payment.service'; import { sign } from 'jsonwebtoken'; -import { Axios, AxiosRequestConfig } from 'axios'; +import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { type AppConfig } from '../config'; function signToken(duration: string, secret: string) { @@ -14,7 +14,7 @@ export class ObjectStorageService { constructor( private readonly paymentService: PaymentService, private readonly config: AppConfig, - private readonly axios: Axios, + private readonly axios: AxiosInstance, ) {} async initObjectStorageUser(payload: { email: string; customerId: string }) { diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 7a99f2d4..0795c9a9 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -471,7 +471,46 @@ export class PaymentService { return { maxSpaceBytes: parseInt(price.metadata.maxSpaceBytes), recurring: isRecurring }; } + /** + * Gets the annual commitment cancellation info + * @param subscription - The subscription we want to get info for + * @returns - The annual commitment cancellation info (remaining payments, amount per month, currency, cancel at) + */ + getAnnualCommitmentCancellationInfo(subscription: Stripe.Subscription): { + remainingPayments: number; + cancelAt: number; + cancellationDate: string; + } { + const createdAt = new Date(subscription.created * 1000); + const now = new Date(); + + const monthsElapsed = (now.getFullYear() - createdAt.getFullYear()) * 12 + (now.getMonth() - createdAt.getMonth()); + const monthsIntoPeriod = monthsElapsed % 12; + const periodsElapsed = Math.floor(monthsElapsed / 12); + + const cancelAtDate = new Date(createdAt); + cancelAtDate.setFullYear(cancelAtDate.getFullYear() + periodsElapsed + 1); + const cancelAt = Math.floor(cancelAtDate.getTime() / 1000); + + const remainingPayments = monthsIntoPeriod === 0 ? 12 : 12 - monthsIntoPeriod; + const cancellationDate = new Date(cancelAt * 1000).toISOString(); + + return { remainingPayments, cancelAt, cancellationDate }; + } + async cancelSubscription(subscriptionId: SubscriptionId): Promise { + const subscription = await this.getSubscriptionById(subscriptionId); + const item = subscription.items.data[0]; + const hasAnnualCommitment = this.hasAnnualCommitment(item.price); + + if (hasAnnualCommitment) { + const { cancelAt } = this.getAnnualCommitmentCancellationInfo(subscription); + await this.provider.subscriptions.update(subscriptionId, { + cancel_at: cancelAt, + }); + return; + } + await this.provider.subscriptions.cancel(subscriptionId, {}); } @@ -924,6 +963,8 @@ export class PaymentService { subscription.plan.metadata.maxSpaceBytes, ) || 0; const item = subscription.items.data[0] as Stripe.SubscriptionItem; + const hasAnnualCommitment = this.hasAnnualCommitment(item.price); + const commitment = hasAnnualCommitment ? this.getAnnualCommitmentCancellationInfo(subscription) : null; const plan: PlanSubscription = { status: subscription.status, @@ -942,7 +983,15 @@ export class PaymentService { isTeam: !!subscription.plan.product.metadata.is_teams, paymentInterval: subscription.plan.nickname, isLifetime: false, - renewalPeriod: this.getRenewalPeriod(subscription.plan.intervalCount, subscription.plan.interval), + renewalPeriod: this.getRenewalPeriod( + subscription.plan.intervalCount, + hasAnnualCommitment ? 'year' : subscription.plan.interval, + ), + commitment: { + enabled: hasAnnualCommitment, + remainingMonths: commitment?.remainingPayments, + cancellationDate: commitment?.cancellationDate, + }, storageLimit: storageLimit, amountOfSeats: item.quantity || 1, seats: { @@ -952,13 +1001,14 @@ export class PaymentService { }; const { price } = subscription.items.data[0]; + const itemInterval = hasAnnualCommitment ? 'year' : price.recurring?.interval; return { type: 'subscription', subscriptionId: subscription.id, amount: price.unit_amount!, currency: price.currency, - interval: price.recurring!.interval as 'year' | 'month', + interval: itemInterval, nextPayment: subscription.current_period_end, amountAfterCoupon: upcomingInvoice.total, priceId: price.id, @@ -988,13 +1038,16 @@ export class PaymentService { ); }) .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' : (price.recurring!.interval as 'year' | 'month'), + interval: price.type === 'one_time' ? 'lifetime' : recurringInterval, }; }); } @@ -1555,4 +1608,8 @@ export class PaymentService { return invoice.status === 'paid'; } + + private hasAnnualCommitment(price: Stripe.Price): boolean { + return price?.metadata.annualCommitment === 'true'; + } } diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts index da0741bf..3fa3a0d2 100644 --- a/src/services/storage.service.ts +++ b/src/services/storage.service.ts @@ -1,4 +1,4 @@ -import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, isAxiosError } from 'axios'; import { sign } from 'jsonwebtoken'; import { isProduction, type AppConfig } from '../config'; import { User } from '../core/users/User'; @@ -15,7 +15,7 @@ function signToken(duration: string, secret: string) { export class StorageService { constructor( private readonly config: AppConfig, - private readonly axios: Axios, + private readonly axios: AxiosInstance, ) {} async updateUserStorageAndTier(uuid: string, newStorageBytes: number, foreignTierId: string): Promise { diff --git a/src/services/users.service.ts b/src/services/users.service.ts index ddec4ec5..4fa3b6d0 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -7,7 +7,7 @@ import { Coupon } from '../core/coupons/Coupon'; import { CouponsRepository } from '../core/coupons/CouponsRepository'; import { UsersCouponsRepository } from '../core/coupons/UsersCouponsRepository'; import { sign } from 'jsonwebtoken'; -import { Axios, AxiosRequestConfig } from 'axios'; +import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { isProduction, type AppConfig } from '../config'; import { Service, VpnFeatures } from '../core/users/Tier'; import { UserNotFoundError } from '../errors/PaymentErrors'; @@ -31,7 +31,7 @@ export class UsersService { private readonly couponsRepository: CouponsRepository, private readonly usersCouponsRepository: UsersCouponsRepository, private readonly config: AppConfig, - private readonly axios: Axios, + private readonly axios: AxiosInstance, ) {} async updateUser(customerId: User['customerId'], body: Partial): Promise { diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 7a9dd94f..fc0f5182 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -23,6 +23,11 @@ export interface PlanSubscription { paymentInterval: string; isLifetime: boolean; renewalPeriod: RenewalPeriod; + commitment: { + enabled: boolean; + remainingMonths?: number; + cancellationDate?: string; + }; storageLimit: number; amountOfSeats: number; seats?: { diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index f2b1d31d..dbfcc5fe 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -464,6 +464,9 @@ export function getSubscription({ type: userType, price: 119.88, monthlyPrice: 9.99, + commitment: { + enabled: false, + }, currency: 'eur', isTeam: false, paymentInterval: '', diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index 1702281b..9c44ff51 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -1125,4 +1125,141 @@ describe('Payments Service tests', () => { }); }); }); + + describe('Annual commitment cancellation info', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const makeSubscriptionWithCommitment = (createdAt: Date, annualCommitment = true): Stripe.Subscription => { + const price = getPrice({ + metadata: { annualCommitment: annualCommitment ? 'true' : 'false' }, + }); + return getCreatedSubscription({ + created: Math.floor(createdAt.getTime() / 1000), + items: { + object: 'list', + has_more: false, + url: '', + data: [ + { + ...getCreatedSubscription().items.data[0], + price, + }, + ], + }, + }); + }; + + test('when the user just subscribed this month, then they have 12 remaining payments and cancel date is one year from now', () => { + const now = new Date('2026-05-05T10:00:00Z'); + jest.setSystemTime(now); + + const subscription = makeSubscriptionWithCommitment(new Date('2026-05-01T00:00:00Z')); + const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(remainingPayments).toBe(12); + expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); + expect(new Date(cancelAt * 1000).getMonth()).toBe(4); + }); + + test('when the user has been subscribed for 7 months, then they have 5 remaining payments', () => { + const now = new Date('2026-05-05T10:00:00Z'); + jest.setSystemTime(now); + + const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const { remainingPayments } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(remainingPayments).toBe(5); + }); + + test('when the user completes exactly 12 months and starts a new period, then they have 12 remaining payments again', () => { + const now = new Date('2026-10-01T10:00:00Z'); + jest.setSystemTime(now); + + const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(remainingPayments).toBe(12); + expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); + }); + + test('when the user has been subscribed for 14 months, then they have 10 remaining payments and cancel date is in the second year', () => { + const now = new Date('2026-12-01T10:00:00Z'); + jest.setSystemTime(now); + + const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(remainingPayments).toBe(10); + expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); + }); + + test('when the user has 1 month left in the period, then they have 1 remaining payment', () => { + const now = new Date('2026-09-01T10:00:00Z'); + jest.setSystemTime(now); + + const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const { remainingPayments } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(remainingPayments).toBe(1); + }); + }); + + describe('Cancelling a subscription', () => { + test('when the subscription has an annual commitment, then it schedules the cancellation at the end of the commitment period instead of cancelling immediately', async () => { + const subscription = getCreatedSubscription({ + created: Math.floor(new Date('2025-10-01T00:00:00Z').getTime() / 1000), + items: { + object: 'list', + has_more: false, + url: '', + data: [ + { + ...getCreatedSubscription().items.data[0], + price: getPrice({ metadata: { annualCommitment: 'true' } }), + }, + ], + }, + }); + + jest.spyOn(paymentService, 'getSubscriptionById').mockResolvedValue(subscription as any); + const updateSpy = jest.spyOn(stripe.subscriptions, 'update').mockResolvedValue(subscription as unknown as any); + const cancelSpy = jest.spyOn(stripe.subscriptions, 'cancel').mockResolvedValue(undefined as any); + + await paymentService.cancelSubscription(subscription.id); + + expect(updateSpy).toHaveBeenCalledWith(subscription.id, { cancel_at: expect.any(Number) }); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + test('when the subscription has no annual commitment, then it cancels immediately', async () => { + const subscription = getCreatedSubscription({ + items: { + object: 'list', + has_more: false, + url: '', + data: [ + { + ...getCreatedSubscription().items.data[0], + price: getPrice({ metadata: { annualCommitment: 'false' } }), + }, + ], + }, + }); + + jest.spyOn(paymentService, 'getSubscriptionById').mockResolvedValue(subscription as any); + const cancelSpy = jest.spyOn(stripe.subscriptions, 'cancel').mockResolvedValue(subscription as unknown as any); + const updateSpy = jest.spyOn(stripe.subscriptions, 'update').mockResolvedValue(undefined as any); + + await paymentService.cancelSubscription(subscription.id); + + expect(cancelSpy).toHaveBeenCalledWith(subscription.id, {}); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); }); From 8bb1de56377cf578ad6b3c09854ae24ce889bc7c Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 09:32:40 +0200 Subject: [PATCH 2/4] fix: remove useless test --- tests/src/services/klaviyo.service.test.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/tests/src/services/klaviyo.service.test.ts b/tests/src/services/klaviyo.service.test.ts index 0066ae8c..cedac0c4 100644 --- a/tests/src/services/klaviyo.service.test.ts +++ b/tests/src/services/klaviyo.service.test.ts @@ -1,7 +1,6 @@ import axios from 'axios'; import { KlaviyoTrackingService, KlaviyoEvent } from '../../../src/services/klaviyo.service'; import Logger from '../../../src/Logger'; -import { BadRequestError } from '../../../src/errors/Errors'; import config from '../../../src/config'; import { createTestServices } from '../helpers/services-factory'; @@ -17,7 +16,6 @@ jest.mock('../../../src/config', () => ({ const mockedAxios = axios as jest.Mocked; - describe('KlaviyoTrackingService', () => { let service: KlaviyoTrackingService; let loggerInfoSpy: jest.SpyInstance; @@ -37,11 +35,6 @@ describe('KlaviyoTrackingService', () => { }); describe('Initialization', () => { - test('When instantiated without an API Key in config, then it throws an error', () => { - (config as any).KLAVIYO_API_KEY = undefined; - expect(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).toThrow(BadRequestError); - }); - test('When instantiated with valid config, then it initializes correctly', () => { expect(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).not.toThrow(); }); @@ -51,7 +44,7 @@ describe('KlaviyoTrackingService', () => { test('When tracking a cancellation, then it sends the correct payload to Klaviyo', async () => { const email = 'user@example.com'; const expectedUrl = `${mockBaseUrl}/events/`; - + const expectedPayload = { data: { type: 'event', @@ -87,7 +80,7 @@ describe('KlaviyoTrackingService', () => { expect(mockedAxios.post).toHaveBeenCalledTimes(1); expect(mockedAxios.post).toHaveBeenCalledWith(expectedUrl, expectedPayload, expectedHeaders); expect(loggerInfoSpy).toHaveBeenCalledWith( - expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} tracked for ${email}`) + expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} tracked for ${email}`), ); }); @@ -99,10 +92,10 @@ describe('KlaviyoTrackingService', () => { mockedAxios.post.mockRejectedValue(error); await expect(service.trackSubscriptionCancelled(email)).rejects.toThrow(errorMessage); - + expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`) + expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`), ); }); }); -}); \ No newline at end of file +}); From 8929f771c1bc58562a101f1362988eff879145af Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 10:42:54 +0200 Subject: [PATCH 3/4] feat: use dayjs instead of Date --- src/services/payment.service.ts | 23 +-- src/types/subscription.ts | 1 + tests/src/services/klaviyo.service.test.ts | 7 + tests/src/services/payment.service.test.ts | 190 ++++++++++++++++++--- 4 files changed, 188 insertions(+), 33 deletions(-) diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 0795c9a9..1fc902a9 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -1,4 +1,5 @@ import Stripe from 'stripe'; +import dayjs from 'dayjs'; import { DisplayPrice } from '../core/users/DisplayPrice'; import { ProductsRepository } from '../core/users/ProductsRepository'; @@ -480,31 +481,32 @@ export class PaymentService { remainingPayments: number; cancelAt: number; cancellationDate: string; + isFirstMonth: boolean; } { - const createdAt = new Date(subscription.created * 1000); - const now = new Date(); + const createdAt = dayjs.unix(subscription.created); + const now = dayjs(); - const monthsElapsed = (now.getFullYear() - createdAt.getFullYear()) * 12 + (now.getMonth() - createdAt.getMonth()); + const monthsElapsed = now.diff(createdAt, 'month'); const monthsIntoPeriod = monthsElapsed % 12; const periodsElapsed = Math.floor(monthsElapsed / 12); - const cancelAtDate = new Date(createdAt); - cancelAtDate.setFullYear(cancelAtDate.getFullYear() + periodsElapsed + 1); - const cancelAt = Math.floor(cancelAtDate.getTime() / 1000); + const cancelAtDate = createdAt.add(periodsElapsed + 1, 'year'); + const cancelAt = cancelAtDate.unix(); + const isFirstMonth = monthsElapsed === 0 && now.diff(createdAt, 'day') < 30; const remainingPayments = monthsIntoPeriod === 0 ? 12 : 12 - monthsIntoPeriod; - const cancellationDate = new Date(cancelAt * 1000).toISOString(); + const cancellationDate = cancelAtDate.toISOString(); - return { remainingPayments, cancelAt, cancellationDate }; + return { remainingPayments, cancelAt, cancellationDate, isFirstMonth }; } async cancelSubscription(subscriptionId: SubscriptionId): Promise { const subscription = await this.getSubscriptionById(subscriptionId); const item = subscription.items.data[0]; const hasAnnualCommitment = this.hasAnnualCommitment(item.price); + const { isFirstMonth, cancelAt } = this.getAnnualCommitmentCancellationInfo(subscription); - if (hasAnnualCommitment) { - const { cancelAt } = this.getAnnualCommitmentCancellationInfo(subscription); + if (hasAnnualCommitment && !isFirstMonth) { await this.provider.subscriptions.update(subscriptionId, { cancel_at: cancelAt, }); @@ -991,6 +993,7 @@ export class PaymentService { enabled: hasAnnualCommitment, remainingMonths: commitment?.remainingPayments, cancellationDate: commitment?.cancellationDate, + isFirstMonth: commitment?.isFirstMonth, }, storageLimit: storageLimit, amountOfSeats: item.quantity || 1, diff --git a/src/types/subscription.ts b/src/types/subscription.ts index fc0f5182..9e479f2c 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -27,6 +27,7 @@ export interface PlanSubscription { enabled: boolean; remainingMonths?: number; cancellationDate?: string; + isFirstMonth?: boolean; }; storageLimit: number; amountOfSeats: number; diff --git a/tests/src/services/klaviyo.service.test.ts b/tests/src/services/klaviyo.service.test.ts index cedac0c4..e71af831 100644 --- a/tests/src/services/klaviyo.service.test.ts +++ b/tests/src/services/klaviyo.service.test.ts @@ -35,6 +35,13 @@ describe('KlaviyoTrackingService', () => { }); describe('Initialization', () => { + test('When instantiated without an API Key in config, then it logs a warning', () => { + const loggerWarnSpy = jest.spyOn(Logger, 'warn').mockImplementation(); + (config as any).KLAVIYO_API_KEY = undefined; + createTestServices({ stripe: {} as any }); + expect(loggerWarnSpy).toHaveBeenCalled(); + }); + test('When instantiated with valid config, then it initializes correctly', () => { expect(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).not.toThrow(); }); diff --git a/tests/src/services/payment.service.test.ts b/tests/src/services/payment.service.test.ts index 9c44ff51..9ccc39ea 100644 --- a/tests/src/services/payment.service.test.ts +++ b/tests/src/services/payment.service.test.ts @@ -1,3 +1,4 @@ +import dayjs from 'dayjs'; import { Reason } from '../../../src/types/payment'; import { UserType } from '../../../src/core/users/User'; import { @@ -16,6 +17,7 @@ import { getPaymentMethod, getPrice, getPrices, + getProduct, getPromoCode, getPromotionCodeResponse, getTaxes, @@ -1135,12 +1137,12 @@ describe('Payments Service tests', () => { jest.useRealTimers(); }); - const makeSubscriptionWithCommitment = (createdAt: Date, annualCommitment = true): Stripe.Subscription => { + const makeSubscriptionWithCommitment = (createdAt: dayjs.Dayjs, annualCommitment = true): Stripe.Subscription => { const price = getPrice({ metadata: { annualCommitment: annualCommitment ? 'true' : 'false' }, }); return getCreatedSubscription({ - created: Math.floor(createdAt.getTime() / 1000), + created: createdAt.unix(), items: { object: 'list', has_more: false, @@ -1156,64 +1158,86 @@ describe('Payments Service tests', () => { }; test('when the user just subscribed this month, then they have 12 remaining payments and cancel date is one year from now', () => { - const now = new Date('2026-05-05T10:00:00Z'); - jest.setSystemTime(now); + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); - const subscription = makeSubscriptionWithCommitment(new Date('2026-05-01T00:00:00Z')); + const subscription = makeSubscriptionWithCommitment(dayjs('2026-05-01T00:00:00Z')); const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); expect(remainingPayments).toBe(12); - expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); - expect(new Date(cancelAt * 1000).getMonth()).toBe(4); + expect(dayjs.unix(cancelAt).year()).toBe(2027); + expect(dayjs.unix(cancelAt).month()).toBe(4); }); test('when the user has been subscribed for 7 months, then they have 5 remaining payments', () => { - const now = new Date('2026-05-05T10:00:00Z'); - jest.setSystemTime(now); + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); - const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const subscription = makeSubscriptionWithCommitment(dayjs('2025-10-01T00:00:00Z')); const { remainingPayments } = paymentService.getAnnualCommitmentCancellationInfo(subscription); expect(remainingPayments).toBe(5); }); test('when the user completes exactly 12 months and starts a new period, then they have 12 remaining payments again', () => { - const now = new Date('2026-10-01T10:00:00Z'); - jest.setSystemTime(now); + jest.setSystemTime(dayjs('2026-10-01T10:00:00Z').toDate()); - const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const subscription = makeSubscriptionWithCommitment(dayjs('2025-10-01T00:00:00Z')); const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); expect(remainingPayments).toBe(12); - expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); + expect(dayjs.unix(cancelAt).year()).toBe(2027); }); test('when the user has been subscribed for 14 months, then they have 10 remaining payments and cancel date is in the second year', () => { - const now = new Date('2026-12-01T10:00:00Z'); - jest.setSystemTime(now); + jest.setSystemTime(dayjs('2026-12-01T10:00:00Z').toDate()); - const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const subscription = makeSubscriptionWithCommitment(dayjs('2025-10-01T00:00:00Z')); const { remainingPayments, cancelAt } = paymentService.getAnnualCommitmentCancellationInfo(subscription); expect(remainingPayments).toBe(10); - expect(new Date(cancelAt * 1000).getFullYear()).toBe(2027); + expect(dayjs.unix(cancelAt).year()).toBe(2027); }); test('when the user has 1 month left in the period, then they have 1 remaining payment', () => { - const now = new Date('2026-09-01T10:00:00Z'); - jest.setSystemTime(now); + jest.setSystemTime(dayjs('2026-09-01T10:00:00Z').toDate()); - const subscription = makeSubscriptionWithCommitment(new Date('2025-10-01T00:00:00Z')); + const subscription = makeSubscriptionWithCommitment(dayjs('2025-10-01T00:00:00Z')); const { remainingPayments } = paymentService.getAnnualCommitmentCancellationInfo(subscription); expect(remainingPayments).toBe(1); }); + + test('when the user subscribed less than 30 days ago, then they are still in their first month', () => { + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); + + const subscription = makeSubscriptionWithCommitment(dayjs('2026-04-28T10:00:00Z')); + const { isFirstMonth } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(isFirstMonth).toBe(true); + }); + + test('when the user subscribed more than 30 days ago, then they are no longer in their first month', () => { + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); + + const subscription = makeSubscriptionWithCommitment(dayjs('2026-03-01T10:00:00Z')); + const { isFirstMonth } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(isFirstMonth).toBe(false); + }); + + test('when the user has completed a full year and starts a new cycle, then they are not considered in their first month', () => { + jest.setSystemTime(dayjs('2026-10-01T10:00:00Z').toDate()); + + const subscription = makeSubscriptionWithCommitment(dayjs('2025-10-01T00:00:00Z')); + const { isFirstMonth } = paymentService.getAnnualCommitmentCancellationInfo(subscription); + + expect(isFirstMonth).toBe(false); + }); }); describe('Cancelling a subscription', () => { test('when the subscription has an annual commitment, then it schedules the cancellation at the end of the commitment period instead of cancelling immediately', async () => { const subscription = getCreatedSubscription({ - created: Math.floor(new Date('2025-10-01T00:00:00Z').getTime() / 1000), + created: dayjs('2025-10-01T00:00:00Z').unix(), items: { object: 'list', has_more: false, @@ -1233,10 +1257,42 @@ describe('Payments Service tests', () => { await paymentService.cancelSubscription(subscription.id); - expect(updateSpy).toHaveBeenCalledWith(subscription.id, { cancel_at: expect.any(Number) }); + const expectedCancelAt = dayjs.unix(subscription.created).add(1, 'year').unix(); + expect(updateSpy).toHaveBeenCalledWith(subscription.id, { cancel_at: expectedCancelAt }); expect(cancelSpy).not.toHaveBeenCalled(); }); + test('when the subscription has annual commitment but the user just subscribed this month, then it cancels immediately without waiting for the year to end', async () => { + jest.useFakeTimers(); + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); + + const subscription = getCreatedSubscription({ + created: dayjs('2026-04-28T00:00:00Z').unix(), + items: { + object: 'list', + has_more: false, + url: '', + data: [ + { + ...getCreatedSubscription().items.data[0], + price: getPrice({ metadata: { annualCommitment: 'true' } }), + }, + ], + }, + }); + + jest.spyOn(paymentService, 'getSubscriptionById').mockResolvedValue(subscription as any); + const cancelSpy = jest.spyOn(stripe.subscriptions, 'cancel').mockResolvedValue(subscription as unknown as any); + const updateSpy = jest.spyOn(stripe.subscriptions, 'update').mockResolvedValue(undefined as any); + + await paymentService.cancelSubscription(subscription.id); + + expect(cancelSpy).toHaveBeenCalledWith(subscription.id, {}); + expect(updateSpy).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + test('when the subscription has no annual commitment, then it cancels immediately', async () => { const subscription = getCreatedSubscription({ items: { @@ -1262,4 +1318,92 @@ describe('Payments Service tests', () => { expect(updateSpy).not.toHaveBeenCalled(); }); }); + + describe('Getting the user subscription', () => { + const makeActiveSubscription = (priceMetadata: Record = {}, overrides = {}) => { + const base = getCreatedSubscription(); + const price = getPrice({ metadata: { maxSpaceBytes: '107374182400', ...priceMetadata } }); + const product = getProduct({}); + return { + ...base, + plan: { + ...base.items.data[0].plan, + product, + amount: 999, + currency: 'eur', + interval: 'month', + interval_count: 1, + intervalCount: 1, + nickname: 'monthly', + }, + items: { ...base.items, data: [{ ...base.items.data[0], price }] }, + current_period_end: 1800000000, + ...overrides, + } as any; + }; + + const mockUpcomingInvoice = () => { + jest.spyOn(stripe.invoices, 'retrieveUpcoming').mockResolvedValue({ total: 999 } as any); + }; + + test('when the user has no active subscription, then it returns a free plan', async () => { + jest.spyOn(paymentService, 'getActiveSubscriptions').mockResolvedValue([]); + + const result = await paymentService.getUserSubscription('cus_test', UserType.Individual); + + expect(result).toEqual({ type: 'free' }); + }); + + test('when the user has an active individual subscription without annual commitment, then it returns the subscription details', async () => { + const subscription = makeActiveSubscription(); + jest.spyOn(paymentService, 'getActiveSubscriptions').mockResolvedValue([subscription]); + mockUpcomingInvoice(); + + const result = await paymentService.getUserSubscription('cus_test', UserType.Individual); + + expect(result.type).toBe('subscription'); + if (result.type === 'subscription') { + expect(result.plan.commitment.enabled).toBe(false); + expect(result.plan.commitment.remainingMonths).toBeUndefined(); + } + }); + + test('when the user has an active subscription with annual commitment, then it includes the remaining months and cancellation date', async () => { + jest.useFakeTimers(); + jest.setSystemTime(dayjs('2026-05-05T10:00:00Z').toDate()); + + const subscription = makeActiveSubscription( + { annualCommitment: 'true' }, + { created: dayjs('2025-10-01T00:00:00Z').unix() }, + ); + jest.spyOn(paymentService, 'getActiveSubscriptions').mockResolvedValue([subscription]); + mockUpcomingInvoice(); + + const result = await paymentService.getUserSubscription('cus_test', UserType.Individual); + + const expectedCancellationDate = dayjs('2025-10-01T00:00:00Z').add(1, 'year').toISOString(); + + if (result.type === 'subscription') { + expect(result.plan.commitment.enabled).toBe(true); + expect(result.plan.commitment.remainingMonths).toBe(5); + expect(result.plan.commitment.cancellationDate).toBe(expectedCancellationDate); + } + + jest.useRealTimers(); + }); + + test('when the user has a business subscription, then it returns the subscription with business type', async () => { + const subscription = makeActiveSubscription({}, { product: { metadata: { type: 'business' } } }); + jest.spyOn(paymentService, 'getActiveSubscriptions').mockResolvedValue([subscription]); + mockUpcomingInvoice(); + + jest + .spyOn(paymentService['productsRepository'], 'findByType') + .mockResolvedValue([{ paymentGatewayId: subscription.items.data[0].price.product } as any]); + + const result = await paymentService.getUserSubscription('cus_test', UserType.Business); + + expect(result.type).toBe('subscription'); + }); + }); }); From 488f9615ab3c8b6d1e5c77c9a19ab1657b41bc94 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 5 May 2026 12:12:36 +0200 Subject: [PATCH 4/4] fix: add equal to 30 when checking if is 1st month --- src/services/payment.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 1fc902a9..12a72af8 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -493,7 +493,7 @@ export class PaymentService { const cancelAtDate = createdAt.add(periodsElapsed + 1, 'year'); const cancelAt = cancelAtDate.unix(); - const isFirstMonth = monthsElapsed === 0 && now.diff(createdAt, 'day') < 30; + const isFirstMonth = monthsElapsed === 0 && now.diff(createdAt, 'day') <= 30; const remainingPayments = monthsIntoPeriod === 0 ? 12 : 12 - monthsIntoPeriod; const cancellationDate = cancelAtDate.toISOString();