Phase 6
Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
73 files changed
+8400
-53
No files matched your search
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
|
||||
* without creating the matching transaction in the same call, and nothing
|
||||
* outside this file should touch either table directly.
|
||||
*
|
||||
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
|
||||
* deliberately not implemented here — redeeming a reward isn't one of the
|
||||
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
|
||||
* above `stampsRequired` once a reward becomes available. Redemption is a
|
||||
* documented Phase 5 limitation, not an oversight.
|
||||
* 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 =
|
||||
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
|
||||
| "REASON_REQUIRED"
|
||||
| "INVALID_AMOUNT"
|
||||
| "APPOINTMENT_NOT_FOUND"
|
||||
| "APPOINTMENT_NOT_COMPLETED";
|
||||
| "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;
|
||||
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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." }),
|
||||
};
|
||||
}
|
||||
|
||||
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." }),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, validation: ok(), discountAmount: discountAmountString };
|
||||
}
|
||||
Reference in new issue
Block a user