feat: Implement FASE 5 (Clients & Loyalty) and FASE 6 (Payments & Financial)

FASE 5 - Clientes y Fidelización:
- Client Management (CRM) con búsqueda fonética
- Galería de fotos restringida por tier (VIP/Black/Gold)
- Sistema de Lealtad con puntos y expiración (6 meses)
- Membresías (Gold, Black, VIP) con beneficios configurables
- Notas técnicas con timestamp

APIs Implementadas:
- GET/POST /api/aperture/clients - CRUD completo de clientes
- GET /api/aperture/clients/[id] - Detalles con historial de reservas
- POST /api/aperture/clients/[id]/notes - Notas técnicas
- GET/POST /api/aperture/clients/[id]/photos - Galería de fotos
- GET /api/aperture/loyalty - Resumen de lealtad
- GET/POST /api/aperture/loyalty/[customerId] - Historial y puntos

FASE 6 - Pagos y Protección:
- Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- No-Show Logic con detección automática (ventana 12h)
- Check-in de clientes para prevenir no-shows
- Override Admin para waivar penalizaciones
- Finanzas y Reportes (expenses, daily closing, staff performance)

APIs Implementadas:
- POST /api/webhooks/stripe - Handler de webhooks Stripe
- GET /api/cron/detect-no-shows - Detectar no-shows (cron job)
- POST /api/aperture/bookings/no-show - Aplicar penalización
- POST /api/aperture/bookings/check-in - Registrar check-in
- GET /api/aperture/finance - Resumen financiero
- POST/GET /api/aperture/finance/daily-closing - Reportes diarios
- GET/POST /api/aperture/finance/expenses - Gestión de gastos
- GET /api/aperture/finance/staff-performance - Performance de staff

Documentación:
- docs/APERATURE_SPECS.md - Especificaciones técnicas completas
- docs/APERTURE_SQUARE_UI.md - Ejemplos de Radix UI con Square UI
- docs/API.md - Actualizado con nuevas rutas

Migraciones SQL:
- 20260118050000_clients_loyalty_system.sql - Clientes, fotos, lealtad, membresías
- 20260118060000_stripe_webhooks_noshow_logic.sql - Webhooks, no-shows, check-ins
- 20260118070000_financial_reporting_expenses.sql - Gastos, reportes financieros
This commit is contained in:
Marco Gallegos
2026-01-18 23:05:09 -06:00
parent f6832c1e29
commit bb25d6bde6
21 changed files with 3845 additions and 13 deletions

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Record check-in for a booking
* @param {NextRequest} request - Body with booking_id and staff_id
* @returns {NextResponse} Check-in result
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, staff_id } = body
if (!booking_id || !staff_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID and Staff ID are required' },
{ status: 400 }
)
}
// Record check-in
const { data: success, error } = await supabaseAdmin.rpc('record_booking_checkin', {
p_booking_id: booking_id,
p_staff_id: staff_id
})
if (error) {
console.error('Error recording check-in:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
if (!success) {
return NextResponse.json(
{ success: false, error: 'Check-in already recorded or booking not found' },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/check-in:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Apply no-show penalty to a specific booking
* @param {NextRequest} request - Body with booking_id and optional override_by (admin)
* @returns {NextResponse} Penalty application result
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, override_by } = body
if (!booking_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID is required' },
{ status: 400 }
)
}
// Apply penalty
const { error } = await supabaseAdmin.rpc('apply_no_show_penalty', {
p_booking_id: booking_id,
p_override_by: override_by || null
})
if (error) {
console.error('Error applying no-show penalty:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/no-show:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Add technical note to client
* @param {NextRequest} request - Body with note content
* @returns {NextResponse} Updated customer with notes
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { note } = await request.json()
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note content is required' },
{ status: 400 }
)
}
// Get current customer
const { data: customer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('notes, technical_notes')
.eq('id', clientId)
.single()
if (fetchError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Append new technical note
const existingNotes = customer.technical_notes || ''
const timestamp = new Date().toISOString()
const newNoteEntry = `[${timestamp}] ${note}`
const updatedNotes = existingNotes
? `${existingNotes}\n${newNoteEntry}`
: newNoteEntry
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
technical_notes: updatedNotes,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error adding technical note:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'technical_note_added',
new_values: { note }
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/notes:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get client photo gallery (VIP/Black/Gold only)
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client photos with metadata
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Check if customer tier allows photo access
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Check tier access
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Get photos
const { data: photos, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select(`
*,
creator:auth.users(id, email)
`)
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
if (photosError) {
console.error('Error fetching photos:', photosError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch photos' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: photos || []
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Upload photo to client gallery (VIP/Black/Gold only)
* @param {NextRequest} request - Body with photo data
* @returns {NextResponse} Uploaded photo metadata
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { storage_path, description } = await request.json()
if (!storage_path) {
return NextResponse.json(
{ success: false, error: 'Storage path is required' },
{ status: 400 }
)
}
// Check if customer tier allows photo gallery
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Create photo record
const { data: photo, error: photoError } = await supabaseAdmin
.from('customer_photos')
.insert({
customer_id: clientId,
storage_path,
description,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (photoError) {
console.error('Error uploading photo:', photoError)
return NextResponse.json(
{ success: false, error: photoError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer_photo',
entity_id: photo.id,
action: 'upload',
new_values: { customer_id: clientId, storage_path }
})
return NextResponse.json({
success: true,
data: photo
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get specific client details with full history
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client details with bookings, loyalty, photos
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Get customer basic info
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Get recent bookings
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select(`
*,
service:services(name, base_price, duration_minutes),
location:locations(name),
staff:staff(id, first_name, last_name)
`)
.eq('customer_id', clientId)
.order('start_time_utc', { ascending: false })
.limit(20)
if (bookingsError) {
console.error('Error fetching bookings:', bookingsError)
}
// Get loyalty summary
const { data: loyaltyTransactions, error: loyaltyError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', clientId)
.order('created_at', { ascending: false })
.limit(10)
if (loyaltyError) {
console.error('Error fetching loyalty transactions:', loyaltyError)
}
// Get photos (if tier allows)
let photos = []
const canAccessPhotos = ['gold', 'black', 'VIP'].includes(customer.tier)
if (canAccessPhotos) {
const { data: photosData, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select('*')
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
.limit(20)
if (!photosError) {
photos = photosData
}
}
// Get subscription (if any)
const { data: subscription, error: subError } = await supabaseAdmin
.from('customer_subscriptions')
.select(`
*,
membership_plan:membership_plans(name, tier, benefits)
`)
.eq('customer_id', clientId)
.eq('status', 'active')
.single()
return NextResponse.json({
success: true,
data: {
customer,
bookings: bookings || [],
loyalty_transactions: loyaltyTransactions || [],
photos,
subscription: subError ? null : subscription
}
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Update client information
* @param {NextRequest} request - Body with updated client data
* @returns {NextResponse} Updated client data
*/
export async function PUT(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const body = await request.json()
// Get current customer
const { data: currentCustomer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (fetchError || !currentCustomer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
...body,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error updating client:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'update',
old_values: currentCustomer,
new_values: updatedCustomer
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in PUT /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description List and search clients with phonetic search, history, and technical notes
* @param {NextRequest} request - Query params: q (search query), tier (filter by tier), limit (results limit), offset (pagination offset)
* @returns {NextResponse} List of clients with their details
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const q = searchParams.get('q') || ''
const tier = searchParams.get('tier')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('customers')
.select(`
*,
bookings:bookings(
id,
short_id,
service_id,
start_time_utc,
status,
total_price
)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
// Apply tier filter
if (tier) {
query = query.eq('tier', tier)
}
// Apply phonetic search if query provided
if (q) {
const searchTerm = `%${q}%`
query = query.or(`first_name.ilike.${searchTerm},last_name.ilike.${searchTerm},email.ilike.${searchTerm},phone.ilike.${searchTerm}`)
}
const { data: customers, error, count } = await query
if (error) {
console.error('Error fetching clients:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch clients' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: customers,
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Create new client
* @param {NextRequest} request - Body with client details
* @returns {NextResponse} Created client data
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
first_name,
last_name,
email,
phone,
tier = 'free',
notes,
preferences,
referral_code
} = body
// Validate required fields
if (!first_name || !last_name) {
return NextResponse.json(
{ success: false, error: 'First name and last name are required' },
{ status: 400 }
)
}
// Generate unique referral code if not provided
let finalReferralCode = referral_code
if (!finalReferralCode) {
finalReferralCode = `${first_name.toLowerCase().replace(/[^a-z]/g, '')}${last_name.toLowerCase().replace(/[^a-z]/g, '')}${Date.now().toString(36)}`
}
// Create customer
const { data: customer, error } = await supabaseAdmin
.from('customers')
.insert({
first_name,
last_name,
email: email || null,
phone: phone || null,
tier,
notes,
preferences: preferences || {},
referral_code: finalReferralCode
})
.select()
.single()
if (error) {
console.error('Error creating client:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: customer.id,
action: 'create',
new_values: {
first_name,
last_name,
email,
tier
}
})
return NextResponse.json({
success: true,
data: customer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get daily closing reports
* @param {NextRequest} request - Query params: location_id, start_date, end_date, status
* @returns {NextResponse} List of daily closing reports
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('daily_closing_reports')
.select('*', { count: 'exact' })
.order('report_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (status) {
query = query.eq('status', status)
}
if (start_date) {
query = query.gte('report_date', start_date)
}
if (end_date) {
query = query.lte('report_date', end_date)
}
const { data: reports, error, count } = await query
if (error) {
console.error('Error fetching daily closing reports:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch daily closing reports' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: reports || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/daily-closing:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Create expense record
* @param {NextRequest} request - Body with expense details
* @returns {NextResponse} Created expense
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes
} = body
if (!category || !description || !amount || !expense_date) {
return NextResponse.json(
{ success: false, error: 'category, description, amount, and expense_date are required' },
{ status: 400 }
)
}
const { data: expense, error } = await supabaseAdmin
.from('expenses')
.insert({
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (error) {
console.error('Error creating expense:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'expense',
entity_id: expense.id,
action: 'create',
new_values: {
category,
description,
amount
}
})
return NextResponse.json({
success: true,
data: expense
})
} catch (error) {
console.error('Error in POST /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Get expenses with filters
* @param {NextRequest} request - Query params: location_id, category, start_date, end_date
* @returns {NextResponse} List of expenses
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const category = searchParams.get('category')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('expenses')
.select('*', { count: 'exact' })
.order('expense_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (category) {
query = query.eq('category', category)
}
if (start_date) {
query = query.gte('expense_date', start_date)
}
if (end_date) {
query = query.lte('expense_date', end_date)
}
const { data: expenses, error, count } = await query
if (error) {
console.error('Error fetching expenses:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch expenses' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: expenses || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get financial summary for date range and location
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Financial summary with revenue, expenses, and profit
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'start_date and end_date are required' },
{ status: 400 }
)
}
// Get financial summary
const { data: summary, error } = await supabaseAdmin.rpc('get_financial_summary', {
p_location_id: location_id || null,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching financial summary:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch financial summary' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: summary
})
} catch (error) {
console.error('Error in GET /api/aperture/finance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get staff performance report for date range
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Staff performance metrics per staff member
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!location_id || !start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'location_id, start_date, and end_date are required' },
{ status: 400 }
)
}
// Get staff performance report
const { data: report, error } = await supabaseAdmin.rpc('get_staff_performance_report', {
p_location_id: location_id,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching staff performance report:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch staff performance report' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: report
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/staff-performance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty history for specific customer
* @param {NextRequest} request - URL params: customerId in path
* @returns {NextResponse} Customer loyalty transactions and history
*/
export async function GET(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get loyalty transactions with pagination
const searchParams = request.nextUrl.searchParams
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
const { data: transactions, error: transactionsError, count } = await supabaseAdmin
.from('loyalty_transactions')
.select('*', { count: 'exact' })
.eq('customer_id', customerId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty transactions' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty/[customerId]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Add or remove loyalty points for customer
* @param {NextRequest} request - Body with points, transaction_type, description, reference_type, reference_id
* @returns {NextResponse} Transaction result and updated summary
*/
export async function POST(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
const body = await request.json()
const {
points,
transaction_type = 'admin_adjustment',
description,
reference_type,
reference_id
} = body
if (!points || typeof points !== 'number') {
return NextResponse.json(
{ success: false, error: 'Points amount is required and must be a number' },
{ status: 400 }
)
}
// Add loyalty points
const { data: transactionId, error: error } = await supabaseAdmin
.rpc('add_loyalty_points', {
p_customer_id: customerId,
p_points: points,
p_transaction_type: transaction_type,
p_description: description,
p_reference_type: reference_type,
p_reference_id: reference_id
})
if (error) {
console.error('Error adding loyalty points:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated summary
const { data: summary } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
return NextResponse.json({
success: true,
data: {
transaction_id: transactionId,
summary
}
})
} catch (error) {
console.error('Error in POST /api/aperture/loyalty/[customerId]/points:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty points and rewards for current customer
* @param {NextRequest} request - Query params: customerId (optional, defaults to authenticated user)
* @returns {NextResponse} Loyalty summary with points, transactions, and rewards
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const customerId = searchParams.get('customerId')
// Get customer ID from auth or query param
let targetCustomerId = customerId
// If no customerId provided, get from authenticated user
if (!targetCustomerId) {
const { data: { user } } = await supabaseAdmin.auth.getUser()
if (!user) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const { data: customer } = await supabaseAdmin
.from('customers')
.select('id')
.eq('user_id', user.id)
.single()
if (!customer) {
return NextResponse.json(
{ success: false, error: 'Customer not found' },
{ status: 404 }
)
}
targetCustomerId = customer.id
}
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: targetCustomerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get recent transactions
const { data: transactions, error: transactionsError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', targetCustomerId)
.order('created_at', { ascending: false })
.limit(50)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
}
// Get available rewards based on points
const { data: membershipPlans, error: plansError } = await supabaseAdmin
.from('membership_plans')
.select('*')
.eq('is_active', true)
if (plansError) {
console.error('Error fetching membership plans:', plansError)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
available_rewards: membershipPlans || []
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description CRITICAL: Detect and mark no-show bookings (runs every 2 hours)
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
* @returns {NextResponse} No-show detection results with count of bookings processed
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/detect-no-shows
* @audit BUSINESS RULE: No-show window is 12 hours after booking start time (UTC)
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
* @audit Validate: Only confirmed/pending bookings without check-in are affected
* @audit AUDIT: Detection action logged in audit_logs with booking details
* @audit PERFORMANCE: Efficient query with date range and status filters
* @audit RELIABILITY: Cron job should run every 2 hours to detect no-shows
*/
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const cronKey = authHeader.replace('Bearer ', '').trim()
if (cronKey !== process.env.CRON_SECRET) {
return NextResponse.json(
{ success: false, error: 'Invalid cron key' },
{ status: 403 }
)
}
// Calculate no-show window: bookings that started more than 12 hours ago
const windowStart = new Date()
windowStart.setHours(windowStart.getHours() - 12)
// Get eligible bookings (confirmed/pending, no check-in, started > 12h ago)
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select('id, start_time_utc, customer_id, service_id, deposit_amount')
.in('status', ['confirmed', 'pending'])
.lt('start_time_utc', windowStart.toISOString())
.is('check_in_time', null)
if (bookingsError) {
console.error('Error fetching bookings for no-show detection:', bookingsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch bookings' },
{ status: 500 }
)
}
if (!bookings || bookings.length === 0) {
return NextResponse.json({
success: true,
message: 'No bookings to process',
processedCount: 0,
detectedCount: 0
})
}
let detectedCount = 0
// Process each booking
for (const booking of bookings) {
const detected = await supabaseAdmin.rpc('detect_no_show_booking', {
p_booking_id: booking.id
})
if (detected) {
detectedCount++
}
}
console.log(`No-show detection completed: ${detectedCount} bookings detected out of ${bookings.length} processed`)
return NextResponse.json({
success: true,
message: 'No-show detection completed successfully',
processedCount: bookings.length,
detectedCount
})
} catch (error) {
console.error('Error in no-show detection:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
import Stripe from 'stripe'
/**
* @description Handle Stripe webhooks for payment intents and refunds
* @param {NextRequest} request - Raw Stripe webhook payload with signature
* @returns {NextResponse} Webhook processing result
*/
export async function POST(request: NextRequest) {
try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!stripeSecretKey || !stripeWebhookSecret) {
return NextResponse.json(
{ error: 'Stripe not configured' },
{ status: 500 }
)
}
const stripe = new Stripe(stripeSecretKey)
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing Stripe signature' },
{ status: 400 }
)
}
// Verify webhook signature
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
stripeWebhookSecret
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
const eventId = event.id
// Check if event already processed
const { data: existingLog } = await supabaseAdmin
.from('webhook_logs')
.select('*')
.eq('event_id', eventId)
.single()
if (existingLog) {
console.log(`Event ${eventId} already processed, skipping`)
return NextResponse.json({ received: true, already_processed: true })
}
// Log webhook event
await supabaseAdmin.from('webhook_logs').insert({
event_type: event.type,
event_id: eventId,
payload: event.data as any
})
// Process based on event type
switch (event.type) {
case 'payment_intent.succeeded':
await supabaseAdmin.rpc('process_payment_intent_succeeded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'payment_intent.payment_failed':
await supabaseAdmin.rpc('process_payment_intent_failed', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'charge.refunded':
await supabaseAdmin.rpc('process_charge_refunded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Error processing Stripe webhook:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}