diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts
index 6930b66..3452f4c 100644
--- a/src/app/actions/admin-enroll.ts
+++ b/src/app/actions/admin-enroll.ts
@@ -11,15 +11,25 @@
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
+import { headers } from 'next/headers';
import { requireAdmin } from '@/lib/auth';
-import { auditLog } from '@/lib/admin-audit';
import { grantManualEnrollment } from '@/lib/enrollment';
+import { PaymentMethodValues } from '@/lib/enums';
import type { ActionResult } from '@/lib/validation';
const manualEnrollSchema = z.object({
email: z.string().trim().toLowerCase().email('Enter a valid email address.'),
name: z.string().trim().max(100).optional(),
pricingTierId: z.string().min(1, 'Pick a pricing tier.'),
+ payment: z
+ .object({
+ method: z.enum(PaymentMethodValues as [string, ...string[]], {
+ message: 'Pick a payment method.',
+ }),
+ amountPhp: z.number().int().positive('Enter the amount paid.'),
+ reference: z.string().trim().max(200).optional(),
+ })
+ .optional(),
});
export interface ManualEnrollActionData {
@@ -29,12 +39,13 @@ export interface ManualEnrollActionData {
tierName: string;
enrolledCount: number;
alreadyEnrolledCount: number;
+ paymentRecorded: boolean;
}
export async function manualEnrollAction(
input: z.infer,
): Promise> {
- await requireAdmin();
+ const actor = await requireAdmin();
const parsed = manualEnrollSchema.safeParse(input);
if (!parsed.success) {
@@ -45,17 +56,19 @@ export async function manualEnrollAction(
}
try {
+ const heads = await headers();
const result = await grantManualEnrollment({
email: parsed.data.email,
name: parsed.data.name || null,
pricingTierId: parsed.data.pricingTierId,
+ payment: parsed.data.payment,
+ audit: {
+ actorId: actor.id,
+ ipAddress: heads.get('x-forwarded-for') ?? heads.get('x-real-ip'),
+ userAgent: heads.get('user-agent'),
+ },
});
- await auditLog({
- action: 'MANUAL_ENROLL',
- entityType: 'User',
- entityId: result.userId,
- });
revalidatePath('/admin/users');
revalidatePath(`/admin/users/${result.userId}`);
@@ -77,6 +90,7 @@ export async function manualEnrollAction(
tierName: result.tierName,
enrolledCount: result.enrolledCourseIds.length,
alreadyEnrolledCount: result.alreadyEnrolledCourseIds.length,
+ paymentRecorded: result.paymentRecorded,
},
};
} catch (err) {
diff --git a/src/app/admin/enroll/EnrollForm.tsx b/src/app/admin/enroll/EnrollForm.tsx
index 5779f46..6685ce3 100644
--- a/src/app/admin/enroll/EnrollForm.tsx
+++ b/src/app/admin/enroll/EnrollForm.tsx
@@ -6,15 +6,29 @@ import {
manualEnrollAction,
type ManualEnrollActionData,
} from '@/app/actions/admin-enroll';
+import { PaymentMethod, PaymentMethodValues } from '@/lib/enums';
import styles from './enroll.module.css';
interface TierOption {
id: string;
name: string;
priceLabel: string;
+ /** Centavos. */
+ pricePhp: number;
courseCount: number;
}
+const PAYMENT_METHOD_LABELS: Record = {
+ [PaymentMethod.GCASH]: 'GCash',
+ [PaymentMethod.MAYA]: 'Maya',
+ [PaymentMethod.GRABPAY]: 'GrabPay',
+ [PaymentMethod.CREDIT_CARD]: 'Credit card',
+ [PaymentMethod.DEBIT_CARD]: 'Debit card',
+ [PaymentMethod.BANK_TRANSFER]: 'Bank transfer',
+ [PaymentMethod.OTC]: 'Over-the-counter',
+ [PaymentMethod.OTHER]: 'Other',
+};
+
export function EnrollForm({
defaultEmail,
tiers,
@@ -26,10 +40,33 @@ export function EnrollForm({
const [email, setEmail] = useState(defaultEmail);
const [name, setName] = useState('');
const [pricingTierId, setPricingTierId] = useState(tiers[0]?.id ?? '');
+ const [recordPayment, setRecordPayment] = useState(false);
+ const [paymentMethod, setPaymentMethod] = useState(
+ PaymentMethodValues[0] ?? '',
+ );
+ const [amountPhpInput, setAmountPhpInput] = useState('');
+ const [reference, setReference] = useState('');
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
const [copied, setCopied] = useState(false);
+ function selectedTierPriceLabel(tierId: string): string {
+ const tier = tiers.find((t) => t.id === tierId);
+ return tier ? (tier.pricePhp / 100).toFixed(2) : '';
+ }
+
+ function handleTierChange(tierId: string) {
+ setPricingTierId(tierId);
+ if (recordPayment) setAmountPhpInput(selectedTierPriceLabel(tierId));
+ }
+
+ function handleRecordPaymentToggle(checked: boolean) {
+ setRecordPayment(checked);
+ if (checked && !amountPhpInput) {
+ setAmountPhpInput(selectedTierPriceLabel(pricingTierId));
+ }
+ }
+
function handleSubmit() {
setError(null);
setResult(null);
@@ -38,11 +75,27 @@ export function EnrollForm({
setError('Enter the student email.');
return;
}
+
+ let payment: { method: string; amountPhp: number; reference?: string } | undefined;
+ if (recordPayment) {
+ const pesos = Number(amountPhpInput);
+ if (!amountPhpInput || Number.isNaN(pesos) || pesos <= 0) {
+ setError('Enter the amount paid.');
+ return;
+ }
+ payment = {
+ method: paymentMethod,
+ amountPhp: Math.round(pesos * 100),
+ reference: reference.trim() || undefined,
+ };
+ }
+
startTransition(async () => {
const res = await manualEnrollAction({
email,
name: name.trim() || undefined,
pricingTierId,
+ payment,
});
if (!res.success) {
setError(res.error);
@@ -92,7 +145,7 @@ export function EnrollForm({
Pricing tier
+
+
+ {recordPayment && (
+
+
+
+
+
+
+
+ )}
+
@@ -120,6 +225,7 @@ export function EnrollForm({
{result.alreadyEnrolledCount > 0
? ` (${result.alreadyEnrolledCount} already active)`
: ''}
+ {result.paymentRecorded ? ' · payment recorded' : ''}
{result.claimUrl ? (
diff --git a/src/app/admin/enroll/enroll.module.css b/src/app/admin/enroll/enroll.module.css
index 8596e45..209d329 100644
--- a/src/app/admin/enroll/enroll.module.css
+++ b/src/app/admin/enroll/enroll.module.css
@@ -42,6 +42,25 @@
}
.select { cursor: pointer; }
+.checkboxRow {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--color-text);
+ cursor: pointer;
+}
+
+.paymentFields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-bg);
+}
+
.error {
color: var(--color-danger, #dc3545);
font-size: var(--text-sm);
diff --git a/src/app/admin/enroll/page.tsx b/src/app/admin/enroll/page.tsx
index 746b271..e453f2f 100644
--- a/src/app/admin/enroll/page.tsx
+++ b/src/app/admin/enroll/page.tsx
@@ -43,6 +43,7 @@ export default async function AdminEnrollPage({
id: t.id,
name: t.name,
priceLabel: formatPhp(t.pricePhp),
+ pricePhp: t.pricePhp,
courseCount: t.courses.length,
}))}
/>
diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx
index ba4bc96..b3a5a5a 100644
--- a/src/app/admin/users/[id]/page.tsx
+++ b/src/app/admin/users/[id]/page.tsx
@@ -172,13 +172,16 @@ export default async function UserDetailPage({
{formatPhp(p.amountPhp)}{' '}
-
+
{p.status}
- {p.pricingTier?.name} · {formatDate(p.createdAt)}
+ {p.pricingTier?.name} · {p.method} · {formatDate(p.createdAt)}
+ {p.metadata && (
+
{p.metadata}
+ )}
))}
diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx
index 9918e67..0bf97e3 100644
--- a/src/components/ui/Icon.tsx
+++ b/src/components/ui/Icon.tsx
@@ -46,6 +46,7 @@ import {
Download,
Video,
Circle,
+ UserPlus,
} from '@phosphor-icons/react/dist/ssr';
import clsx from 'clsx';
import styles from './Icon.module.css';
@@ -113,7 +114,8 @@ export type PhosphorIconName =
| 'ArrowRight'
| 'Download'
| 'Video'
- | 'Circle';
+ | 'Circle'
+ | 'UserPlus';
const ICON_MAP: Record> = {
House,
@@ -149,6 +151,7 @@ const ICON_MAP: Record>
Download,
Video,
Circle,
+ UserPlus,
};
export function Icon({
diff --git a/src/components/ui/NavSidebar.tsx b/src/components/ui/NavSidebar.tsx
index 93d8fab..ae52677 100644
--- a/src/components/ui/NavSidebar.tsx
+++ b/src/components/ui/NavSidebar.tsx
@@ -15,6 +15,7 @@ export interface NavItem {
const ADMIN_NAV_ITEMS: NavItem[] = [
{ href: '/admin', label: 'Dashboard', icon: 'House' },
{ href: '/admin/users', label: 'Users', icon: 'User' },
+ { href: '/admin/enroll', label: 'Enroll student', icon: 'UserPlus' },
{ href: '/admin/courses', label: 'Courses', icon: 'BookOpen' },
{ href: '/admin/refunds', label: 'Refunds', icon: 'Receipt' },
{ href: '/admin/tool-scenarios', label: 'Tool scenarios', icon: 'List' },
diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts
index 4498df7..686957e 100644
--- a/src/lib/__tests__/enrollment.test.ts
+++ b/src/lib/__tests__/enrollment.test.ts
@@ -6,6 +6,8 @@ const mockDb = vi.hoisted(() => {
user: { findUnique: fn(), create: fn() },
pricingTier: { findUnique: fn() },
enrollment: { findMany: fn(), createMany: fn() },
+ payment: { create: fn() },
+ auditLog: { create: fn() },
$transaction: vi.fn(),
};
});
@@ -14,6 +16,7 @@ vi.mock('@/lib/db', () => ({ db: mockDb }));
const mockEnums = vi.hoisted(() => ({
EnrollmentStatus: { ACTIVE: 'ACTIVE' },
+ PaymentStatus: { COMPLETED: 'COMPLETED' },
}));
vi.mock('@/lib/enums', () => mockEnums);
@@ -175,4 +178,94 @@ describe('grantManualEnrollment', () => {
expect(result.enrolledCourseIds).toEqual([]);
expect(mockDb.enrollment.createMany).not.toHaveBeenCalled();
});
+
+ it('does not create a Payment or AuditLog row when payment/audit are omitted', async () => {
+ mockDb.pricingTier.findUnique.mockResolvedValue(tier);
+ mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' });
+ mockDb.enrollment.findMany.mockResolvedValue([]);
+ mockDb.enrollment.createMany.mockResolvedValue({ count: 2 });
+
+ const result = await grantManualEnrollment({
+ email: 'student@email.com',
+ pricingTierId: 'tier-1',
+ });
+
+ expect(result.paymentRecorded).toBe(false);
+ expect(mockDb.payment.create).not.toHaveBeenCalled();
+ expect(mockDb.auditLog.create).not.toHaveBeenCalled();
+ });
+
+ it('records a completed Payment and an AuditLog row in the same transaction', async () => {
+ mockDb.pricingTier.findUnique.mockResolvedValue(tier);
+ mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' });
+ mockDb.enrollment.findMany.mockResolvedValue([]);
+ mockDb.enrollment.createMany.mockResolvedValue({ count: 2 });
+ mockDb.payment.create.mockResolvedValue({ id: 'payment-1' });
+ mockDb.auditLog.create.mockResolvedValue({ id: 'audit-1' });
+
+ const result = await grantManualEnrollment({
+ email: 'student@email.com',
+ pricingTierId: 'tier-1',
+ payment: {
+ method: 'GCASH',
+ amountPhp: 299900,
+ reference: ' ref-123 ',
+ },
+ audit: {
+ actorId: 'admin-1',
+ ipAddress: '127.0.0.1',
+ userAgent: 'vitest',
+ },
+ });
+
+ expect(result.paymentRecorded).toBe(true);
+
+ const paymentArg = mockDb.payment.create.mock.calls[0]![0]!;
+ expect(paymentArg.data).toMatchObject({
+ userId: 'user-1',
+ pricingTierId: 'tier-1',
+ amountPhp: 299900,
+ netAmountPhp: 299900,
+ method: 'GCASH',
+ status: 'COMPLETED',
+ metadata: 'ref-123',
+ });
+ expect(paymentArg.data.paidAt).toBeInstanceOf(Date);
+
+ const auditArg = mockDb.auditLog.create.mock.calls[0]![0]!;
+ expect(auditArg.data).toMatchObject({
+ actorId: 'admin-1',
+ action: 'MANUAL_ENROLL',
+ entityType: 'User',
+ entityId: 'user-1',
+ ipAddress: '127.0.0.1',
+ userAgent: 'vitest',
+ });
+ expect(JSON.parse(auditArg.data.metadata)).toEqual({
+ tier: 'PPC Foundations',
+ paymentMethod: 'GCASH',
+ amountPhp: 299900,
+ reference: 'ref-123',
+ });
+ });
+
+ it('writes an AuditLog row with null metadata when no payment is recorded', async () => {
+ mockDb.pricingTier.findUnique.mockResolvedValue(tier);
+ mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' });
+ mockDb.enrollment.findMany.mockResolvedValue([]);
+ mockDb.enrollment.createMany.mockResolvedValue({ count: 2 });
+ mockDb.auditLog.create.mockResolvedValue({ id: 'audit-1' });
+
+ await grantManualEnrollment({
+ email: 'student@email.com',
+ pricingTierId: 'tier-1',
+ audit: { actorId: 'admin-1' },
+ });
+
+ expect(mockDb.payment.create).not.toHaveBeenCalled();
+ const auditArg = mockDb.auditLog.create.mock.calls[0]![0]!;
+ expect(auditArg.data.metadata).toBeNull();
+ expect(auditArg.data.ipAddress).toBeNull();
+ expect(auditArg.data.userAgent).toBeNull();
+ });
});
diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts
index f210300..ee7b994 100644
--- a/src/lib/enrollment.ts
+++ b/src/lib/enrollment.ts
@@ -12,7 +12,7 @@
import 'server-only';
import { db } from './db';
-import { EnrollmentStatus } from './enums';
+import { EnrollmentStatus, PaymentStatus } from './enums';
import { generateClaimToken, PLACEHOLDER_PASSWORD_PREFIX } from './claim-token';
import { randomUUID } from 'node:crypto';
@@ -65,10 +65,34 @@ export async function findOrCreateUserByEmail(
return { id: placeholder.id, isNew: true, rawClaimToken: claim.raw };
}
+export interface ManualEnrollmentPaymentInput {
+ /** One of the PaymentMethod values (e.g. GCASH, BANK_TRANSFER, OTHER). */
+ method: string;
+ /** Amount actually received, in centavos. */
+ amountPhp: number;
+ /** Free-text reference for bookkeeping, e.g. a GCash ref # or receipt note. */
+ reference?: string | null;
+}
+
+export interface ManualEnrollmentAuditInput {
+ actorId: string;
+ ipAddress?: string | null;
+ userAgent?: string | null;
+}
+
export interface ManualEnrollmentInput {
email: string;
name?: string | null;
pricingTierId: string;
+ /** Omit for comp/free grants. No Payment row is created. */
+ payment?: ManualEnrollmentPaymentInput;
+ /**
+ * When present, an AuditLog row is written in the same transaction as the
+ * enrollment/payment writes — so a commit can never be reported back as a
+ * failure (and retried into a duplicate payment) just because the audit
+ * write failed separately afterward.
+ */
+ audit?: ManualEnrollmentAuditInput;
}
export interface ManualEnrollmentResult {
@@ -79,6 +103,7 @@ export interface ManualEnrollmentResult {
tierName: string;
enrolledCourseIds: string[];
alreadyEnrolledCourseIds: string[];
+ paymentRecorded: boolean;
}
/**
@@ -93,6 +118,8 @@ export async function grantManualEnrollment({
email,
name,
pricingTierId,
+ payment,
+ audit,
}: ManualEnrollmentInput): Promise {
const tier = await db.pricingTier.findUnique({
where: { id: pricingTierId },
@@ -137,6 +164,49 @@ export async function grantManualEnrollment({
});
}
+ if (payment) {
+ // Not tied to a single Enrollment. A tier can bundle several courses,
+ // so this records one payment for the whole grant, not per course.
+ await tx.payment.create({
+ data: {
+ userId: user.id,
+ pricingTierId: tier.id,
+ amountPhp: payment.amountPhp,
+ netAmountPhp: payment.amountPhp,
+ method: payment.method,
+ status: PaymentStatus.COMPLETED,
+ paidAt: new Date(),
+ metadata: payment.reference?.trim() || null,
+ },
+ });
+ }
+
+ if (audit) {
+ // Written via `tx`, not the separate auditLog() helper, so this commits
+ // atomically with the enrollment/payment writes above: a failure here
+ // rolls back the whole grant instead of leaving a committed payment
+ // that gets reported to the admin as a failure (and risks a duplicate
+ // payment on retry).
+ await tx.auditLog.create({
+ data: {
+ actorId: audit.actorId,
+ action: 'MANUAL_ENROLL',
+ entityType: 'User',
+ entityId: user.id,
+ metadata: payment
+ ? JSON.stringify({
+ tier: tier.name,
+ paymentMethod: payment.method,
+ amountPhp: payment.amountPhp,
+ reference: payment.reference?.trim() || undefined,
+ })
+ : null,
+ ipAddress: audit.ipAddress ?? null,
+ userAgent: audit.userAgent ?? null,
+ },
+ });
+ }
+
return {
userId: user.id,
isNewUser: user.isNew,
@@ -144,6 +214,7 @@ export async function grantManualEnrollment({
tierName: tier.name,
enrolledCourseIds: toCreate,
alreadyEnrolledCourseIds: [...alreadyEnrolled],
+ paymentRecorded: !!payment,
};
});
}