import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client"; import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums"; import { fail, ok } from "@/lib/validation-result"; import type { ValidationResult } from "@/lib/validation-result"; import { notifyLoyaltyRewardEarned, notifyLoyaltyRewardExpiring, notifyLoyaltyRewardRedeemed, notifyLoyaltyStampAdded, } from "./loyalty-notifications"; import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold"; export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold"; /** * 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 redemption (Phase 6) follows the same discipline: redeeming a * reward, deducting the consumed stamps from the cached total, and * recording the ledger entry all happen inside one `$transaction` — see * `redeemReward`. This was a documented, deliberate Phase 5 limitation, * not an oversight; it's implemented now. */ export type LoyaltyErrorCode = | "LOYALTY_DISABLED" | "PROGRAMME_NOT_FOUND" | "NOT_ELIGIBLE_SERVICE" | "NOT_ELIGIBLE_STAFF" | "REASON_REQUIRED" | "INVALID_AMOUNT" | "APPOINTMENT_NOT_FOUND" | "APPOINTMENT_NOT_COMPLETED" | "INVALID_INPUT" | "INVALID_PERCENTAGE" | "INVALID_VALUE" | "FREE_SERVICE_REQUIRED" | "REWARD_NOT_FOUND" | "REWARD_NOT_AVAILABLE" | "REWARD_CUSTOMER_MISMATCH" | "APPOINTMENT_NOT_ELIGIBLE"; export interface LoyaltySummary { isEnabled: boolean; accountId: string | null; currentStamps: number; stampsRequired: number; rewardPosition: RewardPositionMode; availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null; rewardLabel: string | null; stampIcon: string | null; cardBackgroundColor: string | null; cardBackgroundImageUrl: string | null; termsLine: string | null; cardTitleText: string | null; cardFont: LoyaltyCardFont; cardTitleTextSize: LoyaltyCardTextSize; cardTitleTextColor: string | null; cardBorderColor: string | null; cardMemberNumberColor: 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, rewardPosition: programme.rewardPosition, availableReward: account?.rewards[0] ? { id: account.rewards[0].id, label: account.rewards[0].label, createdAt: account.rewards[0].createdAt, expiresAt: account.rewards[0].expiresAt, } : null, rewardLabel: programme.rewardLabel, stampIcon: programme.stampIcon, cardBackgroundColor: programme.cardBackgroundColor, cardBackgroundImageUrl: programme.cardBackgroundImageUrl, termsLine: programme.termsLine, cardTitleText: programme.cardTitleText, cardFont: programme.cardFont, cardTitleTextSize: programme.cardTitleTextSize, cardTitleTextColor: programme.cardTitleTextColor, cardBorderColor: programme.cardBorderColor, cardMemberNumberColor: programme.cardMemberNumberColor, }; } /** * 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; /** How many stamps actually earn the reward — see resolveRewardThreshold. */ rewardThreshold: 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 }, }, }); const rewardThreshold = resolveRewardThreshold(programme); return new Map( accounts.map((a) => [ a.customerId, { currentStamps: a.currentStamps, rewardThreshold, 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, rewardId, transactionId } = 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, }); await notifyLoyaltyStampAdded(prisma, { businessId: appointment.businessId, customerId: appointment.customerId, transactionId, appointmentId: appointment.id, }); if (rewardCreated && rewardId) { await notifyLoyaltyRewardEarned(prisma, { businessId: appointment.businessId, customerId: appointment.customerId, rewardId, rewardLabel: programme.rewardLabel ?? "Reward", }); } 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, rewardId, transactionId } = await addStampTransaction(prisma, { loyaltyAccountId: account.id, amount: params.amount, appointmentId: params.appointmentId, authoredByStaffId: params.staffId, reason: params.reason ?? null, programme, }); await notifyLoyaltyStampAdded(prisma, { businessId: params.businessId, customerId: params.customerId, transactionId, appointmentId: params.appointmentId, }); if (rewardCreated && rewardId) { await notifyLoyaltyRewardEarned(prisma, { businessId: params.businessId, customerId: params.customerId, rewardId, rewardLabel: programme.rewardLabel ?? "Reward", }); } 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; rewardId?: string; transactionId: string }> { return prisma.$transaction(async (tx) => { const account = await tx.loyaltyAccount.update({ where: { id: params.loyaltyAccountId }, data: { currentStamps: { increment: params.amount } }, }); const transaction = 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; let rewardId: string | undefined; if (account.currentStamps >= resolveRewardThreshold(params.programme)) { 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) { const reward = 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; rewardId = reward.id; } } return { rewardCreated, rewardId, transactionId: transaction.id }; }); } /** * 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 } }, }, }); } // ── Owner: programme configuration ───────────────────────────────────── export interface ProgrammeConfigInput { isEnabled: boolean; stampsRequired: number; /** See resolveRewardThreshold's doc comment for what each mode means. */ rewardPosition: RewardPositionMode; rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE"; /** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */ rewardValue?: string; rewardLabel?: string; maxRewardValue?: string; freeServiceId?: string; autoApplyReward: boolean; requiresStaffApproval: boolean; stampsExpireAfterDays?: number; rewardExpiresAfterDays?: number; earnOnCancelled: boolean; earnOnNoShow: boolean; cardBackgroundColor?: string; cardBackgroundImageUrl?: string; stampIcon?: string; termsLine?: string; /** Replaces the "Loyalty Card" title (top-left) when set — see schema.prisma's cardTitleText doc comment. */ cardTitleText?: string; cardFont: LoyaltyCardFont; cardTitleTextSize: LoyaltyCardTextSize; cardTitleTextColor?: string; cardBorderColor?: string; cardMemberNumberColor?: string; } /** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */ export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult { if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) { return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` }); } if (!input.isEnabled) return ok(); if (input.rewardType === "PERCENTAGE") { const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN; if (!Number.isFinite(value) || value <= 0 || value > 100) { return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." }); } } else if (input.rewardType === "FIXED_AMOUNT") { const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN; if (!Number.isFinite(value) || value < 0) { return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." }); } } else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) { return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." }); } return ok(); } export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) { return prisma.loyaltyProgramme.findUnique({ where: { businessId }, include: { eligibleServices: { include: { service: true } }, eligibleStaff: true, freeService: true, }, }); } export async function updateProgrammeConfig( prisma: PrismaClient, params: { businessId: string; input: ProgrammeConfigInput }, ): Promise<{ success: boolean; validation: ValidationResult }> { const validation = validateProgrammeConfig(params.input); if (!validation.valid) return { success: false, validation }; if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) { const service = await prisma.service.findFirst({ where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true }, }); if (!service) { return { success: false, validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }), }; } } const data = { isEnabled: params.input.isEnabled, stampsRequired: params.input.stampsRequired, rewardPosition: params.input.rewardPosition, rewardType: params.input.rewardType, rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null), rewardLabel: params.input.rewardLabel?.trim() || null, maxRewardValue: params.input.maxRewardValue || null, freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null, autoApplyReward: params.input.autoApplyReward, requiresStaffApproval: params.input.requiresStaffApproval, stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null, rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null, earnOnCancelled: params.input.earnOnCancelled, earnOnNoShow: params.input.earnOnNoShow, cardBackgroundColor: params.input.cardBackgroundColor || null, cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null, stampIcon: params.input.stampIcon || null, termsLine: params.input.termsLine?.trim() || null, cardTitleText: params.input.cardTitleText?.trim() || null, cardFont: params.input.cardFont, cardTitleTextSize: params.input.cardTitleTextSize, cardTitleTextColor: params.input.cardTitleTextColor || null, cardBorderColor: params.input.cardBorderColor || null, cardMemberNumberColor: params.input.cardMemberNumberColor || null, }; await prisma.loyaltyProgramme.upsert({ where: { businessId: params.businessId }, create: { businessId: params.businessId, ...data }, update: data, }); return { success: true, validation: ok() }; } export async function setEligibleServices( prisma: PrismaClient, params: { loyaltyProgrammeId: string; serviceIds: string[] }, ): Promise<{ success: true }> { await prisma.$transaction([ prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }), prisma.loyaltyEligibleService.createMany({ data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })), }), ]); return { success: true }; } /** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */ export async function setEligibleStaff( prisma: PrismaClient, params: { loyaltyProgrammeId: string; staffIds: string[] }, ): Promise<{ success: true }> { await prisma.$transaction([ prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }), prisma.loyaltyEligibleStaff.createMany({ data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })), }), ]); return { success: true }; } // ── Reward redemption ─────────────────────────────────────────────────── /** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */ export async function listEligibleAppointmentsForReward( prisma: PrismaClient, params: { customerId: string; businessId: string }, ) { return prisma.appointment.findMany({ where: { customerId: params.customerId, businessId: params.businessId, status: { notIn: ["CANCELLED"] }, discountAmount: null, }, include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } }, orderBy: { startAt: "desc" }, take: 10, }); } /** * Redeems an AVAILABLE reward against a specific appointment — the one * place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and * `Appointment.discountAmount` ever change together. The status flip uses * a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the * transaction rather than a read-then-write, so two simultaneous * redemption attempts can't both succeed — the loser sees * REWARD_NOT_AVAILABLE, not a corrupted double-deduction. * * Neither the reward nor the stamps-required count is snapshotted at * *earn* time — redemption always uses the loyalty programme's current * configuration (current stampsRequired, current freeServiceId for a * FREE_SERVICE reward). A documented simplification: if the owner * reconfigures the programme between a reward being earned and redeemed, * redemption reflects the new configuration. See docs/phase-6-summary.md. */ export async function redeemReward( prisma: PrismaClient, params: { rewardId: string; appointmentId: string; staffId: string; businessId: string }, ): Promise<{ success: boolean; validation: ValidationResult; discountAmount?: string }> { const reward = await prisma.loyaltyReward.findUnique({ where: { id: params.rewardId }, include: { loyaltyAccount: { include: { loyaltyProgramme: true } } }, }); if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) { return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) }; } if (reward.status !== "AVAILABLE") { return { success: false, validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }), }; } // Belt-and-braces against the periodic expiry sweep (see // expireOverdueRewards, run from the notification cron) not having run // yet — a reward past its own expiresAt is never redeemable even if its // stored status still says AVAILABLE. if (reward.expiresAt && reward.expiresAt < new Date()) { return { success: false, validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has expired." }), }; } const appointment = await prisma.appointment.findFirst({ where: { id: params.appointmentId, businessId: params.businessId }, include: { services: true }, }); if (!appointment) { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) }; } if (appointment.customerId !== reward.loyaltyAccount.customerId) { return { success: false, validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }), }; } if (appointment.status === "CANCELLED") { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }), }; } if (appointment.discountAmount !== null) { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }), }; } const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0); let discountAmount: number; if (reward.rewardType === "PERCENTAGE") { discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100; } else if (reward.rewardType === "FIXED_AMOUNT") { discountAmount = Number(reward.value ?? 0); } else { const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId); if (!freeLine) { return { success: false, validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "This appointment doesn't include the service this reward covers.", }), }; } discountAmount = Number(freeLine.priceAtBooking); } discountAmount = Math.min(Math.max(discountAmount, 0), subtotal); const discountAmountString = discountAmount.toFixed(2); // Deduct however many stamps actually earned this reward, not the raw // stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the // same number (see resolveRewardThreshold), and the ledger must only // ever remove what was really collected. const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme); const { alreadyRedeemed } = await prisma.$transaction(async (tx) => { const updated = await tx.loyaltyReward.updateMany({ where: { id: params.rewardId, status: "AVAILABLE" }, data: { status: "REDEEMED", redeemedAt: new Date(), redeemedByStaffId: params.staffId, appliedToAppointmentId: params.appointmentId, }, }); if (updated.count === 0) return { alreadyRedeemed: true }; const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } }); const clampedDeduction = Math.min(rewardThreshold, account.currentStamps); await tx.loyaltyAccount.update({ where: { id: account.id }, data: { currentStamps: { decrement: clampedDeduction } }, }); await tx.loyaltyTransaction.create({ data: { loyaltyAccountId: account.id, type: "REWARD_REDEEMED", amount: -clampedDeduction, appointmentId: params.appointmentId, authoredByStaffId: params.staffId, }, }); await tx.appointment.update({ where: { id: params.appointmentId }, data: { discountAmount: discountAmountString }, }); return { alreadyRedeemed: false }; }); if (alreadyRedeemed) { return { success: false, validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }), }; } await notifyLoyaltyRewardRedeemed(prisma, { businessId: params.businessId, customerId: reward.loyaltyAccount.customerId, rewardId: reward.id, rewardLabel: reward.label, appointmentId: params.appointmentId, }); return { success: true, validation: ok(), discountAmount: discountAmountString }; } const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7; /** * Run from the notification cron (see /api/cron/process-notifications) * alongside processDueNotificationDeliveries — one scheduled trigger covers * both. Two independent jobs in one pass: flip any AVAILABLE reward whose * expiresAt has passed to EXPIRED (redeemReward also defends against this * directly, but the sweep is what makes "Expired" actually show up in * history/UI instead of staying stuck at AVAILABLE forever), then notify * once per day for rewards still AVAILABLE and expiring within the next * week. */ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise { await prisma.loyaltyReward.updateMany({ where: { status: "AVAILABLE", expiresAt: { lt: now } }, data: { status: "EXPIRED" }, }); const expiringSoon = await prisma.loyaltyReward.findMany({ where: { status: "AVAILABLE", expiresAt: { gte: now, lte: new Date(now.getTime() + REWARD_EXPIRING_SOON_WINDOW_DAYS * 86_400_000) }, }, include: { loyaltyAccount: { include: { loyaltyProgramme: true } } }, }); for (const reward of expiringSoon) { if (!reward.expiresAt) continue; await notifyLoyaltyRewardExpiring(prisma, { businessId: reward.loyaltyAccount.loyaltyProgramme.businessId, customerId: reward.loyaltyAccount.customerId, rewardId: reward.id, rewardLabel: reward.label, expiresAt: reward.expiresAt, now, }); } }