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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ vite.config.ts.timestamp-*

# Claude Code local tool state
.claude/
.opencode/
.tmp/

# MCP server state directories
.playwright-mcp/
Expand All @@ -153,4 +155,4 @@ vite.config.ts.timestamp-*

# Playwright test output (generated)
playwright-report/
test-results/
test-results/
45 changes: 45 additions & 0 deletions app/api/cron/expire-packages/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'

vi.mock('@/lib/services/payments', () => ({
expirePackages: vi.fn(async () => 0),
}))

import { expirePackages } from '@/lib/services/payments'
import { GET } from './route'

const mockedExpirePackages = vi.mocked(expirePackages)

describe('GET /api/cron/expire-packages', () => {
beforeEach(() => {
vi.unstubAllEnvs()
mockedExpirePackages.mockClear()
})

test('fails closed when CRON_SECRET is missing', async () => {
vi.stubEnv('CRON_SECRET', undefined)

const response = await GET(
new Request('http://localhost/api/cron/expire-packages', {
headers: { authorization: 'Bearer undefined' },
}),
)

expect(response.status).toBe(401)
expect(mockedExpirePackages).not.toHaveBeenCalled()
})

test('runs expiration when the configured bearer token matches', async () => {
vi.stubEnv('CRON_SECRET', 'local-cron-secret')

const response = await GET(
new Request('http://localhost/api/cron/expire-packages', {
headers: { authorization: 'Bearer local-cron-secret' },
}),
)
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toEqual({ ok: true, expired: 0 })
expect(mockedExpirePackages).toHaveBeenCalledTimes(1)
})
})
3 changes: 2 additions & 1 deletion app/api/cron/expire-packages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { expirePackages } from "@/lib/services/payments";

export async function GET(request: Request) {
const authHeader = request.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;

if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

Expand Down
26 changes: 15 additions & 11 deletions app/dashboard/packages/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ export default function PackageDetailPage() {
newProofPath
)
if (!result.success) {
if (file && newProofPath && newProofPath !== payment.proof_path) {
await supabase.storage.from('payment-proofs').remove([newProofPath])
}
setError(result.error || 'Failed to resubmit payment.')
setSubmitting(false)
return
Expand Down Expand Up @@ -221,17 +224,18 @@ export default function PackageDetailPage() {
proofPath = uploadData.path
}

// Update payment with reference and/or proof
const { error: updateError } = await supabase
.from('payments')
.update({
reference: reference.trim() || null,
proof_path: proofPath,
})
.eq('id', payment.id)

if (updateError) {
setError('Failed to save payment details. Please try again.')
const { updatePendingPaymentDetails } = await import('@/app/dashboard/packages/actions')
const result = await updatePendingPaymentDetails(
payment.id,
reference.trim() || null,
proofPath,
)

if (!result.success) {
if (file && proofPath && proofPath !== payment.proof_path) {
await supabase.storage.from('payment-proofs').remove([proofPath])
}
setError(result.error || 'Failed to save payment details. Please try again.')
setSubmitting(false)
return
}
Expand Down
114 changes: 111 additions & 3 deletions app/dashboard/packages/actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
'use server'

import { revalidatePath } from 'next/cache'
import { PACKAGES, type PackageTier } from '@/lib/config/pricing'
import { createAdminClient } from '@/lib/supabase/admin'
import { createClient } from '@/lib/supabase/server'

function getPackageTier(tier: number): PackageTier | null {
return PACKAGES.some((pkg) => pkg.tier === tier) ? (tier as PackageTier) : null
}

/**
* Generate a signed URL for a payment proof file.
* Only the owner of the payment can request this.
Expand Down Expand Up @@ -41,6 +48,96 @@ export async function getPaymentProofSignedUrl(
return { url: data?.signedUrl ?? null, error: null }
}

export async function checkoutPackage(
requestId: string,
tier: number,
): Promise<{ packageId: string | null; error: string | null }> {
const selectedTier = getPackageTier(tier)
if (!selectedTier) {
return { packageId: null, error: 'Invalid package selection.' }
}

const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return { packageId: null, error: 'Not authenticated' }
}

const { data, error } = await supabase.rpc('checkout_package', {
p_request_id: requestId,
p_tier_sessions: selectedTier,
})

if (error || !data) {
return {
packageId: null,
error: error?.message ?? 'Failed to create package. Please try again.',
}
}

revalidatePath('/dashboard')
revalidatePath('/dashboard/requests')
revalidatePath(`/dashboard/packages/${data}`)

return { packageId: data, error: null }
}

export async function updatePendingPaymentDetails(
paymentId: string,
reference: string | null,
proofPath: string | null,
): Promise<{ success: boolean; error: string | null }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) {
return { success: false, error: 'Not authenticated' }
}

const { data: payment } = await supabase
.from('payments')
.select('id, package_id, payer_user_id, status')
.eq('id', paymentId)
.single()

if (!payment || payment.payer_user_id !== user.id) {
return { success: false, error: 'Unauthorized' }
}

if (payment.status !== 'pending') {
return { success: false, error: 'Payment is not in pending status' }
}

const admin = createAdminClient()
const { data: updated, error } = await admin
.from('payments')
.update({
reference: reference?.trim() || null,
proof_path: proofPath,
rejection_note: null,
verified_by_user_id: null,
verified_at: null,
})
.eq('id', paymentId)
.eq('payer_user_id', user.id)
.eq('status', 'pending')
.select('id, package_id')

if (error || !updated || updated.length === 0) {
return { success: false, error: 'Failed to save payment details. Please try again.' }
}

revalidatePath(`/dashboard/packages/${payment.package_id}`)
revalidatePath('/admin/payments')

return { success: true, error: null }
}

/**
* Resubmit a rejected payment — resets status to pending.
*/
Expand Down Expand Up @@ -73,21 +170,32 @@ export async function resubmitRejectedPayment(
return { success: false, error: 'Payment is not in rejected status' }
}

// Reset payment to pending with new proof/reference
const { error } = await supabase
const admin = createAdminClient()

// Reset payment to pending with new proof/reference. The admin client is used
// after ownership validation because the payer RLS update policy only permits
// edits to already-pending rows.
const { data: updated, error } = await admin
.from('payments')
.update({
status: 'pending',
reference: reference?.trim() || null,
proof_path: proofPath,
rejection_note: null,
verified_by_user_id: null,
verified_at: null,
})
.eq('id', paymentId)
.eq('payer_user_id', user.id)
.eq('status', 'rejected')
.select('id, package_id')

if (error) {
if (error || !updated || updated.length === 0) {
return { success: false, error: 'Failed to resubmit payment. Please try again.' }
}

revalidatePath(`/dashboard/packages/${updated[0].package_id}`)
revalidatePath('/admin/payments')

return { success: true, error: null }
}
Loading
Loading