Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export default async function AdminOrganizationDetailPage({

<AdminSection
title="Coupon pools"
description="Limited seats this organization can grant. Course-specific pools first; general pools cover any allocated course. Assigning or Pathment enroll consumes a seat and shows the learner a free/discount banner."
description="Discount seats reserved for organization assignments. Course-specific pools are used before general pools. Partial discounts leave a payment balance for admin confirmation."
className="mt-12"
>
<AdminPanel className="mb-6">
Expand Down Expand Up @@ -307,8 +307,8 @@ export default async function AdminOrganizationDetailPage({
<input name="percentOff" type="number" min={1} max={100} defaultValue={100} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Code (optional)</span>
<input name="code" maxLength={64} placeholder="DW-LIFETIME" className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm" />
<span className="mb-1 block font-medium">Internal reference (optional)</span>
<input name="code" maxLength={64} placeholder="Pathment cohort 2026" className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Expires (optional)</span>
Expand All @@ -323,7 +323,7 @@ export default async function AdminOrganizationDetailPage({
<tr>
<AdminTableHeaderCell>Pool</AdminTableHeaderCell>
<AdminTableHeaderCell>Scope</AdminTableHeaderCell>
<AdminTableHeaderCell align="right">Used / max</AdminTableHeaderCell>
<AdminTableHeaderCell align="right">Seats</AdminTableHeaderCell>
<AdminTableHeaderCell>Status</AdminTableHeaderCell>
<AdminTableHeaderCell>Actions</AdminTableHeaderCell>
</tr>
Expand All @@ -348,19 +348,28 @@ export default async function AdminOrganizationDetailPage({
</AdminTableCell>
<AdminTableCell align="right" className="tabular-nums">
{pool.redemptionCount} / {pool.maxRedemptions}
<span className="mt-1 block text-xs text-muted-foreground">{pool.reservedCount} reserved · {pool.redeemedCount} redeemed</span>
</AdminTableCell>
<AdminTableCell>
{pool.active ? <Badge variant="secondary">Active</Badge> : <Badge variant="outline">Off</Badge>}
</AdminTableCell>
<AdminTableCell>
<form action={setOrganizationCouponPoolActiveAction}>
<div className="flex flex-col items-start gap-2">
<form action={setOrganizationCouponPoolActiveAction} className="flex items-center gap-2">
<input type="hidden" name="organizationId" value={org.id} />
<input type="hidden" name="poolId" value={pool.id} />
<input type="hidden" name="active" value={pool.active ? "false" : "true"} />
<Button type="submit" size="sm" variant="ghost">
{pool.active ? "Turn off" : "Turn on"}
</Button>
</form>
<form action={setOrganizationCouponPoolActiveAction} className="flex items-center gap-2">
<input type="hidden" name="organizationId" value={org.id} />
<input type="hidden" name="poolId" value={pool.id} />
<input name="maxRedemptions" type="number" min={pool.maxRedemptions} max={1_000_000} defaultValue={pool.maxRedemptions} aria-label={`Seat limit for ${pool.label}`} className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs" />
<Button type="submit" size="sm" variant="outline">Update seats</Button>
</form>
</div>
</AdminTableCell>
</AdminTableRow>
))
Expand Down
16 changes: 7 additions & 9 deletions frontend/src/app/(learn)/courses/[courseSlug]/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default async function CourseCheckoutPage({
const { courseSlug } = await params;
const { requested, request_failed, email_unavailable: emailUnavailable, coupon_error: couponError } = await searchParams;
await requireVerifiedUser();
const { course, enrollment, state, automaticDiscount } = await serverApiFetchOrNotFound<Checkout>(
const { course, enrollment, state, automaticDiscount, organizationDiscount, amountDueCents } = await serverApiFetchOrNotFound<Checkout>(
`/api/v1/courses/${encodeURIComponent(courseSlug)}/checkout`
);
if (state === "already_paid") {
Expand All @@ -33,10 +33,7 @@ export default async function CourseCheckoutPage({
redirect(`/catalog/${course.slug}`);
}

const discountedPriceCents = automaticDiscount
? Math.round(course.priceCents * (100 - automaticDiscount.percentOff) / 100)
: course.priceCents;
const priceLabel = formatPriceCents(discountedPriceCents, course.currency);
const priceLabel = formatPriceCents(amountDueCents, course.currency);

return (
<div className="mx-auto flex min-h-dvh max-w-lg flex-col justify-center px-6 py-16">
Expand All @@ -45,11 +42,12 @@ export default async function CourseCheckoutPage({
</Link>

<h1 className="mt-6 text-2xl font-semibold tracking-tight">Enroll in {course.title}</h1>
<p className="mt-2 text-sm text-muted-foreground">Use a discount code or request access after arranging payment with an administrator.</p>
<p className="mt-2 text-sm text-muted-foreground">{organizationDiscount ? "Your organization assigned a course discount. Request access after arranging the remaining payment with an administrator." : "Use a discount code or request access after arranging payment with an administrator."}</p>
{automaticDiscount ? <p className="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm"><strong>{automaticDiscount.percentOff}% discount applied:</strong> {automaticDiscount.label}. Your adjusted price is {priceLabel}.</p> : null}
{organizationDiscount ? <p className="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm"><strong>{organizationDiscount.percentOff}% organization discount applied:</strong> {organizationDiscount.label}. The discount is reserved for your assignment; amount remaining is {priceLabel}.</p> : null}

<div className="mt-8 space-y-5">
<section className="rounded-2xl border bg-card p-6">
{!organizationDiscount ? <section className="rounded-2xl border bg-card p-6">
<h2 className="font-semibold">Have a discount code?</h2>
<p className="mt-1 text-sm text-muted-foreground">Enter a discount code, or use an automatic discount when one is available. A full discount gives immediate access; an administrator will confirm any remaining payment.</p>
{couponError ? <p role="alert" className="mt-3 text-sm text-destructive">{couponError === "COUPON_EXHAUSTED" ? "This code has no redemptions remaining." : couponError === "COUPON_NOT_APPLICABLE" ? "This code cannot be used for this course." : "This code is invalid or expired."}</p> : null}
Expand All @@ -59,13 +57,13 @@ export default async function CourseCheckoutPage({
<Input name="couponCode" required minLength={3} maxLength={32} placeholder="Enter coupon code" aria-label="Coupon code" className="sm:flex-1" />
<Button type="submit">Apply discount</Button>
</form>
</section>
</section> : null}

<section className="rounded-2xl border bg-card p-6">
<h2 className="font-semibold">Request access</h2>
<p className="mt-1 text-sm text-muted-foreground">No online payment is collected here. The administrators will contact you to arrange direct payment and grant course access.</p>
<div className="mt-4 flex items-baseline justify-between gap-4 border-t pt-4">
<span className="text-sm text-muted-foreground">Amount due{automaticDiscount ? ` · ${automaticDiscount.percentOff}% off` : ""}</span>
<span className="text-sm text-muted-foreground">Amount due{organizationDiscount ? ` · ${organizationDiscount.percentOff}% off` : automaticDiscount ? ` · ${automaticDiscount.percentOff}% off` : ""}</span>
<span className="text-2xl font-semibold tabular-nums">{priceLabel}</span>
</div>
{request_failed === "1" && <p role="alert" className="mt-4 text-sm text-destructive">We couldn’t send your request. Please try again or contact an administrator.</p>}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/server/actions/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,14 @@ export async function setOrganizationCouponPoolActiveAction(formData: FormData):
const organizationId = String(formData.get("organizationId") ?? "");
const poolId = String(formData.get("poolId") ?? "");
if (!organizationId || !poolId) throw new Error("Missing organization or coupon pool.");
const maxRedemptions = String(formData.get("maxRedemptions") ?? "").trim();
const active = String(formData.get("active") ?? "").trim();
await adminCall(
`/api/v1/admin/organizations/${encodeURIComponent(organizationId)}/coupons/${encodeURIComponent(poolId)}`,
{ method: "PATCH", json: { active: formData.get("active") === "true" } }
{ method: "PATCH", json: {
...(active ? { active: active === "true" } : {}),
...(maxRedemptions ? { maxRedemptions: Number(maxRedemptions) } : {}),
} }
);
revalidatePath(`/admin/organizations/${organizationId}`);
}
2 changes: 2 additions & 0 deletions frontend/src/shared/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export type AdminCouponPoolRow = {
percentOff: number;
maxRedemptions: number;
redemptionCount: number;
reservedCount: number;
redeemedCount: number;
remaining: number;
active: boolean;
expiresAt: string | null;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/shared/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type CourseOverview = {
export type Checkout = {
course: { id: string; slug: string; title: string; pricingType: CoursePricingType; priceCents: number; currency: string };
automaticDiscount: { percentOff: number; label: string } | null;
organizationDiscount: { percentOff: number; label: string; amountDueCents: number } | null;
amountDueCents: number;
enrollment: { id: string; lifecycle: EnrollmentLifecycle } | null;
state: "no_enrollment" | "payment_required" | "already_paid";
};
Expand Down
2 changes: 2 additions & 0 deletions packages/learning-ui/src/shared/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type CourseOverview = {
export type Checkout = {
course: { id: string; slug: string; title: string; pricingType: CoursePricingType; priceCents: number; currency: string };
automaticDiscount: { percentOff: number; label: string } | null;
organizationDiscount: { percentOff: number; label: string; amountDueCents: number } | null;
amountDueCents: number;
enrollment: { id: string; lifecycle: EnrollmentLifecycle } | null;
state: "no_enrollment" | "payment_required" | "already_paid";
};
Expand Down
2 changes: 1 addition & 1 deletion server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ model InstructorCouponRedemption {
}

/// One reserved seat from a pool, created when a mentor/Pathment assigns the course.
/// Marked redeemed when the learner first opens the course (banner + audit).
/// Marked redeemed when access is granted (immediately for full discounts, after manual approval for partial discounts).
model CouponUsage {
id String @id @default(cuid())
poolId String
Expand Down
6 changes: 3 additions & 3 deletions server/src/controllers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ export async function createCouponPool(req: Request, res: Response) {

// PATCH /api/v1/admin/organizations/:orgId/coupons/:poolId
export async function setCouponPoolActive(req: Request, res: Response) {
const { active } = req.body as { active: boolean };
const { setCouponPoolActive: setActive } = await import("../services/coupons/pools.js");
await setActive(param(req, "orgId"), param(req, "poolId"), active);
const body = req.body as { active?: boolean; maxRedemptions?: number };
const { updateCouponPool } = await import("../services/coupons/pools.js");
await updateCouponPool(param(req, "orgId"), param(req, "poolId"), body);
noContent(res);
}

Expand Down
28 changes: 28 additions & 0 deletions server/src/services/coupons/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,31 @@ export async function issueCouponForEnrollmentStandalone(input: IssueCouponInput
export function couponUnlocksContent(percentOff: number) {
return percentOff >= 100;
}

export function discountedAmountCents(amountCents: number, percentOff: number) {
return Math.max(0, Math.round(amountCents * (100 - percentOff) / 100));
}

/** Return an unused reservation when an assignment is cancelled before access is granted. */
export async function cancelIssuedCouponUsage(tx: Prisma.TransactionClient, enrollmentId: string) {
const usage = await tx.couponUsage.findUnique({ where: { enrollmentId } });
if (!usage || usage.status !== "ISSUED") return false;
const cancelled = await tx.couponUsage.updateMany({
where: { id: usage.id, status: "ISSUED" },
data: { status: "CANCELLED", cancelledAt: new Date() },
});
if (!cancelled.count) return false;
await tx.organizationCouponPool.updateMany({
where: { id: usage.poolId, redemptionCount: { gt: 0 } },
data: { redemptionCount: { decrement: 1 } },
});
return true;
}

/** A coupon reservation becomes consumed once manual payment/access is approved. */
export async function redeemCouponUsage(tx: Prisma.TransactionClient, enrollmentId: string) {
await tx.couponUsage.updateMany({
where: { enrollmentId, status: "ISSUED" },
data: { status: "REDEEMED", redeemedAt: new Date() },
});
}
47 changes: 47 additions & 0 deletions server/src/services/coupons/pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,27 @@ export async function listCouponPools(organizationId: string) {
course: { select: { id: true, slug: true, title: true } },
},
});
const counts = pools.length ? await prisma.couponUsage.groupBy({
by: ["poolId", "status"],
where: { poolId: { in: pools.map((pool) => pool.id) } },
_count: { _all: true },
}) : [];
const countByPool = new Map<string, { reserved: number; redeemed: number }>();
for (const row of counts) {
const current = countByPool.get(row.poolId) ?? { reserved: 0, redeemed: 0 };
if (row.status === "ISSUED") current.reserved = row._count._all;
if (row.status === "REDEEMED") current.redeemed = row._count._all;
countByPool.set(row.poolId, current);
}
return pools.map((pool) => ({
id: pool.id,
label: pool.label,
code: pool.code,
percentOff: pool.percentOff,
maxRedemptions: pool.maxRedemptions,
redemptionCount: pool.redemptionCount,
reservedCount: countByPool.get(pool.id)?.reserved ?? 0,
redeemedCount: countByPool.get(pool.id)?.redeemed ?? 0,
remaining: Math.max(0, pool.maxRedemptions - pool.redemptionCount),
active: pool.active,
expiresAt: pool.expiresAt?.toISOString() ?? null,
Expand All @@ -99,6 +113,39 @@ export async function setCouponPoolActive(organizationId: string, poolId: string
if (updated.count === 0) throw new NotFoundError("Coupon pool not found");
}

/** Change pool state and/or add capacity without invalidating already issued seats. */
export async function updateCouponPool(
organizationId: string,
poolId: string,
input: { active?: boolean; maxRedemptions?: number }
) {
const pool = await prisma.organizationCouponPool.findFirst({ where: { id: poolId, organizationId } });
if (!pool) throw new NotFoundError("Coupon pool not found");
if (input.maxRedemptions !== undefined) {
if (input.maxRedemptions < pool.maxRedemptions) {
throw new UnprocessableError("Coupon pool capacity can only be increased.", "VALIDATION_ERROR");
}
if (input.maxRedemptions < pool.redemptionCount) {
throw new UnprocessableError("Capacity cannot be lower than seats already reserved or redeemed.", "VALIDATION_ERROR");
}
}
const result = await prisma.organizationCouponPool.updateMany({
where: {
id: poolId,
organizationId,
...(input.maxRedemptions !== undefined ? {
redemptionCount: { lte: input.maxRedemptions },
maxRedemptions: { lte: input.maxRedemptions },
} : {}),
},
data: {
...(input.active !== undefined ? { active: input.active } : {}),
...(input.maxRedemptions !== undefined ? { maxRedemptions: input.maxRedemptions } : {}),
},
});
if (!result.count) throw new UnprocessableError("Capacity changed; refresh and try again.", "VALIDATION_ERROR");
}

/** Course-specific pool first, then a general (courseId null) pool with remaining seats. */
export async function findAvailableCouponPool(
tx: Prisma.TransactionClient,
Expand Down
19 changes: 14 additions & 5 deletions server/src/services/enrollment/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ export async function loadCheckout(courseSlug: string, userId: string): Promise<

const enrollment = await prisma.enrollment.findUnique({
where: { courseId_userId: { courseId: course.id, userId } },
select: { id: true, lifecycle: true, payment: { select: { amountCents: true } }, instructorCouponRedemption: { select: { id: true } } },
select: { id: true, lifecycle: true, payment: { select: { amountCents: true } }, instructorCouponRedemption: { select: { id: true } }, couponUsage: { select: { status: true, percentOff: true, displayLabel: true } } },
});
const enrollmentView = enrollment ? { id: enrollment.id, lifecycle: enrollment.lifecycle } : null;
const automaticCoupon = (!enrollment || (enrollment.lifecycle === "PAYMENT_REQUIRED" && !enrollment.instructorCouponRedemption)) && course.pricingType === "PAID"
const activeOrganizationCoupon = enrollment?.couponUsage && enrollment.couponUsage.status !== "CANCELLED" ? enrollment.couponUsage : null;
const automaticCoupon = (!activeOrganizationCoupon && (!enrollment || (enrollment.lifecycle === "PAYMENT_REQUIRED" && !enrollment.instructorCouponRedemption))) && course.pricingType === "PAID"
? await prisma.instructorCoupon.findMany({
where: { courseId: course.id, active: true, autoApply: true, redemptionCount: { lt: 1000000 }, OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
orderBy: [{ percentOff: "desc" }, { createdAt: "asc" }],
Expand All @@ -26,10 +27,18 @@ export async function loadCheckout(courseSlug: string, userId: string): Promise<
return coupon ? { percentOff: coupon.percentOff, label: coupon.label } : null;
})
: null;
if (!enrollment) return { course: courseFields, enrollment: null, state: "no_enrollment", automaticDiscount: automaticCoupon };
const amountDueCents = activeOrganizationCoupon
? enrollment?.payment?.amountCents ?? Math.max(0, Math.round(course.priceCents * (100 - activeOrganizationCoupon.percentOff) / 100))
: enrollment?.payment?.amountCents ?? (automaticCoupon ? Math.round(course.priceCents * (100 - automaticCoupon.percentOff) / 100) : course.priceCents);
const organizationDiscount = activeOrganizationCoupon ? {
percentOff: activeOrganizationCoupon.percentOff,
label: activeOrganizationCoupon.displayLabel,
amountDueCents,
} : null;
if (!enrollment) return { course: courseFields, enrollment: null, state: "no_enrollment", automaticDiscount: automaticCoupon, organizationDiscount: null, amountDueCents };
if (enrollmentGrantsContentAccess(enrollment.lifecycle)) {
return { course: courseFields, enrollment: enrollmentView, state: "already_paid", automaticDiscount: null };
return { course: courseFields, enrollment: enrollmentView, state: "already_paid", automaticDiscount: null, organizationDiscount, amountDueCents: 0 };
}
if (enrollment.lifecycle !== "PAYMENT_REQUIRED") throw new NotFoundError("Enrollment is not awaiting payment");
return { course: { ...courseFields, priceCents: enrollment.payment?.amountCents ?? course.priceCents }, enrollment: enrollmentView, state: "payment_required", automaticDiscount: automaticCoupon };
return { course: courseFields, enrollment: enrollmentView, state: "payment_required", automaticDiscount: activeOrganizationCoupon ? null : automaticCoupon, organizationDiscount, amountDueCents };
}
Loading
Loading