import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client"; import { fail, ok } from "@/lib/validation-result"; import type { ValidationResult } from "@/lib/validation-result"; /** * Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps` * is always derived from — and only ever changed alongside — a * `LoyaltyTransaction` row. Nothing in this file writes `currentStamps` * without creating the matching transaction in the same call, and nothing * outside this file should touch either table directly. * * Reward reset-on-redemption (see LoyaltyReward's own schema comment) is * deliberately not implemented here — redeeming a reward isn't one of the * Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or * above `stampsRequired` once a reward becomes available. Redemption is a * documented Phase 5 limitation, not an oversight. */ export type LoyaltyErrorCode = | "LOYALTY_DISABLED" | "PROGRAMME_NOT_FOUND" | "NOT_ELIGIBLE_SERVICE" | "NOT_ELIGIBLE_STAFF" | "REASON_REQUIRED" | "INVALID_AMOUNT" | "APPOINTMENT_NOT_FOUND" | "APPOINTMENT_NOT_COMPLETED"; export interface LoyaltySummary { isEnabled: boolean; accountId: string | null; currentStamps: number; stampsRequired: number; availableReward: { id: string; label: string; createdAt: Date } | null; rewardLabel: string | null; stampIcon: string | null; cardBackgroundColor: string | null; } /** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */ export async function getLoyaltySummary( prisma: PrismaClient, params: { customerId: string; businessId: string }, ): Promise { const programme = await prisma.loyaltyProgramme.findUnique({ where: { businessId: params.businessId }, }); if (!programme) return null; const account = await prisma.loyaltyAccount.findUnique({ where: { customerId_loyaltyProgrammeId: { customerId: params.customerId, loyaltyProgrammeId: programme.id, }, }, include: { rewards: { where: { status: "AVAILABLE" }, orderBy: { createdAt: "desc" }, take: 1 }, }, }); return { isEnabled: programme.isEnabled, accountId: account?.id ?? null, currentStamps: account?.currentStamps ?? 0, stampsRequired: programme.stampsRequired, availableReward: account?.rewards[0] ? { id: account.rewards[0].id, label: account.rewards[0].label, createdAt: account.rewards[0].createdAt, } : null, rewardLabel: programme.rewardLabel, stampIcon: programme.stampIcon, cardBackgroundColor: programme.cardBackgroundColor, }; } /** * Batched version of getLoyaltySummary's "does this customer have a reward * waiting" check, for rendering a loyalty indicator across a whole day's * appointments (the calendar) without one query per customer. Returns an * empty map if loyalty isn't configured/enabled at all. */ export async function getAvailableRewardFlags( prisma: PrismaClient, params: { businessId: string; customerIds: string[] }, ): Promise> { if (params.customerIds.length === 0) return new Map(); const programme = await prisma.loyaltyProgramme.findUnique({ where: { businessId: params.businessId }, }); if (!programme || !programme.isEnabled) return new Map(); const accounts = await prisma.loyaltyAccount.findMany({ where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } }, select: { customerId: true, rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 }, }, }); return new Map(accounts.map((a) => [a.customerId, a.rewards.length > 0])); } export interface CustomerLoyaltyBrief { currentStamps: number; stampsRequired: number; hasAvailableReward: boolean; } /** Fuller batched summary — current stamps + threshold + reward flag — for the staff customer directory, one query for the whole list rather than one per row. */ export async function getLoyaltySummariesForCustomers( prisma: PrismaClient, params: { businessId: string; customerIds: string[] }, ): Promise> { if (params.customerIds.length === 0) return new Map(); const programme = await prisma.loyaltyProgramme.findUnique({ where: { businessId: params.businessId }, }); if (!programme || !programme.isEnabled) return new Map(); const accounts = await prisma.loyaltyAccount.findMany({ where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } }, select: { customerId: true, currentStamps: true, rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 }, }, }); return new Map( accounts.map((a) => [ a.customerId, { currentStamps: a.currentStamps, stampsRequired: programme.stampsRequired, hasAvailableReward: a.rewards.length > 0, }, ]), ); } async function getOrCreateAccount( prisma: PrismaClient, params: { customerId: string; loyaltyProgrammeId: string }, ) { return prisma.loyaltyAccount.upsert({ where: { customerId_loyaltyProgrammeId: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId, }, }, create: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId }, update: {}, }); } /** * An empty eligible-services/eligible-staff list means "everyone/everything * is eligible" (see schema.prisma) — an explicit row only exists to * *restrict* eligibility. A visit counts if at least one of its services is * eligible (the common "any qualifying service in this visit earns a * stamp" rule), not only if every service is. */ async function checkEligibility( prisma: PrismaClient, params: { loyaltyProgrammeId: string; serviceIds: string[]; staffId: string }, ): Promise> { const [eligibleServices, eligibleStaff] = await Promise.all([ prisma.loyaltyEligibleService.findMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId }, }), prisma.loyaltyEligibleStaff.findMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId }, }), ]); if (eligibleStaff.length > 0 && !eligibleStaff.some((r) => r.staffId === params.staffId)) { return fail({ code: "NOT_ELIGIBLE_STAFF", message: "This staff member's visits don't earn loyalty stamps.", }); } if ( eligibleServices.length > 0 && !params.serviceIds.some((id) => eligibleServices.some((r) => r.serviceId === id)) ) { return fail({ code: "NOT_ELIGIBLE_SERVICE", message: "None of the services on this visit earn a loyalty stamp.", }); } return ok(); } interface AddStampResult { success: boolean; alreadyAwarded?: boolean; rewardCreated?: boolean; validation: ValidationResult; } /** * The auto-award path, called once by the "complete appointment" Server * Action after AppointmentService has already transitioned the status — * this function never changes appointment status itself. Idempotent: if a * STAMP_ADDED transaction already references this appointment (e.g. the * action is somehow triggered twice), it returns success without creating * a second one — the brief's explicit "should not receive the same * automatic stamp twice". */ export async function awardStampForCompletedAppointment( prisma: PrismaClient, params: { appointmentId: string; staffId: string }, ): Promise { const appointment = await prisma.appointment.findUnique({ where: { id: params.appointmentId }, include: { services: true }, }); if (!appointment) return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }), }; if (appointment.status !== "COMPLETED") { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_COMPLETED", message: "Only a completed appointment can earn a stamp.", }), }; } const programme = await prisma.loyaltyProgramme.findUnique({ where: { businessId: appointment.businessId }, }); if (!programme || !programme.isEnabled) { return { success: false, validation: fail({ code: "LOYALTY_DISABLED", message: "Loyalty isn't enabled for this business.", }), }; } const eligibility = await checkEligibility(prisma, { loyaltyProgrammeId: programme.id, serviceIds: appointment.services.map((s) => s.serviceId), staffId: appointment.staffId, }); if (!eligibility.valid) return { success: false, validation: eligibility }; const account = await getOrCreateAccount(prisma, { customerId: appointment.customerId, loyaltyProgrammeId: programme.id, }); const alreadyAwarded = await prisma.loyaltyTransaction.findFirst({ where: { loyaltyAccountId: account.id, appointmentId: appointment.id, type: "STAMP_ADDED" }, }); if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() }; const { rewardCreated } = await addStampTransaction(prisma, { loyaltyAccountId: account.id, amount: 1, // one stamp per completed visit — see file header; not a per-service count appointmentId: appointment.id, authoredByStaffId: params.staffId, reason: null, programme, }); return { success: true, rewardCreated, validation: ok() }; } /** The manual "Add Stamp" action a staff member triggers from Customer Details / Current Appointment — not tied to a completion event, so no duplicate-award check applies (every manual add is its own deliberate transaction). */ export async function addStamp( prisma: PrismaClient, params: { customerId: string; businessId: string; amount: number; staffId: string; appointmentId?: string; reason?: string; }, ): Promise { if (!Number.isInteger(params.amount) || params.amount <= 0) { return { success: false, validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps." }), }; } const programme = await prisma.loyaltyProgramme.findUnique({ where: { businessId: params.businessId }, }); if (!programme || !programme.isEnabled) { return { success: false, validation: fail({ code: "LOYALTY_DISABLED", message: "Loyalty isn't enabled for this business.", }), }; } const account = await getOrCreateAccount(prisma, { customerId: params.customerId, loyaltyProgrammeId: programme.id, }); const { rewardCreated } = await addStampTransaction(prisma, { loyaltyAccountId: account.id, amount: params.amount, appointmentId: params.appointmentId, authoredByStaffId: params.staffId, reason: params.reason ?? null, programme, }); return { success: true, rewardCreated, validation: ok() }; } async function addStampTransaction( prisma: PrismaClient, params: { loyaltyAccountId: string; amount: number; appointmentId?: string; authoredByStaffId: string; reason: string | null; programme: LoyaltyProgramme; }, ): Promise<{ rewardCreated: boolean }> { return prisma.$transaction(async (tx) => { const account = await tx.loyaltyAccount.update({ where: { id: params.loyaltyAccountId }, data: { currentStamps: { increment: params.amount } }, }); await tx.loyaltyTransaction.create({ data: { loyaltyAccountId: params.loyaltyAccountId, type: "STAMP_ADDED", amount: params.amount, reason: params.reason, appointmentId: params.appointmentId, authoredByStaffId: params.authoredByStaffId, }, }); let rewardCreated = false; if (account.currentStamps >= params.programme.stampsRequired) { const existingAvailable = await tx.loyaltyReward.findFirst({ where: { loyaltyAccountId: account.id, status: "AVAILABLE" }, }); // Crossing the threshold again while a reward is already waiting to be // redeemed doesn't stack a second one. if (!existingAvailable) { await tx.loyaltyReward.create({ data: { loyaltyAccountId: account.id, rewardType: params.programme.rewardType, value: params.programme.rewardValue, label: params.programme.rewardLabel ?? "Reward", status: "AVAILABLE", expiresAt: params.programme.rewardExpiresAfterDays ? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000) : null, }, }); rewardCreated = true; } } return { rewardCreated }; }); } /** * Corrects an accidental stamp — always removes (never adds; use `addStamp` * for that), always requires a reason, and always creates a compensating * STAMP_REMOVED transaction rather than editing or deleting the original * entry. The cached total is clamped at zero so a correction can never make * it negative. */ export async function correctStamp( prisma: PrismaClient, params: { loyaltyAccountId: string; amount: number; staffId: string; reason: string }, ): Promise<{ success: boolean; validation: ValidationResult }> { if (!params.reason.trim()) { return { success: false, validation: fail({ code: "REASON_REQUIRED", message: "A reason is required to correct a stamp.", }), }; } if (!Number.isInteger(params.amount) || params.amount <= 0) { return { success: false, validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps to remove.", }), }; } await prisma.$transaction(async (tx) => { const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: params.loyaltyAccountId }, }); const clampedRemoval = Math.min(params.amount, account.currentStamps); await tx.loyaltyAccount.update({ where: { id: params.loyaltyAccountId }, data: { currentStamps: { decrement: clampedRemoval } }, }); await tx.loyaltyTransaction.create({ data: { loyaltyAccountId: params.loyaltyAccountId, type: "STAMP_REMOVED", amount: -clampedRemoval, reason: params.reason, authoredByStaffId: params.staffId, }, }); }); return { success: true, validation: ok() }; } export async function getLoyaltyHistory( prisma: PrismaClient, params: { loyaltyAccountId: string; limit?: number }, ) { return prisma.loyaltyTransaction.findMany({ where: { loyaltyAccountId: params.loyaltyAccountId }, orderBy: { createdAt: "desc" }, take: params.limit ?? 20, include: { authoredByStaff: { include: { user: { select: { name: true } } } }, appointment: { select: { id: true, startAt: true } }, }, }); }