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..ee4e9e35 --- /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 as any).NODE_ENV = 'development'; + + const service = new PolarService('test-token'); + expect(service).toBeInstanceOf(PolarService); + + (process.env as any).NODE_ENV = originalEnv; + }); + + it('debe usar production en producción', () => { + const originalEnv = process.env.NODE_ENV; + (process.env as any).NODE_ENV = 'production'; + + const service = new PolarService('test-token'); + expect(service).toBeInstanceOf(PolarService); + + (process.env as any).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); + }); + }); + }); +});