From 66318edf77ef52bde18980e015041da2f39f653d Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 03:33:51 +0000
Subject: [PATCH 1/4] Add "Enroll student" link to admin sidebar nav
The manual enrollment flow at /admin/enroll (grant a pricing tier
without a PayMongo payment) existed but was only reachable via direct
URL or from a student's detail page, not from the main admin nav.
---
src/components/ui/Icon.tsx | 5 ++++-
src/components/ui/NavSidebar.tsx | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
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' },
From 9927bcecfb1ad3ca33fd95ac654ea4ba2b891345 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 03:39:33 +0000
Subject: [PATCH 2/4] Add optional payment record to manual enrollment for
bookkeeping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/admin/enroll now lets the admin note how a student paid outside the
platform (method, amount, free-text reference) when granting a tier.
grantManualEnrollment() creates a single COMPLETED Payment row for the
grant (not tied to one course enrollment, since a tier can bundle
several) so it shows up under the student's Payments in the admin
panel. Recording payment is optional — comp/free grants still work
exactly as before with no Payment row.
Also fixed the Payments badge on the student detail page, which
checked payment.status against 'PAID', a value the app never uses
(the real enum value is 'COMPLETED'), so completed payments always
rendered as the default gray badge.
---
src/app/actions/admin-enroll.ts | 21 +++++
src/app/admin/enroll/EnrollForm.tsx | 108 ++++++++++++++++++++++++-
src/app/admin/enroll/enroll.module.css | 19 +++++
src/app/admin/enroll/page.tsx | 1 +
src/app/admin/users/[id]/page.tsx | 7 +-
src/lib/enrollment.ts | 33 +++++++-
6 files changed, 185 insertions(+), 4 deletions(-)
diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts
index 6930b66..ce4f408 100644
--- a/src/app/actions/admin-enroll.ts
+++ b/src/app/actions/admin-enroll.ts
@@ -14,12 +14,22 @@ import { revalidatePath } from 'next/cache';
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,6 +39,7 @@ export interface ManualEnrollActionData {
tierName: string;
enrolledCount: number;
alreadyEnrolledCount: number;
+ paymentRecorded: boolean;
}
export async function manualEnrollAction(
@@ -49,12 +60,21 @@ export async function manualEnrollAction(
email: parsed.data.email,
name: parsed.data.name || null,
pricingTierId: parsed.data.pricingTierId,
+ payment: parsed.data.payment,
});
await auditLog({
action: 'MANUAL_ENROLL',
entityType: 'User',
entityId: result.userId,
+ metadata: parsed.data.payment
+ ? {
+ tier: result.tierName,
+ paymentMethod: parsed.data.payment.method,
+ amountPhp: parsed.data.payment.amountPhp,
+ reference: parsed.data.payment.reference || undefined,
+ }
+ : undefined,
});
revalidatePath('/admin/users');
revalidatePath(`/admin/users/${result.userId}`);
@@ -77,6 +97,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/lib/enrollment.ts b/src/lib/enrollment.ts
index f210300..b7ff4ba 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,21 @@ 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 ManualEnrollmentInput {
email: string;
name?: string | null;
pricingTierId: string;
+ /** Omit for comp/free grants — no Payment row is created. */
+ payment?: ManualEnrollmentPaymentInput;
}
export interface ManualEnrollmentResult {
@@ -79,6 +90,7 @@ export interface ManualEnrollmentResult {
tierName: string;
enrolledCourseIds: string[];
alreadyEnrolledCourseIds: string[];
+ paymentRecorded: boolean;
}
/**
@@ -93,6 +105,7 @@ export async function grantManualEnrollment({
email,
name,
pricingTierId,
+ payment,
}: ManualEnrollmentInput): Promise {
const tier = await db.pricingTier.findUnique({
where: { id: pricingTierId },
@@ -137,6 +150,23 @@ 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,
+ },
+ });
+ }
+
return {
userId: user.id,
isNewUser: user.isNew,
@@ -144,6 +174,7 @@ export async function grantManualEnrollment({
tierName: tier.name,
enrolledCourseIds: toCreate,
alreadyEnrolledCourseIds: [...alreadyEnrolled],
+ paymentRecorded: !!payment,
};
});
}
From 3eca663f3159146d4bdfb33b6b4d91e97e02b6d5 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 03:48:22 +0000
Subject: [PATCH 3/4] Make manual-enroll audit write atomic with the
payment/enrollment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses a CodeRabbit review finding on PR #93: the AuditLog write
happened as a separate db call after the enrollment/payment
transaction committed. If that write failed, the action returned an
error even though the Payment row already existed — and since nothing
gated payment creation on idempotency, retrying the same submission
would create a duplicate COMPLETED payment.
grantManualEnrollment() now accepts an `audit` context and writes the
AuditLog row with the same transaction client as the enrollment and
payment writes, so the whole grant commits or rolls back together.
Also fixed an em dash CodeRabbit flagged in a comment (repo style
guidelines prohibit them).
Not done: a durable idempotency key / outbox pattern for the payment
write itself, which CodeRabbit's suggestion also called for. This is
a low-traffic, solo-admin internal tool where an accidental duplicate
payment is easy to spot and soft-delete in the admin UI; the atomicity
fix above removes the concrete failure mode described (committed
payment reported as failed, prompting a retry). Skipping the added
infra as disproportionate to this feature's scope.
---
src/app/actions/admin-enroll.ts | 23 ++++++-----------
src/lib/enrollment.ts | 44 +++++++++++++++++++++++++++++++--
2 files changed, 50 insertions(+), 17 deletions(-)
diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts
index ce4f408..3452f4c 100644
--- a/src/app/actions/admin-enroll.ts
+++ b/src/app/actions/admin-enroll.ts
@@ -11,8 +11,8 @@
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';
@@ -45,7 +45,7 @@ export interface ManualEnrollActionData {
export async function manualEnrollAction(
input: z.infer,
): Promise> {
- await requireAdmin();
+ const actor = await requireAdmin();
const parsed = manualEnrollSchema.safeParse(input);
if (!parsed.success) {
@@ -56,26 +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,
- metadata: parsed.data.payment
- ? {
- tier: result.tierName,
- paymentMethod: parsed.data.payment.method,
- amountPhp: parsed.data.payment.amountPhp,
- reference: parsed.data.payment.reference || undefined,
- }
- : undefined,
- });
revalidatePath('/admin/users');
revalidatePath(`/admin/users/${result.userId}`);
diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts
index b7ff4ba..ee7b994 100644
--- a/src/lib/enrollment.ts
+++ b/src/lib/enrollment.ts
@@ -74,12 +74,25 @@ export interface ManualEnrollmentPaymentInput {
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. */
+ /** 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 {
@@ -106,6 +119,7 @@ export async function grantManualEnrollment({
name,
pricingTierId,
payment,
+ audit,
}: ManualEnrollmentInput): Promise {
const tier = await db.pricingTier.findUnique({
where: { id: pricingTierId },
@@ -151,7 +165,7 @@ export async function grantManualEnrollment({
}
if (payment) {
- // Not tied to a single Enrollment — a tier can bundle several courses,
+ // 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: {
@@ -167,6 +181,32 @@ export async function grantManualEnrollment({
});
}
+ 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,
From 7cc4120c86c3b6080f38b9296512ed24e82847f7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 03:51:28 +0000
Subject: [PATCH 4/4] Add test coverage for manual-enroll payment/audit
branches
CI's Quality Gates job failed on 3eca663: global branch coverage
dropped to 68.39%, below the 70% threshold, because the new
if (payment) / if (audit) branches in grantManualEnrollment() had no
test coverage. Adds three cases to enrollment.test.ts: no payment/audit
provided, a payment + audit recorded together (asserting the Payment
and AuditLog write shapes, including the JSON metadata), and audit
provided without a payment (null metadata). enrollment.ts branch
coverage goes from 57.14% to 92.85%, bringing the global figure to
71.55%.
---
src/lib/__tests__/enrollment.test.ts | 93 ++++++++++++++++++++++++++++
1 file changed, 93 insertions(+)
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();
+ });
});