diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 80a651d2..6113bb8d 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -82,6 +82,7 @@ const schema = a lastFourDigits: a.integer(), }) .identifier(['id']) + .secondaryIndexes(index => [index('userId')]) .authorization(allow => [ allow.ownerDefinedIn('userId').to(['read', 'update', 'delete']), allow.authenticated().to(['create']), @@ -114,7 +115,8 @@ const schema = a onboardingCompleted: a.boolean().required(), onboardingData: a.json(), }) - .secondaryIndexes(index => [index('userId')]) + .identifier(['storeId']) + .secondaryIndexes(index => [index('userId'), index('customDomain'), index('storeName')]) .authorization(allow => [allow.authenticated().to(['read', 'update', 'delete', 'create'])]), Product: a diff --git a/amplify/functions/LambdaEncryptKeys/handler.ts b/amplify/functions/LambdaEncryptKeys/handler.ts index 9b0c61de..75cb311c 100644 --- a/amplify/functions/LambdaEncryptKeys/handler.ts +++ b/amplify/functions/LambdaEncryptKeys/handler.ts @@ -105,11 +105,11 @@ export const handler = async (event: any) => { } // Si hay storeId, cifrar y guardar en la tienda - const { data: stores } = await client.models.UserStore.list({ - filter: { storeId: { eq: storeId } }, + const { data: stores } = await client.models.UserStore.get({ + storeId: storeId, }) - if (!stores || stores.length === 0) { + if (!stores) { return { statusCode: 404, body: JSON.stringify({ success: false, message: 'Tienda no encontrada' }), @@ -120,7 +120,7 @@ export const handler = async (event: any) => { } } - const store = stores[0] + const store = stores // Cifrar la API Key const encryptedKey = encrypt(apiKey) @@ -205,7 +205,7 @@ export const handler = async (event: any) => { // Actualizar la tienda await client.models.UserStore.update({ - id: store.id, + storeId: store.storeId, ...updateData, }) diff --git a/amplify/functions/checkStoreDomain/handler.ts b/amplify/functions/checkStoreDomain/handler.ts index 37ac39dc..28415fbc 100644 --- a/amplify/functions/checkStoreDomain/handler.ts +++ b/amplify/functions/checkStoreDomain/handler.ts @@ -24,10 +24,8 @@ export const handler = async (event: any) => { } try { - const { data: stores } = await clientSchema.models.UserStore.list({ - filter: { - customDomain: { eq: domainName }, - }, + const { data: stores } = await clientSchema.models.UserStore.listUserStoreByCustomDomain({ + customDomain: domainName, }) return { diff --git a/amplify/functions/checkStoreName/handler.ts b/amplify/functions/checkStoreName/handler.ts index 5bb13945..3ef0a3fe 100644 --- a/amplify/functions/checkStoreName/handler.ts +++ b/amplify/functions/checkStoreName/handler.ts @@ -26,10 +26,8 @@ export const handler = async (event: any) => { } try { - const { data: stores } = await clientSchema.models.UserStore.list({ - filter: { - storeName: { eq: storeName }, - }, + const { data: stores } = await clientSchema.models.UserStore.listUserStoreByStoreName({ + storeName: storeName, }) return { diff --git a/amplify/functions/getStoreCollections/handler.ts b/amplify/functions/getStoreCollections/handler.ts index d2a74e77..1de2fd83 100644 --- a/amplify/functions/getStoreCollections/handler.ts +++ b/amplify/functions/getStoreCollections/handler.ts @@ -4,7 +4,6 @@ import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtim import { env } from '$amplify/env/getStoreCollections' import { type Schema } from '../../data/resource' -// Lazy-load del cliente para evitar reconfiguración en cada invocación let clientSchema: ReturnType> | null = null const initializeClient = async () => { @@ -53,12 +52,16 @@ export const handler = async (event: any) => { } // Obtener productos asociados a esta colección - const { data: products } = await client.models.Product.list({ - filter: { - collectionId: { eq: collectionId }, - status: { eq: 'active' }, + const { data: products } = await client.models.Product.listProductByCollectionId( + { + collectionId: collectionId, }, - }) + { + filter: { + status: { eq: 'active' }, + }, + } + ) return { statusCode: 200, diff --git a/amplify/functions/getStoreData/handler.ts b/amplify/functions/getStoreData/handler.ts index dab78003..4c8a1978 100644 --- a/amplify/functions/getStoreData/handler.ts +++ b/amplify/functions/getStoreData/handler.ts @@ -4,7 +4,6 @@ import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtim import { env } from '$amplify/env/getStoreData' import { type Schema } from '../../data/resource' -// Lazy-load del cliente para evitar reconfiguración en cada invocación let clientSchema: ReturnType> | null = null const initializeClient = async () => { @@ -16,9 +15,9 @@ const initializeClient = async () => { return clientSchema } export const handler = async (event: any) => { - const storeId = event.queryStringParameters?.storeId + const storeName = event.queryStringParameters?.storeName - if (!storeId) { + if (!storeName) { return { statusCode: 400, body: JSON.stringify({ message: 'Store ID is required' }), @@ -31,12 +30,8 @@ export const handler = async (event: any) => { try { const client = await initializeClient() - const { data: store } = await client.models.UserStore.list({ - filter: { - storeId: { - eq: storeId, - }, - }, + const { data: store } = await client.models.UserStore.listUserStoreByStoreName({ + storeName: storeName, }) if (!store) { diff --git a/amplify/functions/getStoreProducts/handler.ts b/amplify/functions/getStoreProducts/handler.ts index 6f9f5670..bbf50bdb 100644 --- a/amplify/functions/getStoreProducts/handler.ts +++ b/amplify/functions/getStoreProducts/handler.ts @@ -31,12 +31,16 @@ export const handler = async (event: any) => { try { const client = await initializeClient() - const { data: products } = await client.models.Product.list({ - filter: { - storeId: { eq: storeId }, - status: { eq: 'active' }, // Opcional: filtrar solo productos activos + const { data: products } = await client.models.Product.listProductByStoreId( + { + storeId: storeId, }, - }) + { + filter: { + status: { eq: 'active' }, // Opcional: filtrar solo productos activos + }, + } + ) return { statusCode: 200, diff --git a/amplify/functions/webHookPlan/handler.ts b/amplify/functions/webHookPlan/handler.ts index 964a4f7a..05833547 100644 --- a/amplify/functions/webHookPlan/handler.ts +++ b/amplify/functions/webHookPlan/handler.ts @@ -1,294 +1,128 @@ import { APIGatewayProxyHandler } from 'aws-lambda' -import { - CognitoIdentityProviderClient, - AdminUpdateUserAttributesCommand, - AdminGetUserCommand, -} from '@aws-sdk/client-cognito-identity-provider' -import { createHmac } from 'crypto' -import axios from 'axios' -import { Amplify } from 'aws-amplify' -import { generateClient } from 'aws-amplify/data' -import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' import { env } from '$amplify/env/hookPlan' -import { type Schema } from '../../data/resource' +import { WebhookBody, LambdaHandler, WebhookResponse } from './types' +import { MercadoPagoWebhookValidator } from './services/webhook-validator' +import { MercadoPagoApiService } from './services/mercadopago-api' +import { CognitoUserService } from './services/user-service' +import { MercadoPagoPaymentProcessor } from './services/payment-processor' + +/** + * Enhanced MercadoPago Webhook Handler with TypeScript support + * Handles subscription updates and payment notifications + * + * Follows MercadoPago best practices: + * - Webhook signature validation + * - Proper error handling + * - Modular service architecture + */ +export const handler: LambdaHandler = async event => { + // Initialize services + const webhookValidator = new MercadoPagoWebhookValidator(env.MERCADO_PAGO_WEBHOOK_SECRET) + const mpApiService = new MercadoPagoApiService(env.MERCADOPAGO_ACCESS_TOKEN) + const userService = new CognitoUserService(env.USER_POOL_ID) + const paymentProcessor = new MercadoPagoPaymentProcessor(mpApiService, userService) -const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) -Amplify.configure(resourceConfig, libraryOptions) - -const client = new CognitoIdentityProviderClient() -const clientSchema = generateClient() - -const MP_AUTH_PAYMENTS_SEARCH_URL = 'https://api.mercadopago.com/v1/payments/' -const MP_AUTHORIZED_PAYMENTS_URL = 'https://api.mercadopago.com/authorized_payments/' - -export const handler: APIGatewayProxyHandler = async event => { try { - // 1. Validar la firma del webhook - const body = JSON.parse(event.body || '{}') - const signature = event.headers['x-signature'] || event.headers['X-Signature'] - - if (!signature) throw new Error('Firma no proporcionada en el webhook.') - - const match = signature.match(/ts=([^,]+),v1=([^,]+)/) - if (!match) throw new Error('Formato de firma no válido.') - const [, ts, v1] = match + console.log('🚀 Starting webhook processing') + // 1. Parse and validate request + const body: WebhookBody = JSON.parse(event.body || '{}') + const signature = event.headers['x-signature'] || event.headers['X-Signature'] const dataId = event.queryStringParameters?.['data.id'] const requestId = event.headers['x-request-id'] || event.headers['X-Request-Id'] - if (!dataId || !requestId) { - throw new Error('Faltan parámetros requeridos en la notificación.') + if (!signature || !dataId || !requestId) { + console.error('❌ Missing required webhook parameters') + return createResponse(400, { error: 'Missing required parameters' }) } - const signatureTemplate = `id:${dataId};request-id:${requestId};ts:${ts};` - const expectedSignature = createHmac('sha256', env.MERCADO_PAGO_WEBHOOK_SECRET) - .update(signatureTemplate) - .digest('hex') + // 2. Validate webhook signature + const timestamp = webhookValidator.extractTimestamp(signature) || '' + const isValidSignature = webhookValidator.validateSignature( + signature, + dataId, + requestId, + timestamp + ) - if (v1 !== expectedSignature) throw new Error('Firma del webhook no válida.') - console.log('✅ Firma validada correctamente') + if (!isValidSignature) { + console.error('❌ Invalid webhook signature') + return createResponse(401, { error: 'Invalid signature' }) + } - // 2. Determinar tipo de evento + // 3. Determine event type and validate if supported const eventType = body.type const eventAction = body.action - console.log('🔍 Tipo de evento recibido:', eventType, eventAction) - - // Manejar eventos de cancelación de suscripción - if (eventType === 'subscription_preapproval' && eventAction === 'updated') { - console.log('🛑 Procesando actualización de suscripción') - - const subscriptionId = body.data.id - - // Obtener detalles de la suscripción - const subscriptionResponse = await axios.get( - `https://api.mercadopago.com/preapproval/${subscriptionId}`, - { headers: { Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}` } } - ) - - const subscriptionData = subscriptionResponse.data - // Verificar si es una cancelación - if (subscriptionData.status === 'cancelled') { - const userId = subscriptionData.external_reference - console.log(`⚠️ Detectada cancelación para usuario: ${userId}`) + console.log(`🔍 Processing event: ${eventType}.${eventAction}`) - // Obtener usuario de Cognito - const cognitoUser = await client.send( - new AdminGetUserCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - }) - ) - - // Verificar plan actual - const currentPlan = - cognitoUser.UserAttributes?.find(attr => attr.Name === 'custom:plan')?.Value || 'free' - - if (currentPlan === 'free') { - console.log('🔍 Usuario ya tiene plan free, no se realizan cambios') - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } - - // Calcular tiempo restante de suscripción - const nextPaymentDate = new Date(subscriptionData.next_payment_date) - const now = new Date() - const timeLeft = nextPaymentDate.getTime() - now.getTime() - - // Obtener suscripción existente - const existingSubscription = await clientSchema.models.UserSubscription.get({ - id: userId, - }).catch(() => ({ data: null })) - - // Preparar datos de actualización - const updateData: any = { - id: userId, - subscriptionId: subscriptionId, - pendingPlan: null, - pendingStartDate: null, - lastFourDigts: null, - planPrice: null, - nextPaymentDate: subscriptionData.next_payment_date - ? new Date(subscriptionData.next_payment_date).toISOString() - : null, - } - - if (timeLeft > 0) { - console.log(`⏳ Usuario mantiene acceso hasta ${nextPaymentDate}`) - updateData.pendingPlan = 'free' - updateData.pendingStartDate = nextPaymentDate.toISOString() - updateData.planName = currentPlan - } else { - console.log('🔒 Acceso revocado inmediatamente') - updateData.planName = 'free' - updateData.nextPaymentDate = null - ;(updateData.planPrice = null), - (updateData.pendingStartDate = null), - (updateData.lastFourDigits = null), - (updateData.pendingPlan = null), - // Actualizar Cognito inmediatamente - await client.send( - new AdminUpdateUserAttributesCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - UserAttributes: [{ Name: 'custom:plan', Value: 'free' }], - }) - ) - } - - // Actualizar DynamoDB - if (existingSubscription.data) { - await clientSchema.models.UserSubscription.update(updateData) - } else { - await clientSchema.models.UserSubscription.create({ - ...updateData, - userId: userId, - }) - } - - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } + if (!paymentProcessor.shouldProcessEvent(eventType, eventAction)) { + console.log('ℹ️ Event type not supported, returning OK') + return createResponse(200, { message: 'Event type not supported but acknowledged' }) } - // 3. Procesar diferentes tipos de pagos - let paymentId, paymentUrl - if (eventType === 'subscription_authorized_payment') { - paymentId = body.data?.id - paymentUrl = `${MP_AUTHORIZED_PAYMENTS_URL}${paymentId}` - console.log('🔍 Procesando pago recurrente autorizado') - } else if (eventType === 'payment') { - paymentId = body.data?.id - paymentUrl = `${MP_AUTH_PAYMENTS_SEARCH_URL}${paymentId}` - console.log('🔍 Procesando pago estándar') - } else { - console.warn('⚠️ Tipo de evento no soportado:', eventType) - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } + // 4. Route to appropriate processor + await routeEvent(eventType, eventAction, body.data.id, paymentProcessor) - // 4. Consultar detalles del pago - const paymentResponse = await axios.get(paymentUrl, { - headers: { - Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }) + console.log('✅ Webhook processed successfully') + return createResponse(200, { message: 'Webhook processed successfully' }) + } catch (error) { + console.error('🔥 Webhook processing error:', error) - const paymentData = paymentResponse.data - console.log('💡 Datos del pago:', JSON.stringify(paymentData, null, 2)) + // Return 500 to trigger MercadoPago retry mechanism + return createResponse(500, { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }) + } +} - // 5. Validar estado del pago - if (!(paymentData.status === 'approved' && paymentData.status_detail === 'accredited')) { - console.warn('⚠️ Pago no completado exitosamente') - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), +/** + * Routes events to the appropriate processor based on type + */ +async function routeEvent( + eventType: string, + eventAction: string, + dataId: string, + processor: MercadoPagoPaymentProcessor +): Promise { + switch (eventType) { + case 'subscription_preapproval': + if (eventAction === 'updated') { + await processor.processSubscriptionUpdate(dataId) } - } - - // 6. Obtener información de la suscripción - const subscriptionId = paymentData.metadata?.preapproval_id || paymentData.external_reference - const subscriptionResponse = await axios.get( - `https://api.mercadopago.com/preapproval/${subscriptionId}`, - { headers: { Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}` } } - ) - - const subscriptionData = subscriptionResponse.data - const userId = subscriptionData.external_reference - const newPlanName = subscriptionData.reason - const newAmountFromMP = subscriptionData.auto_recurring.transaction_amount - const nextPaymentDate = subscriptionData.next_payment_date - - // 7. Obtener usuario de Cognito - const cognitoUser = await client.send( - new AdminGetUserCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - }) - ) - - const currentPlan = - cognitoUser.UserAttributes?.find(attr => attr.Name === 'custom:plan')?.Value || 'free' - - // 8. Lógica de actualización de plan - const existingSubscription = await clientSchema.models.UserSubscription.get({ - id: userId, - }).catch(() => ({ data: null })) - - const currentPlanPrice = existingSubscription.data?.planPrice || 0 - const isUpgrade = currentPlan === 'free' || newAmountFromMP > currentPlanPrice - - // Determinar fechas clave - const nextPaymentDateISO = new Date(nextPaymentDate).toISOString() - const now = new Date() - const existingPaymentDate = existingSubscription.data?.nextPaymentDate - ? new Date(existingSubscription.data.nextPaymentDate) - : null + break - // Configurar datos para DynamoDB - const updateData = { - id: userId, - subscriptionId: subscriptionId, - planPrice: newAmountFromMP, - nextPaymentDate: nextPaymentDateISO, - lastFourDigits: paymentData.card.last_four_digits, + case 'subscription_authorized_payment': + case 'payment': + await processor.processPayment(dataId, eventType) + break - planName: isUpgrade ? newPlanName : existingSubscription.data?.planName || currentPlan, - pendingPlan: - !isUpgrade && existingPaymentDate && existingPaymentDate > now ? newPlanName : null, - pendingStartDate: - !isUpgrade && existingPaymentDate && existingPaymentDate > now - ? existingPaymentDate.toISOString() - : null, - } - - // Actualizar DynamoDB - if (existingSubscription.data) { - await clientSchema.models.UserSubscription.update(updateData) - } else { - await clientSchema.models.UserSubscription.create({ - ...updateData, - userId: userId, - }) - } - - // Actualizar Cognito SOLO si es upgrade o no hay tiempo restante - if (newPlanName !== currentPlan) { - const shouldUpdateCognito = isUpgrade || !existingPaymentDate || existingPaymentDate <= now + default: + console.warn(`⚠️ Unhandled event type: ${eventType}.${eventAction}`) + } +} - if (shouldUpdateCognito) { - await client.send( - new AdminUpdateUserAttributesCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - UserAttributes: [{ Name: 'custom:plan', Value: newPlanName }], - }) - ) - console.log(`✅ Plan actualizado en Cognito a ${newPlanName}`) - } else { - console.log(`⏳ Cambio a ${newPlanName} programado para ${existingPaymentDate}`) - } - } +/** + * Creates standardized API Gateway response + */ +function createResponse(statusCode: number, body: any): WebhookResponse { + return { + statusCode, + body: JSON.stringify(body), + } +} - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } catch (error: any) { - console.error('❌ Error en la función Lambda:', error) - return { - statusCode: 500, - body: JSON.stringify({ - error: 'Error procesando el webhook', - details: error instanceof Error ? error.message : 'Unknown error', - }), - } +/** + * Health check endpoint for webhook monitoring + */ +export const healthCheck: APIGatewayProxyHandler = async () => { + return { + statusCode: 200, + body: JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'mercadopago-webhook', + }), } } diff --git a/amplify/functions/webHookPlan/services/mercadopago-api.ts b/amplify/functions/webHookPlan/services/mercadopago-api.ts new file mode 100644 index 00000000..a9967182 --- /dev/null +++ b/amplify/functions/webHookPlan/services/mercadopago-api.ts @@ -0,0 +1,110 @@ +import axios, { AxiosResponse } from 'axios' +import { SubscriptionData, PaymentData } from '../types' + +export class MercadoPagoApiService { + private readonly baseUrl = 'https://api.mercadopago.com' + private readonly paymentsSearchUrl = 'https://api.mercadopago.com/v1/payments/' + private readonly authorizedPaymentsUrl = 'https://api.mercadopago.com/authorized_payments/' + + constructor(private readonly accessToken: string) {} + + /** + * Gets subscription details from MercadoPago + */ + async getSubscription(subscriptionId: string): Promise { + try { + console.log(`🔍 Fetching subscription details for ID: ${subscriptionId}`) + + const response: AxiosResponse = await axios.get( + `${this.baseUrl}/preapproval/${subscriptionId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Subscription data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching subscription ${subscriptionId}:`, error) + throw new Error(`Failed to fetch subscription: ${subscriptionId}`) + } + } + + /** + * Gets payment details for standard payments + */ + async getStandardPayment(paymentId: string): Promise { + try { + console.log(`🔍 Fetching standard payment details for ID: ${paymentId}`) + + const response: AxiosResponse = await axios.get( + `${this.paymentsSearchUrl}${paymentId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Standard payment data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching standard payment ${paymentId}:`, error) + throw new Error(`Failed to fetch payment: ${paymentId}`) + } + } + + /** + * Gets payment details for authorized payments (recurring) + */ + async getAuthorizedPayment(paymentId: string): Promise { + try { + console.log(`🔍 Fetching authorized payment details for ID: ${paymentId}`) + + const response: AxiosResponse = await axios.get( + `${this.authorizedPaymentsUrl}${paymentId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Authorized payment data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching authorized payment ${paymentId}:`, error) + throw new Error(`Failed to fetch authorized payment: ${paymentId}`) + } + } + + /** + * Validates if payment is approved and accredited + */ + isPaymentSuccessful(payment: PaymentData): boolean { + const isApproved = payment?.status === 'approved' + const isAccredited = payment?.status_detail === 'accredited' + + console.log(`💰 Payment status: ${payment.status}, detail: ${payment?.status_detail}`) + + return isApproved && isAccredited + } + + /** + * Gets the payment method based on event type + */ + getPaymentUrl(eventType: string, paymentId: string): string { + if (eventType === 'subscription_authorized_payment') { + return `${this.authorizedPaymentsUrl}${paymentId}` + } else if (eventType === 'payment') { + return `${this.paymentsSearchUrl}${paymentId}` + } else { + throw new Error(`Unsupported event type: ${eventType}`) + } + } +} diff --git a/amplify/functions/webHookPlan/services/payment-processor.ts b/amplify/functions/webHookPlan/services/payment-processor.ts new file mode 100644 index 00000000..a52a55b7 --- /dev/null +++ b/amplify/functions/webHookPlan/services/payment-processor.ts @@ -0,0 +1,153 @@ +import { PaymentProcessor } from '../types' +import { MercadoPagoApiService } from './mercadopago-api' +import { CognitoUserService } from './user-service' + +export class MercadoPagoPaymentProcessor implements PaymentProcessor { + constructor( + private readonly mpApiService: MercadoPagoApiService, + private readonly userService: CognitoUserService + ) {} + + /** + * Processes subscription update events (cancellations, etc.) + */ + async processSubscriptionUpdate(subscriptionId: string): Promise { + try { + console.log('🛑 Processing subscription update') + + const subscriptionData = await this.mpApiService.getSubscription(subscriptionId) + + // Handle subscription cancellation + if (subscriptionData.status === 'cancelled') { + const userId = subscriptionData.external_reference + console.log(`⚠️ Detected cancellation for user: ${userId}`) + + await this.userService.downgradeUser(userId) + } + + console.log('✅ Subscription update processed successfully') + } catch (error) { + console.error('❌ Error processing subscription update:', error) + throw error + } + } + + /** + * Processes payment events (approved payments) + */ + async processPayment(paymentId: string, paymentType: string): Promise { + try { + console.log(`💰 Processing ${paymentType} payment: ${paymentId}`) + + // Get payment data based on type + const paymentData = + paymentType === 'subscription_authorized_payment' + ? await this.mpApiService.getAuthorizedPayment(paymentId) + : await this.mpApiService.getStandardPayment(paymentId) + + console.log('💡 Payment data:', JSON.stringify(paymentData, null, 2)) + + // Validate payment status + if (!this.mpApiService.isPaymentSuccessful(paymentData)) { + console.warn('⚠️ Payment not completed successfully') + return + } + + // Get subscription information + const subscriptionId = + paymentData.point_of_interaction?.transaction_data?.subscription_id || + paymentData.metadata?.preapproval_id || + paymentData.external_reference + + if (!subscriptionId) { + console.warn('⚠️ No subscription ID found in payment data') + return + } + + const subscriptionData = await this.mpApiService.getSubscription(subscriptionId) + const userId = subscriptionData.external_reference + const newPlanName = subscriptionData.reason + const newAmountFromMP = subscriptionData.auto_recurring.transaction_amount + const nextPaymentDate = subscriptionData.next_payment_date + + console.log(`👤 User: ${userId}`) + console.log(`📦 New plan: ${newPlanName}`) + console.log(`💰 Amount: ${newAmountFromMP}`) + console.log(`📅 Next payment: ${nextPaymentDate}`) + + // Update user plan in Cognito + await this.userService.updateUserPlan(userId, newPlanName) + + // Update subscription data in DynamoDB + await this.userService.updateSubscription(userId, { + subscriptionId, + planName: newPlanName, + nextPaymentDate, + lastFourDigits: this.extractLastFourDigits(paymentData), + }) + + console.log('✅ Payment processed successfully') + } catch (error) { + console.error('❌ Error processing payment:', error) + throw error + } + } + + /** + * Extracts last four digits from payment data + * This is a simplified implementation - adjust based on actual MercadoPago response + */ + private extractLastFourDigits(paymentData: any): number | undefined { + // MercadoPago might provide card info in different ways + // Adjust this based on the actual API response structure + try { + if (paymentData.card?.last_four_digits) { + return parseInt(paymentData.card.last_four_digits, 10) + } + if (paymentData.payment_method?.last_four_digits) { + return parseInt(paymentData.payment_method.last_four_digits, 10) + } + return undefined + } catch (error) { + console.warn('⚠️ Could not extract last four digits from payment data') + return undefined + } + } + + /** + * Determines if the webhook event should be processed + */ + shouldProcessEvent(eventType: string, eventAction: string): boolean { + const supportedEvents = [ + 'subscription_preapproval', + 'subscription_authorized_payment', + 'payment', + ] + + const supportedActions = ['updated', 'created', 'payment.created'] + + const isSupported = supportedEvents.includes(eventType) + + if (!isSupported) { + console.warn(`⚠️ Unsupported event type: ${eventType}`) + } + + return isSupported + } + + /** + * Gets event processing priority (for future queue implementation) + */ + getEventPriority(eventType: string): number { + switch (eventType) { + case 'subscription_preapproval': + return 1 // High priority for cancellations + case 'subscription_authorized_payment': + return 2 // Medium priority for recurring payments + case 'payment': + return 3 // Lower priority for one-time payments + default: + return 10 // Lowest priority + } + } +} diff --git a/amplify/functions/webHookPlan/services/user-service.ts b/amplify/functions/webHookPlan/services/user-service.ts new file mode 100644 index 00000000..e5f66ca0 --- /dev/null +++ b/amplify/functions/webHookPlan/services/user-service.ts @@ -0,0 +1,204 @@ +import { + CognitoIdentityProviderClient, + AdminGetUserCommand, + AdminUpdateUserAttributesCommand, +} from '@aws-sdk/client-cognito-identity-provider' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { Amplify } from 'aws-amplify' +import { type Schema } from '../../../data/resource' +import { CognitoUserAttribute, UserService } from '../types' +import { env } from '$amplify/env/hookPlan' + +const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) +Amplify.configure(resourceConfig, libraryOptions) + +const cognitoClient = new CognitoIdentityProviderClient() +const dynamoClient = generateClient() + +export class CognitoUserService implements UserService { + constructor(private readonly userPoolId: string) {} + + /** + * Gets user from Cognito by username + */ + async getCognitoUser(userId: string) { + try { + console.log(`🔍 Fetching Cognito user: ${userId}`) + + const response = await cognitoClient.send( + new AdminGetUserCommand({ + UserPoolId: this.userPoolId, + Username: userId, + }) + ) + + console.log(`✅ Cognito user retrieved successfully`) + return response + } catch (error) { + console.error(`❌ Error fetching Cognito user ${userId}:`, error) + throw new Error(`Failed to fetch user: ${userId}`) + } + } + + /** + * Gets current user plan from Cognito attributes + */ + getCurrentPlan(userAttributes: CognitoUserAttribute[] = []): string { + const planAttribute = userAttributes.find(attr => attr.Name === 'custom:plan') + return planAttribute?.Value || 'free' + } + + /** + * Updates user plan in Cognito + */ + async updateUserPlan(userId: string, planName: string): Promise { + try { + console.log(`🔄 Updating user ${userId} to plan: ${planName}`) + + await cognitoClient.send( + new AdminUpdateUserAttributesCommand({ + UserPoolId: this.userPoolId, + Username: userId, + UserAttributes: [ + { + Name: 'custom:plan', + Value: planName, + }, + ], + }) + ) + + console.log(`✅ User plan updated successfully`) + } catch (error) { + console.error(`❌ Error updating user plan for ${userId}:`, error) + throw new Error(`Failed to update user plan: ${userId}`) + } + } + + /** + * Downgrades user to free plan after subscription cancellation + */ + async downgradeUser(userId: string): Promise { + try { + console.log(`⬇️ Downgrading user ${userId} to free plan`) + + const cognitoUser = await this.getCognitoUser(userId) + const currentPlan = this.getCurrentPlan(cognitoUser.UserAttributes) + + if (currentPlan === 'free') { + console.log('🔍 User already has free plan, no changes needed') + return + } + + // Calculate remaining subscription time + const existingSubscription = await this.getExistingSubscription(userId) + + if (existingSubscription) { + await this.handleSubscriptionCancellation(userId, existingSubscription) + } + + await this.updateUserPlan(userId, 'free') + + console.log(`✅ User successfully downgraded to free plan`) + } catch (error) { + console.error(`❌ Error downgrading user ${userId}:`, error) + throw new Error(`Failed to downgrade user: ${userId}`) + } + } + + /** + * Gets existing subscription from DynamoDB + */ + private async getExistingSubscription(userId: string) { + try { + const response = await dynamoClient.models.UserSubscription.get({ + id: userId, + }) + return response.data + } catch (error) { + console.log(`ℹ️ No existing subscription found for user: ${userId}`) + return null + } + } + + /** + * Handles subscription cancellation logic + */ + private async handleSubscriptionCancellation(userId: string, subscription: any) { + try { + // Calculate pending plan based on remaining time + const pendingPlan = this.calculatePendingPlan(subscription) + + if (pendingPlan !== 'free') { + await dynamoClient.models.UserSubscription.update({ + id: userId, + pendingPlan, + }) + + console.log(`⏰ Pending plan set to: ${pendingPlan}`) + } + } catch (error) { + console.error('❌ Error handling subscription cancellation:', error) + } + } + + /** + * Calculates pending plan based on remaining subscription time + */ + private calculatePendingPlan(subscription: any): string { + // Add your logic here to calculate pending plan + // based on subscription data and remaining time + return 'free' // Simplified for now + } + + /** + * Creates or updates subscription in DynamoDB + */ + async updateSubscription( + userId: string, + subscriptionData: { + subscriptionId?: string + planName: string + nextPaymentDate?: string + lastFourDigits?: number + } + ): Promise { + try { + console.log(`📝 Updating subscription for user: ${userId}`) + + if (!subscriptionData.subscriptionId) { + throw new Error('subscriptionId is required') + } + + // Check if subscription already exists + const existingSubscription = await this.getExistingSubscription(userId) + + const subscriptionPayload = { + id: userId, + userId: userId, + subscriptionId: subscriptionData.subscriptionId, + planName: subscriptionData.planName, + nextPaymentDate: subscriptionData.nextPaymentDate, + lastFourDigits: subscriptionData.lastFourDigits, + pendingPlan: null, // Clear pending plan on successful payment + } + + if (existingSubscription) { + // Update existing record + console.log(`🔄 Updating existing subscription record`) + await dynamoClient.models.UserSubscription.update(subscriptionPayload) + } else { + // Create new record + console.log(`✨ Creating new subscription record`) + await dynamoClient.models.UserSubscription.create(subscriptionPayload) + } + + console.log(`✅ Subscription saved successfully`) + } catch (error) { + console.error(`❌ Error saving subscription for ${userId}:`, error) + console.error('Subscription data:', JSON.stringify(subscriptionData, null, 2)) + throw new Error(`Failed to save subscription: ${userId}`) + } + } +} diff --git a/amplify/functions/webHookPlan/services/webhook-validator.ts b/amplify/functions/webHookPlan/services/webhook-validator.ts new file mode 100644 index 00000000..bff0d7fd --- /dev/null +++ b/amplify/functions/webHookPlan/services/webhook-validator.ts @@ -0,0 +1,70 @@ +import { createHmac } from 'crypto' +import { WebhookValidator } from '../types' + +export class MercadoPagoWebhookValidator implements WebhookValidator { + constructor(private readonly webhookSecret: string) {} + + /** + * Validates MercadoPago webhook signature + * According to: https://www.mercadopago.cl/developers/en/docs/security/oauth/best-practices + */ + validateSignature( + signature: string, + dataId: string, + requestId: string, + timestamp: string + ): boolean { + try { + if (!signature) { + throw new Error('Signature not provided in webhook') + } + + const match = signature.match(/ts=([^,]+),v1=([^,]+)/) + if (!match) { + throw new Error('Invalid signature format') + } + + const [, ts, v1] = match + + if (!dataId || !requestId) { + throw new Error('Missing required parameters in notification') + } + + const signatureTemplate = `id:${dataId};request-id:${requestId};ts:${ts};` + const expectedSignature = createHmac('sha256', this.webhookSecret) + .update(signatureTemplate) + .digest('hex') + + const isValid = v1 === expectedSignature + + if (isValid) { + console.log('✅ Webhook signature validated successfully') + } else { + console.error('❌ Invalid webhook signature') + } + + return isValid + } catch (error) { + console.error('🔥 Error validating webhook signature:', error) + return false + } + } + + /** + * Extracts timestamp from signature for additional validations + */ + extractTimestamp(signature: string): string | null { + const match = signature.match(/ts=([^,]+),v1=([^,]+)/) + return match ? match[1] : null + } + + /** + * Validates if the webhook is not too old (optional security measure) + */ + isTimestampValid(timestamp: string, toleranceSeconds: number = 300): boolean { + const now = Math.floor(Date.now() / 1000) + const webhookTime = parseInt(timestamp, 10) + + return Math.abs(now - webhookTime) <= toleranceSeconds + } +} diff --git a/amplify/functions/webHookPlan/types/index.ts b/amplify/functions/webHookPlan/types/index.ts new file mode 100644 index 00000000..15f0d760 --- /dev/null +++ b/amplify/functions/webHookPlan/types/index.ts @@ -0,0 +1,76 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda' + +// Webhook Event Types +export interface WebhookBody { + type: string + action: string + data: { + id: string + } +} + +// MercadoPago API Response Types +export interface SubscriptionData { + id: string + status: string + external_reference: string + next_payment_date: string + reason: string + auto_recurring: { + transaction_amount: number + } +} + +export interface PaymentData { + id: string + status: string + external_reference: string + status_detail: string + metadata?: { + preapproval_id?: string + } + point_of_interaction?: { + transaction_data?: { + subscription_id?: string + } + } + card?: { + last_four_digits?: string + } +} + +// Cognito User Attributes +export interface CognitoUserAttribute { + Name?: string + Value?: string +} + +// Response Types +export interface WebhookResponse { + statusCode: number + body: string +} + +export interface LambdaHandler { + (event: APIGatewayProxyEvent): Promise +} + +// Service Interfaces +export interface WebhookValidator { + validateSignature( + signature: string, + dataId: string, + requestId: string, + timestamp: string + ): boolean +} + +export interface PaymentProcessor { + processSubscriptionUpdate(subscriptionId: string): Promise + processPayment(paymentId: string, paymentType: string): Promise +} + +export interface UserService { + updateUserPlan(userId: string, planName: string): Promise + downgradeUser(userId: string): Promise +} diff --git a/app/(main-layout)/account-settings/components/PaymentSettings.tsx b/app/(main-layout)/account-settings/components/PaymentSettings.tsx index 00a328cc..d0cf4944 100644 --- a/app/(main-layout)/account-settings/components/PaymentSettings.tsx +++ b/app/(main-layout)/account-settings/components/PaymentSettings.tsx @@ -15,7 +15,7 @@ function SubscriptionLoader() { const [isSubmitting, setIsSubmitting] = useState(false) const { user } = useUserStore() - const cognitoUsername = user?.cognitoUsername + const cognitoUsername = user?.userId const handleCancel = async () => { if (!cognitoUsername) { @@ -142,8 +142,8 @@ export function PaymentSettings() { // Usar useEffect para inicializar el recurso de suscripción useEffect(() => { - if (user?.cognitoUsername) { - setCognitoUsername(user.cognitoUsername) + if (user?.userId) { + setCognitoUsername(user.userId) } }, [user]) diff --git a/app/(main-layout)/pricing/components/PricingCard.tsx b/app/(main-layout)/pricing/components/PricingCard.tsx index 213a1207..a1161d05 100644 --- a/app/(main-layout)/pricing/components/PricingCard.tsx +++ b/app/(main-layout)/pricing/components/PricingCard.tsx @@ -47,7 +47,7 @@ export function PricingCard({ plan }: PricingCardProps) { setIsClient(true) }, []) - const cognitoUsername = user?.cognitoUsername + const cognitoUsername = user?.userId const hasActivePlan = user && user.plan ? user.plan === plan.name : false const formatPrice = (price: string) => { diff --git a/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts b/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts index d65621cc..350cf746 100644 --- a/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts +++ b/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts @@ -59,7 +59,7 @@ export function useApiKeyEncryption() { return null } catch (error) { - console.error('Error encriptando clave API:', error) + console.error('Error encrypting API key:', error) return null } finally { setIsEncrypting(false) diff --git a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts index dc5e4dd4..c7585a64 100644 --- a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts +++ b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts @@ -1,42 +1,14 @@ import { useState } from 'react' import { generateClient } from 'aws-amplify/data' import type { Schema } from '@/amplify/data/resource' +import useUserStore from '@/context/core/userStore' -// Generamos el cliente a partir del schema definido en el backend -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) // Definición del input para UserStore -export interface UserStoreInput { - userId: string // ID del usuario (dueño de la tienda) - storeId: string // ID único de la tienda - storeName: string // Nombre de la tienda - storeDescription?: string // Descripción opcional - storeCurrency?: string // Moneda de la tienda - storeLogo?: string // URL de la imagen del logo - storeFavicon?: string // URL de la imagen del favicon - storeTheme?: string // Tema de la tienda (opcional) - storeBanner?: string // URL de la imagen del banner - storeType?: string // Tipo de tienda - storeStatus?: string // Estado de la tienda - storePolicy?: string // Política de la tienda - storeAdress?: string // Dirección de la tienda - contactEmail?: string - contactPhone?: number - contactName?: string - contactIdentification?: string - contactIdentificationType?: string - wompiConfig?: any // Configuración de wonpi en formato JSON - mercadoPagoConfig?: any // Configuración de mercado pago en formato JSON - mastershopApiKey?: string // Clave API de Mastershop - customDomain?: string // Dominio propio a asignar (opcional) - onboardingCompleted: boolean - onboardingData?: any -} - -// Definimos el tipo de autorización a usar -export interface AuthMode { - authMode: 'userPool' -} +export type UserStore = Schema['UserStore']['type'] // Tipo para pasarelas de pago export type PaymentGatewayType = 'mercadoPago' | 'wompi' @@ -49,12 +21,10 @@ export interface PaymentGatewayConfig { createdAt: string } -// Valor por defecto de autorización -const defaultAuth: AuthMode = { authMode: 'userPool' } - export const useUserStoreData = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const { user } = useUserStore() /** * Función auxiliar que ejecuta una operación y gestiona loading y error. @@ -80,78 +50,70 @@ export const useUserStoreData = () => { } /** - * Obtiene el ID del registro y la información de pasarelas de pago configuradas + * Obtiene la información de pasarelas de pago configuradas * para una tienda específica sin traer datos sensibles. * @param storeId - ID único de la tienda - * @param auth - Modo de autenticación - * @returns Objeto con el ID del registro y un array de pasarelas configuradas + * @returns Array de pasarelas configuradas */ const getStorePaymentInfo = async ( - storeId: string, - auth: AuthMode = defaultAuth + storeId: string ): Promise<{ - id: string | null configuredGateways: PaymentGatewayType[] }> => { try { setLoading(true) setError(null) - // Primero obtenemos el ID del registro - const idResult = await client.models.UserStore.list({ - filter: { storeId: { eq: storeId } }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - if (idResult.errors && idResult.errors.length > 0) { - setError(idResult.errors) - return { id: null, configuredGateways: [] } + if (!user?.userId) { + setError('User not authenticated') + return { configuredGateways: [] } } - if (idResult.data && idResult.data.length > 0) { - const storeId = idResult.data[0].id - const configuredGateways: PaymentGatewayType[] = [] + const configuredGateways: PaymentGatewayType[] = [] - // Verificamos si existe configuración de Wompi - const wompiResult = await client.models.UserStore.list({ + // Verificamos si existe configuración de Wompi + const wompiResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { filter: { - id: { eq: storeId }, + storeId: { eq: storeId }, wompiConfig: { attributeExists: true }, }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - // Verificamos si existe configuración de MercadoPago - const mercadoPagoResult = await client.models.UserStore.list({ + selectionSet: ['storeId'], + } + ) + + // Verificamos si existe configuración de MercadoPago + const mercadoPagoResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { filter: { - id: { eq: storeId }, + storeId: { eq: storeId }, mercadoPagoConfig: { attributeExists: true }, }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - // Agregamos las pasarelas configuradas al array - if (wompiResult.data && wompiResult.data.length > 0) { - configuredGateways.push('wompi') + selectionSet: ['storeId'], } + ) - if (mercadoPagoResult.data && mercadoPagoResult.data.length > 0) { - configuredGateways.push('mercadoPago') - } + // Agregamos las pasarelas configuradas al array + if (wompiResult.data && wompiResult.data.length > 0) { + configuredGateways.push('wompi') + } - return { - id: storeId, - configuredGateways, - } + if (mercadoPagoResult.data && mercadoPagoResult.data.length > 0) { + configuredGateways.push('mercadoPago') } - return { id: null, configuredGateways: [] } + return { + configuredGateways, + } } catch (err) { setError(err) - return { id: null, configuredGateways: [] } + return { configuredGateways: [] } } finally { setLoading(false) } @@ -161,54 +123,84 @@ export const useUserStoreData = () => { * Configura una pasarela de pago para una tienda específica. */ const configurePaymentGateway = async ( - storeRecordId: string, + storeId: string, gateway: PaymentGatewayType, config: any, - convertToJson: boolean = false, - auth: AuthMode = defaultAuth + convertToJson: boolean = false ): Promise => { - if (!storeRecordId) { - setError('ID de registro no proporcionado') + if (!storeId || !user?.userId) { + setError('Store ID or User ID not provided') return false } - // Convertir a JSON si es necesario - const configValue = convertToJson ? JSON.stringify(config) : config + try { + // Primero obtenemos el registro existente + const existingStoreResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { + filter: { + storeId: { eq: storeId }, + }, + selectionSet: ['storeId'], + } + ) - const updatePayload = { - id: storeRecordId, - ...(gateway === 'mercadoPago' - ? { mercadoPagoConfig: configValue } - : { wompiConfig: configValue }), - } + if (!existingStoreResult.data || existingStoreResult.data.length === 0) { + setError('Store not found') + return false + } - const result = await performOperation(() => client.models.UserStore.update(updatePayload, auth)) + // Convertir a JSON si es necesario + const configValue = convertToJson ? JSON.stringify(config) : config + + // El payload de update NO debe incluir el identificador (storeId) + // Solo los campos que queremos actualizar + const updatePayload = { + ...(gateway === 'mercadoPago' + ? { mercadoPagoConfig: configValue } + : { wompiConfig: configValue }), + } - return result !== null + // Para el update, necesitamos pasar el identificador por separado + const result = await performOperation(() => + client.models.UserStore.update({ + storeId: storeId, // Este es el identificador + ...updatePayload, // Estos son los campos a actualizar + }) + ) + + return result !== null + } catch (err) { + setError(err) + return false + } } /** * Crea una tienda (UserStore) en la base de datos. */ - const createUserStore = async (storeInput: UserStoreInput, auth: AuthMode = defaultAuth) => { - return performOperation(() => client.models.UserStore.create(storeInput, auth)) + const createUserStore = async ( + storeInput: Omit + ) => { + return performOperation(() => client.models.UserStore.create(storeInput)) } /** * Actualiza los datos de una tienda. */ const updateUserStore = async ( - storeInput: Partial & { id: string }, - auth: AuthMode = defaultAuth + storeInput: Omit, 'id' | 'createdAt' | 'updatedAt'> & { storeId: string } ) => { - return performOperation(() => client.models.UserStore.update(storeInput, auth)) + return performOperation(() => client.models.UserStore.update(storeInput)) } /** - * Elimina una tienda a partir de su 'id'. + * Elimina una tienda a partir de su 'storeId'. */ - const deleteUserStore = async (id: string, auth: AuthMode = defaultAuth) => { - return performOperation(() => client.models.UserStore.delete({ id }, auth)) + const deleteUserStore = async (storeId: string) => { + return performOperation(() => client.models.UserStore.delete({ storeId })) } return { diff --git a/app/store/components/domains/ChangeDomainDialog.tsx b/app/store/components/domains/ChangeDomainDialog.tsx index e7b2fc26..4d0fd842 100644 --- a/app/store/components/domains/ChangeDomainDialog.tsx +++ b/app/store/components/domains/ChangeDomainDialog.tsx @@ -67,7 +67,7 @@ export function ChangeDomainDialog({ const fullDomain = `${domainName.trim()}.fasttify.com` const result = await updateUserStore({ - id: storeId, + storeId: storeId, customDomain: fullDomain, }) diff --git a/app/store/components/domains/DomainManagement.tsx b/app/store/components/domains/DomainManagement.tsx index daf62d62..60728966 100644 --- a/app/store/components/domains/DomainManagement.tsx +++ b/app/store/components/domains/DomainManagement.tsx @@ -198,13 +198,13 @@ export function DomainManagement() { +

Método de Captura de Pago

+

+ Decide cómo quieres procesar los pagos cuando un cliente realice una compra:{' '} + + Más información + + . +

+ + +
+ +
+ +

+ El pago se procesa de inmediato al realizar el pedido. +

+
+
+ +
+ +
+ +

+ Se autoriza el pago al finalizar la compra y se captura al completar el pedido. +

+
+
+ +
+ +
+ +

+ Se autoriza el pago al finalizar la compra y debe capturarse manualmente. +

+
+
+
+ + ) +} diff --git a/app/store/components/payments/PaymentGatewayCard.tsx b/app/store/components/payments/PaymentGatewayCard.tsx new file mode 100644 index 00000000..70c4dea6 --- /dev/null +++ b/app/store/components/payments/PaymentGatewayCard.tsx @@ -0,0 +1,71 @@ +import Image from 'next/image' +import { Button } from '@/components/ui/button' +import { Check } from 'lucide-react' +import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { + WompiPaymentIcons, + MercadoPagoIcons, +} from '@/app/store/components/payments/PaymentMethodIcons' + +interface PaymentGatewayCardProps { + gateway: PaymentGatewayType + isConfigured: boolean + onActivate: (gateway: PaymentGatewayType) => void +} + +export function PaymentGatewayCard({ gateway, isConfigured, onActivate }: PaymentGatewayCardProps) { + const gatewayConfig = { + wompi: { + name: 'Wompi', + logo: '/icons/wompi.webp', + PaymentIcons: WompiPaymentIcons, + }, + mercadoPago: { + name: 'Mercado Pago', + logo: '/icons/mercadopago-logo.webp', + PaymentIcons: MercadoPagoIcons, + }, + } + + const config = gatewayConfig[gateway] + + return ( +
+
+
+
+ {config.name} + {isConfigured && ( + + + Activo + + )} +
+ Sin cargos adicionales en Fasttify +
+ {`${config.name} +
+
+ +
+ +
+ Métodos de pago + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentMethodsSection.tsx b/app/store/components/payments/PaymentMethodsSection.tsx new file mode 100644 index 00000000..02a7b722 --- /dev/null +++ b/app/store/components/payments/PaymentMethodsSection.tsx @@ -0,0 +1,39 @@ +import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { PaymentGatewayCard } from '@/app/store/components/payments/PaymentGatewayCard' + +interface PaymentMethodsSectionProps { + configuredGateways: PaymentGatewayType[] + onOpenModal: (gateway: PaymentGatewayType) => void +} + +export function PaymentMethodsSection({ + configuredGateways, + onOpenModal, +}: PaymentMethodsSectionProps) { + const isGatewayConfigured = (gateway: PaymentGatewayType) => { + return configuredGateways.includes(gateway) + } + + return ( +
+

Métodos de Pago Admitidos

+

+ Métodos de pago disponibles en Fasttify a través de nuestras pasarelas integradas. +

+ +
+ + + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentProvidersSection.tsx b/app/store/components/payments/PaymentProvidersSection.tsx new file mode 100644 index 00000000..5487a03a --- /dev/null +++ b/app/store/components/payments/PaymentProvidersSection.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link' +import { WompiGuide } from '@/app/store/components/payments/WompiGuide' +import { MercadoPagoGuide } from '@/app/store/components/payments/MercadoPagoGuide' + +export function PaymentProvidersSection() { + return ( +
+

Pasarelas de Pago

+

+ Configura las pasarelas de pago para aceptar transacciones en tu tienda Fasttify. Se pueden + aplicar tarifas según el proveedor seleccionado.{' '} + + Selecciona un plan + + . +

+
+ + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentSettings.tsx b/app/store/components/payments/PaymentSettings.tsx index 103822ad..b811b2d9 100644 --- a/app/store/components/payments/PaymentSettings.tsx +++ b/app/store/components/payments/PaymentSettings.tsx @@ -1,28 +1,11 @@ 'use client' -import { useState } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { Button } from '@/components/ui/button' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Label } from '@/components/ui/label' -import { Check } from 'lucide-react' import { PaymentSettingsSkeleton } from '@/app/store/components/payments/PaymentSettingsSkeleton' import { ApiKeyModal } from '@/app/store/components/payments/ApiKeyModal' -import { useParams } from 'next/navigation' -import { toast } from 'sonner' -import { - useUserStoreData, - PaymentGatewayType, -} from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' -import { WompiGuide } from '@/app/store/components/payments/WompiGuide' -import { MercadoPagoGuide } from '@/app/store/components/payments/MercadoPagoGuide' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - WompiPaymentIcons, - MercadoPagoIcons, -} from '@/app/store/components/payments/PaymentMethodIcons' -import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' +import { PaymentProvidersSection } from '@/app/store/components/payments/PaymentProvidersSection' +import { PaymentMethodsSection } from '@/app/store/components/payments/PaymentMethodsSection' +import { PaymentCaptureSection } from '@/app/store/components/payments/PaymentCaptureSection' +import { usePaymentSettings } from '@/app/store/components/payments/hooks/usePaymentSettings' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' @@ -37,333 +20,28 @@ Amplify.configure({ }) export function PaymentSettings() { - const params = useParams() - const storeId = params.slug as string - const [modalOpen, setModalOpen] = useState(false) - const [selectedGateway, setSelectedGateway] = useState('mercadoPago') - const { getStorePaymentInfo, configurePaymentGateway } = useUserStoreData() - const { encryptApiKey, isEncrypting } = useApiKeyEncryption() - const queryClient = useQueryClient() - - const { data, isLoading, isRefetching } = useQuery({ - queryKey: ['storePaymentInfo', storeId], - queryFn: () => getStorePaymentInfo(storeId), - enabled: !!storeId, - staleTime: 5 * 60 * 1000, // Los datos se consideran frescos por 5 minutos - gcTime: 5 * 60 * 1000, // Los datos se eliminan de caché después de 5 minutos sin usarse - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - }) - - const storeRecordId = data?.id || null - const configuredGateways = data?.configuredGateways || [] - - const configureGatewayMutation = useMutation({ - mutationFn: async (data: { storeId: string; gateway: PaymentGatewayType; configData: any }) => { - return await configurePaymentGateway(data.storeId, data.gateway, data.configData, true) - }, - onSuccess: (_, variables) => { - const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' - toast.success(`¡Configuración exitosa!`, { - description: `La pasarela ${gatewayName} ha sido configurada correctamente.`, - }) - queryClient.invalidateQueries({ queryKey: ['storePaymentInfo', storeId] }) - }, - onError: (error, variables) => { - // Mostrar toast de error - const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' - toast.error(`Error de configuración`, { - description: `No se pudo configurar ${gatewayName}. Por favor, intenta nuevamente.`, - }) - }, - }) - - const handleOpenModal = (gateway: PaymentGatewayType) => { - setSelectedGateway(gateway) - setModalOpen(true) - } - - const handleSubmit = async (data: { - gateway: PaymentGatewayType - publicKey: string - privateKey: string - }): Promise => { - try { - if (!storeRecordId) { - toast.error('Error de configuración', { - description: 'Uyps! Hubo un error al configurar la pasarela de pago.', - }) - console.error('Store record ID not found') - return false - } - - // Encriptar las claves API antes de guardarlas - let configData: any = { isActive: true } - - if (data.gateway === 'wompi') { - // Encriptar la clave pública de Wompi - if (data.publicKey) { - const encryptedPublicKey = await encryptApiKey( - data.publicKey, - 'wompi', - 'publicKey', - storeId - ) - if (encryptedPublicKey) { - configData.publicKey = encryptedPublicKey - } else { - console.error('Error encrypting Wompi public key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - // Encriptar la firma (clave privada) de Wompi - if (data.privateKey) { - const encryptedSignature = await encryptApiKey( - data.privateKey, - 'wompi', - 'signature', - storeId - ) - if (encryptedSignature) { - configData.signature = encryptedSignature - } else { - console.error('Error encrypting Wompi signature') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - } else if (data.gateway === 'mercadoPago') { - // Encriptar la clave pública de Mercado Pago - if (data.publicKey) { - const encryptedPublicKey = await encryptApiKey( - data.publicKey, - 'mercadopago', - 'publicKey', - storeId - ) - if (encryptedPublicKey) { - configData.publicKey = encryptedPublicKey - } else { - console.error('Error encrypting the Mercado Pago public key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - // Encriptar la clave privada de Mercado Pago - if (data.privateKey) { - const encryptedPrivateKey = await encryptApiKey( - data.privateKey, - 'mercadopago', - 'privateKey', - storeId - ) - if (encryptedPrivateKey) { - configData.privateKey = encryptedPrivateKey - } else { - console.error('Error encrypting the Mercado Pago private key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - } - - if (isEncrypting) { - toast.loading('Configurando pasarela de pago...') - return false - } - - const success = await configureGatewayMutation.mutateAsync({ - storeId: storeRecordId, - gateway: data.gateway, - configData, - }) - - return success - } catch (err) { - console.error('Error configuring the payment gateway:', err) - toast.error('Error de configuración', { - description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - const isGatewayConfigured = (gateway: PaymentGatewayType) => { - return configuredGateways.includes(gateway) - } + const { + modalOpen, + setModalOpen, + selectedGateway, + configuredGateways, + isLoading, + handleOpenModal, + handleSubmit, + } = usePaymentSettings() return (
- {isLoading || isRefetching ? ( + {isLoading ? ( ) : ( <> - {/* Sección de Proveedores de Pago */} -
-

Pasarelas de Pago

-

- Configura las pasarelas de pago para aceptar transacciones en tu tienda Fasttify. Se - pueden aplicar tarifas según el proveedor seleccionado.{' '} - - Selecciona un plan - - . -

-
- - -
-
- - {/* Sección de Métodos de Pago */} -
-

Métodos de Pago Admitidos

-

- Métodos de pago disponibles en Fasttify a través de nuestras pasarelas integradas. -

- -
-
-
-
- Wompi - {isGatewayConfigured('wompi') && ( - - - Activo - - )} -
- Sin cargos adicionales en Fasttify -
- Wompi logo -
-
- -
- -
- Métodos de pago - -
-
- -
-
-
-
- Mercado Pago - {isGatewayConfigured('mercadoPago') && ( - - - Activo - - )} -
- Sin cargos adicionales en Fasttify -
- Mercado Pago logo -
-
- -
- -
- Métodos de pago - -
-
-
- - {/* Sección de Método de Captura de Pago */} -
-

Método de Captura de Pago

-

- Decide cómo quieres procesar los pagos cuando un cliente realice una compra:{' '} - - Más información - - . -

- - -
- -
- -

- El pago se procesa de inmediato al realizar el pedido. -

-
-
- -
- -
- -

- Se autoriza el pago al finalizar la compra y se captura al completar el pedido. -

-
-
- -
- -
- -

- Se autoriza el pago al finalizar la compra y debe capturarse manualmente. -

-
-
-
-
+ + + )} diff --git a/app/store/components/payments/hooks/usePaymentSettings.ts b/app/store/components/payments/hooks/usePaymentSettings.ts new file mode 100644 index 00000000..4bcc2312 --- /dev/null +++ b/app/store/components/payments/hooks/usePaymentSettings.ts @@ -0,0 +1,200 @@ +import { useState } from 'react' +import { useParams } from 'next/navigation' +import { toast } from 'sonner' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + useUserStoreData, + PaymentGatewayType, +} from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' +import useUserStore from '@/context/core/userStore' + +export function usePaymentSettings() { + const params = useParams() + const storeId = params.slug as string + const [modalOpen, setModalOpen] = useState(false) + const [selectedGateway, setSelectedGateway] = useState('mercadoPago') + const { getStorePaymentInfo, configurePaymentGateway } = useUserStoreData() + const { encryptApiKey, isEncrypting } = useApiKeyEncryption() + const { user, loading: userLoading } = useUserStore() + const queryClient = useQueryClient() + const userId = user?.userId + + const { data, isLoading, isRefetching } = useQuery({ + queryKey: ['storePaymentInfo', storeId], + queryFn: () => getStorePaymentInfo(storeId), + enabled: !!storeId && !!userId && !userLoading, + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }) + + const configuredGateways = data?.configuredGateways || [] + + const configureGatewayMutation = useMutation({ + mutationFn: async (data: { storeId: string; gateway: PaymentGatewayType; configData: any }) => { + return await configurePaymentGateway(data.storeId, data.gateway, data.configData, true) + }, + onSuccess: (_, variables) => { + const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' + toast.success(`¡Configuración exitosa!`, { + description: `La pasarela ${gatewayName} ha sido configurada correctamente.`, + }) + queryClient.invalidateQueries({ queryKey: ['storePaymentInfo', storeId] }) + }, + onError: (error, variables) => { + const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' + toast.error(`Error de configuración`, { + description: `No se pudo configurar ${gatewayName}. Por favor, intenta nuevamente.`, + }) + }, + }) + + const handleOpenModal = (gateway: PaymentGatewayType) => { + setSelectedGateway(gateway) + setModalOpen(true) + } + + const handleSubmit = async (data: { + gateway: PaymentGatewayType + publicKey: string + privateKey: string + }): Promise => { + try { + if (!storeId) { + toast.error('Error de configuración', { + description: 'Uyps! Hubo un error al configurar la pasarela de pago.', + }) + console.error('Store ID not found') + return false + } + + // Encriptar las claves API antes de guardarlas + let configData: any = { isActive: true } + + if (data.gateway === 'wompi') { + // Encriptar la clave pública de Wompi + if (data.publicKey) { + const encryptedPublicKey = await encryptApiKey( + data.publicKey, + 'wompi', + 'publicKey', + storeId + ) + if (encryptedPublicKey) { + configData.publicKey = encryptedPublicKey + } else { + console.error('Error encrypting Wompi public key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + // Encriptar la firma (clave privada) de Wompi + if (data.privateKey) { + const encryptedSignature = await encryptApiKey( + data.privateKey, + 'wompi', + 'signature', + storeId + ) + if (encryptedSignature) { + configData.signature = encryptedSignature + } else { + console.error('Error encrypting Wompi signature') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + } else if (data.gateway === 'mercadoPago') { + // Encriptar la clave pública de Mercado Pago + if (data.publicKey) { + const encryptedPublicKey = await encryptApiKey( + data.publicKey, + 'mercadopago', + 'publicKey', + storeId + ) + if (encryptedPublicKey) { + configData.publicKey = encryptedPublicKey + } else { + console.error('Error encrypting the Mercado Pago public key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + // Encriptar la clave privada de Mercado Pago + if (data.privateKey) { + const encryptedPrivateKey = await encryptApiKey( + data.privateKey, + 'mercadopago', + 'privateKey', + storeId + ) + if (encryptedPrivateKey) { + configData.privateKey = encryptedPrivateKey + } else { + console.error('Error encrypting the Mercado Pago private key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + } + + if (isEncrypting) { + toast.loading('Configurando pasarela de pago...') + return false + } + + const success = await configureGatewayMutation.mutateAsync({ + storeId: storeId, + gateway: data.gateway, + configData, + }) + + return success + } catch (err) { + console.error('Error configuring the payment gateway:', err) + toast.error('Error de configuración', { + description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + const isGatewayConfigured = (gateway: PaymentGatewayType) => { + return configuredGateways.includes(gateway) + } + + return { + // State + modalOpen, + setModalOpen, + selectedGateway, + storeId, + + // Data + configuredGateways, + isLoading: isLoading || isRefetching || userLoading, + + // Functions + handleOpenModal, + handleSubmit, + isGatewayConfigured, + } +} diff --git a/app/store/components/product-management/collection-form/product-section.tsx b/app/store/components/product-management/collection-form/product-section.tsx index f4fe2659..6dfa81af 100644 --- a/app/store/components/product-management/collection-form/product-section.tsx +++ b/app/store/components/product-management/collection-form/product-section.tsx @@ -173,7 +173,9 @@ export function ProductSection({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" @@ -254,7 +256,9 @@ export function ProductSection({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" diff --git a/app/store/components/product-management/hooks/useProductFilters.ts b/app/store/components/product-management/hooks/useProductFilters.ts index 0091ea7f..60955beb 100644 --- a/app/store/components/product-management/hooks/useProductFilters.ts +++ b/app/store/components/product-management/hooks/useProductFilters.ts @@ -61,6 +61,11 @@ export function useProductFilters(products: IProduct[]) { return 0 } + // Handle null/undefined values + if (valueA == null && valueB == null) return 0 + if (valueA == null) return sortDirection === 'asc' ? -1 : 1 + if (valueB == null) return sortDirection === 'asc' ? 1 : -1 + if (valueA < valueB) return sortDirection === 'asc' ? -1 : 1 if (valueA > valueB) return sortDirection === 'asc' ? 1 : -1 return 0 diff --git a/app/store/components/product-management/product-table/product-card-mobile.tsx b/app/store/components/product-management/product-table/product-card-mobile.tsx index 9c458eff..ce81ae41 100644 --- a/app/store/components/product-management/product-table/product-card-mobile.tsx +++ b/app/store/components/product-management/product-table/product-card-mobile.tsx @@ -39,7 +39,9 @@ export function ProductCardMobile({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-10 h-10 object-cover rounded" @@ -98,7 +100,7 @@ export function ProductCardMobile({ {visibleColumns.inventory && (

Inventario

-

{formatInventory(product.quantity)}

+

{formatInventory(product.quantity ?? 0)}

)}
diff --git a/app/store/components/product-management/product-table/product-table-desktop.tsx b/app/store/components/product-management/product-table/product-table-desktop.tsx index dc9a24ce..fc37ffdd 100644 --- a/app/store/components/product-management/product-table/product-table-desktop.tsx +++ b/app/store/components/product-management/product-table/product-table-desktop.tsx @@ -133,7 +133,9 @@ export function ProductTableDesktop({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" @@ -167,7 +169,7 @@ export function ProductTableDesktop({ )} {visibleColumns.inventory && ( - {formatInventory(product.quantity)} + {formatInventory(product.quantity ?? 0)} )} {visibleColumns.price && ( diff --git a/app/store/components/product-management/utils/productUtils.ts b/app/store/components/product-management/utils/productUtils.ts index 1cd93169..f6ac0b06 100644 --- a/app/store/components/product-management/utils/productUtils.ts +++ b/app/store/components/product-management/utils/productUtils.ts @@ -10,21 +10,26 @@ import { ProductFormValues } from '@/lib/zod-schemas/product-schema' export function mapProductToFormValues(product: IProduct): Partial { return { name: product.name, - description: product.description, + description: product.description ?? undefined, price: product.price, compareAtPrice: product.compareAtPrice, costPerItem: product.costPerItem, - sku: product.sku, - barcode: product.barcode, - quantity: product.quantity, - category: product.category, + sku: product.sku ?? undefined, + barcode: product.barcode ?? undefined, + quantity: product.quantity ?? undefined, + category: product.category ?? undefined, images: typeof product.images === 'string' ? JSON.parse(product.images) : product.images, attributes: typeof product.attributes === 'string' ? JSON.parse(product.attributes) : product.attributes, variants: typeof product.variants === 'string' ? JSON.parse(product.variants) : product.variants, tags: typeof product.tags === 'string' ? JSON.parse(product.tags) : product.tags, - status: product.status, + status: (product.status ?? undefined) as + | 'active' + | 'draft' + | 'inactive' + | 'pending' + | undefined, } } diff --git a/app/store/components/sidebar/nav-main.tsx b/app/store/components/sidebar/nav-main.tsx index 8d4f1795..9ee91a3a 100644 --- a/app/store/components/sidebar/nav-main.tsx +++ b/app/store/components/sidebar/nav-main.tsx @@ -61,7 +61,6 @@ export function NavMain({ items }: NavMainProps) { {items.map(item => { - // Check if this item is active based on the current path const isItemActive = item.url && pathname.startsWith(item.url) const hasActiveChild = item.items?.some(subItem => pathname === subItem.url) diff --git a/app/store/components/sidebar/nav-user.tsx b/app/store/components/sidebar/nav-user.tsx index 0ab1ed7a..af592498 100644 --- a/app/store/components/sidebar/nav-user.tsx +++ b/app/store/components/sidebar/nav-user.tsx @@ -57,7 +57,6 @@ export function NavUser({ user, loading }: NavUserProps) { return (firstInitial + secondInitial).toUpperCase() || 'U' } - // Set isClient to true after component mounts useEffect(() => { setIsClient(true) }, []) diff --git a/app/store/components/store-config/LogoUploader.tsx b/app/store/components/store-config/LogoUploader.tsx index 84e60b28..1a3c4235 100644 --- a/app/store/components/store-config/LogoUploader.tsx +++ b/app/store/components/store-config/LogoUploader.tsx @@ -72,7 +72,7 @@ export function LogoUploader() { return } - if (!currentStore || !currentStore.id) { + if (!currentStore || !currentStore.storeId) { toast.error('No se pudo identificar la tienda', { description: 'Intenta recargar la página', }) @@ -84,7 +84,7 @@ export function LogoUploader() { if (result) { // Actualizar la URL del logo en la base de datos const updateResult = await updateUserStore({ - id: currentStore.id, + storeId: currentStore.storeId, storeLogo: result.url, }) @@ -119,7 +119,7 @@ export function LogoUploader() { return } - if (!currentStore || !currentStore.id) { + if (!currentStore || !currentStore.storeId) { toast.error('No se pudo identificar la tienda', { description: 'Intenta recargar la página', }) @@ -131,7 +131,7 @@ export function LogoUploader() { if (result) { // Actualizar la URL del favicon en la base de datos const updateResult = await updateUserStore({ - id: currentStore.id, + storeId: currentStore.storeId, storeFavicon: result.url, }) diff --git a/app/store/components/store-config/ThemePreview.tsx b/app/store/components/store-config/ThemePreview.tsx index 3b7628d0..4e2886a5 100644 --- a/app/store/components/store-config/ThemePreview.tsx +++ b/app/store/components/store-config/ThemePreview.tsx @@ -5,7 +5,6 @@ import { LogoUploader } from '@/app/store/components/store-config/LogoUploader' import useStoreDataStore from '@/context/core/storeDataStore' import Link from 'next/link' import Image from 'next/image' -import { useState } from 'react' export function ThemePreview() { return ( diff --git a/app/store/hooks/useProducts.ts b/app/store/hooks/useProducts.ts index 9ed88b57..674f6d68 100644 --- a/app/store/hooks/useProducts.ts +++ b/app/store/hooks/useProducts.ts @@ -10,50 +10,7 @@ const client = generateClient({ /** * Interfaz para representar un producto */ -export interface IProduct { - id: string - storeId: string - name: string - description?: string - price: number - compareAtPrice?: number - costPerItem?: number - sku?: string - barcode?: string - quantity: number - category?: string - images?: Array<{ - url: string - alt?: string - position?: number - }> - attributes?: Array<{ - name: string - values: string[] - }> - status: 'active' | 'inactive' | 'pending' | 'draft' - slug?: string - featured?: boolean - tags?: string[] - variants?: Array<{ - id: string - name: string - price?: number - sku?: string - quantity?: number - attributes?: Array<{ - name: string - value: string - }> - images?: Array<{ - url: string - alt?: string - }> - }> - owner: string - createdAt?: string - updatedAt?: string -} +export type IProduct = Schema['Product']['type'] /** * Tipo para los datos necesarios al crear un producto diff --git a/app/store/hooks/useStore.ts b/app/store/hooks/useStore.ts index 59b32465..7dc5e281 100644 --- a/app/store/hooks/useStore.ts +++ b/app/store/hooks/useStore.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import useStoreDataStore from '@/context/core/storeDataStore' +import useUserStore from '@/context/core/userStore' interface UseStoreReturn { store: any @@ -10,8 +11,9 @@ interface UseStoreReturn { /** * Hook para obtener y gestionar los datos de una tienda */ -export function useStore(storeId: string | null): UseStoreReturn { +export function useStore(storeId: string): UseStoreReturn { const { currentStore, isLoading, error, fetchStoreData } = useStoreDataStore() + const { user } = useUserStore() const [isClient, setIsClient] = useState(false) // Efecto para manejar la hidratación del cliente @@ -21,10 +23,10 @@ export function useStore(storeId: string | null): UseStoreReturn { // Efecto para cargar los datos de la tienda useEffect(() => { - if (storeId && isClient) { - fetchStoreData(storeId) + if (storeId && user?.userId && isClient) { + fetchStoreData(storeId, user.userId) } - }, [storeId, isClient, fetchStoreData]) + }, [storeId, user?.userId, isClient, fetchStoreData]) return { store: currentStore, diff --git a/context/core/storeDataStore.ts b/context/core/storeDataStore.ts index 6404526b..cd8c8ce1 100644 --- a/context/core/storeDataStore.ts +++ b/context/core/storeDataStore.ts @@ -4,7 +4,9 @@ import type { Schema } from '@/amplify/data/resource' import { CONNECTION_STATE_CHANGE, ConnectionState } from 'aws-amplify/data' import { Hub } from 'aws-amplify/utils' -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) type StoreType = Schema['UserStore']['type'] @@ -16,7 +18,7 @@ interface StoreDataState { connectionState: ConnectionState | null hasMasterShopApiKey: boolean setStoreId: (id: string | null) => void - fetchStoreData: (id: string) => Promise + fetchStoreData: (storeId: string, userId: string) => Promise clearStore: () => void setupSubscription: (id: string) => () => void setConnectionState: (state: ConnectionState) => void @@ -35,6 +37,7 @@ Hub.listen('api', (data: any) => { const useStoreDataStore = create((set, get) => ({ currentStore: null, storeId: null, + userId: null, isLoading: true, error: null, connectionState: null, @@ -44,9 +47,9 @@ const useStoreDataStore = create((set, get) => ({ setConnectionState: (state: ConnectionState) => set({ connectionState: state }), - fetchStoreData: async id => { + fetchStoreData: async (storeId, userId) => { // No hacer fetch si ya tenemos los datos de esta tienda - if (get().currentStore && get().storeId === id) { + if (get().currentStore && get().storeId === storeId) { set({ isLoading: false }) return } @@ -54,45 +57,45 @@ const useStoreDataStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { - const { data: stores } = await client.models.UserStore.list({ - authMode: 'userPool', - filter: { storeId: { eq: id } }, - selectionSet: [ - 'id', - 'storeId', - 'storeName', - 'storeLogo', - 'customDomain', - 'contactPhone', - 'contactEmail', - 'storeFavicon', - 'storeTheme', - 'onboardingData', - ], - }) + const { data: store } = await client.models.UserStore.get( + { storeId: storeId }, + { + selectionSet: [ + 'storeId', + 'storeName', + 'storeLogo', + 'customDomain', + 'contactPhone', + 'contactEmail', + 'storeFavicon', + 'storeTheme', + 'onboardingData', + ], + } + ) - if (stores && stores.length > 0) { + if (store) { set({ - currentStore: stores[0] as StoreType, - storeId: id, + currentStore: store as StoreType, + storeId: storeId, isLoading: false, }) // Verificar si existe la API key de Master Shop - const hasMasterShopApiKey = await get().checkMasterShopApiKey(stores[0].id) + const hasMasterShopApiKey = await get().checkMasterShopApiKey(store.storeId) set({ hasMasterShopApiKey }) // Configurar suscripción automáticamente después de obtener los datos - get().setupSubscription(id) + get().setupSubscription(storeId) } else { set({ - error: new Error('Tienda no encontrada'), + error: new Error('Store not found'), isLoading: false, }) } } catch (err) { set({ - error: err instanceof Error ? err : new Error('Error al obtener la tienda'), + error: err instanceof Error ? err : new Error('Error fetching store data'), isLoading: false, }) } @@ -101,14 +104,16 @@ const useStoreDataStore = create((set, get) => ({ // Verificar si existe la API key de Master Shop sin traer su valor checkMasterShopApiKey: async (id: string) => { try { - const { data } = await client.models.UserStore.list({ - filter: { - id: { eq: id }, - mastershopApiKey: { attributeExists: true }, + const { data } = await client.models.UserStore.listUserStoreByUserId( + { + userId: id, }, - selectionSet: ['id'], - authMode: 'userPool', - }) + { + filter: { + mastershopApiKey: { attributeExists: true }, + }, + } + ) const hasApiKey = data && data.length > 0 set({ hasMasterShopApiKey: hasApiKey }) @@ -133,9 +138,7 @@ const useStoreDataStore = create((set, get) => ({ // Usar observeQuery para mantener los datos actualizados automáticamente const subscription = client.models.UserStore.observeQuery({ filter: { storeId: { eq: id } }, - authMode: 'userPool', selectionSet: [ - 'id', 'storeId', 'storeName', 'storeLogo', @@ -157,22 +160,22 @@ const useStoreDataStore = create((set, get) => ({ // Verificar si existe la API key cuando hay cambios if (isSynced) { - get().checkMasterShopApiKey(items[0].id) + get().checkMasterShopApiKey(items[0].storeId) } } else if (isSynced) { // Si no hay elementos después de sincronizar, la tienda podría haber sido eliminada set({ currentStore: null, - error: new Error('Tienda no encontrada o eliminada'), + error: new Error('Store not found or deleted'), isLoading: false, hasMasterShopApiKey: false, }) } }, error: error => { - console.error('Error en la suscripción:', error) + console.error('Error in data subscription:', error) set({ - error: new Error('Error en la suscripción de datos'), + error: new Error('Error in data subscription'), isLoading: false, }) }, diff --git a/context/core/useSubscriptionStore.ts b/context/core/useSubscriptionStore.ts index a49aebd1..de88c974 100644 --- a/context/core/useSubscriptionStore.ts +++ b/context/core/useSubscriptionStore.ts @@ -2,17 +2,12 @@ import { create } from 'zustand' import { generateClient } from 'aws-amplify/data' import { type Schema } from '@/amplify/data/resource' -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) // tipo con solo los campos necesarios -interface MinimalSubscription { - subscriptionId: Schema['UserSubscription']['type']['subscriptionId'] - planName: Schema['UserSubscription']['type']['planName'] - nextPaymentDate: Schema['UserSubscription']['type']['nextPaymentDate'] - lastFourDigits: Schema['UserSubscription']['type']['lastFourDigits'] - pendingPlan: Schema['UserSubscription']['type']['pendingPlan'] - createdAt: string -} +export type MinimalSubscription = Schema['UserSubscription']['type'] interface SubscriptionState { cognitoUsername: string | null @@ -70,17 +65,8 @@ function createResource() { // Función auxiliar para obtener los datos de suscripción async function fetchSubscriptionData(username: string): Promise { try { - const { data, errors } = await client.models.UserSubscription.list({ - filter: { userId: { eq: username } }, - selectionSet: [ - 'subscriptionId', - 'planName', - 'pendingPlan', - 'nextPaymentDate', - 'lastFourDigits', - 'createdAt', - ], - authMode: 'userPool', + const { data, errors } = await client.models.UserSubscription.listUserSubscriptionByUserId({ + userId: username, }) if (errors && errors.length > 0) { @@ -95,14 +81,7 @@ async function fetchSubscriptionData(username: string): Promise