diff --git a/app/(auth)/add-loved-one.tsx b/app/(auth)/add-loved-one.tsx index 55da88f..90eaf5e 100644 --- a/app/(auth)/add-loved-one.tsx +++ b/app/(auth)/add-loved-one.tsx @@ -93,8 +93,10 @@ export default function AddLovedOneScreen() { try { // Create beneficiary on server IMMEDIATELY + const trimmedAddress = address.trim(); const result = await api.createBeneficiary({ name: trimmedName, + address: trimmedAddress || undefined, }); if (!result.ok || !result.data) { @@ -104,6 +106,15 @@ export default function AddLovedOneScreen() { const beneficiaryId = result.data.id; + // Upload avatar if selected + if (avatarUri) { + const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, avatarUri); + if (!avatarResult.ok) { + console.warn('[AddLovedOne] Failed to upload avatar:', avatarResult.error?.message); + // Continue anyway - avatar is not critical + } + } + // Navigate to the purchase/subscription screen with beneficiary ID router.replace({ pathname: '/(auth)/purchase', @@ -111,7 +122,6 @@ export default function AddLovedOneScreen() { beneficiaryId: String(beneficiaryId), lovedOneName: trimmedName, lovedOneAddress: address.trim(), - lovedOneAvatar: avatarUri || '', inviteCode, }, }); diff --git a/app/(auth)/purchase.tsx b/app/(auth)/purchase.tsx index 1a03277..04eeba0 100644 --- a/app/(auth)/purchase.tsx +++ b/app/(auth)/purchase.tsx @@ -164,7 +164,9 @@ export default function PurchaseScreen() { } await api.setOnboardingCompleted(true); - setStep('order_placed'); + + // Redirect directly to equipment-status page (skip order_placed screen) + router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`); } catch (error) { console.error('Payment error:', error); Alert.alert( diff --git a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx index e29e262..1ea1de1 100644 --- a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx +++ b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx @@ -326,6 +326,15 @@ export default function EquipmentStatusScreen() { Need help? Contact support + + {/* Back to Loved Ones Button */} + router.replace('/(tabs)/beneficiaries')} + > + + Back to My Loved Ones + ); @@ -539,4 +548,22 @@ const styles = StyleSheet.create({ fontSize: FontSizes.base, color: AppColors.textSecondary, }, + // Back to Loved Ones Button + backToLovedOnesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: AppColors.surface, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: AppColors.border, + gap: Spacing.sm, + marginTop: Spacing.lg, + }, + backToLovedOnesText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.medium, + color: AppColors.primary, + }, }); diff --git a/app/(tabs)/beneficiaries/[id]/index.tsx b/app/(tabs)/beneficiaries/[id]/index.tsx index 7f1c8ae..d9ebc00 100644 --- a/app/(tabs)/beneficiaries/[id]/index.tsx +++ b/app/(tabs)/beneficiaries/[id]/index.tsx @@ -41,6 +41,7 @@ import { hasActiveSubscription, shouldShowSubscriptionWarning, } from '@/services/BeneficiaryDetailController'; +import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; // WebView Dashboard URL - opens specific deployment directly const getDashboardUrl = (deploymentId?: number) => { @@ -60,7 +61,6 @@ export default function BeneficiaryDetailScreen() { const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); const [showWebView, setShowWebView] = useState(false); const [isWebViewReady, setIsWebViewReady] = useState(false); const [authToken, setAuthToken] = useState(null); @@ -179,19 +179,33 @@ export default function BeneficiaryDetailScreen() { return; } + const beneficiaryId = parseInt(id, 10); + try { - const response = await api.updateWellNuoBeneficiary(parseInt(id, 10), { + // Update basic info + const response = await api.updateWellNuoBeneficiary(beneficiaryId, { name: editForm.name.trim(), address: editForm.address.trim() || undefined, }); - if (response.ok) { - setIsEditModalVisible(false); - toast.success('Saved', 'Profile updated successfully'); - loadBeneficiary(false); - } else { + if (!response.ok) { toast.error('Error', response.error?.message || 'Failed to save changes.'); + return; } + + // Upload avatar if changed (new local file URI) + if (editForm.avatar && editForm.avatar.startsWith('file://')) { + const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar); + if (!avatarResult.ok) { + console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message); + // Show info but don't fail the whole operation + toast.info('Note', 'Profile saved but avatar upload failed'); + } + } + + setIsEditModalVisible(false); + toast.success('Saved', 'Profile updated successfully'); + loadBeneficiary(false); } catch (err) { toast.error('Error', 'Failed to save changes.'); } @@ -279,83 +293,14 @@ export default function BeneficiaryDetailScreen() { {beneficiary.name} - - setIsMenuVisible(!isMenuVisible)}> - - - - {/* Dropdown Menu */} - {isMenuVisible && ( - - { - setIsMenuVisible(false); - handleEditPress(); - }} - > - - Edit - - - { - setIsMenuVisible(false); - router.push(`/(tabs)/beneficiaries/${id}/share`); - }} - > - - Access - - - { - setIsMenuVisible(false); - router.push(`/(tabs)/beneficiaries/${id}/subscription`); - }} - > - - Subscription - - - { - setIsMenuVisible(false); - router.push(`/(tabs)/beneficiaries/${id}/equipment`); - }} - > - - Equipment - - - { - setIsMenuVisible(false); - handleDeleteBeneficiary(); - }} - > - - Remove - - - )} - + - {/* Backdrop to close menu */} - {isMenuVisible && ( - setIsMenuVisible(false)} - /> - )} - - {/* DEBUG PANEL */} + {/* DEBUG PANEL - commented out {__DEV__ && ( DEBUG INFO (tap to copy) @@ -377,6 +322,7 @@ export default function BeneficiaryDetailScreen() { )} + */} {/* Dashboard Content */} @@ -571,42 +517,6 @@ const styles = StyleSheet.create({ fontWeight: FontWeights.semibold, color: AppColors.textPrimary, }, - // Dropdown Menu - dropdownMenu: { - position: 'absolute', - top: 44, - right: 0, - backgroundColor: AppColors.surface, - borderRadius: BorderRadius.lg, - minWidth: 160, - ...Shadows.lg, - zIndex: 100, - }, - dropdownItem: { - flexDirection: 'row', - alignItems: 'center', - padding: Spacing.md, - gap: Spacing.sm, - }, - dropdownItemText: { - fontSize: FontSizes.base, - color: AppColors.textPrimary, - }, - dropdownItemDanger: { - borderTopWidth: 1, - borderTopColor: AppColors.border, - }, - dropdownItemTextDanger: { - color: AppColors.error, - }, - menuBackdrop: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - zIndex: 5, - }, // Debug Panel debugPanel: { backgroundColor: '#FFF9C4', diff --git a/app/(tabs)/beneficiaries/[id]/subscription.tsx b/app/(tabs)/beneficiaries/[id]/subscription.tsx index 6810ff5..8e0b592 100644 --- a/app/(tabs)/beneficiaries/[id]/subscription.tsx +++ b/app/(tabs)/beneficiaries/[id]/subscription.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, @@ -8,7 +8,7 @@ import { ActivityIndicator, Modal, ScrollView, - Clipboard, + Linking, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -18,15 +18,13 @@ import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } fro import { useAuth } from '@/contexts/AuthContext'; import { api } from '@/services/api'; import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController'; +import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu'; import type { Beneficiary } from '@/types'; -import * as ExpoClipboard from 'expo-clipboard'; -import Toast from 'react-native-root-toast'; const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe'; -const SUBSCRIPTION_PRICE = 49; // $49/month +const SUBSCRIPTION_PRICE = 49; -// DEBUG MODE - set to true to show debug panel -const DEBUG_MODE = true; +type SubscriptionState = 'active' | 'canceling' | 'none'; export default function SubscriptionScreen() { const { id } = useLocalSearchParams<{ id: string }>(); @@ -35,45 +33,52 @@ export default function SubscriptionScreen() { const [beneficiary, setBeneficiary] = useState(null); const [isLoading, setIsLoading] = useState(true); const [showSuccessModal, setShowSuccessModal] = useState(false); - const [justSubscribed, setJustSubscribed] = useState(false); // Prevent self-guard redirect after payment - - // Debug state - const [debugLogs, setDebugLogs] = useState([]); - const [showDebugPanel, setShowDebugPanel] = useState(DEBUG_MODE); - - const addDebugLog = (message: string) => { - const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; - setDebugLogs(prev => [...prev, `[${timestamp}] ${message}`]); - console.log(`[DEBUG] ${message}`); - }; + const [justSubscribed, setJustSubscribed] = useState(false); + const [transactions, setTransactions] = useState>([]); + const [isLoadingTransactions, setIsLoadingTransactions] = useState(false); const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); const { user } = useAuth(); useEffect(() => { loadBeneficiary(); + loadTransactions(); }, [id]); - const loadBeneficiary = async () => { - if (!id) { - addDebugLog(`loadBeneficiary: no id provided`); - return; - } - - addDebugLog(`loadBeneficiary: fetching id=${id}`); - + const loadTransactions = async () => { + if (!id) return; + setIsLoadingTransactions(true); try { - const response = await api.getWellNuoBeneficiary(parseInt(id, 10)); - addDebugLog(`loadBeneficiary: response.ok=${response.ok}`); + const response = await api.getTransactionHistory(parseInt(id, 10)); if (response.ok && response.data) { - setBeneficiary(response.data); - addDebugLog(`loadBeneficiary: got beneficiary id=${response.data.id}, name=${response.data.name}`); - } else { - addDebugLog(`loadBeneficiary: ERROR - ${response.error}`); - console.error('Failed to load beneficiary:', response.error); + setTransactions(response.data.transactions); + } + } catch (error) { + console.error('Failed to load transactions:', error); + } finally { + setIsLoadingTransactions(false); + } + }; + + const loadBeneficiary = async () => { + if (!id) return; + try { + const response = await api.getWellNuoBeneficiary(parseInt(id, 10)); + if (response.ok && response.data) { + setBeneficiary(response.data); } } catch (error) { - addDebugLog(`loadBeneficiary: EXCEPTION - ${error}`); console.error('Failed to load beneficiary:', error); } finally { setIsLoading(false); @@ -81,21 +86,27 @@ export default function SubscriptionScreen() { }; const subscription = beneficiary?.subscription; - const isActive = (subscription?.status === 'active' || subscription?.status === 'trialing') && - subscription?.endDate && - new Date(subscription.endDate) > new Date(); - // Check if subscription is canceled but still active until period end - const isCanceledButActive = isActive && subscription?.cancelAtPeriodEnd === true; + // Determine subscription state + const getSubscriptionState = (): SubscriptionState => { + if (!subscription) return 'none'; - // Self-guard: redirect if user shouldn't be on this page + const isStatusActive = subscription.status === 'active' || subscription.status === 'trialing'; + const isNotExpired = !subscription.endDate || new Date(subscription.endDate) > new Date(); + + if (isStatusActive && isNotExpired) { + return subscription.cancelAtPeriodEnd ? 'canceling' : 'active'; + } + return 'none'; + }; + + const subscriptionState = getSubscriptionState(); + + // Self-guard redirect useEffect(() => { if (isLoading || !beneficiary || !id) return; - - // Don't redirect if we just subscribed and modal is showing if (justSubscribed || showSuccessModal) return; - // If no devices - redirect to purchase (waterfall priority) if (!hasBeneficiaryDevices(beneficiary)) { const status = beneficiary.equipmentStatus; if (status && ['ordered', 'shipped', 'delivered'].includes(status)) { @@ -103,21 +114,11 @@ export default function SubscriptionScreen() { } else { router.replace(`/(tabs)/beneficiaries/${id}/purchase`); } - return; } - - // If already has active subscription - redirect to dashboard - if (isActive) { - router.replace(`/(tabs)/beneficiaries/${id}`); - } - }, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]); + }, [beneficiary, isLoading, id, justSubscribed, showSuccessModal]); const handleSubscribe = async () => { - addDebugLog(`handleSubscribe: START`); - addDebugLog(`handleSubscribe: beneficiary=${JSON.stringify(beneficiary ? {id: beneficiary.id, name: beneficiary.name} : null)}`); - if (!beneficiary) { - addDebugLog(`handleSubscribe: ERROR - no beneficiary`); Alert.alert('Error', 'Beneficiary data not loaded.'); return; } @@ -125,36 +126,24 @@ export default function SubscriptionScreen() { setIsProcessing(true); try { - // Get auth token const token = await api.getToken(); - addDebugLog(`handleSubscribe: token=${token ? token.substring(0, 20) + '...' : 'null'}`); if (!token) { - addDebugLog(`handleSubscribe: ERROR - no token`); Alert.alert('Error', 'Please log in again'); setIsProcessing(false); return; } - // 1. Create subscription payment sheet via Stripe Subscriptions API - const requestBody = { beneficiaryId: beneficiary.id }; - addDebugLog(`handleSubscribe: calling ${STRIPE_API_URL}/create-subscription-payment-sheet`); - addDebugLog(`handleSubscribe: body=${JSON.stringify(requestBody)}`); - const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify(requestBody), + body: JSON.stringify({ beneficiaryId: beneficiary.id }), }); - addDebugLog(`handleSubscribe: response.status=${response.status}`); - - // Check if response is OK before parsing JSON if (!response.ok) { const errorText = await response.text(); - addDebugLog(`handleSubscribe: ERROR response - ${errorText}`); let errorMessage = 'Failed to create payment'; try { const errorJson = JSON.parse(errorText); @@ -166,45 +155,30 @@ export default function SubscriptionScreen() { } const data = await response.json(); - addDebugLog(`handleSubscribe: response data=${JSON.stringify(data)}`); - // Check if already subscribed if (data.alreadySubscribed) { - Alert.alert( - 'Already Subscribed!', - `${beneficiary.name} already has an active subscription.` - ); + Alert.alert('Already Subscribed!', `${beneficiary.name} already has an active subscription.`); await loadBeneficiary(); return; } if (!data.clientSecret) { - throw new Error(data.error || 'Failed to create subscription'); + throw new Error(data.error || 'Failed to create subscription - no clientSecret'); } - // 2. Initialize the Payment Sheet - // Determine if clientSecret is for PaymentIntent (pi_) or SetupIntent (seti_) const isSetupIntent = data.clientSecret.startsWith('seti_'); const paymentSheetParams: Parameters[0] = { merchantDisplayName: 'WellNuo', customerId: data.customer, - // Use Customer Session for showing saved payment methods (new API) - // Falls back to Ephemeral Key for backwards compatibility ...(data.customerSessionClientSecret ? { customerSessionClientSecret: data.customerSessionClientSecret } : { customerEphemeralKeySecret: data.ephemeralKey }), returnURL: 'wellnuo://stripe-redirect', - applePay: { - merchantCountryCode: 'US', - }, - googlePay: { - merchantCountryCode: 'US', - testEnv: true, - }, + applePay: { merchantCountryCode: 'US' }, + googlePay: { merchantCountryCode: 'US', testEnv: true }, }; - // Use correct parameter based on secret type if (isSetupIntent) { paymentSheetParams.setupIntentClientSecret = data.clientSecret; } else { @@ -212,14 +186,11 @@ export default function SubscriptionScreen() { } const { error: initError } = await initPaymentSheet(paymentSheetParams); - if (initError) { throw new Error(initError.message); } - // 3. Present the Payment Sheet const { error: presentError } = await presentPaymentSheet(); - if (presentError) { if (presentError.code === 'Canceled') { setIsProcessing(false); @@ -228,56 +199,39 @@ export default function SubscriptionScreen() { throw new Error(presentError.message); } - // 4. Payment successful! Create subscription with the payment method from SetupIntent - addDebugLog(`handleSubscribe: PaymentSheet completed, creating subscription...`); - console.log('[Subscription] Payment Sheet completed, creating subscription...'); - const confirmResponse = await fetch( - `${STRIPE_API_URL}/confirm-subscription-payment`, - { + // For PaymentIntent (subscription with immediate payment), the subscription + // is automatically activated via Stripe webhook when payment succeeds. + // No need to call confirm endpoint for PaymentIntent. + + if (isSetupIntent && data.subscriptionId) { + // Only for SetupIntent flow (future payment), we need to confirm + const confirmResponse = await fetch(`${STRIPE_API_URL}/confirm-subscription-payment`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - setupIntentId: data.setupIntentId, + subscriptionId: data.subscriptionId, beneficiaryId: beneficiary.id, }), - } - ); - const confirmData = await confirmResponse.json(); - addDebugLog(`handleSubscribe: confirm response=${JSON.stringify(confirmData)}`); - console.log('[Subscription] Confirm response:', confirmData); + }); + const confirmData = await confirmResponse.json(); - if (!confirmResponse.ok || confirmData.error) { - throw new Error(confirmData.error || 'Failed to create subscription'); + if (!confirmResponse.ok || confirmData.error) { + throw new Error(confirmData.error || 'Failed to confirm subscription'); + } } - // 5. Fetch subscription status from Stripe - const statusResponse = await fetch( - `${STRIPE_API_URL}/subscription-status/${beneficiary.id}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - const statusData = await statusResponse.json(); + // Wait a moment for webhook to process + await new Promise(resolve => setTimeout(resolve, 2000)); - // Mark as just subscribed to prevent self-guard redirect during modal setJustSubscribed(true); - - // Reload beneficiary to get updated subscription await loadBeneficiary(); - - // Show success modal instead of Alert setShowSuccessModal(true); } catch (error) { - console.error('Payment error:', error); - Alert.alert( - 'Payment Failed', - error instanceof Error ? error.message : 'Something went wrong. Please try again.' - ); + const errorMsg = error instanceof Error ? error.message : 'Something went wrong'; + Alert.alert('Payment Failed', errorMsg); } finally { setIsProcessing(false); } @@ -288,44 +242,25 @@ export default function SubscriptionScreen() { Alert.alert( 'Cancel Subscription?', - `Are you sure you want to cancel the subscription for ${beneficiary.name}?\n\n• You'll keep access until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'the end of the billing period'}\n• No refunds for remaining time\n• You can reactivate anytime before the period ends`, + `Your subscription will remain active until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'the end of your billing period'}. You can reactivate anytime before then.`, [ { text: 'Keep Subscription', style: 'cancel' }, - { - text: 'Cancel Subscription', - style: 'destructive', - onPress: confirmCancelSubscription, - }, + { text: 'Cancel', style: 'destructive', onPress: confirmCancelSubscription }, ] ); }; const confirmCancelSubscription = async () => { if (!beneficiary) return; - setIsCanceling(true); try { const response = await api.cancelSubscription(beneficiary.id); - - if (!response.ok) { - throw new Error(response.error || 'Failed to cancel subscription'); - } - - // Reload beneficiary to get updated subscription status + if (!response.ok) throw new Error(response.error || 'Failed to cancel subscription'); await loadBeneficiary(); - - Alert.alert( - 'Subscription Canceled', - `Your subscription will remain active until ${response.data?.cancelAt ? formatDate(new Date(response.data.cancelAt)) : 'the end of the billing period'}. You can reactivate anytime before then.`, - [{ text: 'OK' }] - ); + Alert.alert('Subscription Canceled', 'You can reactivate anytime before the period ends.'); } catch (error) { - console.error('Cancel error:', error); - Alert.alert( - 'Cancellation Failed', - error instanceof Error ? error.message : 'Something went wrong. Please try again.' - ); + Alert.alert('Error', error instanceof Error ? error.message : 'Something went wrong.'); } finally { setIsCanceling(false); } @@ -333,11 +268,9 @@ export default function SubscriptionScreen() { const handleReactivateSubscription = async () => { if (!beneficiary) return; - setIsProcessing(true); try { - // Get auth token const token = await api.getToken(); if (!token) { Alert.alert('Error', 'Please log in again'); @@ -351,50 +284,37 @@ export default function SubscriptionScreen() { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify({ - beneficiaryId: beneficiary.id, - }), + body: JSON.stringify({ beneficiaryId: beneficiary.id }), }); const data = await response.json(); - if (!response.ok || data.error) { throw new Error(data.error || 'Failed to reactivate subscription'); } - // Reload beneficiary to get updated subscription status await loadBeneficiary(); - - Alert.alert( - 'Subscription Reactivated!', - 'Your subscription has been reactivated and will continue to renew automatically.', - [{ text: 'Great!' }] - ); + Alert.alert('Reactivated!', 'Your subscription will continue to renew automatically.'); } catch (error) { - console.error('Reactivate error:', error); - Alert.alert( - 'Reactivation Failed', - error instanceof Error ? error.message : 'Something went wrong. Please try again.' - ); + Alert.alert('Error', error instanceof Error ? error.message : 'Something went wrong.'); } finally { setIsProcessing(false); } }; const formatDate = (date: Date) => { - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }; + + const openReceipt = (url?: string) => { + if (url) Linking.openURL(url); }; const handleSuccessModalClose = () => { setShowSuccessModal(false); - // Navigate to beneficiary dashboard after closing modal router.replace(`/(tabs)/beneficiaries/${id}`); }; + // Loading state if (isLoading) { return ( @@ -405,7 +325,7 @@ export default function SubscriptionScreen() { Subscription - + @@ -422,169 +342,183 @@ export default function SubscriptionScreen() { Subscription - - - - - Beneficiary Not Found - - Unable to load beneficiary information. - + + Unable to load beneficiary ); } - const copyDebugLogs = async () => { - const logsText = debugLogs.join('\n'); - try { - await ExpoClipboard.setStringAsync(logsText); - Toast.show('Debug logs copied to clipboard!', { - duration: Toast.durations.SHORT, - position: Toast.positions.BOTTOM, - }); - } catch (e) { - Alert.alert('Copied!', 'Debug logs copied to clipboard'); + // Render subscription status card based on state + const renderStatusCard = () => { + switch (subscriptionState) { + case 'active': + return ( + + + + + Active Subscription + + {subscription?.endDate + ? `Renews ${formatDate(new Date(subscription.endDate))}` + : 'Renews monthly'} + + + ${SUBSCRIPTION_PRICE} + /month + + + ); + + case 'canceling': + return ( + + + + + Subscription Ending + + Access until {subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'period end'} + + + After this date, monitoring and alerts for {beneficiary.name} will stop. + + + ); + + case 'none': + default: + return ( + + + + + No Active Subscription + + Subscribe to unlock monitoring for {beneficiary.name} + + + ${SUBSCRIPTION_PRICE} + /month + + + ); + } + }; + + // Render action button based on state + const renderActionButton = () => { + switch (subscriptionState) { + case 'active': + return ( + + {isCanceling ? ( + + ) : ( + Cancel Subscription + )} + + ); + + case 'canceling': + return ( + + {isProcessing ? ( + + ) : ( + <> + + Reactivate Subscription + + )} + + ); + + case 'none': + default: + return ( + + {isProcessing ? ( + + ) : ( + <> + + Subscribe + + )} + + ); } }; return ( - {/* Debug Panel */} - {showDebugPanel && ( - - - šŸ› Debug Panel - - - - Copy - - setDebugLogs([])} style={styles.debugClearBtn}> - Clear - - setShowDebugPanel(false)} style={styles.debugCloseBtn}> - - - - - - {debugLogs.length === 0 ? ( - No logs yet. Press Subscribe to see logs. - ) : ( - debugLogs.map((log, i) => ( - {log} - )) - )} - - - )} - {/* Header */} router.back()}> Subscription - setShowDebugPanel(!showDebugPanel)}> - - + - - {/* Subscription Card */} - - - WellNuo Subscription - - ${SUBSCRIPTION_PRICE} - /month - - + + {/* Status Card */} + {renderStatusCard()} - - Full access to real-time monitoring, AI insights, alerts, voice companion, and family sharing for {beneficiary.name} - + {/* Action Button */} + + {renderActionButton()} - {/* Status indicator */} - {isActive && ( - - - - {isCanceledButActive - ? `Ends ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'soon'}` - : `Active until ${subscription?.endDate ? formatDate(new Date(subscription.endDate)) : 'N/A'}`} - - - )} - - {/* Security Badge */} - - + {/* Security note */} + + Secure payment by Stripe - {/* Actions */} - - {/* Subscribe button when not active */} - {!isActive && ( - - {isProcessing ? ( - - ) : ( - <> - - - Subscribe - - - )} - - )} - - {/* Reactivate button when canceled but still active */} - {isCanceledButActive && ( - - {isProcessing ? ( - - ) : ( - <> - - Reactivate Subscription - - )} - - )} - - {/* Cancel link - only show when active subscription */} - {isActive && !isCanceledButActive && ( - - {isCanceling ? ( - - ) : ( - Cancel Subscription - )} - - )} - - + {/* Transaction History */} + {transactions.length > 0 && ( + + Payment History + {transactions.map((tx) => ( + openReceipt(tx.invoicePdf || tx.hostedUrl || tx.receiptUrl)} + disabled={!tx.invoicePdf && !tx.hostedUrl && !tx.receiptUrl} + > + + {tx.description} + {formatDate(new Date(tx.date))} + + + ${tx.amount.toFixed(2)} + {(tx.invoicePdf || tx.hostedUrl || tx.receiptUrl) && ( + + )} + + + ))} + + )} + {/* Success Modal */} - - + + - Subscription Activated! + Subscription Active! - Subscription for {beneficiary?.name} is now active. + Monitoring for {beneficiary?.name} is now enabled. - + Continue @@ -640,130 +571,124 @@ const styles = StyleSheet.create({ placeholder: { width: 32, }, - loadingContainer: { + centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - noBeneficiaryContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: Spacing.xl, + errorText: { + fontSize: FontSizes.base, + color: AppColors.textMuted, }, - noBeneficiaryIcon: { - width: 96, - height: 96, - borderRadius: 48, + scrollContent: { + flex: 1, + }, + content: { + padding: Spacing.lg, + paddingBottom: Spacing.xxl, + }, + + // Status Card Styles + statusCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.xl, + alignItems: 'center', + borderWidth: 2, + borderColor: AppColors.success, + ...Shadows.sm, + }, + statusCardCanceling: { + borderColor: '#F59E0B', + backgroundColor: '#FFFBEB', + }, + statusCardNone: { + borderColor: AppColors.border, + backgroundColor: AppColors.surface, + }, + statusIconActive: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: AppColors.success, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.md, + }, + statusIconCanceling: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#F59E0B', + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.md, + }, + statusIconNone: { + width: 56, + height: 56, + borderRadius: 28, backgroundColor: AppColors.surfaceSecondary, - justifyContent: 'center', alignItems: 'center', - marginBottom: Spacing.lg, + justifyContent: 'center', + marginBottom: Spacing.md, }, - noBeneficiaryTitle: { + statusTitle: { fontSize: FontSizes.xl, fontWeight: FontWeights.bold, color: AppColors.textPrimary, + marginBottom: Spacing.xs, + }, + statusTitleNone: { + fontSize: FontSizes.xl, + fontWeight: FontWeights.bold, + color: AppColors.textSecondary, + marginBottom: Spacing.xs, + }, + statusSubtitle: { + fontSize: FontSizes.sm, + color: AppColors.success, + marginBottom: Spacing.md, + }, + statusSubtitleCanceling: { + fontSize: FontSizes.sm, + color: '#B45309', + fontWeight: FontWeights.medium, marginBottom: Spacing.sm, }, - noBeneficiaryText: { - fontSize: FontSizes.base, - color: AppColors.textSecondary, + statusSubtitleNone: { + fontSize: FontSizes.sm, + color: AppColors.textMuted, textAlign: 'center', - lineHeight: 24, + marginBottom: Spacing.md, }, - content: { - flex: 1, - padding: Spacing.lg, - justifyContent: 'center', - alignItems: 'center', + cancelingNote: { + fontSize: FontSizes.sm, + color: '#92400E', + textAlign: 'center', + lineHeight: 20, }, - subscriptionCard: { - backgroundColor: AppColors.surface, - borderRadius: BorderRadius.xl, - padding: Spacing.lg, - borderWidth: 2, - borderColor: AppColors.primary, - width: '100%', - ...Shadows.md, - }, - cardHeader: { - alignItems: 'center', - }, - proBadgeText: { - fontSize: FontSizes.lg, - fontWeight: FontWeights.bold, - color: AppColors.textPrimary, - }, - priceContainer: { + priceRow: { flexDirection: 'row', alignItems: 'baseline', - marginTop: Spacing.sm, }, priceAmount: { - fontSize: FontSizes['3xl'], + fontSize: FontSizes['2xl'], fontWeight: FontWeights.bold, color: AppColors.primary, }, pricePeriod: { - fontSize: FontSizes.lg, - color: AppColors.textSecondary, - marginLeft: Spacing.xs, - }, - planDescription: { fontSize: FontSizes.base, - color: AppColors.textSecondary, - textAlign: 'center', - lineHeight: 22, - marginTop: Spacing.md, + color: AppColors.textMuted, + marginLeft: 2, }, - activeBadge: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#D1FAE5', - paddingHorizontal: Spacing.md, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.lg, - gap: Spacing.xs, - marginTop: Spacing.md, - }, - activeBadgeText: { - fontSize: FontSizes.sm, - fontWeight: FontWeights.medium, - color: AppColors.success, - }, - cancelingBadge: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#FEF3C7', - paddingHorizontal: Spacing.md, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.lg, - gap: Spacing.xs, - marginTop: Spacing.md, - }, - cancelingBadgeText: { - fontSize: FontSizes.sm, - fontWeight: FontWeights.medium, - color: AppColors.warning, - }, - securityBadge: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginTop: Spacing.md, - gap: Spacing.xs, - }, - securityText: { - fontSize: FontSizes.xs, - color: AppColors.success, - }, - actionsSection: { - width: '100%', - gap: Spacing.md, + + // Action Section + actionSection: { marginTop: Spacing.xl, + gap: Spacing.md, }, - subscribeButton: { + primaryButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -771,34 +696,83 @@ const styles = StyleSheet.create({ backgroundColor: AppColors.primary, paddingVertical: Spacing.md, borderRadius: BorderRadius.lg, + ...Shadows.sm, + }, + reactivateButton: { + backgroundColor: AppColors.success, }, buttonDisabled: { opacity: 0.7, }, - subscribeButtonText: { + primaryButtonText: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.white, }, - reactivateButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.sm, - backgroundColor: AppColors.success, - paddingVertical: Spacing.md, - borderRadius: BorderRadius.lg, - }, - linkButton: { - paddingVertical: Spacing.xs, + cancelButton: { + paddingVertical: Spacing.sm, alignItems: 'center', }, - linkButtonTextMuted: { + cancelButtonText: { fontSize: FontSizes.sm, color: AppColors.textMuted, textDecorationLine: 'underline', }, - // Modal styles + securityRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.xs, + }, + securityText: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + }, + + // Transaction Section + transactionSection: { + marginTop: Spacing.xl, + }, + sectionTitle: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: Spacing.md, + }, + transactionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: AppColors.surface, + padding: Spacing.md, + borderRadius: BorderRadius.md, + marginBottom: Spacing.sm, + }, + transactionLeft: { + flex: 1, + }, + transactionDesc: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + color: AppColors.textPrimary, + }, + transactionDate: { + fontSize: FontSizes.xs, + color: AppColors.textMuted, + marginTop: 2, + }, + transactionRight: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xs, + }, + transactionAmount: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + }, + + // Modal modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -811,11 +785,11 @@ const styles = StyleSheet.create({ borderRadius: BorderRadius.xl, padding: Spacing.xl, width: '100%', - maxWidth: 340, + maxWidth: 320, alignItems: 'center', ...Shadows.lg, }, - modalIconContainer: { + modalIcon: { marginBottom: Spacing.md, }, modalTitle: { @@ -823,14 +797,12 @@ const styles = StyleSheet.create({ fontWeight: FontWeights.bold, color: AppColors.textPrimary, marginBottom: Spacing.sm, - textAlign: 'center', }, modalMessage: { fontSize: FontSizes.base, color: AppColors.textSecondary, textAlign: 'center', marginBottom: Spacing.lg, - lineHeight: 22, }, modalButton: { backgroundColor: AppColors.primary, @@ -845,67 +817,4 @@ const styles = StyleSheet.create({ color: AppColors.white, textAlign: 'center', }, - // Debug Panel styles - debugPanel: { - backgroundColor: '#1a1a2e', - maxHeight: 200, - borderBottomWidth: 2, - borderBottomColor: '#e94560', - }, - debugHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 8, - backgroundColor: '#16213e', - }, - debugTitle: { - color: '#e94560', - fontWeight: 'bold', - fontSize: 14, - }, - debugButtons: { - flexDirection: 'row', - gap: 8, - }, - debugCopyBtn: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#0f3460', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 4, - gap: 4, - }, - debugClearBtn: { - backgroundColor: '#e94560', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 4, - }, - debugCloseBtn: { - backgroundColor: '#333', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - debugBtnText: { - color: '#fff', - fontSize: 12, - fontWeight: '600', - }, - debugScroll: { - padding: 8, - }, - debugLog: { - color: '#00ff88', - fontSize: 11, - fontFamily: 'monospace', - marginBottom: 2, - }, - debugEmpty: { - color: '#666', - fontSize: 12, - fontStyle: 'italic', - }, }); diff --git a/backend/src/config/database.js b/backend/src/config/database.js index 5a6140a..9ed5fd6 100644 --- a/backend/src/config/database.js +++ b/backend/src/config/database.js @@ -1,3 +1,5 @@ +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); const { Pool } = require('pg'); // PostgreSQL connection to eluxnetworks.net diff --git a/backend/src/routes/beneficiaries.js b/backend/src/routes/beneficiaries.js index 818ea7e..072d1a3 100644 --- a/backend/src/routes/beneficiaries.js +++ b/backend/src/routes/beneficiaries.js @@ -46,12 +46,16 @@ async function getStripeSubscriptionStatus(stripeCustomerId) { if (subscriptions.data.length > 0) { const sub = subscriptions.data[0]; + // Use cancel_at if subscription is set to cancel, otherwise use current_period_end + const periodEndTimestamp = sub.cancel_at || sub.current_period_end; + const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null; + return { plan: 'premium', status: 'active', hasSubscription: true, - currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(), - cancelAtPeriodEnd: sub.cancel_at_period_end + endDate: endDate, + cancelAtPeriodEnd: sub.cancel_at_period_end || false }; } @@ -64,11 +68,15 @@ async function getStripeSubscriptionStatus(stripeCustomerId) { if (allSubs.data.length > 0) { const sub = allSubs.data[0]; const normalizedStatus = normalizeStripeStatus(sub.status); + const periodEndTimestamp = sub.cancel_at || sub.current_period_end; + const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null; + return { plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium', status: normalizedStatus, hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing', - currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString() + endDate: endDate, + cancelAtPeriodEnd: sub.cancel_at_period_end || false }; } @@ -136,12 +144,15 @@ router.get('/', async (req, res) => { } // Query from beneficiaries table (new architecture) - const { data: beneficiary } = await supabase + console.log('[GET BENEFICIARIES] querying beneficiaries table for id:', beneficiaryTableId); + const { data: beneficiary, error: beneficiaryError } = await supabase .from('beneficiaries') - .select('id, name, phone, address_street, address_city, address_zip, address_state, address_country, created_at, equipment_status, stripe_customer_id') + .select('id, name, phone, address, avatar_url, created_at, equipment_status, stripe_customer_id') .eq('id', beneficiaryTableId) .single(); + console.log('[GET BENEFICIARIES] got beneficiary:', beneficiary ? beneficiary.name : null, 'error:', beneficiaryError); + if (beneficiary) { const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id); beneficiaries.push({ @@ -151,13 +162,8 @@ router.get('/', async (req, res) => { grantedAt: record.granted_at, name: beneficiary.name, phone: beneficiary.phone, - address: { - street: beneficiary.address_street, - city: beneficiary.address_city, - zip: beneficiary.address_zip, - state: beneficiary.address_state, - country: beneficiary.address_country - }, + address: beneficiary.address || null, + avatarUrl: beneficiary.avatar_url, createdAt: beneficiary.created_at, subscription: subscription, // Equipment status from beneficiaries table - CRITICAL for navigation! @@ -223,13 +229,8 @@ router.get('/:id', async (req, res) => { id: beneficiary.id, name: beneficiary.name, phone: beneficiary.phone, - address: { - street: beneficiary.address_street, - city: beneficiary.address_city, - zip: beneficiary.address_zip, - state: beneficiary.address_state, - country: beneficiary.address_country - }, + address: beneficiary.address || null, + avatarUrl: beneficiary.avatar_url, role: access.role, subscription: subscription, orders: orders || [], @@ -266,11 +267,7 @@ router.post('/', async (req, res) => { .insert({ name: name, phone: phone || null, - address_street: address?.street || null, - address_city: address?.city || null, - address_zip: address?.zip || null, - address_state: address?.state || null, - address_country: address?.country || null, + address: address || null, equipment_status: 'none', created_by: userId, created_at: new Date().toISOString(), @@ -313,13 +310,8 @@ router.post('/', async (req, res) => { id: beneficiary.id, name: beneficiary.name, phone: beneficiary.phone, - address: { - street: beneficiary.address_street, - city: beneficiary.address_city, - zip: beneficiary.address_zip, - state: beneficiary.address_state, - country: beneficiary.address_country - }, + address: beneficiary.address || null, + avatarUrl: beneficiary.avatar_url, role: 'custodian', equipmentStatus: 'none' } @@ -341,6 +333,8 @@ router.patch('/:id', async (req, res) => { const userId = req.user.userId; const beneficiaryId = parseInt(req.params.id, 10); + console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body }); + // Check user has custodian or guardian access - using beneficiary_id const { data: access, error: accessError } = await supabase .from('user_access') @@ -353,7 +347,7 @@ router.patch('/:id', async (req, res) => { return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' }); } - const { name, phone, addressStreet, addressCity, addressZip, addressState, addressCountry } = req.body; + const { name, phone, address } = req.body; const updateData = { updated_at: new Date().toISOString() @@ -361,11 +355,7 @@ router.patch('/:id', async (req, res) => { if (name !== undefined) updateData.name = name; if (phone !== undefined) updateData.phone = phone; - if (addressStreet !== undefined) updateData.address_street = addressStreet; - if (addressCity !== undefined) updateData.address_city = addressCity; - if (addressZip !== undefined) updateData.address_zip = addressZip; - if (addressState !== undefined) updateData.address_state = addressState; - if (addressCountry !== undefined) updateData.address_country = addressCountry; + if (address !== undefined) updateData.address = address; // Update in beneficiaries table const { data: beneficiary, error } = await supabase @@ -376,27 +366,25 @@ router.patch('/:id', async (req, res) => { .single(); if (error) { + console.error('[BENEFICIARY PATCH] Supabase error:', error); return res.status(500).json({ error: 'Failed to update beneficiary' }); } + console.log('[BENEFICIARY PATCH] Success:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address }); + res.json({ success: true, beneficiary: { id: beneficiary.id, name: beneficiary.name, phone: beneficiary.phone, - address: { - street: beneficiary.address_street, - city: beneficiary.address_city, - zip: beneficiary.address_zip, - state: beneficiary.address_state, - country: beneficiary.address_country - } + address: beneficiary.address || null, + avatarUrl: beneficiary.avatar_url } }); } catch (error) { - console.error('Update beneficiary error:', error); + console.error('[BENEFICIARY PATCH] Error:', error); res.status(500).json({ error: error.message }); } }); @@ -713,7 +701,7 @@ router.post('/:id/activate', async (req, res) => { updated_at: new Date().toISOString() }) .eq('id', beneficiaryId) - .select('id, first_name, last_name, equipment_status') + .select('id, name, equipment_status') .single(); if (updateError) { @@ -727,8 +715,7 @@ router.post('/:id/activate', async (req, res) => { success: true, beneficiary: { id: beneficiary?.id || beneficiaryId, - firstName: beneficiary?.first_name || null, - lastName: beneficiary?.last_name || null, + name: beneficiary?.name || null, hasDevices: true, equipmentStatus: equipmentStatus } @@ -828,6 +815,68 @@ router.post('/:id/transfer', async (req, res) => { } }); +/** + * PATCH /api/me/beneficiaries/:id/avatar + * Upload/update beneficiary avatar (base64 image) + */ +router.patch('/:id/avatar', async (req, res) => { + try { + const userId = req.user.userId; + const beneficiaryId = parseInt(req.params.id, 10); + const { avatar } = req.body; // base64 string or null to remove + + console.log('[BENEFICIARY] Avatar update:', { userId, beneficiaryId, hasAvatar: !!avatar }); + + // Check user has custodian or guardian access + const { data: access, error: accessError } = await supabase + .from('user_access') + .select('role') + .eq('accessor_id', userId) + .eq('beneficiary_id', beneficiaryId) + .single(); + + if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) { + return res.status(403).json({ error: 'Only custodian or guardian can update avatar' }); + } + + // Validate base64 if provided + if (avatar && !avatar.startsWith('data:image/')) { + return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' }); + } + + // Update avatar_url in beneficiaries table + const { data: beneficiary, error } = await supabase + .from('beneficiaries') + .update({ + avatar_url: avatar || null, + updated_at: new Date().toISOString() + }) + .eq('id', beneficiaryId) + .select('id, name, avatar_url') + .single(); + + if (error) { + console.error('[BENEFICIARY] Avatar update error:', error); + return res.status(500).json({ error: 'Failed to update avatar' }); + } + + console.log('[BENEFICIARY] Avatar updated:', { beneficiaryId, hasAvatar: !!beneficiary.avatar_url }); + + res.json({ + success: true, + beneficiary: { + id: beneficiary.id, + name: beneficiary.name, + avatarUrl: beneficiary.avatar_url + } + }); + + } catch (error) { + console.error('[BENEFICIARY] Avatar error:', error); + res.status(500).json({ error: error.message }); + } +}); + /** * Update equipment status for a beneficiary * PATCH /me/beneficiaries/:id/equipment-status @@ -870,7 +919,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => { updated_at: new Date().toISOString() }) .eq('id', beneficiaryId) - .select('id, first_name, equipment_status') + .select('id, name, equipment_status') .single(); if (updateError) { @@ -888,7 +937,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => { res.json({ success: true, id: updated.id, - firstName: updated.first_name, + name: updated.name, equipmentStatus: updated.equipment_status }); } catch (error) { diff --git a/backend/src/routes/stripe.js b/backend/src/routes/stripe.js index ba10b19..e6ae2fa 100644 --- a/backend/src/routes/stripe.js +++ b/backend/src/routes/stripe.js @@ -424,19 +424,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => { router.post('/cancel-subscription', async (req, res) => { try { const { beneficiaryId } = req.body; + console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId); if (!beneficiaryId) { return res.status(400).json({ error: 'beneficiaryId is required' }); } // Get beneficiary's stripe_customer_id - const { data: beneficiary } = await supabase + const { data: beneficiary, error: dbError } = await supabase .from('beneficiaries') .select('stripe_customer_id') .eq('id', beneficiaryId) .single(); + console.log('[CANCEL] DB result:', { beneficiary, dbError }); + if (!beneficiary?.stripe_customer_id) { + console.log('[CANCEL] No stripe_customer_id found'); return res.status(404).json({ error: 'No subscription found' }); } @@ -456,12 +460,16 @@ router.post('/cancel-subscription', async (req, res) => { cancel_at_period_end: true }); - console.log(`āœ“ Subscription ${subscription.id} will cancel at period end`); + console.log(`āœ“ Subscription ${subscription.id} will cancel at period end:`, subscription.current_period_end); + + const cancelAt = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000).toISOString() + : null; res.json({ success: true, message: 'Subscription will cancel at the end of the billing period', - cancelAt: new Date(subscription.current_period_end * 1000).toISOString() + cancelAt }); } catch (error) { @@ -553,6 +561,22 @@ router.post('/create-subscription-payment-sheet', async (req, res) => { }); } + // Cancel any incomplete subscriptions to avoid duplicates + const incompleteSubs = await stripe.subscriptions.list({ + customer: customerId, + status: 'incomplete', + limit: 10 + }); + + for (const sub of incompleteSubs.data) { + try { + await stripe.subscriptions.cancel(sub.id); + console.log(`Canceled incomplete subscription ${sub.id} for customer ${customerId}`); + } catch (cancelError) { + console.warn(`Failed to cancel incomplete subscription ${sub.id}:`, cancelError.message); + } + } + // Create ephemeral key const ephemeralKey = await stripe.ephemeralKeys.create( { customer: customerId }, @@ -574,11 +598,37 @@ router.post('/create-subscription-payment-sheet', async (req, res) => { } }); - const paymentIntent = subscription.latest_invoice?.payment_intent; + // Try to get payment_intent from expanded invoice + let clientSecret = subscription.latest_invoice?.payment_intent?.client_secret; + + // Stripe SDK v20+ doesn't expose payment_intent field in Invoice object + // Need to fetch PaymentIntent via list API as a workaround + if (!clientSecret && subscription.latest_invoice) { + const invoiceId = typeof subscription.latest_invoice === 'string' + ? subscription.latest_invoice + : subscription.latest_invoice.id; + + if (invoiceId) { + // List recent PaymentIntents for this customer and find the one for this invoice + const paymentIntents = await stripe.paymentIntents.list({ + customer: customerId, + limit: 5 + }); + + for (const pi of paymentIntents.data) { + if (pi.invoice === invoiceId || pi.description?.includes('Subscription')) { + clientSecret = pi.client_secret; + break; + } + } + } + } + + console.log(`[SUBSCRIPTION] Created subscription ${subscription.id}, clientSecret: ${!!clientSecret}`); res.json({ subscriptionId: subscription.id, - clientSecret: paymentIntent?.client_secret, + clientSecret: clientSecret, ephemeralKey: ephemeralKey.secret, customer: customerId, publishableKey: process.env.STRIPE_PUBLISHABLE_KEY @@ -626,6 +676,95 @@ router.post('/confirm-subscription-payment', async (req, res) => { } }); +/** + * GET /api/stripe/transaction-history/:beneficiaryId + * Gets transaction/invoice history directly from Stripe + */ +router.get('/transaction-history/:beneficiaryId', async (req, res) => { + try { + const { beneficiaryId } = req.params; + const limit = parseInt(req.query.limit) || 10; + + // Get beneficiary's stripe_customer_id + const { data: beneficiary } = await supabase + .from('beneficiaries') + .select('stripe_customer_id') + .eq('id', beneficiaryId) + .single(); + + if (!beneficiary?.stripe_customer_id) { + return res.json({ + transactions: [], + hasMore: false + }); + } + + // Get invoices from Stripe + const invoices = await stripe.invoices.list({ + customer: beneficiary.stripe_customer_id, + limit: limit, + expand: ['data.subscription'] + }); + + // Also get PaymentIntents for one-time purchases (equipment) + const paymentIntents = await stripe.paymentIntents.list({ + customer: beneficiary.stripe_customer_id, + limit: limit + }); + + // Format invoices (subscription payments) - only show paid invoices + const formattedInvoices = invoices.data + .filter(invoice => invoice.status === 'paid' && invoice.amount_paid > 0) + .map(invoice => ({ + id: invoice.id, + type: 'subscription', + amount: invoice.amount_paid / 100, + currency: invoice.currency.toUpperCase(), + status: invoice.status, + date: new Date(invoice.created * 1000).toISOString(), + description: invoice.lines.data[0]?.description || 'WellNuo Premium', + invoicePdf: invoice.invoice_pdf, + hostedUrl: invoice.hosted_invoice_url + })); + + // Format payment intents (one-time purchases like equipment) + // Exclude subscription-related payments (they're already in invoices) + const formattedPayments = paymentIntents.data + .filter(pi => { + if (pi.status !== 'succeeded') return false; + if (pi.invoice) return false; // Has linked invoice + // Exclude "Subscription creation" - it's duplicate of invoice + if (pi.description === 'Subscription creation') return false; + return true; + }) + .map(pi => ({ + id: pi.id, + type: 'one_time', + amount: pi.amount / 100, + currency: pi.currency.toUpperCase(), + status: pi.status, + date: new Date(pi.created * 1000).toISOString(), + description: pi.metadata?.orderType === 'starter_kit' + ? 'WellNuo Starter Kit' + : (pi.description || 'One-time payment'), + receiptUrl: pi.charges?.data[0]?.receipt_url + })); + + // Combine and sort by date (newest first) + const allTransactions = [...formattedInvoices, ...formattedPayments] + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + res.json({ + transactions: allTransactions, + hasMore: invoices.has_more || paymentIntents.has_more + }); + + } catch (error) { + console.error('Get transaction history error:', error); + res.status(500).json({ error: error.message }); + } +}); + /** * GET /api/stripe/session/:sessionId * Get checkout session details (for success page) diff --git a/components/ui/BeneficiaryMenu.tsx b/components/ui/BeneficiaryMenu.tsx new file mode 100644 index 0000000..cbde5c7 --- /dev/null +++ b/components/ui/BeneficiaryMenu.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Modal, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme'; + +export type MenuItemId = 'edit' | 'access' | 'subscription' | 'equipment' | 'remove'; + +interface MenuItem { + id: MenuItemId; + icon: keyof typeof Ionicons.glyphMap; + label: string; + danger?: boolean; +} + +const ALL_MENU_ITEMS: MenuItem[] = [ + { id: 'edit', icon: 'create-outline', label: 'Edit' }, + { id: 'access', icon: 'share-outline', label: 'Access' }, + { id: 'subscription', icon: 'diamond-outline', label: 'Subscription' }, + { id: 'equipment', icon: 'hardware-chip-outline', label: 'Equipment' }, + { id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true }, +]; + +interface BeneficiaryMenuProps { + beneficiaryId: string | number; + /** Which menu items to show. If not provided, shows all except current page */ + visibleItems?: MenuItemId[]; + /** Which menu item represents the current page (will be hidden) */ + currentPage?: MenuItemId; + /** Custom handler for Edit action */ + onEdit?: () => void; + /** Custom handler for Remove action */ + onRemove?: () => void; +} + +export function BeneficiaryMenu({ + beneficiaryId, + visibleItems, + currentPage, + onEdit, + onRemove, +}: BeneficiaryMenuProps) { + const [isVisible, setIsVisible] = useState(false); + + const handleMenuAction = (itemId: MenuItemId) => { + setIsVisible(false); + + switch (itemId) { + case 'edit': + if (onEdit) { + onEdit(); + } else { + // Navigate to main page with edit intent + router.push(`/(tabs)/beneficiaries/${beneficiaryId}`); + } + break; + case 'access': + router.push(`/(tabs)/beneficiaries/${beneficiaryId}/share`); + break; + case 'subscription': + router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`); + break; + case 'equipment': + router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`); + break; + case 'remove': + if (onRemove) { + onRemove(); + } else { + // Navigate to main page + router.push(`/(tabs)/beneficiaries/${beneficiaryId}`); + } + break; + } + }; + + // Filter menu items - only hide current page + let menuItems = ALL_MENU_ITEMS; + + if (visibleItems) { + menuItems = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id)); + } else if (currentPage) { + menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage); + } + + return ( + + setIsVisible(!isVisible)} + > + + + + setIsVisible(false)} + > + {/* Full screen backdrop */} + setIsVisible(false)} + > + {/* Menu positioned at top right */} + e.stopPropagation()} + > + + {menuItems.map((item) => ( + handleMenuAction(item.id)} + > + + + {item.label} + + + ))} + + + + + + ); +} + +const styles = StyleSheet.create({ + menuButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + modalBackdrop: { + flex: 1, + backgroundColor: 'transparent', + }, + dropdownMenuContainer: { + position: 'absolute', + top: 100, // Below status bar and header + right: Spacing.md, + }, + dropdownMenu: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + minWidth: 160, + ...Shadows.lg, + }, + dropdownItem: { + flexDirection: 'row', + alignItems: 'center', + padding: Spacing.md, + gap: Spacing.sm, + }, + dropdownItemText: { + fontSize: FontSizes.base, + color: AppColors.textPrimary, + }, + dropdownItemDanger: { + borderTopWidth: 1, + borderTopColor: AppColors.border, + }, + dropdownItemTextDanger: { + color: AppColors.error, + }, +}); diff --git a/services/api.ts b/services/api.ts index dd957d2..eb2c94d 100644 --- a/services/api.ts +++ b/services/api.ts @@ -649,7 +649,8 @@ class ApiService { id: data.id, name: data.name, hasDevices: data.hasDevices, - equipmentStatus: data.equipmentStatus + equipmentStatus: data.equipmentStatus, + subscription: data.subscription })); if (!response.ok) { @@ -666,7 +667,8 @@ class ApiService { subscription: data.subscription ? { status: data.subscription.status, plan: data.subscription.plan, - endDate: data.subscription.currentPeriodEnd, + endDate: data.subscription.endDate, + cancelAtPeriodEnd: data.subscription.cancelAtPeriodEnd, } : undefined, // Equipment status from orders equipmentStatus: data.equipmentStatus, @@ -761,6 +763,55 @@ class ApiService { } } + // Upload/update beneficiary avatar + async updateBeneficiaryAvatar(id: number, imageUri: string | null): Promise> { + const token = await this.getToken(); + + if (!token) { + return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } }; + } + + try { + let base64Image: string | null = null; + + if (imageUri) { + // Convert file URI to base64 + const response = await fetch(imageUri); + const blob = await response.blob(); + + // Convert blob to base64 + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + base64Image = base64; + } + + const apiResponse = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}/avatar`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ avatar: base64Image }), + }); + + const data = await apiResponse.json(); + + if (!apiResponse.ok) { + return { ok: false, error: { message: data.error || 'Failed to update avatar' } }; + } + + return { data: { avatarUrl: data.beneficiary?.avatarUrl || null }, ok: true }; + } catch (error) { + console.error('[API] updateBeneficiaryAvatar error:', error); + return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } }; + } + } + // Delete beneficiary (removes access record) async deleteBeneficiary(id: number): Promise> { const token = await this.getToken(); @@ -1047,6 +1098,53 @@ class ApiService { } } + // Get transaction history from Stripe + async getTransactionHistory(beneficiaryId: number, limit = 10): Promise; + hasMore: boolean; + }>> { + const token = await this.getToken(); + if (!token) { + return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } }; + } + + try { + const response = await fetch(`${WELLNUO_API_URL}/stripe/transaction-history/${beneficiaryId}?limit=${limit}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + const data = await response.json(); + + if (response.ok) { + return { data, ok: true }; + } + + return { + ok: false, + error: { message: data.error || 'Failed to get transaction history' }, + }; + } catch (error) { + return { + ok: false, + error: { message: 'Network error. Please check your connection.' }, + }; + } + } + // Reactivate subscription that was set to cancel async reactivateSubscription(beneficiaryId: number): Promise> { const token = await this.getToken();