mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 21:24:35 +00:00
- Add KiosksManagement component with full CRUD for kiosks - Add ScheduleManagement for staff schedules with break reminders - Update booking flow to allow artist selection by customers - Add staff_services API for assigning services to artists - Update staff management UI with service assignment dialog - Add auto-break reminder when schedule >= 8 hours - Update availability API to filter artists by service - Add kiosk management to Aperture dashboard - Clean up ralphy artifacts and logs
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
/**
|
|
* @description Processes end-of-day cash register closure with financial reconciliation
|
|
* @param {NextRequest} request - HTTP request containing date, location_id, cash_count object, expected_totals, and optional notes
|
|
* @returns {NextResponse} JSON with success status, reconciliation report including actual totals, discrepancies, and closure record
|
|
* @example POST /api/aperture/pos/close-day { date: "2026-01-21", location_id: "...", cash_count: { cash_amount: 5000, card_amount: 8000, transfer_amount: 2000 }, notes: "Day closure" }
|
|
* @audit BUSINESS RULE: Compares physical cash count with system-recorded transactions to identify discrepancies
|
|
* @audit BUSINESS RULE: Creates immutable daily_closing_report record after successful reconciliation
|
|
* @audit SECURITY: Requires authenticated manager/admin role
|
|
* @audit Validate: Ensures date is valid and location exists
|
|
* @audit Validate: Calculates discrepancies for each payment method
|
|
* @audit PERFORMANCE: Uses audit_logs for transaction aggregation (single source of truth)
|
|
* @audit AUDIT: Daily closure creates permanent financial record with all discrepancies documented
|
|
* @audit COMPLIANCE: Closure records are immutable and used for financial reporting
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { supabaseAdmin } from '@/lib/supabase/admin'
|
|
|
|
interface CashCount {
|
|
cash_amount: number
|
|
card_amount: number
|
|
transfer_amount: number
|
|
giftcard_amount: number
|
|
membership_amount: number
|
|
other_amount: number
|
|
notes?: string
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = await request.json()
|
|
const {
|
|
date,
|
|
location_id,
|
|
cash_count,
|
|
expected_totals,
|
|
notes
|
|
} = body
|
|
|
|
if (!date || !location_id || !cash_count) {
|
|
return NextResponse.json(
|
|
{ error: 'Missing required fields: date, location_id, cash_count' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Get actual sales data for the day
|
|
const { data: transactions } = await supabaseAdmin
|
|
.from('audit_logs')
|
|
.select('*')
|
|
.eq('entity_type', 'pos_sale')
|
|
.eq('action', 'sale_completed')
|
|
.eq('new_values->location_id', location_id)
|
|
.gte('created_at', `${date}T00:00:00Z`)
|
|
.lte('created_at', `${date}T23:59:59Z`)
|
|
|
|
// Calculate actual totals from transactions
|
|
const actualTotals = (transactions || []).reduce((totals: any, transaction: any) => {
|
|
const sale = transaction.new_values
|
|
const payments = sale.payment_methods || []
|
|
|
|
return {
|
|
total_sales: totals.total_sales + 1,
|
|
total_revenue: totals.total_revenue + (sale.total_amount || 0),
|
|
payment_breakdown: payments.reduce((breakdown: any, payment: any) => ({
|
|
...breakdown,
|
|
[payment.method]: (breakdown[payment.method] || 0) + payment.amount
|
|
}), totals.payment_breakdown)
|
|
}
|
|
}, {
|
|
total_sales: 0,
|
|
total_revenue: 0,
|
|
payment_breakdown: {}
|
|
})
|
|
|
|
// Calculate discrepancies
|
|
const discrepancies = {
|
|
cash: (cash_count.cash_amount || 0) - (actualTotals.payment_breakdown.cash || 0),
|
|
card: (cash_count.card_amount || 0) - (actualTotals.payment_breakdown.card || 0),
|
|
transfer: (cash_count.transfer_amount || 0) - (actualTotals.payment_breakdown.transfer || 0),
|
|
giftcard: (cash_count.giftcard_amount || 0) - (actualTotals.payment_breakdown.giftcard || 0),
|
|
membership: (cash_count.membership_amount || 0) - (actualTotals.payment_breakdown.membership || 0),
|
|
other: (cash_count.other_amount || 0) - (actualTotals.payment_breakdown.other || 0)
|
|
}
|
|
|
|
// Get current user (manager closing the register)
|
|
const { data: { user } } = await supabaseAdmin.auth.getUser()
|
|
|
|
// Create cash closure record
|
|
const closureRecord = {
|
|
date,
|
|
location_id,
|
|
actual_totals: actualTotals,
|
|
counted_totals: cash_count,
|
|
discrepancies,
|
|
total_discrepancy: Object.values(discrepancies).reduce((sum: number, disc: any) => sum + disc, 0),
|
|
closed_by: user?.id,
|
|
status: 'closed',
|
|
notes
|
|
}
|
|
|
|
const { data: closure, error: closureError } = await supabaseAdmin
|
|
.from('audit_logs')
|
|
.insert({
|
|
entity_type: 'cash_closure',
|
|
entity_id: `closure-${date}-${location_id}`,
|
|
action: 'register_closed',
|
|
new_values: closureRecord,
|
|
performed_by_role: 'admin'
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (closureError) {
|
|
console.error('Cash closure error:', closureError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to close cash register' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
closure: closureRecord,
|
|
report: {
|
|
date,
|
|
location_id,
|
|
actual_sales: actualTotals.total_sales,
|
|
actual_revenue: actualTotals.total_revenue,
|
|
counted_amounts: cash_count,
|
|
discrepancies,
|
|
total_discrepancy: closureRecord.total_discrepancy,
|
|
status: Math.abs(closureRecord.total_discrepancy) < 0.01 ? 'balanced' : 'discrepancy'
|
|
}
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error('Cash closure API error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(request.url)
|
|
const date = searchParams.get('date')
|
|
const location_id = searchParams.get('location_id')
|
|
|
|
if (!date || !location_id) {
|
|
return NextResponse.json(
|
|
{ error: 'Missing required parameters: date, location_id' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Get closure record for the day
|
|
const { data: closures } = await supabaseAdmin
|
|
.from('audit_logs')
|
|
.select('*')
|
|
.eq('entity_type', 'cash_closure')
|
|
.eq('entity_id', `closure-${date}-${location_id}`)
|
|
.eq('action', 'register_closed')
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
|
|
if (closures && closures.length > 0) {
|
|
const closure = closures[0]
|
|
return NextResponse.json({
|
|
success: true,
|
|
closure: closure.new_values,
|
|
already_closed: true
|
|
})
|
|
}
|
|
|
|
// Get sales data for closure preparation
|
|
const { data: transactions } = await supabaseAdmin
|
|
.from('audit_logs')
|
|
.select('*')
|
|
.eq('entity_type', 'pos_sale')
|
|
.eq('action', 'sale_completed')
|
|
.gte('created_at', `${date}T00:00:00Z`)
|
|
.lte('created_at', `${date}T23:59:59Z`)
|
|
|
|
const salesSummary = (transactions || []).reduce((summary: any, transaction: any) => {
|
|
const sale = transaction.new_values
|
|
const payments = sale.payment_methods || []
|
|
|
|
return {
|
|
total_sales: summary.total_sales + 1,
|
|
total_revenue: summary.total_revenue + (sale.total_amount || 0),
|
|
payment_breakdown: payments.reduce((breakdown: any, payment: any) => ({
|
|
...breakdown,
|
|
[payment.method]: (breakdown[payment.method] || 0) + payment.amount
|
|
}), summary.payment_breakdown)
|
|
}
|
|
}, {
|
|
total_sales: 0,
|
|
total_revenue: 0,
|
|
payment_breakdown: {}
|
|
})
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
already_closed: false,
|
|
sales_summary: salesSummary,
|
|
expected_counts: salesSummary.payment_breakdown
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error('Cash closure GET error:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
} |