Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions amplify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/api/_lib/polar/repositories/subscription.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export class SubscriptionRepository {
*/
async create(data: UserSubscriptionData): Promise<UserSubscriptionData> {
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,
Expand Down Expand Up @@ -71,9 +76,20 @@ export class SubscriptionRepository {
*/
async update(userId: string, data: Partial<UserSubscriptionData>): Promise<UserSubscriptionData> {
try {
// Filtrar campos undefined/null para evitar errores en DynamoDB
const updateFields: Record<string, any> = {};

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);
Expand Down
251 changes: 251 additions & 0 deletions app/api/_lib/polar/services/polar-webhook-processor.service.ts
Original file line number Diff line number Diff line change
@@ -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<SubscriptionProcessResult> {
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<SubscriptionProcessResult> {
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<SubscriptionProcessResult> {
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<SubscriptionProcessResult> {
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<SubscriptionProcessResult> {
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,
};
}
}
21 changes: 17 additions & 4 deletions app/api/_lib/polar/services/polar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading