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();