import { initContract } from '@ts-rest/core'; import { z } from 'zod'; import { shopSaleSchema } from '../schemas/shopkeeper.schemas'; import { errorResponseSchema, insufficientTokensErrorSchema } from '../schemas/common.schemas'; const c = initContract(); /** * Shop analytics API. Path segment "analytics" reflects the resource/domain; * the UI that consumes this is the "dashboard". */ export const shopkeeperAnalyticsContract = c.router({ getLiveView: { method: 'GET', path: '/shopkeeper/analytics/live', query: z.object({ since: z.coerce.date().optional() }), responses: { 200: z.object({ recentSales: z.array(shopSaleSchema), revenueToday: z.number(), transactionsToday: z.number(), lowStockItems: z.array( z.object({ productId: z.string().uuid(), name: z.string(), currentStock: z.number(), reorderThreshold: z.number().nullable(), }), ), lastSyncAt: z.coerce.date().nullable(), }), 401: errorResponseSchema, }, summary: 'Real-time transaction feed and stock levels', }, getAnalytics: { method: 'GET', path: '/shopkeeper/analytics/trends', query: z.object({ period: z.enum(['day', 'week', 'month', 'quarter', 'year']).default('month'), from: z.coerce.date().optional(), to: z.coerce.date().optional(), }), responses: { 200: z.object({ salesTrend: z.array( z.object({ date: z.string(), revenue: z.number(), transactions: z.number(), }), ), productPerformance: z.array( z.object({ productId: z.string().uuid(), name: z.string(), revenue: z.number(), margin: z.number().nullable(), velocity: z.number(), }), ), paymentBreakdown: z.record(z.number()), inputMethodBreakdown: z.record(z.number()), peakHours: z.array( z.object({ hour: z.number().int(), transactions: z.number(), revenue: z.number(), }), ), }), 401: errorResponseSchema, }, summary: 'Sales trends, product performance, payment breakdowns', }, getEmployeeView: { method: 'GET', path: '/shopkeeper/analytics/employees', query: z.object({ from: z.coerce.date().optional(), to: z.coerce.date().optional(), }), responses: { 200: z.object({ employees: z.array( z.object({ name: z.string(), totalTransactions: z.number(), totalRevenue: z.number(), cashCollected: z.number(), expectedCash: z.number(), discrepancy: z.number(), }), ), }), 401: errorResponseSchema, }, summary: 'Employee transactions and cash reconciliation', }, getMultiShopComparison: { method: 'GET', path: '/shopkeeper/analytics/multi-shop', query: z.object({ period: z.enum(['day', 'week', 'month']).default('month'), }), responses: { 200: z.object({ shops: z.array( z.object({ shopId: z.string().uuid(), shopName: z.string(), revenue: z.number(), transactions: z.number(), topProduct: z.string().nullable(), activeEmployees: z.number(), lowStockCount: z.number(), }), ), }), 401: errorResponseSchema, 403: errorResponseSchema, }, summary: 'Side-by-side shop comparison for multi-shop owners', }, getMultiShopSummary: { method: 'GET', path: '/shopkeeper/analytics/multi-shop/summary', query: z.object({ period: z.enum(['day', 'week', 'month']).default('month'), }), responses: { 200: z.object({ aggregate: z.object({ shopCount: z.number(), totalRevenue: z.number(), totalTransactions: z.number(), totalLowStockSkus: z.number(), avgRevenuePerShop: z.number(), }), shops: z.array( z.object({ shopId: z.string().uuid(), shopName: z.string(), revenue: z.number(), transactions: z.number(), topProduct: z.string().nullable(), activeEmployees: z.number(), lowStockCount: z.number(), vsAverage: z.number(), }), ), }), 401: errorResponseSchema, 403: errorResponseSchema, }, summary: 'Aggregate KPIs and per-shop health for multi-shop command center', }, createShareLink: { method: 'POST', path: '/shopkeeper/analytics/share', body: z.object({ permissions: z.array( z.enum(['read_analytics', 'read_reports', 'read_transactions', 'bank_share']), ), expiresInDays: z.number().int().min(1).max(90).default(30), label: z.string().max(100).optional(), }), responses: { 201: z.object({ shareToken: z.string(), shareUrl: z.string(), expiresAt: z.coerce.date(), }), 401: errorResponseSchema, }, summary: 'Generate tokenized read-only link for accountant/lender', }, getSharedView: { method: 'GET', path: '/shopkeeper/analytics/shared/:token', pathParams: z.object({ token: z.string() }), query: z.object({ period: z.enum(['week', 'month', 'quarter']).default('month'), }), responses: { 200: z.object({ shopName: z.string(), analytics: z.object({ salesTrend: z.array( z.object({ date: z.string(), revenue: z.number(), transactions: z.number(), }), ), totalRevenue: z.number(), totalTransactions: z.number(), }), financialStatement: z.record(z.unknown()).optional(), generatedAt: z.coerce.date(), }), 401: errorResponseSchema, 404: errorResponseSchema, }, summary: 'Public read-only view via shared link', }, /** Gap 11: Sales forecast (moving average) per product. */ getSalesForecast: { method: 'GET', path: '/shopkeeper/analytics/forecast', query: z.object({ productId: z.string().uuid().optional() }), responses: { 200: z.object({ forecast: z.array( z.object({ productId: z.string().uuid(), productName: z.string(), avgDailyQty: z.number(), forecastNext7Days: z.number(), }), ), }), 401: errorResponseSchema, }, summary: 'Next 7 days demand forecast per product', }, /** Gap 11: Customer segments (RFM). */ getCustomerSegmentation: { method: 'GET', path: '/shopkeeper/analytics/segmentation', responses: { 200: z.object({ segments: z.array( z.object({ segment: z.string(), count: z.number(), customers: z.array( z.object({ id: z.string().uuid(), name: z.string(), recencyDays: z.number(), visits: z.number(), totalSpent: z.number(), }), ), }), ), }), 401: errorResponseSchema, }, summary: 'Customer segments by recency, frequency, monetary', }, /** Gap 11: Day-of-week and hour seasonality. */ getSeasonality: { method: 'GET', path: '/shopkeeper/analytics/seasonality', query: z.object({ days: z.coerce.number().int().min(7).max(365).default(90) }), responses: { 200: z.object({ byDayOfWeek: z.array( z.object({ day: z.number().int(), dayName: z.string(), revenue: z.number(), transactions: z.number(), }), ), byHour: z.array( z.object({ hour: z.number().int(), revenue: z.number(), transactions: z.number(), }), ), }), 401: errorResponseSchema, }, summary: 'Sales patterns by day of week and hour', }, /** Aggregated market price benchmark (2 tokens per pull). */ getMarketBenchmark: { method: 'GET', path: '/shopkeeper/analytics/market-benchmark', query: z.object({ normalizedName: z.string().min(1), market: z.string().min(1), city: z.string().min(1), }), responses: { 200: z .object({ normalizedName: z.string(), market: z.string(), city: z.string(), medianPrice: z.number(), p25: z.number().nullable(), p75: z.number().nullable(), sampleSize: z.number().int(), updatedAt: z.coerce.date(), }) .nullable(), 401: errorResponseSchema, 402: insufficientTokensErrorSchema, }, summary: 'Median / IQR selling price for a product label in a market (privacy-safe aggregate)', }, /** Owner-opt-in lender handoff package (internal credit score + financial statement). */ getLenderPackage: { method: 'GET', path: '/shopkeeper/analytics/lender-package', query: z.object({ /** Explicit owner consent required before score is returned. */ lenderOptIn: z.enum(['true']).optional(), }), responses: { 200: z.object({ scoreSummary: z.object({ score: z.number().int().min(0).max(100), band: z.enum(['excellent', 'good', 'fair', 'limited']), components: z.object({ revenueConsistency: z.number(), paymentMethodDiversification: z.number(), creditRepaymentRate: z.number(), growthTrend: z.number(), }), }), financialStatement: z.record(z.unknown()), computedAt: z.coerce.date(), }), 401: errorResponseSchema, 403: errorResponseSchema.extend({ code: z.literal('lending_not_eligible').optional(), daysRemaining: z.number().int().nonnegative().optional(), }), }, summary: 'MFI handoff package — owner-only with explicit opt-in', }, /** Gap 11: Margin optimization suggestions. */ getMarginOptimization: { method: 'GET', path: '/shopkeeper/analytics/margin', responses: { 200: z.object({ products: z.array( z.object({ productId: z.string().uuid(), productName: z.string(), currentMarginPercent: z.number(), costPrice: z.number(), sellingPrice: z.number(), suggestion: z.string(), }), ), }), 401: errorResponseSchema, }, summary: 'Products with low margin or selling below cost', }, });