From 137c86bae37612280e52b15ab9942a975346556e Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 16:31:51 -0500 Subject: [PATCH 1/2] Enhance Polar integration with improved subscription handling and environment configuration This commit introduces several enhancements to the Polar integration, including the addition of new environment variables in the AWS Amplify configuration for better management of sensitive data. It also refines the subscription handling logic by validating required fields during subscription creation and updating, ensuring robust error handling. Furthermore, new methods are added to check for subscription cancellation statuses, and the webhook processing logic is improved to handle payload validation more effectively. These changes aim to streamline interactions with the Polar API and enhance overall system reliability. --- amplify.yml | 5 + .../repositories/subscription.repository.ts | 18 +- .../polar-webhook-processor.service.ts | 251 +++++++++ app/api/_lib/polar/services/polar.service.ts | 21 +- .../polar/services/subscription.service.ts | 128 +++-- app/api/_lib/polar/types/index.ts | 4 +- app/api/webhooks/polar/route.ts | 62 ++- .../polar-webhook-processor.service.test.ts | 438 +++++++++++++++ test/unit/api/polar/polar.service.test.ts | 466 ++++++++++++++++ .../api/polar/subscription.repository.test.ts | 498 ++++++++++++++++++ .../api/polar/subscription.service.test.ts | 404 ++++++++++++++ test/unit/api/polar/user.repository.test.ts | 299 +++++++++++ 12 files changed, 2536 insertions(+), 58 deletions(-) create mode 100644 app/api/_lib/polar/services/polar-webhook-processor.service.ts create mode 100644 test/unit/api/polar/polar-webhook-processor.service.test.ts create mode 100644 test/unit/api/polar/polar.service.test.ts create mode 100644 test/unit/api/polar/subscription.repository.test.ts create mode 100644 test/unit/api/polar/subscription.service.test.ts create mode 100644 test/unit/api/polar/user.repository.test.ts diff --git a/amplify.yml b/amplify.yml index ab809be2..55538cd2 100644 --- a/amplify.yml +++ b/amplify.yml @@ -28,6 +28,11 @@ frontend: - env | grep -e LAMBDA_EMAIL_BULK >> .env.production - env | grep -e CLOUDFRONT_KEY_PAIR_ID >> .env.production - env | grep -e CLOUDFRONT_PRIVATE_KEY >> .env.production + - env | grep -e POLAR_ACCESS_TOKEN >> .env.production + - env | grep -e POLAR_WEBHOOK_SECRET >> .env.production + - env | grep -e USER_POOL_ID >> .env.production + - env | grep -e POLAR_ORGANIZATION_ID >> .env.production + - env | grep -e SUCCESSS_URL >> .env.production - echo "TS_NODE_TRANSPILE_ONLY=true" >> .env.production - echo "TS_NODE_FILES=false" >> .env.production - pnpm install --frozen-lockfile diff --git a/app/api/_lib/polar/repositories/subscription.repository.ts b/app/api/_lib/polar/repositories/subscription.repository.ts index b5eebb29..3efe59b7 100644 --- a/app/api/_lib/polar/repositories/subscription.repository.ts +++ b/app/api/_lib/polar/repositories/subscription.repository.ts @@ -43,6 +43,11 @@ export class SubscriptionRepository { */ async create(data: UserSubscriptionData): Promise { try { + // Validar campos requeridos + if (!data.userId || !data.subscriptionId || !data.planName) { + throw new Error('Missing required fields: userId, subscriptionId, planName'); + } + const subscriptionPayload = { id: data.userId, userId: data.userId, @@ -71,9 +76,20 @@ export class SubscriptionRepository { */ async update(userId: string, data: Partial): Promise { try { + // Filtrar campos undefined/null para evitar errores en DynamoDB + const updateFields: Record = {}; + + if (data.subscriptionId !== undefined) updateFields.subscriptionId = data.subscriptionId; + if (data.planName !== undefined) updateFields.planName = data.planName; + if (data.nextPaymentDate !== undefined) updateFields.nextPaymentDate = data.nextPaymentDate; + if (data.lastFourDigits !== undefined) updateFields.lastFourDigits = data.lastFourDigits; + if (data.pendingPlan !== undefined) updateFields.pendingPlan = data.pendingPlan; + if (data.pendingStartDate !== undefined) updateFields.pendingStartDate = data.pendingStartDate; + if (data.planPrice !== undefined) updateFields.planPrice = data.planPrice; + const updatePayload = { id: userId, - ...data, + ...updateFields, }; const response = await cookiesClient.models.UserSubscription.update(updatePayload); diff --git a/app/api/_lib/polar/services/polar-webhook-processor.service.ts b/app/api/_lib/polar/services/polar-webhook-processor.service.ts new file mode 100644 index 00000000..9af9bede --- /dev/null +++ b/app/api/_lib/polar/services/polar-webhook-processor.service.ts @@ -0,0 +1,251 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { PlanType, SubscriptionProcessResult } from '@/app/api/_lib/polar/types'; + +/** + * Servicio para procesar datos de webhooks de Polar + * Implementa Clean Architecture separando lógica de procesamiento de datos + */ +export class PolarWebhookProcessorService { + constructor( + private readonly userRepository: UserRepository, + private readonly subscriptionRepository: SubscriptionRepository, + private readonly mapProductIdToPlan: (productId: string) => PlanType + ) {} + + /** + * Procesa una suscripción usando datos del webhook de Polar + */ + async processSubscriptionWithData(subscriptionId: string, polarData: any): Promise { + try { + const userId = polarData.customerExternalId || polarData.customer?.externalId; + + if (!userId) { + return { + success: false, + userId: '', + plan: PlanType.FREE, + message: 'No user ID found in subscription data', + }; + } + + // Determinar acción basada en el estado de la suscripción + if (this.isSubscriptionActiveFromData(polarData)) { + return await this.activateSubscriptionWithData(userId, polarData); + } else if (this.isSubscriptionCanceledFromData(polarData)) { + return await this.cancelSubscriptionWithData(userId, polarData); + } + + return { + success: true, + userId, + plan: PlanType.FREE, + message: 'Subscription processed successfully', + }; + } catch (error) { + console.error(`Error processing subscription with data ${subscriptionId}:`, error); + return { + success: false, + userId: '', + plan: PlanType.FREE, + message: `Error processing subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Determina si una suscripción está activa basándose en datos del webhook + */ + private isSubscriptionActiveFromData(polarData: any): boolean { + return polarData.status === 'active' || polarData.status === 'trialing'; + } + + /** + * Determina si una suscripción está cancelada basándose en datos del webhook + */ + private isSubscriptionCanceledFromData(polarData: any): boolean { + return ( + polarData.status === 'canceled' || polarData.status === 'incomplete_expired' || polarData.status === 'unpaid' + ); + } + + /** + * Activa una suscripción usando datos del webhook de Polar + */ + private async activateSubscriptionWithData(userId: string, polarData: any): Promise { + try { + const plan = this.mapProductIdToPlan(polarData.productId); + + if (plan === PlanType.FREE) { + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Invalid product ID: ${polarData.productId}`, + }; + } + + // Verificar si es una renovación o activación inicial + const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + const isRenewal = existingSubscription && existingSubscription.planName === plan; + + if (isRenewal) { + // RENOVACIÓN: Actualizar nextPaymentDate, planPrice y limpiar campos pendientes + const nextPaymentDate = polarData.currentPeriodEnd + ? new Date(polarData.currentPeriodEnd).toISOString() + : undefined; + const planPrice = polarData.amount ? polarData.amount / 100 : undefined; + + await this.subscriptionRepository.update(userId, { + nextPaymentDate, + planPrice, + pendingPlan: null, + pendingStartDate: null, + }); + + console.log(`Successfully renewed subscription for user ${userId} with plan ${plan}`); + return { + success: true, + userId, + plan, + message: `Subscription renewed with plan ${plan}`, + }; + } else { + // ACTIVACIÓN INICIAL: Cambiar plan y activar tiendas + await this.userRepository.updateUserPlan(userId, plan); + await this.userRepository.updateStoresStatus(userId, true); + + // Extraer datos necesarios para el planScheduler + const subscriptionData = this.extractSubscriptionDataFromPolar(userId, polarData, plan); + + // Crear o actualizar registro de suscripción + await this.subscriptionRepository.upsert(subscriptionData); + + console.log(`Successfully activated subscription for user ${userId} with plan ${plan}`); + return { + success: true, + userId, + plan, + message: `Subscription activated with plan ${plan}`, + }; + } + } catch (error) { + console.error(`Error activating subscription for user ${userId}:`, error); + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Error activating subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Cancela una suscripción usando datos del webhook de Polar + */ + private async cancelSubscriptionWithData(userId: string, polarData: any): Promise { + try { + const cancelAtPeriodEnd = polarData.cancelAtPeriodEnd || false; + const currentPeriodEnd = polarData.currentPeriodEnd ? new Date(polarData.currentPeriodEnd) : null; + + if (cancelAtPeriodEnd && currentPeriodEnd) { + return await this.scheduleSubscriptionCancellation(userId, currentPeriodEnd); + } else { + return await this.immediateSubscriptionCancellation(userId); + } + } catch (error) { + console.error(`Error canceling subscription with data for user ${userId}:`, error); + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Error canceling subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Programa la cancelación de suscripción para el final del período + */ + private async scheduleSubscriptionCancellation(userId: string, periodEnd: Date): Promise { + const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + if (existingSubscription) { + await this.subscriptionRepository.update(userId, { + pendingPlan: PlanType.FREE, + pendingStartDate: periodEnd.toISOString(), + }); + } + + console.log(`Successfully scheduled subscription cancellation for user ${userId} at ${periodEnd.toISOString()}`); + + return { + success: true, + userId, + plan: PlanType.FREE, + message: 'Subscription cancellation scheduled', + }; + } + + /** + * Cancela la suscripción inmediatamente + */ + private async immediateSubscriptionCancellation(userId: string): Promise { + await this.userRepository.updateUserPlan(userId, PlanType.FREE); + await this.userRepository.updateStoresStatus(userId, false); + + const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + if (existingSubscription) { + await this.subscriptionRepository.update(userId, { + planName: PlanType.FREE, + pendingPlan: null, + pendingStartDate: undefined, + nextPaymentDate: undefined, + planPrice: undefined, + }); + } + + console.log(`Successfully canceled subscription immediately for user ${userId}`); + + return { + success: true, + userId, + plan: PlanType.FREE, + message: 'Subscription canceled successfully', + }; + } + + /** + * Extrae datos de suscripción de la respuesta de Polar + */ + private extractSubscriptionDataFromPolar(userId: string, polarData: any, plan: PlanType): any { + const nextPaymentDate = polarData.currentPeriodEnd ? new Date(polarData.currentPeriodEnd).toISOString() : undefined; + const planPrice = polarData.amount ? polarData.amount / 100 : undefined; // Convertir de centavos a dólares + + return { + id: userId, + userId, + subscriptionId: polarData.id || '', + planName: plan, + nextPaymentDate, + planPrice, + pendingPlan: null, + pendingStartDate: null, + }; + } +} diff --git a/app/api/_lib/polar/services/polar.service.ts b/app/api/_lib/polar/services/polar.service.ts index d443c13a..fc1d640e 100644 --- a/app/api/_lib/polar/services/polar.service.ts +++ b/app/api/_lib/polar/services/polar.service.ts @@ -172,11 +172,24 @@ export class PolarService { } /** - * Verifica si una suscripción está cancelada + * Verifica si una suscripción está cancelada (inmediatamente o programada) */ isSubscriptionCanceled(subscription: PolarSubscription): boolean { - return [SubscriptionStatus.CANCELED, SubscriptionStatus.INCOMPLETE_EXPIRED, SubscriptionStatus.UNPAID].includes( - subscription.status - ); + const isStatusCanceled = [ + SubscriptionStatus.CANCELED, + SubscriptionStatus.INCOMPLETE_EXPIRED, + SubscriptionStatus.UNPAID, + ].includes(subscription.status); + + const isScheduledForCancellation = subscription.cancelAtPeriodEnd === true; + + return isStatusCanceled || isScheduledForCancellation; + } + + /** + * Verifica si una suscripción está programada para cancelación + */ + isSubscriptionScheduledForCancellation(subscription: PolarSubscription): boolean { + return subscription.status === SubscriptionStatus.ACTIVE && subscription.cancelAtPeriodEnd === true; } } diff --git a/app/api/_lib/polar/services/subscription.service.ts b/app/api/_lib/polar/services/subscription.service.ts index 546de4a8..c566506f 100644 --- a/app/api/_lib/polar/services/subscription.service.ts +++ b/app/api/_lib/polar/services/subscription.service.ts @@ -65,10 +65,15 @@ export class SubscriptionService { } // Determinar acción basada en el estado de la suscripción - if (this.polarService.isSubscriptionActive(polarSubscription)) { - return await this.activateSubscription(userId, polarSubscription.productId); - } else if (this.polarService.isSubscriptionCanceled(polarSubscription)) { - return await this.cancelSubscription(userId); + const isActive = this.polarService.isSubscriptionActive(polarSubscription); + const isCanceled = this.polarService.isSubscriptionCanceled(polarSubscription); + const isScheduledForCancellation = this.polarService.isSubscriptionScheduledForCancellation(polarSubscription); + + // Priorizar cancelación sobre activación + if (isCanceled || isScheduledForCancellation) { + return await this.cancelSubscription(userId, polarSubscription); + } else if (isActive) { + return await this.activateSubscription(userId, polarSubscription.productId, subscriptionId, polarSubscription); } return { @@ -91,7 +96,12 @@ export class SubscriptionService { /** * Activa una suscripción para un usuario */ - async activateSubscription(userId: string, productId: string): Promise { + async activateSubscription( + userId: string, + productId: string, + subscriptionId?: string, + polarSubscription?: any + ): Promise { try { const plan = this.mapProductIdToPlan(productId); @@ -104,29 +114,60 @@ export class SubscriptionService { }; } - // Actualizar plan en Cognito - await this.userRepository.updateUserPlan(userId, plan); + // Verificar si es una renovación o activación inicial + const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + const isRenewal = existingSubscription && existingSubscription.planName === plan; - // Activar tiendas del usuario - await this.userRepository.updateStoresStatus(userId, true); + if (isRenewal) { + // RENOVACIÓN: Actualizar nextPaymentDate, planPrice y limpiar campos pendientes + const nextPaymentDate = polarSubscription?.currentPeriodEnd + ? new Date(polarSubscription.currentPeriodEnd).toISOString() + : undefined; + const planPrice = polarSubscription?.amount ? polarSubscription.amount / 100 : undefined; - // Crear o actualizar registro de suscripción - await this.subscriptionRepository.upsert({ - id: userId, - userId, - subscriptionId: '', // Se actualizará cuando tengamos el ID real - planName: plan, - pendingPlan: null, - }); + await this.subscriptionRepository.update(userId, { + nextPaymentDate, + planPrice, + pendingPlan: null, + pendingStartDate: null, + }); - console.log(`Successfully activated subscription for user ${userId} with plan ${plan}`); + console.log(`Successfully renewed subscription for user ${userId} with plan ${plan}`); + return { + success: true, + userId, + plan, + message: `Subscription renewed with plan ${plan}`, + }; + } else { + // ACTIVACIÓN INICIAL: Cambiar plan y activar tiendas + await this.userRepository.updateUserPlan(userId, plan); + await this.userRepository.updateStoresStatus(userId, true); - return { - success: true, - userId, - plan, - message: `Subscription activated with plan ${plan}`, - }; + const nextPaymentDate = polarSubscription?.currentPeriodEnd + ? new Date(polarSubscription.currentPeriodEnd).toISOString() + : undefined; + const planPrice = polarSubscription?.amount ? polarSubscription.amount / 100 : undefined; + + await this.subscriptionRepository.upsert({ + id: userId, + userId, + subscriptionId: subscriptionId || '', + planName: plan, + nextPaymentDate, + planPrice, + pendingPlan: null, + pendingStartDate: null, + }); + + console.log(`Successfully activated subscription for user ${userId} with plan ${plan}`); + return { + success: true, + userId, + plan, + message: `Subscription activated with plan ${plan}`, + }; + } } catch (error) { console.error(`Error activating subscription for user ${userId}:`, error); return { @@ -141,24 +182,39 @@ export class SubscriptionService { /** * Cancela/downgrade una suscripción de un usuario */ - async cancelSubscription(userId: string): Promise { + async cancelSubscription(userId: string, polarSubscription?: any): Promise { try { - // Downgrade a plan gratuito - await this.userRepository.updateUserPlan(userId, PlanType.FREE); - - // Desactivar tiendas del usuario - await this.userRepository.updateStoresStatus(userId, false); + const cancelAtPeriodEnd = polarSubscription?.cancelAtPeriodEnd || false; + const currentPeriodEnd = polarSubscription?.currentPeriodEnd + ? new Date(polarSubscription.currentPeriodEnd) + : null; // Actualizar registro de suscripción const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + if (existingSubscription) { - await this.subscriptionRepository.update(userId, { - planName: PlanType.FREE, - pendingPlan: null, - }); - } + if (cancelAtPeriodEnd && currentPeriodEnd) { + // Cancelación al final del período - mantener plan actual, establecer pendingPlan + const updateData = { + pendingPlan: PlanType.FREE, + pendingStartDate: currentPeriodEnd.toISOString(), + }; + + await this.subscriptionRepository.update(userId, updateData); + } else { + // Cancelación inmediata - downgrade a plan gratuito + await this.userRepository.updateUserPlan(userId, PlanType.FREE); + await this.userRepository.updateStoresStatus(userId, false); - console.log(`Successfully canceled subscription for user ${userId}`); + await this.subscriptionRepository.update(userId, { + planName: PlanType.FREE, + pendingPlan: null, + pendingStartDate: undefined, + nextPaymentDate: undefined, + planPrice: undefined, + }); + } + } return { success: true, diff --git a/app/api/_lib/polar/types/index.ts b/app/api/_lib/polar/types/index.ts index 967f10a7..b3645788 100644 --- a/app/api/_lib/polar/types/index.ts +++ b/app/api/_lib/polar/types/index.ts @@ -21,7 +21,7 @@ export enum PlanType { ROYAL = 'Royal', MAJESTIC = 'Majestic', IMPERIAL = 'Imperial', - FREE = 'Free', + FREE = 'free', } /** @@ -72,6 +72,8 @@ export interface UserSubscriptionData { nextPaymentDate?: string; lastFourDigits?: number; pendingPlan?: PlanType | null; + pendingStartDate?: string | null; + planPrice?: number; } /** diff --git a/app/api/webhooks/polar/route.ts b/app/api/webhooks/polar/route.ts index 674f8a7b..130f5f2a 100644 --- a/app/api/webhooks/polar/route.ts +++ b/app/api/webhooks/polar/route.ts @@ -4,6 +4,7 @@ import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repositor import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; import { SubscriptionService } from '@/app/api/_lib/polar/services/subscription.service'; +import { PolarWebhookProcessorService } from '@/app/api/_lib/polar/services/polar-webhook-processor.service'; import { getNextCorsHeaders } from '@/lib/utils/next-cors'; /** @@ -19,36 +20,49 @@ export const POST = Webhooks({ webhookSecret: process.env.POLAR_WEBHOOK_SECRET || '', onSubscriptionCreated: async (payload) => { - console.log('Subscription created:', payload.id); - await processSubscriptionEvent(payload.id); + if (payload.data?.id) { + await processSubscriptionEvent(payload.data.id); + } else { + console.error('Subscription created payload missing id:', payload); + } }, onSubscriptionUpdated: async (payload) => { - console.log('Subscription updated:', payload.id); - await processSubscriptionEvent(payload.id); + if (payload.data?.id) { + await processSubscriptionEvent(payload.data.id); + } else { + console.error('Subscription updated payload missing id:', payload); + } }, onSubscriptionActive: async (payload) => { - console.log('Subscription active:', payload.id); - await processSubscriptionEvent(payload.id); + if (payload.data?.id) { + await processSubscriptionEvent(payload.data.id); + } else { + console.error('Subscription active payload missing id:', payload); + } }, onSubscriptionCanceled: async (payload) => { - console.log('Subscription canceled:', payload.id); - await processSubscriptionEvent(payload.id); + if (payload.data?.id) { + await processSubscriptionEvent(payload.data.id); + } else { + console.error('Subscription canceled payload missing id:', payload); + } }, onSubscriptionRevoked: async (payload) => { - console.log('Subscription revoked:', payload.id); - await processSubscriptionEvent(payload.id); + if (payload.data?.id) { + await processSubscriptionEvent(payload.data.id); + } else { + console.error('Subscription revoked payload missing id:', payload); + } }, onOrderCreated: async (payload) => { - console.log('Order created:', payload.id); - // Si el pago está asociado a una suscripción, procesar la suscripción - if (payload.subscription_id) { - await processSubscriptionEvent(payload.subscription_id); + if (payload.data?.subscription_id) { + await processSubscriptionEvent(payload.data.subscription_id); } }, }); @@ -56,8 +70,14 @@ export const POST = Webhooks({ /** * Procesa un evento de suscripción usando los servicios de la aplicación */ -async function processSubscriptionEvent(subscriptionId: string): Promise { +async function processSubscriptionEvent(subscriptionId: string, payloadData?: any): Promise { try { + // Validar que tenemos un subscriptionId válido + if (!subscriptionId || subscriptionId === 'undefined') { + console.error('Invalid subscription ID provided:', subscriptionId); + return; + } + // Validar variables de entorno const userPoolId = process.env.USER_POOL_ID; const accessToken = process.env.POLAR_ACCESS_TOKEN; @@ -74,7 +94,17 @@ async function processSubscriptionEvent(subscriptionId: string): Promise { const subscriptionService = new SubscriptionService(userRepository, subscriptionRepository, polarService); // Procesar actualización de suscripción - const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + let result; + if (payloadData) { + const webhookProcessor = new PolarWebhookProcessorService( + userRepository, + subscriptionRepository, + subscriptionService.mapProductIdToPlan.bind(subscriptionService) + ); + result = await webhookProcessor.processSubscriptionWithData(subscriptionId, payloadData); + } else { + result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + } if (result.success) { console.log(`Successfully processed subscription ${subscriptionId}:`, result.message); diff --git a/test/unit/api/polar/polar-webhook-processor.service.test.ts b/test/unit/api/polar/polar-webhook-processor.service.test.ts new file mode 100644 index 00000000..2126594b --- /dev/null +++ b/test/unit/api/polar/polar-webhook-processor.service.test.ts @@ -0,0 +1,438 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PolarWebhookProcessorService } from '@/app/api/_lib/polar/services/polar-webhook-processor.service'; +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { PlanType } from '@/app/api/_lib/polar/types'; + +jest.mock('@/app/api/_lib/polar/repositories/user.repository'); +jest.mock('@/app/api/_lib/polar/repositories/subscription.repository'); + +describe('PolarWebhookProcessorService', () => { + let webhookProcessorService: PolarWebhookProcessorService; + let mockUserRepository: jest.Mocked; + let mockSubscriptionRepository: jest.Mocked; + let mockMapProductIdToPlan: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUserRepository = { + updateUserPlan: jest.fn(), + updateStoresStatus: jest.fn(), + getUserById: jest.fn(), + } as any; + + mockSubscriptionRepository = { + findByUserId: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + } as any; + + mockMapProductIdToPlan = jest.fn(); + + webhookProcessorService = new PolarWebhookProcessorService( + mockUserRepository, + mockSubscriptionRepository, + mockMapProductIdToPlan + ); + }); + + describe('processSubscriptionWithData', () => { + it('debe procesar suscripción activa exitosamente', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + amount: 2500, + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_123'); + expect(result.plan).toBe(PlanType.IMPERIAL); + expect(result.message).toContain('Subscription activated'); + expect(mockUserRepository.updateUserPlan).toHaveBeenCalledWith('user_123', PlanType.IMPERIAL); + expect(mockUserRepository.updateStoresStatus).toHaveBeenCalledWith('user_123', true); + expect(mockSubscriptionRepository.upsert).toHaveBeenCalled(); + }); + + it('debe procesar renovación de suscripción exitosamente', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + amount: 2500, + }; + + const existingSubscription = { + id: 'user_123', + userId: 'user_123', + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_123', + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription); + mockSubscriptionRepository.update.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_123'); + expect(result.plan).toBe(PlanType.IMPERIAL); + expect(result.message).toContain('Subscription renewed'); + expect(mockUserRepository.updateUserPlan).not.toHaveBeenCalled(); + expect(mockUserRepository.updateStoresStatus).not.toHaveBeenCalled(); + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith('user_123', { + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, + pendingPlan: null, + pendingStartDate: null, + }); + }); + + it('debe procesar cancelación programada exitosamente', async () => { + const polarData = { + id: 'sub_123', + status: 'canceled', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + cancelAtPeriodEnd: true, + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + }; + + const existingSubscription = { + id: 'user_123', + userId: 'user_123', + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_123', + }; + + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription); + mockSubscriptionRepository.update.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_123'); + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('Subscription cancellation scheduled'); + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith('user_123', { + pendingPlan: PlanType.FREE, + pendingStartDate: '2025-12-16T20:21:54.151Z', + }); + }); + + it('debe procesar cancelación inmediata exitosamente', async () => { + const polarData = { + id: 'sub_123', + status: 'canceled', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + cancelAtPeriodEnd: false, + }; + + const existingSubscription = { + id: 'user_123', + userId: 'user_123', + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_123', + }; + + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.update.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_123'); + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('Subscription canceled successfully'); + expect(mockUserRepository.updateUserPlan).toHaveBeenCalledWith('user_123', PlanType.FREE); + expect(mockUserRepository.updateStoresStatus).toHaveBeenCalledWith('user_123', false); + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith('user_123', { + planName: PlanType.FREE, + pendingPlan: null, + pendingStartDate: undefined, + nextPaymentDate: undefined, + planPrice: undefined, + }); + }); + + it('debe retornar error si no se encuentra user ID', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + // Sin customerExternalId + }; + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(false); + expect(result.message).toContain('No user ID found'); + }); + + it('debe retornar error si product ID es inválido', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_invalid', + customerExternalId: 'user_123', + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.FREE); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(false); + expect(result.message).toContain('Invalid product ID'); + }); + + it('debe procesar suscripción sin acción específica', async () => { + const polarData = { + id: 'sub_123', + status: 'incomplete', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + }; + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_123'); + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('Subscription processed successfully'); + }); + + it('debe manejar errores correctamente', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockRejectedValue(new Error('Database error')); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.success).toBe(false); + expect(result.message).toContain('Error activating subscription'); + }); + }); + + describe('Detección de estado de suscripción', () => { + it('debe detectar suscripción activa correctamente', async () => { + const activeData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + }; + + const trialingData = { + id: 'sub_124', + status: 'trialing', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + // Test status 'active' + const activeResult = await webhookProcessorService.processSubscriptionWithData('sub_123', activeData); + expect(activeResult.message).toContain('Subscription activated'); + + // Test status 'trialing' + const trialingResult = await webhookProcessorService.processSubscriptionWithData('sub_124', trialingData); + expect(trialingResult.message).toContain('Subscription activated'); + }); + + it('debe detectar suscripción cancelada correctamente', async () => { + const canceledData = { + id: 'sub_123', + status: 'canceled', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + cancelAtPeriodEnd: false, + }; + + const expiredData = { + id: 'sub_124', + status: 'incomplete_expired', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + cancelAtPeriodEnd: false, + }; + + const unpaidData = { + id: 'sub_125', + status: 'unpaid', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + cancelAtPeriodEnd: false, + }; + + const existingSubscription = { + id: 'user_123', + userId: 'user_123', + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_123', + }; + + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.update.mockResolvedValue({} as any); + + // Test status 'canceled' + const canceledResult = await webhookProcessorService.processSubscriptionWithData('sub_123', canceledData); + expect(canceledResult.message).toContain('Subscription canceled successfully'); + + // Test status 'incomplete_expired' + const expiredResult = await webhookProcessorService.processSubscriptionWithData('sub_124', expiredData); + expect(expiredResult.message).toContain('Subscription canceled successfully'); + + // Test status 'unpaid' + const unpaidResult = await webhookProcessorService.processSubscriptionWithData('sub_125', unpaidData); + expect(unpaidResult.message).toContain('Subscription canceled successfully'); + }); + }); + + describe('Extracción de datos de Polar', () => { + it('debe extraer datos de suscripción correctamente', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + amount: 2500, + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(mockSubscriptionRepository.upsert).toHaveBeenCalledWith({ + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, + pendingPlan: null, + pendingStartDate: null, + }); + }); + + it('debe manejar datos faltantes correctamente', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + // Sin currentPeriodEnd ni amount + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(mockSubscriptionRepository.upsert).toHaveBeenCalledWith({ + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + nextPaymentDate: undefined, + planPrice: undefined, + pendingPlan: null, + pendingStartDate: null, + }); + }); + }); + + describe('Manejo de customerExternalId', () => { + it('debe usar customerExternalId cuando está disponible', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customerExternalId: 'user_123', + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.userId).toBe('user_123'); + }); + + it('debe usar customer.externalId como fallback', async () => { + const polarData = { + id: 'sub_123', + status: 'active', + productId: 'prod_imperial_dev', + customer: { + externalId: 'user_456', + }, + }; + + mockMapProductIdToPlan.mockReturnValue(PlanType.IMPERIAL); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + mockUserRepository.updateUserPlan.mockResolvedValue(); + mockUserRepository.updateStoresStatus.mockResolvedValue(); + mockSubscriptionRepository.upsert.mockResolvedValue({} as any); + + const result = await webhookProcessorService.processSubscriptionWithData('sub_123', polarData); + + expect(result.userId).toBe('user_456'); + }); + }); +}); diff --git a/test/unit/api/polar/polar.service.test.ts b/test/unit/api/polar/polar.service.test.ts new file mode 100644 index 00000000..2f69e941 --- /dev/null +++ b/test/unit/api/polar/polar.service.test.ts @@ -0,0 +1,466 @@ +import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; +import { PolarSubscription, SubscriptionStatus } from '@/app/api/_lib/polar/types'; + +// Mock del SDK de Polar +const mockPolarInstance = { + subscriptions: { + get: jest.fn(), + }, + customers: { + get: jest.fn(), + getExternal: jest.fn(), + getState: jest.fn(), + }, +}; + +jest.mock('@polar-sh/sdk', () => { + return { + Polar: jest.fn().mockImplementation(() => mockPolarInstance), + }; +}); + +describe('PolarService', () => { + let polarService: PolarService; + + beforeEach(() => { + jest.clearAllMocks(); + + // Crear instancia del servicio + polarService = new PolarService('test-access-token'); + }); + + describe('Constructor', () => { + it('debe inicializar con access token correcto', () => { + expect(polarService).toBeInstanceOf(PolarService); + }); + + it('debe usar sandbox en desarrollo', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const service = new PolarService('test-token'); + expect(service).toBeInstanceOf(PolarService); + + process.env.NODE_ENV = originalEnv; + }); + + it('debe usar production en producción', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const service = new PolarService('test-token'); + expect(service).toBeInstanceOf(PolarService); + + process.env.NODE_ENV = originalEnv; + }); + }); + + describe('getSubscription', () => { + it('debe obtener suscripción exitosamente', async () => { + const mockSubscription = { + id: 'sub_123', + status: 'active', + customer: { + id: 'customer_123', + externalId: 'user_123', + }, + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + cancelAtPeriodEnd: false, + }; + + mockPolarInstance.subscriptions.get.mockResolvedValue(mockSubscription); + + const result = await polarService.getSubscription('sub_123'); + + expect(mockPolarInstance.subscriptions.get).toHaveBeenCalledWith({ id: 'sub_123' }); + expect(result).toEqual({ + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + cancelAtPeriodEnd: false, + }); + }); + + it('debe retornar null si suscripción no existe', async () => { + mockPolarInstance.subscriptions.get.mockResolvedValue(null); + + const result = await polarService.getSubscription('sub_inexistente'); + + expect(result).toBeNull(); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPolarInstance.subscriptions.get.mockRejectedValue(new Error('API Error')); + + const result = await polarService.getSubscription('sub_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching subscription sub_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getCustomer', () => { + it('debe obtener cliente exitosamente', async () => { + const mockCustomer = { + id: 'customer_123', + email: 'test@example.com', + name: 'Test User', + externalId: 'user_123', + }; + + mockPolarInstance.customers.get.mockResolvedValue(mockCustomer); + + const result = await polarService.getCustomer('customer_123'); + + expect(mockPolarInstance.customers.get).toHaveBeenCalledWith({ id: 'customer_123' }); + expect(result).toEqual({ + id: 'customer_123', + email: 'test@example.com', + name: 'Test User', + externalId: 'user_123', + }); + }); + + it('debe retornar null si cliente no existe', async () => { + mockPolarInstance.customers.get.mockResolvedValue(null); + + const result = await polarService.getCustomer('customer_inexistente'); + + expect(result).toBeNull(); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPolarInstance.customers.get.mockRejectedValue(new Error('API Error')); + + const result = await polarService.getCustomer('customer_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching customer customer_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getCustomerByExternalId', () => { + it('debe obtener cliente por external ID exitosamente', async () => { + const mockCustomer = { + id: 'customer_123', + email: 'test@example.com', + name: 'Test User', + externalId: 'user_123', + }; + + mockPolarInstance.customers.getExternal.mockResolvedValue(mockCustomer); + + const result = await polarService.getCustomerByExternalId('user_123'); + + expect(mockPolarInstance.customers.getExternal).toHaveBeenCalledWith({ externalId: 'user_123' }); + expect(result).toEqual({ + id: 'customer_123', + email: 'test@example.com', + name: 'Test User', + externalId: 'user_123', + }); + }); + + it('debe retornar null si cliente no existe', async () => { + mockPolarInstance.customers.getExternal.mockResolvedValue(null); + + const result = await polarService.getCustomerByExternalId('user_inexistente'); + + expect(result).toBeNull(); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPolarInstance.customers.getExternal.mockRejectedValue(new Error('API Error')); + + const result = await polarService.getCustomerByExternalId('user_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching customer by external ID user_123:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getCustomerState', () => { + it('debe obtener estado del cliente exitosamente', async () => { + const mockState = { + id: 'customer_123', + activeSubscriptions: [ + { id: 'sub_1', status: 'active' }, + { id: 'sub_2', status: 'active' }, + ], + }; + + mockPolarInstance.customers.getState.mockResolvedValue(mockState); + + const result = await polarService.getCustomerState('customer_123'); + + expect(mockPolarInstance.customers.getState).toHaveBeenCalledWith({ id: 'customer_123' }); + expect(result).toEqual(mockState); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPolarInstance.customers.getState.mockRejectedValue(new Error('API Error')); + + const result = await polarService.getCustomerState('customer_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching customer state customer_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('hasActiveSubscriptions', () => { + it('debe retornar true si cliente tiene suscripciones activas', async () => { + const mockState = { + id: 'customer_123', + activeSubscriptions: [ + { id: 'sub_1', status: 'active' }, + { id: 'sub_2', status: 'active' }, + ], + }; + + mockPolarInstance.customers.getState.mockResolvedValue(mockState); + + const result = await polarService.hasActiveSubscriptions('customer_123'); + + expect(result).toBe(true); + }); + + it('debe retornar false si cliente no tiene suscripciones activas', async () => { + const mockState = { + id: 'customer_123', + activeSubscriptions: [], + }; + + mockPolarInstance.customers.getState.mockResolvedValue(mockState); + + const result = await polarService.hasActiveSubscriptions('customer_123'); + + expect(result).toBe(false); + }); + + it('debe retornar false si estado del cliente es null', async () => { + mockPolarInstance.customers.getState.mockResolvedValue(null); + + const result = await polarService.hasActiveSubscriptions('customer_123'); + + expect(result).toBe(false); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockPolarInstance.customers.getState.mockRejectedValue(new Error('API Error')); + + const result = await polarService.hasActiveSubscriptions('customer_123'); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching customer state customer_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('isSubscriptionActive', () => { + it('debe retornar true para suscripción activa', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + + const result = polarService.isSubscriptionActive(subscription); + + expect(result).toBe(true); + }); + + it('debe retornar false para suscripción no activa', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.CANCELED, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + + const result = polarService.isSubscriptionActive(subscription); + + expect(result).toBe(false); + }); + }); + + describe('isSubscriptionCanceled', () => { + it('debe retornar true para suscripción cancelada', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.CANCELED, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + + const result = polarService.isSubscriptionCanceled(subscription); + + expect(result).toBe(true); + }); + + it('debe retornar true para suscripción programada para cancelación', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: true, + }; + + const result = polarService.isSubscriptionCanceled(subscription); + + expect(result).toBe(true); + }); + + it('debe retornar false para suscripción activa no cancelada', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + + const result = polarService.isSubscriptionCanceled(subscription); + + expect(result).toBe(false); + }); + }); + + describe('isSubscriptionScheduledForCancellation', () => { + it('debe retornar true para suscripción programada para cancelación', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: true, + }; + + const result = polarService.isSubscriptionScheduledForCancellation(subscription); + + expect(result).toBe(true); + }); + + it('debe retornar false para suscripción no programada para cancelación', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + + const result = polarService.isSubscriptionScheduledForCancellation(subscription); + + expect(result).toBe(false); + }); + + it('debe retornar false para suscripción cancelada (no activa)', () => { + const subscription: PolarSubscription = { + id: 'sub_123', + status: SubscriptionStatus.CANCELED, + customerId: 'customer_123', + customerExternalId: 'user_123', + productId: 'prod_123', + amount: 2500, + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: true, + }; + + const result = polarService.isSubscriptionScheduledForCancellation(subscription); + + expect(result).toBe(false); + }); + }); + + describe('Mapeo de datos', () => { + it('debe mapear suscripción con datos faltantes correctamente', async () => { + const mockSubscription = { + id: 'sub_123', + status: 'active', + // Sin customer, productId, amount, currentPeriodEnd + }; + + mockPolarInstance.subscriptions.get.mockResolvedValue(mockSubscription); + + const result = await polarService.getSubscription('sub_123'); + + expect(result).toEqual({ + id: 'sub_123', + status: SubscriptionStatus.ACTIVE, + customerId: '', + customerExternalId: '', + productId: '', + amount: 0, + currentPeriodEnd: expect.any(Date), + cancelAtPeriodEnd: false, + }); + }); + + it('debe mapear cliente con datos faltantes correctamente', async () => { + const mockCustomer = { + id: 'customer_123', + // Sin email, name, externalId + }; + + mockPolarInstance.customers.get.mockResolvedValue(mockCustomer); + + const result = await polarService.getCustomer('customer_123'); + + expect(result).toEqual({ + id: 'customer_123', + email: '', + name: '', + externalId: '', + }); + }); + }); +}); diff --git a/test/unit/api/polar/subscription.repository.test.ts b/test/unit/api/polar/subscription.repository.test.ts new file mode 100644 index 00000000..19881140 --- /dev/null +++ b/test/unit/api/polar/subscription.repository.test.ts @@ -0,0 +1,498 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { UserSubscriptionData, PlanType } from '@/app/api/_lib/polar/types'; + +// Mock de AmplifyServer +jest.mock('@/utils/server/AmplifyServer', () => ({ + cookiesClient: { + models: { + UserSubscription: { + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + listUserSubscriptionByUserId: jest.fn(), + }, + }, + }, +})); + +describe('SubscriptionRepository', () => { + let subscriptionRepository: SubscriptionRepository; + let cookiesClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + subscriptionRepository = new SubscriptionRepository(); + cookiesClient = require('@/utils/server/AmplifyServer').cookiesClient; + }); + + describe('findByUserId', () => { + it('debe encontrar suscripción por user ID exitosamente', async () => { + const mockSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, + }; + + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: mockSubscription, + }); + + const result = await subscriptionRepository.findByUserId('user_123'); + + expect(result).toEqual(mockSubscription); + expect(cookiesClient.models.UserSubscription.get).toHaveBeenCalledWith({ + id: 'user_123', + }); + }); + + it('debe retornar null si suscripción no existe', async () => { + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: null, + }); + + const result = await subscriptionRepository.findByUserId('user_inexistente'); + + expect(result).toBeNull(); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.get.mockRejectedValue(new Error('Database error')); + + const result = await subscriptionRepository.findByUserId('user_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error finding subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('create', () => { + it('debe crear suscripción exitosamente', async () => { + const subscriptionData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, + pendingPlan: null, + pendingStartDate: undefined, + }; + + cookiesClient.models.UserSubscription.create.mockResolvedValue({ + data: subscriptionData, + }); + + const result = await subscriptionRepository.create(subscriptionData); + + expect(result).toEqual(subscriptionData); + expect(cookiesClient.models.UserSubscription.create).toHaveBeenCalledWith({ + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + lastFourDigits: undefined, + pendingPlan: null, + }); + }); + + it('debe validar campos requeridos', async () => { + const invalidData = { + id: 'user_123', + userId: 'user_123', + // Sin subscriptionId ni planName + } as UserSubscriptionData; + + await expect(subscriptionRepository.create(invalidData)).rejects.toThrow( + 'Failed to create subscription: user_123' + ); + }); + + it('debe manejar errores de creación', async () => { + const subscriptionData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.create.mockRejectedValue(new Error('Create failed')); + + await expect(subscriptionRepository.create(subscriptionData)).rejects.toThrow( + 'Failed to create subscription: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error creating subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + + it('debe manejar respuesta sin data', async () => { + const subscriptionData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.create.mockResolvedValue({ + data: null, + }); + + await expect(subscriptionRepository.create(subscriptionData)).rejects.toThrow( + 'Failed to create subscription: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error creating subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('update', () => { + it('debe actualizar suscripción exitosamente', async () => { + const updateData = { + planName: PlanType.MAJESTIC, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 50, + }; + + const updatedSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.MAJESTIC, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 50, + }; + + cookiesClient.models.UserSubscription.update.mockResolvedValue({ + data: updatedSubscription, + }); + + const result = await subscriptionRepository.update('user_123', updateData); + + expect(result).toEqual(updatedSubscription); + expect(cookiesClient.models.UserSubscription.update).toHaveBeenCalledWith({ + id: 'user_123', + planName: PlanType.MAJESTIC, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 50, + }); + }); + + it('debe filtrar campos undefined', async () => { + const updateData = { + planName: PlanType.MAJESTIC, + nextPaymentDate: undefined, + planPrice: undefined, + }; + + cookiesClient.models.UserSubscription.update.mockResolvedValue({ + data: { id: 'user_123' }, + }); + + await subscriptionRepository.update('user_123', updateData); + + expect(cookiesClient.models.UserSubscription.update).toHaveBeenCalledWith({ + id: 'user_123', + planName: PlanType.MAJESTIC, + }); + }); + + it('debe manejar errores de actualización', async () => { + const updateData = { planName: PlanType.MAJESTIC }; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.update.mockRejectedValue(new Error('Update failed')); + + await expect(subscriptionRepository.update('user_123', updateData)).rejects.toThrow( + 'Failed to update subscription: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error updating subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('upsert', () => { + it('debe crear nueva suscripción si no existe', async () => { + const subscriptionData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + cookiesClient.models.UserSubscription.get.mockResolvedValue({ data: null }); + cookiesClient.models.UserSubscription.create.mockResolvedValue({ + data: subscriptionData, + }); + + const result = await subscriptionRepository.upsert(subscriptionData); + + expect(result).toEqual(subscriptionData); + expect(cookiesClient.models.UserSubscription.create).toHaveBeenCalled(); + expect(cookiesClient.models.UserSubscription.update).not.toHaveBeenCalled(); + }); + + it('debe actualizar suscripción existente', async () => { + const existingSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + const updatedData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.MAJESTIC, + }; + + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: existingSubscription, + }); + cookiesClient.models.UserSubscription.update.mockResolvedValue({ + data: updatedData, + }); + + const result = await subscriptionRepository.upsert(updatedData); + + expect(result).toEqual(updatedData); + expect(cookiesClient.models.UserSubscription.update).toHaveBeenCalledWith({ + id: 'user_123', + planName: PlanType.MAJESTIC, + subscriptionId: 'sub_123', + }); + expect(cookiesClient.models.UserSubscription.create).not.toHaveBeenCalled(); + }); + + it('debe manejar errores correctamente', async () => { + const subscriptionData: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: { id: 'user_123' }, + }); + cookiesClient.models.UserSubscription.update.mockRejectedValue(new Error('Database error')); + + await expect(subscriptionRepository.upsert(subscriptionData)).rejects.toThrow( + 'Failed to upsert subscription: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error upserting subscription for user user_123:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('delete', () => { + it('debe eliminar suscripción exitosamente', async () => { + cookiesClient.models.UserSubscription.delete.mockResolvedValue({}); + + await subscriptionRepository.delete('user_123'); + + expect(cookiesClient.models.UserSubscription.delete).toHaveBeenCalledWith({ + id: 'user_123', + }); + }); + + it('debe manejar errores de eliminación', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.delete.mockRejectedValue(new Error('Delete failed')); + + await expect(subscriptionRepository.delete('user_123')).rejects.toThrow( + 'Failed to delete subscription: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error deleting subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('updatePendingPlan', () => { + it('debe actualizar plan pendiente exitosamente', async () => { + const updatedSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + pendingPlan: PlanType.FREE, + }; + + cookiesClient.models.UserSubscription.update.mockResolvedValue({ + data: updatedSubscription, + }); + + await subscriptionRepository.updatePendingPlan('user_123', PlanType.FREE); + + expect(cookiesClient.models.UserSubscription.update).toHaveBeenCalledWith({ + id: 'user_123', + pendingPlan: PlanType.FREE, + }); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.update.mockRejectedValue(new Error('Update failed')); + + await expect(subscriptionRepository.updatePendingPlan('user_123', PlanType.FREE)).rejects.toThrow( + 'Failed to update pending plan: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error updating pending plan for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('hasActiveSubscription', () => { + it('debe retornar true para suscripción activa', async () => { + const activeSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }; + + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: activeSubscription, + }); + + const result = await subscriptionRepository.hasActiveSubscription('user_123'); + + expect(result).toBe(true); + }); + + it('debe retornar false para suscripción FREE', async () => { + const freeSubscription: UserSubscriptionData = { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.FREE, + }; + + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: freeSubscription, + }); + + const result = await subscriptionRepository.hasActiveSubscription('user_123'); + + expect(result).toBe(false); + }); + + it('debe retornar false si no hay suscripción', async () => { + cookiesClient.models.UserSubscription.get.mockResolvedValue({ + data: null, + }); + + const result = await subscriptionRepository.hasActiveSubscription('user_123'); + + expect(result).toBe(false); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.get.mockRejectedValue(new Error('Database error')); + + const result = await subscriptionRepository.hasActiveSubscription('user_123'); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error finding subscription for user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('findAllByUserId', () => { + it('debe encontrar todas las suscripciones del usuario', async () => { + const subscriptions: UserSubscriptionData[] = [ + { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_123', + planName: PlanType.IMPERIAL, + }, + { + id: 'user_123', + userId: 'user_123', + subscriptionId: 'sub_124', + planName: PlanType.MAJESTIC, + }, + ]; + + cookiesClient.models.UserSubscription.listUserSubscriptionByUserId.mockResolvedValue({ + data: subscriptions, + }); + + const result = await subscriptionRepository.findAllByUserId('user_123'); + + expect(result).toEqual(subscriptions); + expect(cookiesClient.models.UserSubscription.listUserSubscriptionByUserId).toHaveBeenCalledWith({ + userId: 'user_123', + }); + }); + + it('debe retornar array vacío si no hay suscripciones', async () => { + cookiesClient.models.UserSubscription.listUserSubscriptionByUserId.mockResolvedValue({ + data: [], + }); + + const result = await subscriptionRepository.findAllByUserId('user_123'); + + expect(result).toEqual([]); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserSubscription.listUserSubscriptionByUserId.mockRejectedValue(new Error('Database error')); + + const result = await subscriptionRepository.findAllByUserId('user_123'); + + expect(result).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error finding all subscriptions for user user_123:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/test/unit/api/polar/subscription.service.test.ts b/test/unit/api/polar/subscription.service.test.ts new file mode 100644 index 00000000..394d0d85 --- /dev/null +++ b/test/unit/api/polar/subscription.service.test.ts @@ -0,0 +1,404 @@ +import { SubscriptionService } from '@/app/api/_lib/polar/services/subscription.service'; +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; +import { PlanType } from '@/app/api/_lib/polar/types'; + +// Mock de los repositorios y servicios +jest.mock('@/app/api/_lib/polar/repositories/user.repository'); +jest.mock('@/app/api/_lib/polar/repositories/subscription.repository'); +jest.mock('@/app/api/_lib/polar/services/polar.service'); + +describe('SubscriptionService', () => { + let subscriptionService: SubscriptionService; + let mockUserRepository: jest.Mocked; + let mockSubscriptionRepository: jest.Mocked; + let mockPolarService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUserRepository = { + updateUserPlan: jest.fn(), + updateStoresStatus: jest.fn(), + getUserById: jest.fn(), + } as any; + + mockSubscriptionRepository = { + findByUserId: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + } as any; + + mockPolarService = { + getSubscription: jest.fn(), + isSubscriptionActive: jest.fn(), + isSubscriptionCanceled: jest.fn(), + isSubscriptionScheduledForCancellation: jest.fn(), + } as any; + + subscriptionService = new SubscriptionService(mockUserRepository, mockSubscriptionRepository, mockPolarService); + }); + + describe('activateSubscription', () => { + it('debe activar suscripción inicial correctamente', async () => { + const userId = 'user123'; + const productId = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; // Imperial UUID de desarrollo + const subscriptionId = 'sub_123'; + const polarSubscription = { + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + amount: 2500, // $25.00 en centavos + }; + + // Mock: no hay suscripción existente + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + + const result = await subscriptionService.activateSubscription( + userId, + productId, + subscriptionId, + polarSubscription + ); + + // Verificar que se actualiza el plan del usuario + expect(mockUserRepository.updateUserPlan).toHaveBeenCalledWith(userId, PlanType.IMPERIAL); + + // Verificar que se activan las tiendas + expect(mockUserRepository.updateStoresStatus).toHaveBeenCalledWith(userId, true); + + // Verificar que se crea/actualiza la suscripción + expect(mockSubscriptionRepository.upsert).toHaveBeenCalledWith({ + id: userId, + userId, + subscriptionId, + planName: PlanType.IMPERIAL, + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, // Convertido de centavos a dólares + pendingPlan: null, + pendingStartDate: null, + }); + + expect(result.success).toBe(true); + expect(result.plan).toBe(PlanType.IMPERIAL); + expect(result.message).toContain('activated'); + }); + + it('debe manejar renovación correctamente', async () => { + const userId = 'user123'; + const productId = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; // Imperial UUID de desarrollo + const subscriptionId = 'sub_123'; + const polarSubscription = { + currentPeriodEnd: '2025-12-16T20:21:54.151Z', + amount: 2500, + }; + + // Mock: hay suscripción existente con el mismo plan + const existingSubscription = { + id: userId, + userId, + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_old', + }; + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription as any); + + const result = await subscriptionService.activateSubscription( + userId, + productId, + subscriptionId, + polarSubscription + ); + + // Verificar que NO se actualiza el plan del usuario (es renovación) + expect(mockUserRepository.updateUserPlan).not.toHaveBeenCalled(); + + // Verificar que NO se activan las tiendas (es renovación) + expect(mockUserRepository.updateStoresStatus).not.toHaveBeenCalled(); + + // Verificar que solo se actualiza la suscripción existente + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(userId, { + nextPaymentDate: '2025-12-16T20:21:54.151Z', + planPrice: 25, + pendingPlan: null, + pendingStartDate: null, + }); + + expect(result.success).toBe(true); + expect(result.plan).toBe(PlanType.IMPERIAL); + expect(result.message).toContain('renewed'); + }); + + it('debe manejar activación sin datos de Polar', async () => { + const userId = 'user123'; + const productId = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; // Imperial UUID de desarrollo + const subscriptionId = 'sub_123'; + + // Mock: no hay suscripción existente + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + + const result = await subscriptionService.activateSubscription(userId, productId, subscriptionId); + + // Verificar que se actualiza el plan del usuario + expect(mockUserRepository.updateUserPlan).toHaveBeenCalledWith(userId, PlanType.IMPERIAL); + + // Verificar que se activan las tiendas + expect(mockUserRepository.updateStoresStatus).toHaveBeenCalledWith(userId, true); + + // Verificar que se crea la suscripción sin datos de Polar + expect(mockSubscriptionRepository.upsert).toHaveBeenCalledWith({ + id: userId, + userId, + subscriptionId, + planName: PlanType.IMPERIAL, + nextPaymentDate: undefined, + planPrice: undefined, + pendingPlan: null, + pendingStartDate: null, + }); + + expect(result.success).toBe(true); + }); + + it('debe manejar plan FREE correctamente', async () => { + const userId = 'user123'; + const productId = 'unknown-id'; // ID que no existe, debería mapear a FREE + + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + + const result = await subscriptionService.activateSubscription(userId, productId); + + expect(result.success).toBe(false); // Debe fallar porque es FREE + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('Invalid product ID'); + }); + }); + + describe('cancelSubscription', () => { + it('debe cancelar suscripción inmediatamente', async () => { + const userId = 'user123'; + const polarSubscription = { + cancelAtPeriodEnd: false, + status: 'canceled', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + }; + + // Mock: hay suscripción existente + const existingSubscription = { + id: userId, + userId, + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_old', + }; + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription as any); + + const result = await subscriptionService.cancelSubscription(userId, polarSubscription); + + // Verificar que se actualiza el plan a FREE + expect(mockUserRepository.updateUserPlan).toHaveBeenCalledWith(userId, PlanType.FREE); + + // Verificar que se desactivan las tiendas + expect(mockUserRepository.updateStoresStatus).toHaveBeenCalledWith(userId, false); + + // Verificar que se actualiza la suscripción + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(userId, { + planName: PlanType.FREE, + pendingPlan: null, + pendingStartDate: undefined, + nextPaymentDate: undefined, + planPrice: undefined, + }); + + expect(result.success).toBe(true); + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('canceled'); + }); + + it('debe programar cancelación al final del período', async () => { + const userId = 'user123'; + const polarSubscription = { + cancelAtPeriodEnd: true, + status: 'active', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + }; + + // Mock: hay suscripción existente + const existingSubscription = { + id: userId, + userId, + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_old', + }; + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription as any); + + const result = await subscriptionService.cancelSubscription(userId, polarSubscription); + + // Verificar que NO se actualiza el plan del usuario (se mantiene hasta el final del período) + expect(mockUserRepository.updateUserPlan).not.toHaveBeenCalled(); + + // Verificar que NO se desactivan las tiendas (se mantienen hasta el final del período) + expect(mockUserRepository.updateStoresStatus).not.toHaveBeenCalled(); + + // Verificar que se programa la cancelación + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(userId, { + pendingPlan: PlanType.FREE, + pendingStartDate: '2025-12-16T20:21:54.151Z', + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Subscription canceled successfully'); + }); + }); + + describe('processSubscriptionUpdate', () => { + it('debe procesar activación cuando está activa', async () => { + const subscriptionId = 'sub_123'; + const userId = 'user123'; + const polarSubscription = { + id: subscriptionId, + customerId: 'customer_123', + customerExternalId: userId, + status: 'active' as any, + cancelAtPeriodEnd: false, + productId: '21e675ee-db9d-4cd7-9902-0fead14a85f5', // Imperial UUID de desarrollo + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + amount: 2500, + }; + + mockPolarService.getSubscription.mockResolvedValue(polarSubscription); + mockPolarService.isSubscriptionActive.mockReturnValue(true); + mockPolarService.isSubscriptionCanceled.mockReturnValue(false); + mockPolarService.isSubscriptionScheduledForCancellation.mockReturnValue(false); + mockSubscriptionRepository.findByUserId.mockResolvedValue(null); + + const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + + expect(result.success).toBe(true); + expect(result.plan).toBe(PlanType.IMPERIAL); + expect(result.message).toContain('activated'); + }); + + it('debe procesar cancelación inmediata', async () => { + const subscriptionId = 'sub_123'; + const userId = 'user123'; + const polarSubscription = { + id: subscriptionId, + customerId: 'customer_123', + customerExternalId: userId, + status: 'canceled' as any, + cancelAtPeriodEnd: false, + productId: '21e675ee-db9d-4cd7-9902-0fead14a85f5', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + amount: 2500, + }; + + mockPolarService.getSubscription.mockResolvedValue(polarSubscription); + mockPolarService.isSubscriptionActive.mockReturnValue(false); + mockPolarService.isSubscriptionCanceled.mockReturnValue(true); + mockPolarService.isSubscriptionScheduledForCancellation.mockReturnValue(false); + + const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + + expect(result.success).toBe(true); + expect(result.plan).toBe(PlanType.FREE); + expect(result.message).toContain('canceled'); + }); + + it('debe procesar cancelación programada', async () => { + const subscriptionId = 'sub_123'; + const userId = 'user123'; + const polarSubscription = { + id: subscriptionId, + customerId: 'customer_123', + customerExternalId: userId, + status: 'active' as any, + cancelAtPeriodEnd: true, + productId: '21e675ee-db9d-4cd7-9902-0fead14a85f5', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + amount: 2500, + }; + + mockPolarService.getSubscription.mockResolvedValue(polarSubscription); + mockPolarService.isSubscriptionActive.mockReturnValue(true); + mockPolarService.isSubscriptionCanceled.mockReturnValue(false); + mockPolarService.isSubscriptionScheduledForCancellation.mockReturnValue(true); + + const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + + expect(result.success).toBe(true); + expect(result.message).toBe('Subscription canceled successfully'); + }); + + it('debe priorizar cancelación sobre activación', async () => { + const subscriptionId = 'sub_123'; + const userId = 'user123'; + const polarSubscription = { + id: subscriptionId, + customerId: 'customer_123', + customerExternalId: userId, + status: 'active' as any, + cancelAtPeriodEnd: true, // Programada para cancelación + productId: '21e675ee-db9d-4cd7-9902-0fead14a85f5', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + amount: 2500, + }; + + mockPolarService.getSubscription.mockResolvedValue(polarSubscription); + mockPolarService.isSubscriptionActive.mockReturnValue(true); + mockPolarService.isSubscriptionCanceled.mockReturnValue(false); + mockPolarService.isSubscriptionScheduledForCancellation.mockReturnValue(true); + + const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + + // Debe cancelar, no activar + expect(result.message).toBe('Subscription canceled successfully'); + expect(result.message).not.toContain('activated'); + }); + }); + + describe('mapProductIdToPlan', () => { + it('debe mapear product IDs correctamente', () => { + // UUIDs de desarrollo + expect(subscriptionService['mapProductIdToPlan']('d889915d-bb1a-4c54-badd-de697857e624')).toBe(PlanType.ROYAL); + expect(subscriptionService['mapProductIdToPlan']('21e675ee-db9d-4cd7-9902-0fead14a85f5')).toBe(PlanType.IMPERIAL); + expect(subscriptionService['mapProductIdToPlan']('442aacda-1fa3-47cd-8fba-6ad028285218')).toBe(PlanType.MAJESTIC); + expect(subscriptionService['mapProductIdToPlan']('unknown-id')).toBe(PlanType.FREE); + }); + }); + + describe('Manejo de errores', () => { + it('debe manejar errores en activación', async () => { + const userId = 'user123'; + const productId = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; // Imperial UUID de desarrollo + + mockUserRepository.updateUserPlan.mockRejectedValue(new Error('Cognito error')); + + const result = await subscriptionService.activateSubscription(userId, productId); + + expect(result.success).toBe(false); + expect(result.message).toContain('Error activating subscription'); + }); + + it('debe manejar errores en cancelación', async () => { + const userId = 'user123'; + const polarSubscription = { + cancelAtPeriodEnd: false, + status: 'canceled', + currentPeriodEnd: new Date('2025-12-16T20:21:54.151Z'), + }; + + // Mock: hay suscripción existente + const existingSubscription = { + id: userId, + userId, + planName: PlanType.IMPERIAL, + subscriptionId: 'sub_old', + }; + mockSubscriptionRepository.findByUserId.mockResolvedValue(existingSubscription as any); + mockUserRepository.updateUserPlan.mockRejectedValue(new Error('Cognito error')); + + const result = await subscriptionService.cancelSubscription(userId, polarSubscription); + + expect(result.success).toBe(false); + expect(result.message).toContain('Error canceling subscription'); + }); + }); +}); diff --git a/test/unit/api/polar/user.repository.test.ts b/test/unit/api/polar/user.repository.test.ts new file mode 100644 index 00000000..28f1afcc --- /dev/null +++ b/test/unit/api/polar/user.repository.test.ts @@ -0,0 +1,299 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { PlanType } from '@/app/api/_lib/polar/types'; +import { AttributeType } from '@aws-sdk/client-cognito-identity-provider'; + +// Mock de AWS SDK +const mockCognitoClient = { + send: jest.fn(), +}; + +jest.mock('@aws-sdk/client-cognito-identity-provider', () => ({ + CognitoIdentityProviderClient: jest.fn().mockImplementation(() => mockCognitoClient), + AdminGetUserCommand: jest.fn().mockImplementation((params) => ({ input: params })), + AdminUpdateUserAttributesCommand: jest.fn().mockImplementation((params) => ({ input: params })), +})); + +// Mock de AmplifyServer +jest.mock('@/utils/server/AmplifyServer', () => ({ + cookiesClient: { + models: { + UserStore: { + listUserStoreByUserId: jest.fn(), + update: jest.fn(), + }, + }, + }, +})); + +describe('UserRepository', () => { + let userRepository: UserRepository; + const userPoolId = 'us-east-1_test123'; + let cookiesClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + userRepository = new UserRepository(userPoolId); + cookiesClient = require('@/utils/server/AmplifyServer').cookiesClient; + }); + + describe('Constructor', () => { + it('debe inicializar con user pool ID correcto', () => { + expect(userRepository).toBeInstanceOf(UserRepository); + }); + }); + + describe('getUserById', () => { + it('debe obtener usuario exitosamente', async () => { + const mockUserAttributes: AttributeType[] = [ + { Name: 'custom:plan', Value: 'Imperial' }, + { Name: 'email', Value: 'test@example.com' }, + ]; + + mockCognitoClient.send.mockResolvedValue({ + UserAttributes: mockUserAttributes, + }); + + const result = await userRepository.getUserById('user_123'); + + expect(result).toEqual(mockUserAttributes); + expect(mockCognitoClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + UserPoolId: userPoolId, + Username: 'user_123', + }, + }) + ); + }); + + it('debe retornar null si usuario no existe', async () => { + mockCognitoClient.send.mockResolvedValue({ + UserAttributes: null, + }); + + const result = await userRepository.getUserById('user_inexistente'); + + expect(result).toBeNull(); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockCognitoClient.send.mockRejectedValue(new Error('User not found')); + + const result = await userRepository.getUserById('user_123'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching user user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('updateUserPlan', () => { + it('debe actualizar plan del usuario exitosamente', async () => { + mockCognitoClient.send.mockResolvedValue({}); + + await userRepository.updateUserPlan('user_123', PlanType.IMPERIAL); + + expect(mockCognitoClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + UserPoolId: userPoolId, + Username: 'user_123', + UserAttributes: [ + { + Name: 'custom:plan', + Value: PlanType.IMPERIAL, + }, + ], + }, + }) + ); + }); + + it('debe manejar errores correctamente', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockCognitoClient.send.mockRejectedValue(new Error('Update failed')); + + await expect(userRepository.updateUserPlan('user_123', PlanType.IMPERIAL)).rejects.toThrow( + 'Failed to update user plan: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error updating user plan for user_123:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('updateStoresStatus', () => { + it('debe actualizar estado de tiendas exitosamente', async () => { + const mockUserStores = [ + { storeId: 'store_1', userId: 'user_123' }, + { storeId: 'store_2', userId: 'user_123' }, + ]; + + cookiesClient.models.UserStore.listUserStoreByUserId.mockResolvedValue({ + data: mockUserStores, + }); + cookiesClient.models.UserStore.update.mockResolvedValue({}); + + await userRepository.updateStoresStatus('user_123', true); + + expect(cookiesClient.models.UserStore.listUserStoreByUserId).toHaveBeenCalledWith({ + userId: 'user_123', + }); + expect(cookiesClient.models.UserStore.update).toHaveBeenCalledTimes(2); + expect(cookiesClient.models.UserStore.update).toHaveBeenCalledWith({ + storeId: 'store_1', + storeStatus: true, + }); + expect(cookiesClient.models.UserStore.update).toHaveBeenCalledWith({ + storeId: 'store_2', + storeStatus: true, + }); + }); + + it('debe manejar tiendas vacías', async () => { + cookiesClient.models.UserStore.listUserStoreByUserId.mockResolvedValue({ + data: [], + }); + + await userRepository.updateStoresStatus('user_123', false); + + expect(cookiesClient.models.UserStore.listUserStoreByUserId).toHaveBeenCalledWith({ + userId: 'user_123', + }); + expect(cookiesClient.models.UserStore.update).not.toHaveBeenCalled(); + }); + + it('debe continuar si falla la actualización de una tienda individual', async () => { + const mockUserStores = [ + { storeId: 'store_1', userId: 'user_123' }, + { storeId: 'store_2', userId: 'user_123' }, + ]; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + cookiesClient.models.UserStore.listUserStoreByUserId.mockResolvedValue({ + data: mockUserStores, + }); + cookiesClient.models.UserStore.update + .mockResolvedValueOnce({}) // store_1 exitosa + .mockRejectedValueOnce(new Error('Store update failed')); // store_2 falla + + await userRepository.updateStoresStatus('user_123', true); + + expect(cookiesClient.models.UserStore.update).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error updating store store_2 status:', expect.any(Error)); + + consoleErrorSpy.mockRestore(); + }); + + it('debe manejar errores en listUserStoreByUserId', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + cookiesClient.models.UserStore.listUserStoreByUserId.mockRejectedValue(new Error('Database error')); + + await expect(userRepository.updateStoresStatus('user_123', true)).rejects.toThrow( + 'Failed to update stores status: user_123' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error updating stores status for user user_123:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getCurrentPlanFromAttributes', () => { + it('debe obtener plan desde atributos correctamente', () => { + const userAttributes: AttributeType[] = [ + { Name: 'custom:plan', Value: 'Imperial' }, + { Name: 'email', Value: 'test@example.com' }, + ]; + + const result = userRepository.getCurrentPlanFromAttributes(userAttributes); + + expect(result).toBe(PlanType.IMPERIAL); + }); + + it('debe retornar FREE si no encuentra atributo de plan', () => { + const userAttributes: AttributeType[] = [{ Name: 'email', Value: 'test@example.com' }]; + + const result = userRepository.getCurrentPlanFromAttributes(userAttributes); + + expect(result).toBe(PlanType.FREE); + }); + + it('debe retornar FREE si atributos están vacíos', () => { + const result = userRepository.getCurrentPlanFromAttributes([]); + + expect(result).toBe(PlanType.FREE); + }); + + it('debe retornar FREE si atributos son undefined', () => { + const result = userRepository.getCurrentPlanFromAttributes(); + + expect(result).toBe(PlanType.FREE); + }); + + it('debe retornar FREE si plan no es válido', () => { + const userAttributes: AttributeType[] = [{ Name: 'custom:plan', Value: 'InvalidPlan' }]; + + const result = userRepository.getCurrentPlanFromAttributes(userAttributes); + + expect(result).toBe(PlanType.FREE); + }); + + it('debe manejar todos los tipos de plan válidos', () => { + const testCases = [ + { plan: PlanType.FREE, expected: PlanType.FREE }, + { plan: PlanType.IMPERIAL, expected: PlanType.IMPERIAL }, + { plan: PlanType.MAJESTIC, expected: PlanType.MAJESTIC }, + { plan: PlanType.ROYAL, expected: PlanType.ROYAL }, + ]; + + testCases.forEach(({ plan, expected }) => { + const userAttributes: AttributeType[] = [{ Name: 'custom:plan', Value: plan }]; + + const result = userRepository.getCurrentPlanFromAttributes(userAttributes); + + expect(result).toBe(expected); + }); + }); + }); + + describe('isPaidPlan', () => { + it('debe retornar false para plan FREE', () => { + const result = userRepository.isPaidPlan(PlanType.FREE); + + expect(result).toBe(false); + }); + + it('debe retornar true para planes pagados', () => { + const paidPlans = [PlanType.IMPERIAL, PlanType.MAJESTIC, PlanType.ROYAL]; + + paidPlans.forEach((plan) => { + const result = userRepository.isPaidPlan(plan); + expect(result).toBe(true); + }); + }); + }); +}); From eb9e8315bcf9430045ae265d96b6f3a487d3c90f Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 16:38:46 -0500 Subject: [PATCH 2/2] Refactor environment variable handling in PolarService tests This commit updates the PolarService unit tests to use type casting for the `process.env` object when setting environment variables. This change ensures compatibility with TypeScript and improves the clarity of the test setup, maintaining the integrity of the testing environment for both development and production scenarios. --- test/unit/api/polar/polar.service.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/api/polar/polar.service.test.ts b/test/unit/api/polar/polar.service.test.ts index 2f69e941..ee4e9e35 100644 --- a/test/unit/api/polar/polar.service.test.ts +++ b/test/unit/api/polar/polar.service.test.ts @@ -36,22 +36,22 @@ describe('PolarService', () => { it('debe usar sandbox en desarrollo', () => { const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; + (process.env as any).NODE_ENV = 'development'; const service = new PolarService('test-token'); expect(service).toBeInstanceOf(PolarService); - process.env.NODE_ENV = originalEnv; + (process.env as any).NODE_ENV = originalEnv; }); it('debe usar production en producción', () => { const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; + (process.env as any).NODE_ENV = 'production'; const service = new PolarService('test-token'); expect(service).toBeInstanceOf(PolarService); - process.env.NODE_ENV = originalEnv; + (process.env as any).NODE_ENV = originalEnv; }); });