fix: activation flow - generate invoice on install, block prepaid if unpaid, activate sub on resolve; sub type/status badges

This commit is contained in:
Nemo
2026-03-24 15:26:09 +08:00
parent 5123d8c114
commit c778d14a13
2 changed files with 113 additions and 116 deletions

View File

@@ -268,11 +268,33 @@ export default function ClientDetailScreen() {
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active subscription</Text> <Text style={{ fontSize: 15, color: '#94A3B8' }}>No active subscription</Text>
</View> </View>
) : ( ) : (
{/* Plan type badge */}
{sub.type && (
<View style={{ flexDirection: 'row', marginBottom: 12, gap: 8 }}>
<View style={{
borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6,
backgroundColor: sub.type === 'PREPAID' ? '#FEF3C7' : '#ECFEFF',
}}>
<Text style={{
fontSize: 13, fontWeight: '800',
color: sub.type === 'PREPAID' ? '#D97706' : '#0891B2',
}}>{sub.type}</Text>
</View>
<View style={{
borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6,
backgroundColor: sub.status === 'ACTIVE' ? '#DCFCE7' : sub.status === 'SUSPENDED' ? '#FEF3C7' : sub.status === 'PENDING' ? '#F0F9FF' : '#F1F5F9',
}}>
<Text style={{
fontSize: 13, fontWeight: '700',
color: sub.status === 'ACTIVE' ? '#166534' : sub.status === 'SUSPENDED' ? '#D97706' : sub.status === 'PENDING' ? '#0891B2' : '#6B7280',
}}>{sub.status}</Text>
</View>
</View>
)}
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}> <View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
<InfoRow label="Plan" value={sub.plan?.name} /> <InfoRow label="Plan" value={sub.plan?.name} />
<InfoRow label="Speed" value={sub.plan?.speed ? `${sub.plan.speed} Mbps` : null} /> <InfoRow label="Speed" value={sub.plan?.speedDownMbps ? `${sub.plan.speedDownMbps} Mbps` : null} />
<InfoRow label="Monthly" value={sub.plan?.price ? `${Number(sub.plan.price).toLocaleString()}` : null} /> <InfoRow label="Monthly" value={sub.monthlyPrice ? `${Number(sub.monthlyPrice).toLocaleString()}` : null} />
<InfoRow label="Status" value={sub.status} />
<InfoRow label="Start Date" value={sub.startDate ? new Date(sub.startDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : null} /> <InfoRow label="Start Date" value={sub.startDate ? new Date(sub.startDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : null} />
<InfoRow label="Billing Day" value={sub.billingDay ? `Day ${sub.billingDay}` : null} isLast /> <InfoRow label="Billing Day" value={sub.billingDay ? `Day ${sub.billingDay}` : null} isLast />
</View> </View>

View File

@@ -59,35 +59,40 @@ export default function TicketDetailScreen() {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [sendingComment, setSendingComment] = useState(false); 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({ const { data: ticket, isLoading, refetch } = useQuery({
queryKey: ['task', id], queryKey: ['task', id],
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data), queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
staleTime: 0, staleTime: 0,
}); });
// Fetch client with subscriptions when ticket loads (for prepaid gate) // For activation tickets — fetch client (subscription) + first invoice
const { data: clientDetail } = useQuery({ const isActivationTicket = ticket?.type === 'BILLING' && ticket?.subject?.includes('Activation');
const { data: clientDetail, refetch: refetchClient } = useQuery({
queryKey: ['task-client', ticket?.clientId], queryKey: ['task-client', ticket?.clientId],
queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data), 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) // Fetch invoices for this client (to check if first invoice is PAID)
const { data: clientPayments, refetch: refetchPayments } = useQuery({ const { data: clientInvoices, refetch: refetchInvoices } = useQuery({
queryKey: ['task-client-payments', ticket?.clientId], queryKey: ['task-client-invoices', ticket?.clientId],
queryFn: () => api.get(`/api/v1/payments?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []), queryFn: () => api.get(`/api/v1/invoices?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []),
enabled: !!ticket?.clientId && ticket?.type === 'BILLING', enabled: !!ticket?.clientId && isActivationTicket,
staleTime: 0,
}); });
const updateStatus = useMutation({ const updateStatus = useMutation({
mutationFn: async (status: TaskStatus) => { mutationFn: async (status: TaskStatus) => {
await api.patch(`/api/v1/tickets/${id}`, { status }); 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'; const who = user?.firstName ?? 'Staff';
await api.post(`/api/v1/tickets/${id}/messages`, { await api.post(`/api/v1/tickets/${id}/messages`, {
body: `Status changed to ${status.replace('_', ' ')} by ${who}`, body: `Status changed to ${status.replace('_', ' ')} by ${who}`,
@@ -97,7 +102,10 @@ export default function TicketDetailScreen() {
setShowStatusPicker(false); setShowStatusPicker(false);
await qc.invalidateQueries({ queryKey: ['task', id] }); await qc.invalidateQueries({ queryKey: ['task', id] });
await qc.invalidateQueries({ queryKey: ['tasks'] }); await qc.invalidateQueries({ queryKey: ['tasks'] });
await qc.invalidateQueries({ queryKey: ['clients'] });
await refetch(); await refetch();
await refetchClient().catch(() => {});
await refetchInvoices().catch(() => {});
}, },
onError: () => Alert.alert('Error', 'Could not update status.'), onError: () => Alert.alert('Error', 'Could not update status.'),
}); });
@@ -147,7 +155,12 @@ export default function TicketDetailScreen() {
: `Installation confirmed. Location recorded: ${coordStr}`; : `Installation confirmed. Location recorded: ${coordStr}`;
await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {}); 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', { await api.post('/api/v1/tickets', {
clientId: ticket?.clientId, clientId: ticket?.clientId,
subject: `Account Activation — ${ticket?.client?.firstName ?? ''} ${ticket?.client?.lastName ?? ''}`.trim(), 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 typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9';
const messages: any[] = ticket?.messages ?? []; const messages: any[] = ticket?.messages ?? [];
// Prepaid activation gate // Activation ticket gate — check first invoice paid
const sub = clientDetail?.subscriptions?.[0]; const sub = clientDetail?.subscriptions?.[0];
const isPrepaidActivation = const isPrepaid = sub?.type === 'PREPAID';
ticket?.type === 'BILLING' && const invoiceList: any[] = Array.isArray(clientInvoices) ? clientInvoices : [];
ticket?.subject?.includes('Activation') && const firstInvoice = invoiceList[0] ?? null;
sub?.type === 'PREPAID' && const firstInvoicePaid = firstInvoice?.status === 'PAID' || firstInvoice?.balance === 0;
sub?.status === 'PENDING'; const blockResolve = isActivationTicket && isPrepaid && !firstInvoicePaid;
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);
}
};
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
@@ -357,55 +323,60 @@ export default function TicketDetailScreen() {
</View> </View>
) : null} ) : null}
{/* ── PREPAID ACTIVATION GATE ── */} {/* ── ACTIVATION TICKET BANNER ── */}
{isPrepaidActivation && ( {isActivationTicket && !isDone && (
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5, borderColor: '#FDE68A' }}> <View style={{
<Text style={{ fontSize: 16, fontWeight: '800', color: '#92400E', marginBottom: 4 }}> borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5,
{hasFirstPayment ? '✓ Payment Received — Ready to Activate' : '⚠️ Prepaid — Payment Required'} backgroundColor: blockResolve ? '#FFFBEB' : '#F0FDF4',
</Text> borderColor: blockResolve ? '#FDE68A' : '#86EFAC',
<Text style={{ fontSize: 14, color: '#92400E', marginBottom: 16 }}> }}>
{hasFirstPayment {blockResolve ? (
? 'First payment has been recorded. Account is now active.'
: `Collect ₱${planPrice.toLocaleString()} first payment before activating this account.`}
</Text>
{!hasFirstPayment && !isDone && (
<> <>
{/* Amount */} <Text style={{ fontSize: 16, fontWeight: '800', color: '#92400E', marginBottom: 6 }}>
<Text style={{ fontSize: 13, fontWeight: '600', color: '#92400E', marginBottom: 6 }}>Amount</Text> First Invoice Not Yet Paid
<TextInput </Text>
value={prepaidPayAmount} <Text style={{ fontSize: 14, color: '#92400E', lineHeight: 20 }}>
onChangeText={setPrepaidPayAmount} This is a <Text style={{ fontWeight: '700' }}>PREPAID</Text> account. The first month's invoice must be settled before this account can be activated.
keyboardType="decimal-pad" {'\n\n'}Go to the <Text style={{ fontWeight: '700', color: '#D97706' }}>Collect</Text> screen to record the payment, then come back here to resolve this ticket.
style={{ borderWidth: 1.5, borderColor: '#FDE68A', borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 20, fontWeight: '700', color: '#92400E', marginBottom: 12, backgroundColor: '#fff' }} </Text>
placeholder={planPrice ? String(planPrice) : '0.00'} {firstInvoice && (
/> <View style={{ marginTop: 12, backgroundColor: '#FEF3C7', borderRadius: 10, padding: 12 }}>
<Text style={{ fontSize: 13, color: '#92400E', fontWeight: '600' }}>
{/* Method */} Invoice #{firstInvoice.invoiceNumber}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#92400E', marginBottom: 8 }}>Payment Method</Text> </Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}> <Text style={{ fontSize: 15, color: '#92400E', fontWeight: '800', marginTop: 2 }}>
{[['CASH','Cash'],['GCASH','GCash'],['MAYA','Maya'],['BANK_TRANSFER','Bank']].map(([id, label]) => ( Balance: ₱{Number(firstInvoice.balance ?? firstInvoice.total ?? 0).toLocaleString()}
<TouchableOpacity key={id} onPress={() => setPrepaidPayMethod(id)} </Text>
style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, <Text style={{ fontSize: 12, color: '#B45309', marginTop: 2 }}>
borderColor: prepaidPayMethod === id ? '#D97706' : '#FDE68A', Status: {firstInvoice.status}
backgroundColor: prepaidPayMethod === id ? '#FEF3C7' : '#fff' }} </Text>
>
<Text style={{ fontSize: 13, fontWeight: '700', color: prepaidPayMethod === id ? '#92400E' : '#B45309' }}>{label}</Text>
</TouchableOpacity>
))}
</View> </View>
)}
<SlideToConfirm </>
label={`Slide to collect ₱${parseFloat(prepaidPayAmount || String(planPrice) || '0').toLocaleString()} & activate`} ) : (
color="#D97706" <>
onConfirm={submitPrepaidPayment} <Text style={{ fontSize: 16, fontWeight: '800', color: '#166534', marginBottom: 4 }}>
disabled={prepaidPaying} ✓ Ready to Activate
/> </Text>
<Text style={{ fontSize: 14, color: '#166534' }}>
{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.'}
</Text>
</> </>
)} )}
</View> </View>
)} )}
{isActivationTicket && isDone && (
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC' }}>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#166534' }}>✅ Account Activated</Text>
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>
Subscription is now ACTIVE.
</Text>
</View>
)}
{/* ── INSTALLATION SECTION ── */} {/* ── INSTALLATION SECTION ── */}
{isInstallation && ( {isInstallation && (
<> <>
@@ -663,9 +634,13 @@ export default function TicketDetailScreen() {
key={s} key={s}
onPress={() => { onPress={() => {
if (isActive) return; if (isActive) return;
if (isPrepaidActivation && !hasFirstPayment && (s === 'RESOLVED' || s === 'CLOSED')) { if (blockResolve && (s === 'RESOLVED' || s === 'CLOSED')) {
setShowStatusPicker(false); 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; return;
} }
updateStatus.mutate(s); updateStatus.mutate(s);