From 28b27565f8143fbfc0afb1dbaac823313a1a0c11 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 08:23:59 -0600 Subject: [PATCH] feat(billing): Add billing-usage frontend module with components, pages and routes - Add billing-usage feature module with types, API clients, hooks, and components - Create PlanCard, PlanSelector, SubscriptionStatusBadge, InvoiceList, UsageSummaryCard, CouponInput components - Add BillingPage, PlansPage, InvoicesPage, UsagePage - Update routes to include billing section Co-Authored-By: Claude Opus 4.5 --- src/app/router/routes.tsx | 48 ++++ src/features/billing-usage/api/coupons.api.ts | 86 ++++++ src/features/billing-usage/api/index.ts | 5 + .../billing-usage/api/invoices.api.ts | 91 +++++++ src/features/billing-usage/api/plans.api.ts | 71 +++++ .../billing-usage/api/subscriptions.api.ts | 102 +++++++ src/features/billing-usage/api/usage.api.ts | 86 ++++++ .../billing-usage/components/CouponInput.tsx | 198 ++++++++++++++ .../billing-usage/components/InvoiceList.tsx | 161 ++++++++++++ .../components/InvoiceStatusBadge.tsx | 55 ++++ .../billing-usage/components/PlanCard.tsx | 167 ++++++++++++ .../billing-usage/components/PlanSelector.tsx | 90 +++++++ .../components/SubscriptionStatusBadge.tsx | 55 ++++ .../components/UsageProgressBar.tsx | 72 +++++ .../components/UsageSummaryCard.tsx | 102 +++++++ .../billing-usage/components/index.ts | 8 + src/features/billing-usage/hooks/index.ts | 12 + .../billing-usage/hooks/useCoupons.ts | 192 ++++++++++++++ .../billing-usage/hooks/useInvoices.ts | 140 ++++++++++ src/features/billing-usage/hooks/usePlans.ts | 155 +++++++++++ .../billing-usage/hooks/useSubscription.ts | 151 +++++++++++ src/features/billing-usage/hooks/useUsage.ts | 199 ++++++++++++++ .../billing-usage/types/coupon.types.ts | 98 +++++++ src/features/billing-usage/types/index.ts | 5 + .../billing-usage/types/invoice.types.ts | 113 ++++++++ .../billing-usage/types/plan.types.ts | 81 ++++++ .../billing-usage/types/subscription.types.ts | 103 ++++++++ .../billing-usage/types/usage.types.ts | 66 +++++ src/pages/billing/BillingPage.tsx | 248 ++++++++++++++++++ src/pages/billing/InvoicesPage.tsx | 169 ++++++++++++ src/pages/billing/PlansPage.tsx | 132 ++++++++++ src/pages/billing/UsagePage.tsx | 225 ++++++++++++++++ src/pages/billing/index.ts | 4 + 33 files changed, 3490 insertions(+) create mode 100644 src/features/billing-usage/api/coupons.api.ts create mode 100644 src/features/billing-usage/api/index.ts create mode 100644 src/features/billing-usage/api/invoices.api.ts create mode 100644 src/features/billing-usage/api/plans.api.ts create mode 100644 src/features/billing-usage/api/subscriptions.api.ts create mode 100644 src/features/billing-usage/api/usage.api.ts create mode 100644 src/features/billing-usage/components/CouponInput.tsx create mode 100644 src/features/billing-usage/components/InvoiceList.tsx create mode 100644 src/features/billing-usage/components/InvoiceStatusBadge.tsx create mode 100644 src/features/billing-usage/components/PlanCard.tsx create mode 100644 src/features/billing-usage/components/PlanSelector.tsx create mode 100644 src/features/billing-usage/components/SubscriptionStatusBadge.tsx create mode 100644 src/features/billing-usage/components/UsageProgressBar.tsx create mode 100644 src/features/billing-usage/components/UsageSummaryCard.tsx create mode 100644 src/features/billing-usage/components/index.ts create mode 100644 src/features/billing-usage/hooks/index.ts create mode 100644 src/features/billing-usage/hooks/useCoupons.ts create mode 100644 src/features/billing-usage/hooks/useInvoices.ts create mode 100644 src/features/billing-usage/hooks/usePlans.ts create mode 100644 src/features/billing-usage/hooks/useSubscription.ts create mode 100644 src/features/billing-usage/hooks/useUsage.ts create mode 100644 src/features/billing-usage/types/coupon.types.ts create mode 100644 src/features/billing-usage/types/index.ts create mode 100644 src/features/billing-usage/types/invoice.types.ts create mode 100644 src/features/billing-usage/types/plan.types.ts create mode 100644 src/features/billing-usage/types/subscription.types.ts create mode 100644 src/features/billing-usage/types/usage.types.ts create mode 100644 src/pages/billing/BillingPage.tsx create mode 100644 src/pages/billing/InvoicesPage.tsx create mode 100644 src/pages/billing/PlansPage.tsx create mode 100644 src/pages/billing/UsagePage.tsx create mode 100644 src/pages/billing/index.ts diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 8d50834..86a4dbe 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -29,6 +29,12 @@ const PartnerDetailPage = lazy(() => import('@pages/partners/PartnerDetailPage') const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage')); const PartnerEditPage = lazy(() => import('@pages/partners/PartnerEditPage')); +// Billing pages +const BillingPage = lazy(() => import('@pages/billing/BillingPage').then(m => ({ default: m.BillingPage }))); +const PlansPage = lazy(() => import('@pages/billing/PlansPage').then(m => ({ default: m.PlansPage }))); +const InvoicesPage = lazy(() => import('@pages/billing/InvoicesPage').then(m => ({ default: m.InvoicesPage }))); +const UsagePage = lazy(() => import('@pages/billing/UsagePage').then(m => ({ default: m.UsagePage }))); + function LazyWrapper({ children }: { children: React.ReactNode }) { return }>{children}; } @@ -249,6 +255,48 @@ export const router = createBrowserRouter([ ), }, + // Billing routes + { + path: '/billing', + element: ( + + + + ), + }, + { + path: '/billing/plans', + element: ( + + + + ), + }, + { + path: '/billing/invoices', + element: ( + + + + ), + }, + { + path: '/billing/usage', + element: ( + + + + ), + }, + { + path: '/billing/*', + element: ( + +
Sección de facturación - En desarrollo
+
+ ), + }, + // Error pages { path: '/unauthorized', diff --git a/src/features/billing-usage/api/coupons.api.ts b/src/features/billing-usage/api/coupons.api.ts new file mode 100644 index 0000000..ab22448 --- /dev/null +++ b/src/features/billing-usage/api/coupons.api.ts @@ -0,0 +1,86 @@ +import { api } from '@services/api/axios-instance'; +import type { + Coupon, + CreateCouponDto, + UpdateCouponDto, + CouponValidation, + CouponFilters, + CouponsResponse, + CouponStats, + CouponRedemption, +} from '../types'; + +const BASE_URL = '/api/v1/billing/coupons'; + +export const couponsApi = { + getAll: async (filters?: CouponFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.discountType) params.append('discountType', filters.discountType); + if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive)); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${BASE_URL}/${id}`); + return response.data; + }, + + getByCode: async (code: string): Promise => { + const response = await api.get(`${BASE_URL}/code/${code}`); + return response.data; + }, + + create: async (data: CreateCouponDto): Promise => { + const response = await api.post(BASE_URL, data); + return response.data; + }, + + update: async (id: string, data: UpdateCouponDto): Promise => { + const response = await api.patch(`${BASE_URL}/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${BASE_URL}/${id}`); + }, + + validate: async (code: string, planId?: string, amount?: number): Promise => { + const response = await api.post(`${BASE_URL}/validate`, { + code, + planId, + amount, + }); + return response.data; + }, + + activate: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/activate`); + return response.data; + }, + + deactivate: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/deactivate`); + return response.data; + }, + + getStats: async (): Promise => { + const response = await api.get(`${BASE_URL}/stats`); + return response.data; + }, + + getRedemptions: async ( + couponId: string + ): Promise<{ data: CouponRedemption[]; total: number }> => { + const response = await api.get<{ data: CouponRedemption[]; total: number }>( + `${BASE_URL}/${couponId}/redemptions` + ); + return response.data; + }, +}; diff --git a/src/features/billing-usage/api/index.ts b/src/features/billing-usage/api/index.ts new file mode 100644 index 0000000..45ea88c --- /dev/null +++ b/src/features/billing-usage/api/index.ts @@ -0,0 +1,5 @@ +export { plansApi } from './plans.api'; +export { subscriptionsApi } from './subscriptions.api'; +export { invoicesApi } from './invoices.api'; +export { usageApi } from './usage.api'; +export { couponsApi } from './coupons.api'; diff --git a/src/features/billing-usage/api/invoices.api.ts b/src/features/billing-usage/api/invoices.api.ts new file mode 100644 index 0000000..22704d7 --- /dev/null +++ b/src/features/billing-usage/api/invoices.api.ts @@ -0,0 +1,91 @@ +import { api } from '@services/api/axios-instance'; +import type { Invoice, CreateInvoiceDto, InvoiceFilters, InvoicesResponse } from '../types'; + +const BASE_URL = '/api/v1/billing/invoices'; + +export const invoicesApi = { + getAll: async (filters?: InvoiceFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.tenantId) params.append('tenantId', filters.tenantId); + if (filters?.companyId) params.append('companyId', filters.companyId); + if (filters?.partnerId) params.append('partnerId', filters.partnerId); + if (filters?.subscriptionId) params.append('subscriptionId', filters.subscriptionId); + if (filters?.status) params.append('status', filters.status); + if (filters?.invoiceType) params.append('invoiceType', filters.invoiceType); + if (filters?.invoiceContext) params.append('invoiceContext', filters.invoiceContext); + if (filters?.startDate) params.append('startDate', filters.startDate); + if (filters?.endDate) params.append('endDate', filters.endDate); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${BASE_URL}/${id}`); + return response.data; + }, + + getByNumber: async (invoiceNumber: string): Promise => { + const response = await api.get(`${BASE_URL}/number/${invoiceNumber}`); + return response.data; + }, + + getSaasInvoices: async (filters?: InvoiceFilters): Promise => { + return invoicesApi.getAll({ ...filters, invoiceContext: 'saas' }); + }, + + create: async (data: CreateInvoiceDto): Promise => { + const response = await api.post(BASE_URL, data); + return response.data; + }, + + issue: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/issue`); + return response.data; + }, + + markAsPaid: async (id: string, paymentDate?: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/mark-paid`, { paymentDate }); + return response.data; + }, + + cancel: async (id: string, reason?: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/cancel`, { reason }); + return response.data; + }, + + download: async (id: string): Promise => { + const response = await api.get(`${BASE_URL}/${id}/download`, { + responseType: 'blob', + }); + return response.data; + }, + + sendByEmail: async (id: string, email?: string): Promise => { + await api.post(`${BASE_URL}/${id}/send-email`, { email }); + }, + + getUpcoming: async (): Promise => { + try { + const response = await api.get(`${BASE_URL}/upcoming`); + return response.data; + } catch { + return null; + } + }, + + getSummary: async ( + startDate: string, + endDate: string + ): Promise<{ total: number; paid: number; pending: number; overdue: number }> => { + const response = await api.get<{ total: number; paid: number; pending: number; overdue: number }>( + `${BASE_URL}/summary`, + { params: { startDate, endDate } } + ); + return response.data; + }, +}; diff --git a/src/features/billing-usage/api/plans.api.ts b/src/features/billing-usage/api/plans.api.ts new file mode 100644 index 0000000..6bf5134 --- /dev/null +++ b/src/features/billing-usage/api/plans.api.ts @@ -0,0 +1,71 @@ +import { api } from '@services/api/axios-instance'; +import type { + SubscriptionPlan, + CreatePlanDto, + UpdatePlanDto, + PlanFilters, + PlansResponse, +} from '../types'; + +const BASE_URL = '/api/v1/billing/plans'; + +export const plansApi = { + getAll: async (filters?: PlanFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.planType) params.append('planType', filters.planType); + if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive)); + if (filters?.isPublic !== undefined) params.append('isPublic', String(filters.isPublic)); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + return response.data; + }, + + getPublic: async (): Promise => { + const response = await api.get(`${BASE_URL}/public`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${BASE_URL}/${id}`); + return response.data; + }, + + getByCode: async (code: string): Promise => { + const response = await api.get(`${BASE_URL}/code/${code}`); + return response.data; + }, + + create: async (data: CreatePlanDto): Promise => { + const response = await api.post(BASE_URL, data); + return response.data; + }, + + update: async (id: string, data: UpdatePlanDto): Promise => { + const response = await api.patch(`${BASE_URL}/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${BASE_URL}/${id}`); + }, + + activate: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/activate`); + return response.data; + }, + + deactivate: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/deactivate`); + return response.data; + }, + + comparePlans: async (planIds: string[]): Promise => { + const response = await api.post(`${BASE_URL}/compare`, { planIds }); + return response.data; + }, +}; diff --git a/src/features/billing-usage/api/subscriptions.api.ts b/src/features/billing-usage/api/subscriptions.api.ts new file mode 100644 index 0000000..3ab4fac --- /dev/null +++ b/src/features/billing-usage/api/subscriptions.api.ts @@ -0,0 +1,102 @@ +import { api } from '@services/api/axios-instance'; +import type { + TenantSubscription, + CreateSubscriptionDto, + UpdateSubscriptionDto, + CancelSubscriptionDto, + SubscriptionFilters, + SubscriptionsResponse, +} from '../types'; + +const BASE_URL = '/api/v1/billing/subscriptions'; + +export const subscriptionsApi = { + getAll: async (filters?: SubscriptionFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.tenantId) params.append('tenantId', filters.tenantId); + if (filters?.planId) params.append('planId', filters.planId); + if (filters?.status) params.append('status', filters.status); + if (filters?.billingCycle) params.append('billingCycle', filters.billingCycle); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${BASE_URL}/${id}`); + return response.data; + }, + + getCurrent: async (): Promise => { + const response = await api.get(`${BASE_URL}/current`); + return response.data; + }, + + getByTenant: async (tenantId: string): Promise => { + const response = await api.get(`${BASE_URL}/tenant/${tenantId}`); + return response.data; + }, + + create: async (data: CreateSubscriptionDto): Promise => { + const response = await api.post(BASE_URL, data); + return response.data; + }, + + update: async (id: string, data: UpdateSubscriptionDto): Promise => { + const response = await api.patch(`${BASE_URL}/${id}`, data); + return response.data; + }, + + cancel: async (id: string, data?: CancelSubscriptionDto): Promise => { + const response = await api.post(`${BASE_URL}/${id}/cancel`, data); + return response.data; + }, + + reactivate: async (id: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/reactivate`); + return response.data; + }, + + changePlan: async ( + id: string, + planId: string, + billingCycle?: 'monthly' | 'annual' + ): Promise => { + const response = await api.post(`${BASE_URL}/${id}/change-plan`, { + planId, + billingCycle, + }); + return response.data; + }, + + applyCoupon: async (id: string, couponCode: string): Promise => { + const response = await api.post(`${BASE_URL}/${id}/apply-coupon`, { + couponCode, + }); + return response.data; + }, + + updateBillingInfo: async ( + id: string, + data: Pick + ): Promise => { + const response = await api.patch(`${BASE_URL}/${id}/billing-info`, data); + return response.data; + }, + + updatePaymentMethod: async ( + id: string, + paymentMethodId: string, + paymentProvider: string + ): Promise => { + const response = await api.patch(`${BASE_URL}/${id}/payment-method`, { + paymentMethodId, + paymentProvider, + }); + return response.data; + }, +}; diff --git a/src/features/billing-usage/api/usage.api.ts b/src/features/billing-usage/api/usage.api.ts new file mode 100644 index 0000000..ae07053 --- /dev/null +++ b/src/features/billing-usage/api/usage.api.ts @@ -0,0 +1,86 @@ +import { api } from '@services/api/axios-instance'; +import type { UsageTracking, UsageSummary, UsageFilters, UsageResponse } from '../types'; + +const BASE_URL = '/api/v1/billing/usage'; + +export const usageApi = { + getAll: async (filters?: UsageFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.tenantId) params.append('tenantId', filters.tenantId); + if (filters?.startDate) params.append('startDate', filters.startDate); + if (filters?.endDate) params.append('endDate', filters.endDate); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + return response.data; + }, + + getCurrent: async (): Promise => { + try { + const response = await api.get(`${BASE_URL}/current`); + return response.data; + } catch { + return null; + } + }, + + getByPeriod: async (periodStart: string): Promise => { + try { + const response = await api.get(`${BASE_URL}/period/${periodStart}`); + return response.data; + } catch { + return null; + } + }, + + getSummary: async (): Promise => { + const response = await api.get(`${BASE_URL}/summary`); + return response.data; + }, + + getHistory: async (months: number = 12): Promise => { + const response = await api.get(`${BASE_URL}/history`, { + params: { months }, + }); + return response.data; + }, + + getTrend: async ( + metric: 'users' | 'storage' | 'apiCalls' | 'sales', + months: number = 6 + ): Promise<{ period: string; value: number }[]> => { + const response = await api.get<{ period: string; value: number }[]>(`${BASE_URL}/trend/${metric}`, { + params: { months }, + }); + return response.data; + }, + + checkLimit: async ( + limitKey: string + ): Promise<{ allowed: boolean; current: number; limit: number; percent: number }> => { + const response = await api.get<{ allowed: boolean; current: number; limit: number; percent: number }>( + `${BASE_URL}/check-limit/${limitKey}` + ); + return response.data; + }, + + getOverages: async (): Promise<{ + users: number; + branches: number; + storage: number; + apiCalls: number; + totalAmount: number; + }> => { + const response = await api.get<{ + users: number; + branches: number; + storage: number; + apiCalls: number; + totalAmount: number; + }>(`${BASE_URL}/overages`); + return response.data; + }, +}; diff --git a/src/features/billing-usage/components/CouponInput.tsx b/src/features/billing-usage/components/CouponInput.tsx new file mode 100644 index 0000000..fc3390b --- /dev/null +++ b/src/features/billing-usage/components/CouponInput.tsx @@ -0,0 +1,198 @@ +import React, { useState } from 'react'; +import type { CouponValidation } from '../types'; + +interface CouponInputProps { + onValidate: (code: string) => Promise; + onApply?: (code: string) => Promise; + disabled?: boolean; + planId?: string; + amount?: number; +} + +export const CouponInput: React.FC = ({ + onValidate, + onApply, + disabled = false, +}) => { + const [code, setCode] = useState(''); + const [validation, setValidation] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [applied, setApplied] = useState(false); + + const handleValidate = async () => { + if (!code.trim()) return; + + setIsValidating(true); + setValidation(null); + + try { + const result = await onValidate(code.trim().toUpperCase()); + setValidation(result); + } finally { + setIsValidating(false); + } + }; + + const handleApply = async () => { + if (!onApply || !validation?.isValid) return; + + setIsApplying(true); + try { + await onApply(code.trim().toUpperCase()); + setApplied(true); + } finally { + setIsApplying(false); + } + }; + + const handleClear = () => { + setCode(''); + setValidation(null); + setApplied(false); + }; + + const formatDiscount = () => { + if (!validation?.coupon) return ''; + const { discountType, discountValue } = validation.coupon; + if (discountType === 'percentage') { + return `${discountValue}% de descuento`; + } + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: validation.coupon.currency || 'MXN', + }).format(discountValue) + ' de descuento'; + }; + + if (applied && validation?.isValid) { + return ( +
+
+ + + +
+

+ Cupón {validation.coupon?.code} aplicado +

+

{formatDiscount()}

+
+
+ +
+ ); + } + + return ( +
+
+
+ setCode(e.target.value.toUpperCase())} + placeholder="Código de cupón" + disabled={disabled || isValidating || isApplying} + className={` + w-full rounded-lg border px-4 py-2 text-sm uppercase + ${validation?.isValid === false + ? 'border-red-300 focus:border-red-500 focus:ring-red-500' + : validation?.isValid + ? 'border-green-300 focus:border-green-500 focus:ring-green-500' + : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500' + } + disabled:bg-gray-100 disabled:text-gray-500 + `} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleValidate(); + } + }} + /> + {code && ( + + )} +
+ + {validation?.isValid && onApply ? ( + + ) : ( + + )} +
+ + {validation && ( +
+ {validation.isValid ? ( +
+ + + + + {validation.coupon?.name}: {formatDiscount()} + +
+ ) : ( +
+ + + + {validation.message || 'Cupón no válido'} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/features/billing-usage/components/InvoiceList.tsx b/src/features/billing-usage/components/InvoiceList.tsx new file mode 100644 index 0000000..a28572b --- /dev/null +++ b/src/features/billing-usage/components/InvoiceList.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { InvoiceStatusBadge } from './InvoiceStatusBadge'; +import type { Invoice } from '../types'; + +interface InvoiceListProps { + invoices: Invoice[]; + isLoading?: boolean; + onDownload?: (invoice: Invoice) => void; + onView?: (invoice: Invoice) => void; +} + +export const InvoiceList: React.FC = ({ + invoices, + isLoading = false, + onDownload, + onView, +}) => { + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const formatCurrency = (amount: number, currency: string = 'MXN') => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency, + }).format(amount); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (invoices.length === 0) { + return ( +
+ + + +

No hay facturas

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + ))} + +
+ Factura + + Fecha + + Período + + Estado + + Total + + Acciones +
+ {invoice.invoiceNumber} + + {formatDate(invoice.issueDate)} + + {invoice.periodStart && invoice.periodEnd ? ( + <> + {formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)} + + ) : ( + '-' + )} + + + + {formatCurrency(invoice.total, invoice.currency)} + +
+ {onView && ( + + )} + {onDownload && ( + + )} +
+
+
+ ); +}; diff --git a/src/features/billing-usage/components/InvoiceStatusBadge.tsx b/src/features/billing-usage/components/InvoiceStatusBadge.tsx new file mode 100644 index 0000000..1b29238 --- /dev/null +++ b/src/features/billing-usage/components/InvoiceStatusBadge.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { InvoiceStatus } from '../types'; + +interface InvoiceStatusBadgeProps { + status: InvoiceStatus; + size?: 'sm' | 'md' | 'lg'; +} + +const statusConfig: Record = { + draft: { + label: 'Borrador', + className: 'bg-gray-100 text-gray-700', + }, + issued: { + label: 'Emitida', + className: 'bg-blue-100 text-blue-700', + }, + paid: { + label: 'Pagada', + className: 'bg-green-100 text-green-700', + }, + cancelled: { + label: 'Cancelada', + className: 'bg-red-100 text-red-700', + }, + overdue: { + label: 'Vencida', + className: 'bg-orange-100 text-orange-700', + }, +}; + +const sizeClasses = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-1 text-sm', + lg: 'px-3 py-1.5 text-base', +}; + +export const InvoiceStatusBadge: React.FC = ({ + status, + size = 'md', +}) => { + const config = statusConfig[status]; + + return ( + + {config.label} + + ); +}; diff --git a/src/features/billing-usage/components/PlanCard.tsx b/src/features/billing-usage/components/PlanCard.tsx new file mode 100644 index 0000000..fed208f --- /dev/null +++ b/src/features/billing-usage/components/PlanCard.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import type { SubscriptionPlan } from '../types'; + +interface PlanCardProps { + plan: SubscriptionPlan; + isSelected?: boolean; + isCurrent?: boolean; + billingCycle?: 'monthly' | 'annual'; + onSelect?: (plan: SubscriptionPlan) => void; + showFeatures?: boolean; +} + +export const PlanCard: React.FC = ({ + plan, + isSelected = false, + isCurrent = false, + billingCycle = 'monthly', + onSelect, + showFeatures = true, +}) => { + const price = billingCycle === 'annual' && plan.baseAnnualPrice + ? plan.baseAnnualPrice / 12 + : plan.baseMonthlyPrice; + + const annualSavings = plan.baseAnnualPrice + ? ((plan.baseMonthlyPrice * 12 - plan.baseAnnualPrice) / (plan.baseMonthlyPrice * 12)) * 100 + : 0; + + const formatPrice = (amount: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 0, + }).format(amount); + }; + + const enabledFeatures = Object.entries(plan.features) + .filter(([, enabled]) => enabled) + .map(([name]) => name); + + return ( +
onSelect?.(plan)} + > + {isCurrent && ( + + Plan actual + + )} + +
+

{plan.name}

+ {plan.description && ( +

{plan.description}

+ )} +
+ +
+
+ {formatPrice(price)} + /mes +
+ {billingCycle === 'annual' && annualSavings > 0 && ( +

+ Ahorra {annualSavings.toFixed(0)}% con facturación anual +

+ )} + {plan.setupFee > 0 && ( +

+ + {formatPrice(plan.setupFee)} setup único +

+ )} +
+ +
+
+ Usuarios + + {plan.maxUsers === -1 ? 'Ilimitados' : `Hasta ${plan.maxUsers}`} + +
+
+ Sucursales + + {plan.maxBranches === -1 ? 'Ilimitadas' : `Hasta ${plan.maxBranches}`} + +
+
+ Almacenamiento + {plan.storageGb} GB +
+
+ API calls/mes + + {plan.apiCallsMonthly === -1 ? 'Ilimitadas' : plan.apiCallsMonthly.toLocaleString()} + +
+
+ + {showFeatures && enabledFeatures.length > 0 && ( +
+

Incluye:

+
    + {enabledFeatures.slice(0, 5).map((feature) => ( +
  • + + + + {feature.replace(/_/g, ' ')} +
  • + ))} + {enabledFeatures.length > 5 && ( +
  • + +{enabledFeatures.length - 5} más +
  • + )} +
+
+ )} + + {plan.includedModules.length > 0 && ( +
+

Módulos:

+
+ {plan.includedModules.map((module) => ( + + {module} + + ))} +
+
+ )} + + {onSelect && ( + + )} +
+ ); +}; diff --git a/src/features/billing-usage/components/PlanSelector.tsx b/src/features/billing-usage/components/PlanSelector.tsx new file mode 100644 index 0000000..8c102c0 --- /dev/null +++ b/src/features/billing-usage/components/PlanSelector.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { PlanCard } from './PlanCard'; +import type { SubscriptionPlan, BillingCycle } from '../types'; + +interface PlanSelectorProps { + plans: SubscriptionPlan[]; + selectedPlanId?: string; + currentPlanId?: string; + onSelect: (plan: SubscriptionPlan, billingCycle: BillingCycle) => void; + isLoading?: boolean; +} + +export const PlanSelector: React.FC = ({ + plans, + selectedPlanId, + currentPlanId, + onSelect, + isLoading = false, +}) => { + const [billingCycle, setBillingCycle] = useState('monthly'); + + const activePlans = plans.filter((plan) => plan.isActive && plan.isPublic); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (activePlans.length === 0) { + return ( +
+

No hay planes disponibles

+
+ ); + } + + return ( +
+
+
+ + +
+
+ +
+ {activePlans.map((plan) => ( + onSelect(p, billingCycle)} + /> + ))} +
+
+ ); +}; diff --git a/src/features/billing-usage/components/SubscriptionStatusBadge.tsx b/src/features/billing-usage/components/SubscriptionStatusBadge.tsx new file mode 100644 index 0000000..9cefac7 --- /dev/null +++ b/src/features/billing-usage/components/SubscriptionStatusBadge.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type { SubscriptionStatus } from '../types'; + +interface SubscriptionStatusBadgeProps { + status: SubscriptionStatus; + size?: 'sm' | 'md' | 'lg'; +} + +const statusConfig: Record = { + trial: { + label: 'Prueba', + className: 'bg-purple-100 text-purple-700', + }, + active: { + label: 'Activa', + className: 'bg-green-100 text-green-700', + }, + past_due: { + label: 'Pago pendiente', + className: 'bg-yellow-100 text-yellow-700', + }, + cancelled: { + label: 'Cancelada', + className: 'bg-red-100 text-red-700', + }, + suspended: { + label: 'Suspendida', + className: 'bg-gray-100 text-gray-700', + }, +}; + +const sizeClasses = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-1 text-sm', + lg: 'px-3 py-1.5 text-base', +}; + +export const SubscriptionStatusBadge: React.FC = ({ + status, + size = 'md', +}) => { + const config = statusConfig[status]; + + return ( + + {config.label} + + ); +}; diff --git a/src/features/billing-usage/components/UsageProgressBar.tsx b/src/features/billing-usage/components/UsageProgressBar.tsx new file mode 100644 index 0000000..e8b6dc5 --- /dev/null +++ b/src/features/billing-usage/components/UsageProgressBar.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +interface UsageProgressBarProps { + label: string; + current: number; + limit: number; + unit?: string; + showOverage?: boolean; +} + +export const UsageProgressBar: React.FC = ({ + label, + current, + limit, + unit = '', + showOverage = true, +}) => { + const isUnlimited = limit === -1; + const percent = isUnlimited ? 0 : Math.min((current / limit) * 100, 100); + const overage = !isUnlimited && current > limit ? current - limit : 0; + + const getColorClass = () => { + if (isUnlimited) return 'bg-gray-400'; + if (percent >= 100) return 'bg-red-500'; + if (percent >= 80) return 'bg-yellow-500'; + return 'bg-blue-500'; + }; + + const formatValue = (value: number) => { + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; + return value.toString(); + }; + + return ( +
+
+ {label} + + {formatValue(current)} + {unit && ` ${unit}`} + {!isUnlimited && ( + <> + {' / '} + {formatValue(limit)} + {unit && ` ${unit}`} + + )} + {isUnlimited && ' (ilimitado)'} + +
+ +
+
+
+ +
+ + {isUnlimited ? 'Sin límite' : `${percent.toFixed(1)}% usado`} + + {showOverage && overage > 0 && ( + + +{formatValue(overage)} excedente + + )} +
+
+ ); +}; diff --git a/src/features/billing-usage/components/UsageSummaryCard.tsx b/src/features/billing-usage/components/UsageSummaryCard.tsx new file mode 100644 index 0000000..dc65284 --- /dev/null +++ b/src/features/billing-usage/components/UsageSummaryCard.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { UsageProgressBar } from './UsageProgressBar'; +import type { UsageSummary } from '../types'; + +interface UsageSummaryCardProps { + summary: UsageSummary | null; + isLoading?: boolean; +} + +export const UsageSummaryCard: React.FC = ({ + summary, + isLoading = false, +}) => { + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (!summary) { + return ( +
+

No hay datos de uso disponibles

+
+ ); + } + + const hasOverages = + summary.overages.users > 0 || + summary.overages.branches > 0 || + summary.overages.storage > 0 || + summary.overages.apiCalls > 0; + + return ( +
+
+

Uso del Plan

+ {summary.currentPeriod && ( +

+ Período: {new Date(summary.currentPeriod.periodStart).toLocaleDateString('es-MX')} -{' '} + {new Date(summary.currentPeriod.periodEnd).toLocaleDateString('es-MX')} +

+ )} +
+ +
+ + + + + + + +
+ + {hasOverages && ( +
+
+ + + +
+

Has excedido los límites de tu plan

+

+ Los excedentes se facturarán en tu próximo período de facturación. +

+
+
+
+ )} +
+ ); +}; diff --git a/src/features/billing-usage/components/index.ts b/src/features/billing-usage/components/index.ts new file mode 100644 index 0000000..f55b992 --- /dev/null +++ b/src/features/billing-usage/components/index.ts @@ -0,0 +1,8 @@ +export { PlanCard } from './PlanCard'; +export { PlanSelector } from './PlanSelector'; +export { SubscriptionStatusBadge } from './SubscriptionStatusBadge'; +export { InvoiceStatusBadge } from './InvoiceStatusBadge'; +export { InvoiceList } from './InvoiceList'; +export { UsageProgressBar } from './UsageProgressBar'; +export { UsageSummaryCard } from './UsageSummaryCard'; +export { CouponInput } from './CouponInput'; diff --git a/src/features/billing-usage/hooks/index.ts b/src/features/billing-usage/hooks/index.ts new file mode 100644 index 0000000..b06f5ca --- /dev/null +++ b/src/features/billing-usage/hooks/index.ts @@ -0,0 +1,12 @@ +export { usePlans, usePlan, usePublicPlans } from './usePlans'; +export { useSubscription, useSubscriptionById } from './useSubscription'; +export { useInvoices, useInvoice, useUpcomingInvoice } from './useInvoices'; +export { + useUsageHistory, + useCurrentUsage, + useUsageSummary, + useUsageTrend, + useLimitCheck, + useOverages, +} from './useUsage'; +export { useCoupons, useCoupon, useCouponValidation, useCouponStats } from './useCoupons'; diff --git a/src/features/billing-usage/hooks/useCoupons.ts b/src/features/billing-usage/hooks/useCoupons.ts new file mode 100644 index 0000000..489b091 --- /dev/null +++ b/src/features/billing-usage/hooks/useCoupons.ts @@ -0,0 +1,192 @@ +import { useState, useCallback, useEffect } from 'react'; +import { couponsApi } from '../api'; +import type { + Coupon, + CouponFilters, + CreateCouponDto, + UpdateCouponDto, + CouponValidation, + CouponStats, +} from '../types'; + +interface UseCouponsState { + coupons: Coupon[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: string | null; +} + +interface UseCouponsReturn extends UseCouponsState { + filters: CouponFilters; + setFilters: (filters: CouponFilters) => void; + refresh: () => Promise; + createCoupon: (data: CreateCouponDto) => Promise; + updateCoupon: (id: string, data: UpdateCouponDto) => Promise; + deleteCoupon: (id: string) => Promise; + activateCoupon: (id: string) => Promise; + deactivateCoupon: (id: string) => Promise; +} + +export function useCoupons(initialFilters?: CouponFilters): UseCouponsReturn { + const [state, setState] = useState({ + coupons: [], + total: 0, + page: 1, + totalPages: 1, + isLoading: true, + error: null, + }); + + const [filters, setFilters] = useState(initialFilters || { page: 1, limit: 10 }); + + const fetchCoupons = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + try { + const response = await couponsApi.getAll(filters); + setState({ + coupons: response.data, + total: response.meta.total, + page: response.meta.page, + totalPages: response.meta.totalPages, + isLoading: false, + error: null, + }); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Error al cargar cupones', + })); + } + }, [filters]); + + useEffect(() => { + fetchCoupons(); + }, [fetchCoupons]); + + const createCoupon = async (data: CreateCouponDto): Promise => { + const coupon = await couponsApi.create(data); + await fetchCoupons(); + return coupon; + }; + + const updateCoupon = async (id: string, data: UpdateCouponDto): Promise => { + const coupon = await couponsApi.update(id, data); + await fetchCoupons(); + return coupon; + }; + + const deleteCoupon = async (id: string): Promise => { + await couponsApi.delete(id); + await fetchCoupons(); + }; + + const activateCoupon = async (id: string): Promise => { + await couponsApi.activate(id); + await fetchCoupons(); + }; + + const deactivateCoupon = async (id: string): Promise => { + await couponsApi.deactivate(id); + await fetchCoupons(); + }; + + return { + ...state, + filters, + setFilters, + refresh: fetchCoupons, + createCoupon, + updateCoupon, + deleteCoupon, + activateCoupon, + deactivateCoupon, + }; +} + +export function useCoupon(id: string | undefined) { + const [coupon, setCoupon] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCoupon = useCallback(async () => { + if (!id) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await couponsApi.getById(id); + setCoupon(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar cupón'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchCoupon(); + }, [fetchCoupon]); + + return { coupon, isLoading, error, refresh: fetchCoupon }; +} + +export function useCouponValidation() { + const [validation, setValidation] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [error, setError] = useState(null); + + const validateCoupon = useCallback(async (code: string, planId?: string, amount?: number) => { + setIsValidating(true); + setError(null); + try { + const result = await couponsApi.validate(code, planId, amount); + setValidation(result); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Error al validar cupón'; + setError(message); + setValidation({ isValid: false, message, errors: [message] }); + return null; + } finally { + setIsValidating(false); + } + }, []); + + const clearValidation = useCallback(() => { + setValidation(null); + setError(null); + }, []); + + return { validation, isValidating, error, validateCoupon, clearValidation }; +} + +export function useCouponStats() { + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchStats = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await couponsApi.getStats(); + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar estadísticas'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + return { stats, isLoading, error, refresh: fetchStats }; +} diff --git a/src/features/billing-usage/hooks/useInvoices.ts b/src/features/billing-usage/hooks/useInvoices.ts new file mode 100644 index 0000000..9f522f1 --- /dev/null +++ b/src/features/billing-usage/hooks/useInvoices.ts @@ -0,0 +1,140 @@ +import { useState, useCallback, useEffect } from 'react'; +import { invoicesApi } from '../api'; +import type { Invoice, InvoiceFilters } from '../types'; + +interface UseInvoicesState { + invoices: Invoice[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: string | null; +} + +interface UseInvoicesReturn extends UseInvoicesState { + filters: InvoiceFilters; + setFilters: (filters: InvoiceFilters) => void; + refresh: () => Promise; + downloadInvoice: (id: string) => Promise; + sendInvoiceByEmail: (id: string, email?: string) => Promise; +} + +export function useInvoices(initialFilters?: InvoiceFilters): UseInvoicesReturn { + const [state, setState] = useState({ + invoices: [], + total: 0, + page: 1, + totalPages: 1, + isLoading: true, + error: null, + }); + + const [filters, setFilters] = useState( + initialFilters || { page: 1, limit: 10, invoiceContext: 'saas' } + ); + + const fetchInvoices = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + try { + const response = await invoicesApi.getAll(filters); + setState({ + invoices: response.data, + total: response.meta.total, + page: response.meta.page, + totalPages: response.meta.totalPages, + isLoading: false, + error: null, + }); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Error al cargar facturas', + })); + } + }, [filters]); + + useEffect(() => { + fetchInvoices(); + }, [fetchInvoices]); + + const downloadInvoice = async (id: string): Promise => { + const blob = await invoicesApi.download(id); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `invoice-${id}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }; + + const sendInvoiceByEmail = async (id: string, email?: string): Promise => { + await invoicesApi.sendByEmail(id, email); + }; + + return { + ...state, + filters, + setFilters, + refresh: fetchInvoices, + downloadInvoice, + sendInvoiceByEmail, + }; +} + +export function useInvoice(id: string | undefined) { + const [invoice, setInvoice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchInvoice = useCallback(async () => { + if (!id) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await invoicesApi.getById(id); + setInvoice(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar factura'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchInvoice(); + }, [fetchInvoice]); + + return { invoice, isLoading, error, refresh: fetchInvoice }; +} + +export function useUpcomingInvoice() { + const [invoice, setInvoice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchInvoice = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await invoicesApi.getUpcoming(); + setInvoice(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar próxima factura'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchInvoice(); + }, [fetchInvoice]); + + return { invoice, isLoading, error, refresh: fetchInvoice }; +} diff --git a/src/features/billing-usage/hooks/usePlans.ts b/src/features/billing-usage/hooks/usePlans.ts new file mode 100644 index 0000000..09a3a5a --- /dev/null +++ b/src/features/billing-usage/hooks/usePlans.ts @@ -0,0 +1,155 @@ +import { useState, useCallback, useEffect } from 'react'; +import { plansApi } from '../api'; +import type { SubscriptionPlan, PlanFilters, CreatePlanDto, UpdatePlanDto } from '../types'; + +interface UsePlansState { + plans: SubscriptionPlan[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: string | null; +} + +interface UsePlansReturn extends UsePlansState { + filters: PlanFilters; + setFilters: (filters: PlanFilters) => void; + refresh: () => Promise; + createPlan: (data: CreatePlanDto) => Promise; + updatePlan: (id: string, data: UpdatePlanDto) => Promise; + deletePlan: (id: string) => Promise; + activatePlan: (id: string) => Promise; + deactivatePlan: (id: string) => Promise; +} + +export function usePlans(initialFilters?: PlanFilters): UsePlansReturn { + const [state, setState] = useState({ + plans: [], + total: 0, + page: 1, + totalPages: 1, + isLoading: true, + error: null, + }); + + const [filters, setFilters] = useState(initialFilters || { page: 1, limit: 10 }); + + const fetchPlans = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + try { + const response = await plansApi.getAll(filters); + setState({ + plans: response.data, + total: response.meta.total, + page: response.meta.page, + totalPages: response.meta.totalPages, + isLoading: false, + error: null, + }); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Error al cargar planes', + })); + } + }, [filters]); + + useEffect(() => { + fetchPlans(); + }, [fetchPlans]); + + const createPlan = async (data: CreatePlanDto): Promise => { + const plan = await plansApi.create(data); + await fetchPlans(); + return plan; + }; + + const updatePlan = async (id: string, data: UpdatePlanDto): Promise => { + const plan = await plansApi.update(id, data); + await fetchPlans(); + return plan; + }; + + const deletePlan = async (id: string): Promise => { + await plansApi.delete(id); + await fetchPlans(); + }; + + const activatePlan = async (id: string): Promise => { + await plansApi.activate(id); + await fetchPlans(); + }; + + const deactivatePlan = async (id: string): Promise => { + await plansApi.deactivate(id); + await fetchPlans(); + }; + + return { + ...state, + filters, + setFilters, + refresh: fetchPlans, + createPlan, + updatePlan, + deletePlan, + activatePlan, + deactivatePlan, + }; +} + +export function usePlan(id: string | undefined) { + const [plan, setPlan] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPlan = useCallback(async () => { + if (!id) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await plansApi.getById(id); + setPlan(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar plan'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchPlan(); + }, [fetchPlan]); + + return { plan, isLoading, error, refresh: fetchPlan }; +} + +export function usePublicPlans() { + const [plans, setPlans] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPlans = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await plansApi.getPublic(); + setPlans(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar planes'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchPlans(); + }, [fetchPlans]); + + return { plans, isLoading, error, refresh: fetchPlans }; +} diff --git a/src/features/billing-usage/hooks/useSubscription.ts b/src/features/billing-usage/hooks/useSubscription.ts new file mode 100644 index 0000000..dca6dab --- /dev/null +++ b/src/features/billing-usage/hooks/useSubscription.ts @@ -0,0 +1,151 @@ +import { useState, useCallback, useEffect } from 'react'; +import { subscriptionsApi } from '../api'; +import type { + TenantSubscription, + UpdateSubscriptionDto, + CancelSubscriptionDto, + BillingCycle, +} from '../types'; + +interface UseSubscriptionState { + subscription: TenantSubscription | null; + isLoading: boolean; + error: string | null; +} + +interface UseSubscriptionReturn extends UseSubscriptionState { + refresh: () => Promise; + updateSubscription: (data: UpdateSubscriptionDto) => Promise; + cancelSubscription: (data?: CancelSubscriptionDto) => Promise; + reactivateSubscription: () => Promise; + changePlan: (planId: string, billingCycle?: BillingCycle) => Promise; + applyCoupon: (couponCode: string) => Promise; + updateBillingInfo: ( + data: Pick + ) => Promise; + updatePaymentMethod: (paymentMethodId: string, paymentProvider: string) => Promise; +} + +export function useSubscription(): UseSubscriptionReturn { + const [state, setState] = useState({ + subscription: null, + isLoading: true, + error: null, + }); + + const fetchSubscription = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + try { + const data = await subscriptionsApi.getCurrent(); + setState({ + subscription: data, + isLoading: false, + error: null, + }); + } catch (err) { + setState({ + subscription: null, + isLoading: false, + error: err instanceof Error ? err.message : 'Error al cargar suscripción', + }); + } + }, []); + + useEffect(() => { + fetchSubscription(); + }, [fetchSubscription]); + + const updateSubscription = async (data: UpdateSubscriptionDto): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.update(state.subscription.id, data); + setState((prev) => ({ ...prev, subscription: updated })); + return updated; + }; + + const cancelSubscription = async (data?: CancelSubscriptionDto): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.cancel(state.subscription.id, data); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + const reactivateSubscription = async (): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.reactivate(state.subscription.id); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + const changePlan = async (planId: string, billingCycle?: BillingCycle): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.changePlan(state.subscription.id, planId, billingCycle); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + const applyCoupon = async (couponCode: string): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.applyCoupon(state.subscription.id, couponCode); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + const updateBillingInfo = async ( + data: Pick + ): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.updateBillingInfo(state.subscription.id, data); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + const updatePaymentMethod = async ( + paymentMethodId: string, + paymentProvider: string + ): Promise => { + if (!state.subscription) throw new Error('No hay suscripción activa'); + const updated = await subscriptionsApi.updatePaymentMethod( + state.subscription.id, + paymentMethodId, + paymentProvider + ); + setState((prev) => ({ ...prev, subscription: updated })); + }; + + return { + ...state, + refresh: fetchSubscription, + updateSubscription, + cancelSubscription, + reactivateSubscription, + changePlan, + applyCoupon, + updateBillingInfo, + updatePaymentMethod, + }; +} + +export function useSubscriptionById(id: string | undefined) { + const [subscription, setSubscription] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSubscription = useCallback(async () => { + if (!id) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await subscriptionsApi.getById(id); + setSubscription(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar suscripción'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchSubscription(); + }, [fetchSubscription]); + + return { subscription, isLoading, error, refresh: fetchSubscription }; +} diff --git a/src/features/billing-usage/hooks/useUsage.ts b/src/features/billing-usage/hooks/useUsage.ts new file mode 100644 index 0000000..4d8a273 --- /dev/null +++ b/src/features/billing-usage/hooks/useUsage.ts @@ -0,0 +1,199 @@ +import { useState, useCallback, useEffect } from 'react'; +import { usageApi } from '../api'; +import type { UsageTracking, UsageSummary, UsageFilters } from '../types'; + +interface UseUsageState { + usage: UsageTracking[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: string | null; +} + +interface UseUsageReturn extends UseUsageState { + filters: UsageFilters; + setFilters: (filters: UsageFilters) => void; + refresh: () => Promise; +} + +export function useUsageHistory(initialFilters?: UsageFilters): UseUsageReturn { + const [state, setState] = useState({ + usage: [], + total: 0, + page: 1, + totalPages: 1, + isLoading: true, + error: null, + }); + + const [filters, setFilters] = useState(initialFilters || { page: 1, limit: 12 }); + + const fetchUsage = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + try { + const response = await usageApi.getAll(filters); + setState({ + usage: response.data, + total: response.meta.total, + page: response.meta.page, + totalPages: response.meta.totalPages, + isLoading: false, + error: null, + }); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Error al cargar historial de uso', + })); + } + }, [filters]); + + useEffect(() => { + fetchUsage(); + }, [fetchUsage]); + + return { + ...state, + filters, + setFilters, + refresh: fetchUsage, + }; +} + +export function useCurrentUsage() { + const [usage, setUsage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchUsage = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await usageApi.getCurrent(); + setUsage(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar uso actual'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchUsage(); + }, [fetchUsage]); + + return { usage, isLoading, error, refresh: fetchUsage }; +} + +export function useUsageSummary() { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSummary = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await usageApi.getSummary(); + setSummary(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar resumen de uso'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchSummary(); + }, [fetchSummary]); + + return { summary, isLoading, error, refresh: fetchSummary }; +} + +export function useUsageTrend(metric: 'users' | 'storage' | 'apiCalls' | 'sales', months: number = 6) { + const [trend, setTrend] = useState<{ period: string; value: number }[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchTrend = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await usageApi.getTrend(metric, months); + setTrend(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar tendencia'); + } finally { + setIsLoading(false); + } + }, [metric, months]); + + useEffect(() => { + fetchTrend(); + }, [fetchTrend]); + + return { trend, isLoading, error, refresh: fetchTrend }; +} + +export function useLimitCheck(limitKey: string) { + const [limitStatus, setLimitStatus] = useState<{ + allowed: boolean; + current: number; + limit: number; + percent: number; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const checkLimit = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await usageApi.checkLimit(limitKey); + setLimitStatus(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al verificar límite'); + } finally { + setIsLoading(false); + } + }, [limitKey]); + + useEffect(() => { + checkLimit(); + }, [checkLimit]); + + return { limitStatus, isLoading, error, refresh: checkLimit }; +} + +export function useOverages() { + const [overages, setOverages] = useState<{ + users: number; + branches: number; + storage: number; + apiCalls: number; + totalAmount: number; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchOverages = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await usageApi.getOverages(); + setOverages(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar excedentes'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchOverages(); + }, [fetchOverages]); + + return { overages, isLoading, error, refresh: fetchOverages }; +} diff --git a/src/features/billing-usage/types/coupon.types.ts b/src/features/billing-usage/types/coupon.types.ts new file mode 100644 index 0000000..79a2864 --- /dev/null +++ b/src/features/billing-usage/types/coupon.types.ts @@ -0,0 +1,98 @@ +export type DiscountType = 'percentage' | 'fixed'; +export type DurationPeriod = 'once' | 'forever' | 'months'; + +export interface Coupon { + id: string; + code: string; + name: string; + description?: string | null; + discountType: DiscountType; + discountValue: number; + currency: string; + applicablePlans: string[]; + minAmount: number; + durationPeriod: DurationPeriod; + durationMonths?: number | null; + maxRedemptions?: number | null; + currentRedemptions: number; + validFrom?: string | null; + validUntil?: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CouponRedemption { + id: string; + couponId: string; + subscriptionId: string; + tenantId: string; + discountApplied: number; + appliedAt: string; + expiresAt?: string | null; + createdAt: string; + coupon?: Coupon; +} + +export interface CreateCouponDto { + code: string; + name: string; + description?: string; + discountType: DiscountType; + discountValue: number; + currency?: string; + applicablePlans?: string[]; + minAmount?: number; + durationPeriod?: DurationPeriod; + durationMonths?: number; + maxRedemptions?: number; + validFrom?: string; + validUntil?: string; + isActive?: boolean; +} + +export interface UpdateCouponDto { + name?: string; + description?: string; + applicablePlans?: string[]; + minAmount?: number; + maxRedemptions?: number; + validFrom?: string; + validUntil?: string; + isActive?: boolean; +} + +export interface CouponValidation { + isValid: boolean; + coupon?: Coupon; + discountAmount?: number; + message?: string; + errors?: string[]; +} + +export interface CouponFilters { + search?: string; + discountType?: DiscountType; + isActive?: boolean; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface CouponsResponse { + data: Coupon[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface CouponStats { + totalCoupons: number; + activeCoupons: number; + totalRedemptions: number; + totalDiscountGiven: number; +} diff --git a/src/features/billing-usage/types/index.ts b/src/features/billing-usage/types/index.ts new file mode 100644 index 0000000..c09d188 --- /dev/null +++ b/src/features/billing-usage/types/index.ts @@ -0,0 +1,5 @@ +export * from './plan.types'; +export * from './subscription.types'; +export * from './invoice.types'; +export * from './usage.types'; +export * from './coupon.types'; diff --git a/src/features/billing-usage/types/invoice.types.ts b/src/features/billing-usage/types/invoice.types.ts new file mode 100644 index 0000000..b31d9c9 --- /dev/null +++ b/src/features/billing-usage/types/invoice.types.ts @@ -0,0 +1,113 @@ +export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'cancelled' | 'overdue'; +export type InvoiceType = 'standard' | 'credit_note' | 'debit_note'; +export type InvoiceContext = 'commercial' | 'saas'; + +export interface InvoiceItem { + id: string; + invoiceId: string; + description: string; + quantity: number; + unitPrice: number; + discount: number; + taxRate: number; + taxAmount: number; + subtotal: number; + total: number; + productId?: string | null; + productCode?: string | null; + productName?: string | null; + unitOfMeasure?: string | null; +} + +export interface Invoice { + id: string; + tenantId: string; + companyId: string; + invoiceNumber: string; + invoiceType: InvoiceType; + invoiceContext: InvoiceContext; + status: InvoiceStatus; + partnerId?: string | null; + partnerName?: string | null; + subscriptionId?: string | null; + periodStart?: string | null; + periodEnd?: string | null; + issueDate: string; + dueDate: string; + paymentDate?: string | null; + currency: string; + exchangeRate: number; + subtotal: number; + discountTotal: number; + taxTotal: number; + total: number; + amountPaid: number; + amountDue: number; + notes?: string | null; + internalNotes?: string | null; + paymentTerms?: string | null; + paymentMethodId?: string | null; + billingAddress: Record; + cfdiUse?: string | null; + cfdiUuid?: string | null; + satStatus?: string | null; + cancelledAt?: string | null; + cancellationReason?: string | null; + createdAt: string; + updatedAt: string; + items?: InvoiceItem[]; +} + +export interface CreateInvoiceDto { + companyId: string; + invoiceType?: InvoiceType; + invoiceContext?: InvoiceContext; + partnerId?: string; + subscriptionId?: string; + periodStart?: string; + periodEnd?: string; + dueDate: string; + currency?: string; + notes?: string; + internalNotes?: string; + paymentTerms?: string; + billingAddress?: Record; + cfdiUse?: string; + items: CreateInvoiceItemDto[]; +} + +export interface CreateInvoiceItemDto { + description: string; + quantity: number; + unitPrice: number; + discount?: number; + taxRate?: number; + productId?: string; + unitOfMeasure?: string; +} + +export interface InvoiceFilters { + tenantId?: string; + companyId?: string; + partnerId?: string; + subscriptionId?: string; + status?: InvoiceStatus; + invoiceType?: InvoiceType; + invoiceContext?: InvoiceContext; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface InvoicesResponse { + data: Invoice[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} diff --git a/src/features/billing-usage/types/plan.types.ts b/src/features/billing-usage/types/plan.types.ts new file mode 100644 index 0000000..63945fe --- /dev/null +++ b/src/features/billing-usage/types/plan.types.ts @@ -0,0 +1,81 @@ +export type PlanType = 'saas' | 'on_premise' | 'hybrid'; + +export interface SubscriptionPlan { + id: string; + code: string; + name: string; + description?: string | null; + planType: PlanType; + baseMonthlyPrice: number; + baseAnnualPrice?: number | null; + setupFee: number; + maxUsers: number; + maxBranches: number; + storageGb: number; + apiCallsMonthly: number; + includedModules: string[]; + includedPlatforms: string[]; + features: Record; + isActive: boolean; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreatePlanDto { + code: string; + name: string; + description?: string; + planType?: PlanType; + baseMonthlyPrice: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} + +export interface UpdatePlanDto { + name?: string; + description?: string; + planType?: PlanType; + baseMonthlyPrice?: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} + +export interface PlanFilters { + search?: string; + planType?: PlanType; + isActive?: boolean; + isPublic?: boolean; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface PlansResponse { + data: SubscriptionPlan[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} diff --git a/src/features/billing-usage/types/subscription.types.ts b/src/features/billing-usage/types/subscription.types.ts new file mode 100644 index 0000000..19d425e --- /dev/null +++ b/src/features/billing-usage/types/subscription.types.ts @@ -0,0 +1,103 @@ +import type { SubscriptionPlan } from './plan.types'; + +export type BillingCycle = 'monthly' | 'annual'; +export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended'; + +export interface BillingAddress { + street?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; +} + +export interface TenantSubscription { + id: string; + tenantId: string; + planId: string; + billingCycle: BillingCycle; + currentPeriodStart: string; + currentPeriodEnd: string; + status: SubscriptionStatus; + trialStart?: string | null; + trialEnd?: string | null; + billingEmail?: string | null; + billingName?: string | null; + billingAddress: BillingAddress; + taxId?: string | null; + paymentMethodId?: string | null; + paymentProvider?: string | null; + stripeCustomerId?: string | null; + stripeSubscriptionId?: string | null; + lastPaymentAt?: string | null; + lastPaymentAmount?: number | null; + currentPrice: number; + discountPercent: number; + discountReason?: string | null; + contractedUsers?: number | null; + contractedBranches?: number | null; + autoRenew: boolean; + nextInvoiceDate?: string | null; + cancelAtPeriodEnd: boolean; + cancelledAt?: string | null; + cancellationReason?: string | null; + createdAt: string; + updatedAt: string; + plan?: SubscriptionPlan; +} + +export interface CreateSubscriptionDto { + tenantId: string; + planId: string; + billingCycle?: BillingCycle; + billingEmail?: string; + billingName?: string; + billingAddress?: BillingAddress; + taxId?: string; + paymentMethodId?: string; + paymentProvider?: string; + currentPrice?: number; + contractedUsers?: number; + contractedBranches?: number; + couponCode?: string; +} + +export interface UpdateSubscriptionDto { + planId?: string; + billingCycle?: BillingCycle; + billingEmail?: string; + billingName?: string; + billingAddress?: BillingAddress; + taxId?: string; + paymentMethodId?: string; + paymentProvider?: string; + autoRenew?: boolean; + contractedUsers?: number; + contractedBranches?: number; +} + +export interface CancelSubscriptionDto { + cancelImmediately?: boolean; + reason?: string; +} + +export interface SubscriptionFilters { + tenantId?: string; + planId?: string; + status?: SubscriptionStatus; + billingCycle?: BillingCycle; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface SubscriptionsResponse { + data: TenantSubscription[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} diff --git a/src/features/billing-usage/types/usage.types.ts b/src/features/billing-usage/types/usage.types.ts new file mode 100644 index 0000000..dff0605 --- /dev/null +++ b/src/features/billing-usage/types/usage.types.ts @@ -0,0 +1,66 @@ +export interface UsageTracking { + id: string; + tenantId: string; + periodStart: string; + periodEnd: string; + activeUsers: number; + peakConcurrentUsers: number; + usersByProfile: Record; + usersByPlatform: Record; + activeBranches: number; + storageUsedGb: number; + documentsCount: number; + apiCalls: number; + apiErrors: number; + salesCount: number; + salesAmount: number; + invoicesGenerated: number; + mobileSessions: number; + offlineSyncs: number; + paymentTransactions: number; + totalBillableAmount: number; + createdAt: string; + updatedAt: string; +} + +export interface UsageSummary { + currentPeriod: UsageTracking | null; + limits: { + maxUsers: number; + maxBranches: number; + storageGb: number; + apiCallsMonthly: number; + }; + usage: { + usersPercent: number; + branchesPercent: number; + storagePercent: number; + apiCallsPercent: number; + }; + overages: { + users: number; + branches: number; + storage: number; + apiCalls: number; + }; +} + +export interface UsageFilters { + tenantId?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface UsageResponse { + data: UsageTracking[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} diff --git a/src/pages/billing/BillingPage.tsx b/src/pages/billing/BillingPage.tsx new file mode 100644 index 0000000..b9ae3d5 --- /dev/null +++ b/src/pages/billing/BillingPage.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + useSubscription, + useUsageSummary, + useInvoices, + useUpcomingInvoice, + useCouponValidation, +} from '@features/billing-usage/hooks'; +import { + SubscriptionStatusBadge, + UsageSummaryCard, + InvoiceList, + CouponInput, +} from '@features/billing-usage/components'; + +export const BillingPage: React.FC = () => { + const { subscription, isLoading: subscriptionLoading } = useSubscription(); + const { summary, isLoading: usageLoading } = useUsageSummary(); + const { invoices, isLoading: invoicesLoading, downloadInvoice } = useInvoices({ limit: 5 }); + const { invoice: upcomingInvoice } = useUpcomingInvoice(); + const { validateCoupon } = useCouponValidation(); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(amount); + }; + + if (subscriptionLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Facturación y Uso

+

Gestiona tu suscripción, uso y facturas

+
+ +
+ {/* Main Content */} +
+ {/* Current Subscription */} +
+
+

Suscripción Actual

+ {subscription && } +
+ + {subscription ? ( +
+
+
+

+ {subscription.plan?.name || 'Plan'} +

+

+ Facturación {subscription.billingCycle === 'annual' ? 'anual' : 'mensual'} +

+
+
+

+ {formatCurrency(subscription.currentPrice)} + + /{subscription.billingCycle === 'annual' ? 'año' : 'mes'} + +

+ {subscription.discountPercent > 0 && ( +

+ {subscription.discountPercent}% descuento aplicado +

+ )} +
+
+ +
+
+
+

Período actual

+

+ {formatDate(subscription.currentPeriodStart)} -{' '} + {formatDate(subscription.currentPeriodEnd)} +

+
+ {subscription.nextInvoiceDate && ( +
+

Próxima factura

+

+ {formatDate(subscription.nextInvoiceDate)} +

+
+ )} +
+
+ + {subscription.cancelAtPeriodEnd && ( +
+

+ Tu suscripción se cancelará el{' '} + {formatDate(subscription.currentPeriodEnd)} +

+
+ )} + +
+ + Cambiar Plan + + + Gestionar Suscripción + +
+
+ ) : ( +
+

No tienes una suscripción activa

+ + Ver Planes + +
+ )} +
+ + {/* Upcoming Invoice */} + {upcomingInvoice && ( +
+

Próxima Factura

+
+
+

+ Fecha estimada: {formatDate(upcomingInvoice.dueDate)} +

+
+

+ {formatCurrency(upcomingInvoice.total)} +

+
+
+ )} + + {/* Recent Invoices */} +
+
+

Facturas Recientes

+ + Ver todas + +
+ downloadInvoice(inv.id)} + /> +
+
+ + {/* Sidebar */} +
+ {/* Usage Summary */} + + + {/* Apply Coupon */} +
+

Aplicar Cupón

+ +
+ + {/* Quick Links */} +
+

Acciones Rápidas

+
+ + + + + Ver historial de uso + + + + + + Métodos de pago + + + + + + Datos de facturación + +
+
+
+
+
+
+ ); +}; diff --git a/src/pages/billing/InvoicesPage.tsx b/src/pages/billing/InvoicesPage.tsx new file mode 100644 index 0000000..10c9e1a --- /dev/null +++ b/src/pages/billing/InvoicesPage.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { useInvoices } from '@features/billing-usage/hooks'; +import { InvoiceList } from '@features/billing-usage/components'; +import type { InvoiceFilters, InvoiceStatus } from '@features/billing-usage/types'; + +export const InvoicesPage: React.FC = () => { + const { + invoices, + total, + page, + totalPages, + isLoading, + error, + filters, + setFilters, + downloadInvoice, + } = useInvoices({ invoiceContext: 'saas' }); + + const handleStatusFilter = (status: InvoiceStatus | '') => { + setFilters({ + ...filters, + status: status || undefined, + page: 1, + } as InvoiceFilters); + }; + + const handleDateFilter = (startDate: string, endDate: string) => { + setFilters({ + ...filters, + startDate: startDate || undefined, + endDate: endDate || undefined, + page: 1, + }); + }; + + const handlePageChange = (newPage: number) => { + setFilters({ + ...filters, + page: newPage, + }); + }; + + if (error) { + return ( +
+
+
+

{error}

+
+
+
+ ); + } + + return ( +
+
+
+

Facturas

+

Historial de todas tus facturas de suscripción

+
+ + {/* Filters */} +
+
+ + +
+ +
+ + handleDateFilter(e.target.value, filters.endDate || '')} + className="mt-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ +
+ + handleDateFilter(filters.startDate || '', e.target.value)} + className="mt-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ + {(filters.status || filters.startDate || filters.endDate) && ( + + )} +
+ + {/* Invoice List */} +
+ downloadInvoice(inv.id)} + /> + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * (filters.limit || 10) + 1} a{' '} + {Math.min(page * (filters.limit || 10), total)} de {total} facturas +

+
+ + + Página {page} de {totalPages} + + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/pages/billing/PlansPage.tsx b/src/pages/billing/PlansPage.tsx new file mode 100644 index 0000000..64bde54 --- /dev/null +++ b/src/pages/billing/PlansPage.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { usePublicPlans, useSubscription } from '@features/billing-usage/hooks'; +import { PlanSelector } from '@features/billing-usage/components'; +import type { SubscriptionPlan, BillingCycle } from '@features/billing-usage/types'; + +export const PlansPage: React.FC = () => { + const navigate = useNavigate(); + const { plans, isLoading, error } = usePublicPlans(); + const { subscription } = useSubscription(); + const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedCycle, setSelectedCycle] = useState('monthly'); + + const handleSelect = (plan: SubscriptionPlan, billingCycle: BillingCycle) => { + setSelectedPlan(plan); + setSelectedCycle(billingCycle); + }; + + const handleContinue = () => { + if (!selectedPlan) return; + navigate(`/billing/checkout?planId=${selectedPlan.id}&cycle=${selectedCycle}`); + }; + + if (error) { + return ( +
+
+
+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+

Elige tu Plan

+

+ Selecciona el plan que mejor se adapte a las necesidades de tu negocio +

+
+ + + + {selectedPlan && ( +
+
+
+
+

Plan seleccionado

+

{selectedPlan.name}

+

+ Facturación {selectedCycle === 'annual' ? 'anual' : 'mensual'} +

+
+ +
+
+
+ )} + + {/* FAQ Section */} +
+

+ Preguntas Frecuentes +

+
+
+ + ¿Puedo cambiar de plan en cualquier momento? + +

+ Sí, puedes cambiar tu plan en cualquier momento. Si cambias a un plan superior, el + cambio es inmediato y se te cobrará la diferencia proporcional. Si cambias a un plan + inferior, el cambio se aplicará en tu próximo período de facturación. +

+
+
+ + ¿Qué sucede si excedo los límites de mi plan? + +

+ Si excedes los límites de tu plan, el servicio continuará funcionando pero se te + facturarán los excedentes según las tarifas vigentes en tu próxima factura. +

+
+
+ + ¿Puedo cancelar mi suscripción? + +

+ Sí, puedes cancelar tu suscripción en cualquier momento. Tu acceso continuará hasta + el final del período de facturación actual. No hay penalizaciones por cancelación. +

+
+
+ + ¿Qué métodos de pago aceptan? + +

+ Aceptamos tarjetas de crédito y débito (Visa, Mastercard, American Express), + transferencias bancarias y pagos a través de MercadoPago. +

+
+
+
+
+
+ ); +}; diff --git a/src/pages/billing/UsagePage.tsx b/src/pages/billing/UsagePage.tsx new file mode 100644 index 0000000..3dca78f --- /dev/null +++ b/src/pages/billing/UsagePage.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { useUsageHistory, useUsageSummary, useOverages } from '@features/billing-usage/hooks'; +import { UsageSummaryCard } from '@features/billing-usage/components'; + +export const UsagePage: React.FC = () => { + const { summary, isLoading: summaryLoading } = useUsageSummary(); + const { usage: history, isLoading: historyLoading } = useUsageHistory({ limit: 12 }); + const { overages, isLoading: overagesLoading } = useOverages(); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + }); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(amount); + }; + + return ( +
+
+
+

Uso y Consumo

+

Monitorea el uso de tu suscripción

+
+ +
+ {/* Current Usage Summary */} +
+ +
+ + {/* Overages */} +
+

Excedentes del Período

+ + {overagesLoading ? ( +
+
+
+ ) : overages ? ( +
+ {overages.users > 0 && ( +
+ Usuarios adicionales + +{overages.users} +
+ )} + {overages.branches > 0 && ( +
+ Sucursales adicionales + +{overages.branches} +
+ )} + {overages.storage > 0 && ( +
+ Almacenamiento extra + +{overages.storage} GB +
+ )} + {overages.apiCalls > 0 && ( +
+ API calls extra + + +{overages.apiCalls.toLocaleString()} + +
+ )} + + {overages.totalAmount > 0 ? ( +
+
+ Costo estimado + + {formatCurrency(overages.totalAmount)} + +
+

+ Se añadirá a tu próxima factura +

+
+ ) : ( +
+ + + +

Sin excedentes este período

+
+ )} +
+ ) : ( +

No hay datos disponibles

+ )} +
+
+ + {/* Usage History */} +
+
+

Historial de Uso

+
+ + {historyLoading ? ( +
+
+
+ ) : history.length > 0 ? ( +
+ + + + + + + + + + + + + + {history.map((record) => ( + + + + + + + + + + ))} + +
+ Período + + Usuarios + + Sucursales + + Storage + + API Calls + + Ventas + + Facturable +
+ {formatDate(record.periodStart)} + + {record.activeUsers} + + {record.activeBranches} + + {record.storageUsedGb.toFixed(1)} GB + + {record.apiCalls.toLocaleString()} + + {record.salesCount.toLocaleString()} + + {formatCurrency(record.totalBillableAmount)} +
+
+ ) : ( +
+

No hay historial de uso disponible

+
+ )} +
+ + {/* Usage Details */} + {summary?.currentPeriod && ( +
+ {/* By Profile */} +
+

Usuarios por Perfil

+
+ {Object.entries(summary.currentPeriod.usersByProfile).length > 0 ? ( + Object.entries(summary.currentPeriod.usersByProfile).map(([profile, count]) => ( +
+ {profile} + {count} +
+ )) + ) : ( +

Sin datos

+ )} +
+
+ + {/* By Platform */} +
+

Usuarios por Plataforma

+
+ {Object.entries(summary.currentPeriod.usersByPlatform).length > 0 ? ( + Object.entries(summary.currentPeriod.usersByPlatform).map(([platform, count]) => ( +
+ {platform} + {count} +
+ )) + ) : ( +

Sin datos

+ )} +
+
+
+ )} +
+
+ ); +}; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts new file mode 100644 index 0000000..5244c88 --- /dev/null +++ b/src/pages/billing/index.ts @@ -0,0 +1,4 @@ +export { BillingPage } from './BillingPage'; +export { PlansPage } from './PlansPage'; +export { InvoicesPage } from './InvoicesPage'; +export { UsagePage } from './UsagePage';