839 lines
30 KiB
TypeScript
839 lines
30 KiB
TypeScript
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
|
import { fail, ok } from "@/lib/validation-result";
|
|
import type { ValidationResult } from "@/lib/validation-result";
|
|
import {
|
|
notifyLoyaltyRewardEarned,
|
|
notifyLoyaltyRewardExpiring,
|
|
notifyLoyaltyRewardRedeemed,
|
|
notifyLoyaltyStampAdded,
|
|
} from "./loyalty-notifications";
|
|
|
|
/**
|
|
* 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;
|
|
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | 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<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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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<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 },
|
|
},
|
|
});
|
|
|
|
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<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 >= 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) {
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/** 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) {
|
|
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
|
|
}
|
|
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,
|
|
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,
|
|
};
|
|
|
|
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);
|
|
|
|
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
|
|
|
|
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(stampsRequired, 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,
|
|
});
|
|
}
|
|
}
|