From c778d14a1314ba207fb53ac9f0e404fa4640b66c Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 15:26:09 +0800 Subject: [PATCH] fix: activation flow - generate invoice on install, block prepaid if unpaid, activate sub on resolve; sub type/status badges --- app/(app)/clients/[id].tsx | 28 +++++- app/(app)/tasks/[id].tsx | 201 ++++++++++++++++--------------------- 2 files changed, 113 insertions(+), 116 deletions(-) diff --git a/app/(app)/clients/[id].tsx b/app/(app)/clients/[id].tsx index e3439ea..8c7d501 100644 --- a/app/(app)/clients/[id].tsx +++ b/app/(app)/clients/[id].tsx @@ -268,11 +268,33 @@ export default function ClientDetailScreen() { No active subscription ) : ( + {/* Plan type badge */} + {sub.type && ( + + + {sub.type} + + + {sub.status} + + + )} - - - + + diff --git a/app/(app)/tasks/[id].tsx b/app/(app)/tasks/[id].tsx index 95e4cbb..1ad6773 100644 --- a/app/(app)/tasks/[id].tsx +++ b/app/(app)/tasks/[id].tsx @@ -59,35 +59,40 @@ 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), staleTime: 0, }); - // Fetch client with subscriptions when ticket loads (for prepaid gate) - const { data: clientDetail } = useQuery({ + // For activation tickets — fetch client (subscription) + first invoice + const isActivationTicket = ticket?.type === 'BILLING' && ticket?.subject?.includes('Activation'); + + const { data: clientDetail, refetch: refetchClient } = useQuery({ queryKey: ['task-client', ticket?.clientId], queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data), - enabled: !!ticket?.clientId && ticket?.type === 'BILLING', + enabled: !!ticket?.clientId && isActivationTicket, + staleTime: 0, }); - // 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', + // Fetch invoices for this client (to check if first invoice is PAID) + const { data: clientInvoices, refetch: refetchInvoices } = useQuery({ + queryKey: ['task-client-invoices', ticket?.clientId], + queryFn: () => api.get(`/api/v1/invoices?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []), + enabled: !!ticket?.clientId && isActivationTicket, + staleTime: 0, }); const updateStatus = useMutation({ mutationFn: async (status: TaskStatus) => { await api.patch(`/api/v1/tickets/${id}`, { status }); - // Log status change as a system comment + // If this is an activation ticket being RESOLVED → activate subscription + if ((status === 'RESOLVED' || status === 'CLOSED') && isActivationTicket) { + const sub = clientDetail?.subscriptions?.[0]; + if (sub?.id && sub?.status !== 'ACTIVE') { + await api.patch(`/api/v1/subscriptions/${sub.id}`, { status: 'ACTIVE' }).catch(() => {}); + } + } const who = user?.firstName ?? 'Staff'; await api.post(`/api/v1/tickets/${id}/messages`, { body: `Status changed to ${status.replace('_', ' ')} by ${who}`, @@ -97,7 +102,10 @@ export default function TicketDetailScreen() { setShowStatusPicker(false); await qc.invalidateQueries({ queryKey: ['task', id] }); await qc.invalidateQueries({ queryKey: ['tasks'] }); + await qc.invalidateQueries({ queryKey: ['clients'] }); await refetch(); + await refetchClient().catch(() => {}); + await refetchInvoices().catch(() => {}); }, onError: () => Alert.alert('Error', 'Could not update status.'), }); @@ -147,7 +155,12 @@ export default function TicketDetailScreen() { : `Installation confirmed. Location recorded: ${coordStr}`; await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {}); - // 4. Create follow-up activation ticket (non-fatal if fails for role reasons) + // 4. Generate first invoice so it appears in Collect screen + if (ticket?.clientId) { + await api.post(`/api/v1/invoices/generate/${ticket.clientId}`).catch(() => {}); + } + + // 5. Create follow-up activation ticket (non-fatal) await api.post('/api/v1/tickets', { clientId: ticket?.clientId, subject: `Account Activation — ${ticket?.client?.firstName ?? ''} ${ticket?.client?.lastName ?? ''}`.trim(), @@ -210,60 +223,13 @@ export default function TicketDetailScreen() { const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9'; const messages: any[] = ticket?.messages ?? []; - // Prepaid activation gate + // Activation ticket gate — check first invoice paid 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 effectiveAmount = prepaidPayAmount || String(planPrice); - const amt = parseFloat(effectiveAmount); - if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Enter a valid amount.'); return; } - setPrepaidPaying(true); - try { - // 1. Generate first invoice for client - let invoiceId: string | undefined; - try { - const invRes = await api.post(`/api/v1/invoices/generate/${ticket.clientId}`); - invoiceId = invRes.data?.id; - } catch {} - - // 2. Record payment (link to invoice if available) - await api.post('/api/v1/payments', { - clientId: ticket.clientId, - amount: amt, - channel: prepaidPayMethod, - paymentDate: new Date().toISOString(), - notes: 'First prepaid payment — account activation', - ...(invoiceId ? { invoiceId } : {}), - }); - // 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); - } - }; + const isPrepaid = sub?.type === 'PREPAID'; + const invoiceList: any[] = Array.isArray(clientInvoices) ? clientInvoices : []; + const firstInvoice = invoiceList[0] ?? null; + const firstInvoicePaid = firstInvoice?.status === 'PAID' || firstInvoice?.balance === 0; + const blockResolve = isActivationTicket && isPrepaid && !firstInvoicePaid; return ( @@ -357,55 +323,60 @@ 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 && ( + {/* ── ACTIVATION TICKET BANNER ── */} + {isActivationTicket && !isDone && ( + + {blockResolve ? ( <> - {/* 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} - - ))} - - - + + ⚠️ First Invoice Not Yet Paid + + + This is a PREPAID account. The first month's invoice must be settled before this account can be activated. + {'\n\n'}Go to the Collect screen to record the payment, then come back here to resolve this ticket. + + {firstInvoice && ( + + + Invoice #{firstInvoice.invoiceNumber} + + + Balance: ₱{Number(firstInvoice.balance ?? firstInvoice.total ?? 0).toLocaleString()} + + + Status: {firstInvoice.status} + + + )} + + ) : ( + <> + + ✓ Ready to Activate + + + {isPrepaid + ? 'First invoice has been paid. Tap "Update Status" → Resolved to activate this account.' + : 'Postpaid account is ready to activate. Tap "Update Status" → Resolved to activate.'} + )} )} + {isActivationTicket && isDone && ( + + ✅ Account Activated + + Subscription is now ACTIVE. + + + )} + {/* ── INSTALLATION SECTION ── */} {isInstallation && ( <> @@ -663,9 +634,13 @@ export default function TicketDetailScreen() { key={s} onPress={() => { if (isActive) return; - if (isPrepaidActivation && !hasFirstPayment && (s === 'RESOLVED' || s === 'CLOSED')) { + if (blockResolve && (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.'); + Alert.alert( + 'Invoice Not Yet Paid', + 'This is a PREPAID account. The first month\'s invoice must be paid before activating the account.\n\nGo to Collect screen to record the payment first.', + [{ text: 'OK' }] + ); return; } updateStatus.mutate(s);