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, }; }); }