From 705e37566733634497bdf52ec97ac5600dcf72d1 Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 12:44:55 +0800 Subject: [PATCH] feat: prepaid/postpaid onboarding; prepaid payment gate on activation ticket; slide-to-confirm on all payments; pay button on dashboard invoices --- app/(app)/clients/new.tsx | 51 +++++++++++++--- app/(app)/tasks/[id].tsx | 125 +++++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 11 deletions(-) diff --git a/app/(app)/clients/new.tsx b/app/(app)/clients/new.tsx index 835acf5..1e4132a 100644 --- a/app/(app)/clients/new.tsx +++ b/app/(app)/clients/new.tsx @@ -89,7 +89,8 @@ export default function NewClientScreen() { const [areaId, setAreaId] = useState(''); // Step 2 - const [planId, setPlanId] = useState(''); + const [planId, setPlanId] = useState(''); + const [subType, setSubType] = useState<'PREPAID' | 'POSTPAID'>('POSTPAID'); const { data: areas = [] } = useQuery({ queryKey: ['areas'], @@ -142,12 +143,12 @@ export default function NewClientScreen() { // 2. Create subscription (PENDING until installation confirmed) await api.post('/api/v1/subscriptions', { - clientId: client.id, + clientId: client.id, planId, - type: 'POSTPAID', - status: 'PENDING', - billingDay: 5, - startDate: new Date().toISOString(), + type: subType, + status: 'PENDING', + billingDay: 5, + startDate: new Date().toISOString(), }); // 3. Create installation ticket @@ -156,6 +157,8 @@ export default function NewClientScreen() { subject: `New Installation — ${firstName.trim()} ${lastName.trim()}`, type: 'INSTALLATION', priority: 'NORMAL', + // Pass subType so activation ticket knows to gate on payment for PREPAID + ...(subType === 'PREPAID' ? { priority: 'HIGH' } : {}), }); const ticket = ticketRes.data; @@ -252,7 +255,27 @@ export default function NewClientScreen() { {step === 1 && ( Select a Plan - Choose the internet plan for this subscriber. + Choose the internet plan and billing type. + + {/* Subscription type toggle */} + + Billing Type * + + {(['POSTPAID', 'PREPAID'] as const).map(t => ( + setSubType(t)} style={{ flex: 1, paddingVertical: 10, borderRadius: 10, alignItems: 'center', backgroundColor: subType === t ? '#fff' : 'transparent' }}> + {t} + + {t === 'POSTPAID' ? 'Pay after billing day' : 'Pay before activation'} + + + ))} + + {subType === 'PREPAID' && ( + + ⚠️ Prepaid clients must settle their first payment before the account can be activated. + + )} + {plansLoading ? @@ -319,11 +342,19 @@ export default function NewClientScreen() { {/* Plan card */} PLAN - - - + + + + + {subType === 'PREPAID' && ( + + ⚠️ Prepaid — Payment Required Before Activation + After installation, a payment of ₱{Number(selectedPlan?.monthlyPrice ?? 0).toLocaleString()} must be collected before the account goes active. + + )} + {/* What will happen */} What happens next diff --git a/app/(app)/tasks/[id].tsx b/app/(app)/tasks/[id].tsx index 49be9c1..96fb297 100644 --- a/app/(app)/tasks/[id].tsx +++ b/app/(app)/tasks/[id].tsx @@ -9,6 +9,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import * as Location from 'expo-location'; import { api } from '../../../services/api'; import { useAuthStore } from '../../../stores/authStore'; +import { SlideToConfirm } from '../../../components/SlideToConfirm'; // ─── Constants ──────────────────────────────────────────────────────────────── const STATUS_FLOW = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const; @@ -58,11 +59,30 @@ export default function TicketDetailScreen() { const [comment, setComment] = useState(''); const [sendingComment, setSendingComment] = useState(false); + // Prepaid activation payment state + const [prepaidPayAmount, setPrepaidPayAmount] = useState(''); + const [prepaidPayMethod, setPrepaidPayMethod] = useState('CASH'); + const [prepaidPaying, setPrepaidPaying] = useState(false); + const { data: ticket, isLoading, refetch } = useQuery({ queryKey: ['task', id], queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data), }); + // Fetch client with subscriptions when ticket loads (for prepaid gate) + const { data: clientDetail } = useQuery({ + queryKey: ['task-client', ticket?.clientId], + queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data), + enabled: !!ticket?.clientId && ticket?.type === 'BILLING', + }); + + // Fetch existing payments for this client (to check if first payment done) + const { data: clientPayments, refetch: refetchPayments } = useQuery({ + queryKey: ['task-client-payments', ticket?.clientId], + queryFn: () => api.get(`/api/v1/payments?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []), + enabled: !!ticket?.clientId && ticket?.type === 'BILLING', + }); + const updateStatus = useMutation({ mutationFn: async (status: TaskStatus) => { await api.patch(`/api/v1/tickets/${id}`, { status }); @@ -190,6 +210,52 @@ export default function TicketDetailScreen() { const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9'; const messages: any[] = ticket?.messages ?? []; + // Prepaid activation gate + const sub = clientDetail?.subscriptions?.[0]; + const isPrepaidActivation = + ticket?.type === 'BILLING' && + ticket?.subject?.includes('Activation') && + sub?.type === 'PREPAID' && + sub?.status === 'PENDING'; + const hasFirstPayment = Array.isArray(clientPayments) && clientPayments.length > 0; + const planPrice = Number(sub?.monthlyPrice ?? 0); + + const submitPrepaidPayment = async () => { + const amt = parseFloat(prepaidPayAmount); + if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Enter a valid amount.'); return; } + setPrepaidPaying(true); + try { + // 1. Record payment + await api.post('/api/v1/payments', { + clientId: ticket.clientId, + amount: amt, + channel: prepaidPayMethod, + paymentDate: new Date().toISOString(), + notes: 'First prepaid payment — account activation', + }); + // 2. Activate subscription + if (sub?.id) { + await api.patch(`/api/v1/subscriptions/${sub.id}`, { status: 'ACTIVE' }).catch(() => {}); + } + // 3. Log comment + resolve ticket + await api.post(`/api/v1/tickets/${id}/messages`, { + body: `First payment of ₱${amt.toLocaleString()} recorded via ${prepaidPayMethod}. Account activated.`, + }).catch(() => {}); + await api.patch(`/api/v1/tickets/${id}`, { status: 'RESOLVED' }); + + await refetch(); + await refetchPayments(); + qc.invalidateQueries({ queryKey: ['tasks'] }); + qc.invalidateQueries({ queryKey: ['clients'] }); + Alert.alert('Account Activated! ✓', `Payment of ₱${amt.toLocaleString()} recorded and account is now ACTIVE.`); + } catch (e: any) { + const msg = e?.response?.data?.message ?? 'Could not process payment.'; + Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg); + } finally { + setPrepaidPaying(false); + } + }; + return ( @@ -282,6 +348,55 @@ export default function TicketDetailScreen() { ) : null} + {/* ── PREPAID ACTIVATION GATE ── */} + {isPrepaidActivation && ( + + + {hasFirstPayment ? '✓ Payment Received — Ready to Activate' : '⚠️ Prepaid — Payment Required'} + + + {hasFirstPayment + ? 'First payment has been recorded. Account is now active.' + : `Collect ₱${planPrice.toLocaleString()} first payment before activating this account.`} + + + {!hasFirstPayment && !isDone && ( + <> + {/* Amount */} + Amount + + + {/* Method */} + Payment Method + + {[['CASH','Cash'],['GCASH','GCash'],['MAYA','Maya'],['BANK_TRANSFER','Bank']].map(([id, label]) => ( + setPrepaidPayMethod(id)} + style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, + borderColor: prepaidPayMethod === id ? '#D97706' : '#FDE68A', + backgroundColor: prepaidPayMethod === id ? '#FEF3C7' : '#fff' }} + > + {label} + + ))} + + + + + )} + + )} + {/* ── INSTALLATION SECTION ── */} {isInstallation && ( <> @@ -537,7 +652,15 @@ export default function TicketDetailScreen() { return ( !isActive && updateStatus.mutate(s)} + onPress={() => { + if (isActive) return; + if (isPrepaidActivation && !hasFirstPayment && (s === 'RESOLVED' || s === 'CLOSED')) { + setShowStatusPicker(false); + Alert.alert('Payment Required', 'This is a PREPAID account. Please collect and record the first payment before resolving this ticket.'); + return; + } + updateStatus.mutate(s); + }} disabled={isActive || updateStatus.isPending} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',