Files
NextChair/src/services/loyalty/loyalty.service.ts
T
Stefan Lazic cf3a28f3cd Loyalty Card updates
1. Card title fix: The "top-right text" feature was renamed and repositioned to be the actual card title (top-left, replacing the hardcoded "Loyalty Card"), matching what you asked for. This included a proper database migration (cardTopRightText → cardTitleText, etc., using CHANGE COLUMN so any already-configured value is preserved, not dropped) and updates through every layer — schema, service, owner form, and both the owner preview and customer-facing card. Verified live: typed "VIP Rewards" into the new "Card title" field and confirmed it renders top-left on both the owner's live preview and the real customer loyalty page, with the member number staying in its own badge top-right.

2. Tabs: The long "Card Appearance" control list is now split into Colours / Stamp / Text tabs (using the same Tabs component used elsewhere in the app), exactly as you suggested — Colours holds every colour + the background image, Stamp holds just the icon picker, Text holds the title, font, size, and terms line.

Along the way, while running full E2E regression, I found 6 test failures — but traced them via git stash to prove they're pre-existing bugs unrelated to this change (a booking/appointments-list bug), so I reverted-then-confirmed before restoring my work, and filed a separate follow-up task for them rather than mixing it into this change.

Both fixes are typechecked, linted, verified with the full unit (253) and integration (185) suites, confirmed live in a real browser end-to-end, and deployed to your running Docker container — the migration applied cleanly against your real database with no data loss.
2026-08-05 12:19:08 +01:00

883 lines
32 KiB
TypeScript

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<LoyaltySummary | null> {
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<Map<string, boolean>> {
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<Map<string, CustomerLoyaltyBrief>> {
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<ValidationResult<LoyaltyErrorCode>> {
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<LoyaltyErrorCode>;
}
/**
* 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<AddStampResult> {
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<AddStampResult> {
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<LoyaltyErrorCode> }> {
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<LoyaltyErrorCode> {
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<LoyaltyErrorCode> }> {
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<LoyaltyErrorCode>; 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<void> {
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,
});
}
}