Compare commits

..

2 Commits

Author SHA1 Message Date
Sergei
01bebeedbe Fix invitations: remove expires_at (invitations are permanent)
- Remove expires_at from SELECT queries
- Remove expiresAt from API responses
- DB change: dropped expires_at column, fixed FK to beneficiaries table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 13:12:17 -08:00
Sergei
e7831327bd Fix Stripe subscription flow - use SetupIntent
- Changed from PaymentIntent to SetupIntent flow
- SetupIntent always has client_secret (unlike incomplete subscriptions)
- Two-step process: collect payment method, then create subscription
- Added debug panel for troubleshooting (DEBUG_MODE=true)
- Updated price to $49/month (price_1SnYfkP0gvUw6M9C1095uFgW)
- Added react-native-root-toast dependency

Server changes (on 91.98.205.156):
- /create-subscription-payment-sheet: returns SetupIntent instead of subscription
- /confirm-subscription-payment: creates subscription with saved payment method
- Added safeTimestampToISO() to prevent "Invalid time value" errors
2026-01-08 22:16:22 -08:00
4 changed files with 184 additions and 15 deletions

View File

@ -7,6 +7,8 @@ import {
Alert, Alert,
ActivityIndicator, ActivityIndicator,
Modal, Modal,
ScrollView,
Clipboard,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -17,10 +19,14 @@ import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController'; import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
import type { Beneficiary } from '@/types'; 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 STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const SUBSCRIPTION_PRICE = 49; // $49/month const SUBSCRIPTION_PRICE = 49; // $49/month
// DEBUG MODE - set to true to show debug panel
const DEBUG_MODE = true;
export default function SubscriptionScreen() { export default function SubscriptionScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@ -31,6 +37,16 @@ export default function SubscriptionScreen() {
const [showSuccessModal, setShowSuccessModal] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false);
const [justSubscribed, setJustSubscribed] = useState(false); // Prevent self-guard redirect after payment const [justSubscribed, setJustSubscribed] = useState(false); // Prevent self-guard redirect after payment
// Debug state
const [debugLogs, setDebugLogs] = useState<string[]>([]);
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 { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth(); const { user } = useAuth();
@ -39,16 +55,25 @@ export default function SubscriptionScreen() {
}, [id]); }, [id]);
const loadBeneficiary = async () => { const loadBeneficiary = async () => {
if (!id) return; if (!id) {
addDebugLog(`loadBeneficiary: no id provided`);
return;
}
addDebugLog(`loadBeneficiary: fetching id=${id}`);
try { try {
const response = await api.getWellNuoBeneficiary(parseInt(id, 10)); const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
addDebugLog(`loadBeneficiary: response.ok=${response.ok}`);
if (response.ok && response.data) { if (response.ok && response.data) {
setBeneficiary(response.data); setBeneficiary(response.data);
addDebugLog(`loadBeneficiary: got beneficiary id=${response.data.id}, name=${response.data.name}`);
} else { } else {
addDebugLog(`loadBeneficiary: ERROR - ${response.error}`);
console.error('Failed to load beneficiary:', response.error); console.error('Failed to load beneficiary:', response.error);
} }
} catch (error) { } catch (error) {
addDebugLog(`loadBeneficiary: EXCEPTION - ${error}`);
console.error('Failed to load beneficiary:', error); console.error('Failed to load beneficiary:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -88,7 +113,11 @@ export default function SubscriptionScreen() {
}, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]); }, [beneficiary, isLoading, id, isActive, justSubscribed, showSuccessModal]);
const handleSubscribe = async () => { const handleSubscribe = async () => {
addDebugLog(`handleSubscribe: START`);
addDebugLog(`handleSubscribe: beneficiary=${JSON.stringify(beneficiary ? {id: beneficiary.id, name: beneficiary.name} : null)}`);
if (!beneficiary) { if (!beneficiary) {
addDebugLog(`handleSubscribe: ERROR - no beneficiary`);
Alert.alert('Error', 'Beneficiary data not loaded.'); Alert.alert('Error', 'Beneficiary data not loaded.');
return; return;
} }
@ -98,27 +127,34 @@ export default function SubscriptionScreen() {
try { try {
// Get auth token // Get auth token
const token = await api.getToken(); const token = await api.getToken();
addDebugLog(`handleSubscribe: token=${token ? token.substring(0, 20) + '...' : 'null'}`);
if (!token) { if (!token) {
addDebugLog(`handleSubscribe: ERROR - no token`);
Alert.alert('Error', 'Please log in again'); Alert.alert('Error', 'Please log in again');
setIsProcessing(false); setIsProcessing(false);
return; return;
} }
// 1. Create subscription payment sheet via Stripe Subscriptions API // 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`, { const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody),
beneficiaryId: beneficiary.id,
}),
}); });
addDebugLog(`handleSubscribe: response.status=${response.status}`);
// Check if response is OK before parsing JSON // Check if response is OK before parsing JSON
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
addDebugLog(`handleSubscribe: ERROR response - ${errorText}`);
let errorMessage = 'Failed to create payment'; let errorMessage = 'Failed to create payment';
try { try {
const errorJson = JSON.parse(errorText); const errorJson = JSON.parse(errorText);
@ -130,6 +166,7 @@ export default function SubscriptionScreen() {
} }
const data = await response.json(); const data = await response.json();
addDebugLog(`handleSubscribe: response data=${JSON.stringify(data)}`);
// Check if already subscribed // Check if already subscribed
if (data.alreadySubscribed) { if (data.alreadySubscribed) {
@ -191,9 +228,9 @@ export default function SubscriptionScreen() {
throw new Error(presentError.message); throw new Error(presentError.message);
} }
// 4. Payment successful! Confirm the subscription payment // 4. Payment successful! Create subscription with the payment method from SetupIntent
// This ensures the subscription invoice gets paid if using manual PaymentIntent addDebugLog(`handleSubscribe: PaymentSheet completed, creating subscription...`);
console.log('[Subscription] Payment Sheet completed, confirming payment...'); console.log('[Subscription] Payment Sheet completed, creating subscription...');
const confirmResponse = await fetch( const confirmResponse = await fetch(
`${STRIPE_API_URL}/confirm-subscription-payment`, `${STRIPE_API_URL}/confirm-subscription-payment`,
{ {
@ -203,13 +240,19 @@ export default function SubscriptionScreen() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
subscriptionId: data.subscriptionId, setupIntentId: data.setupIntentId,
beneficiaryId: beneficiary.id,
}), }),
} }
); );
const confirmData = await confirmResponse.json(); const confirmData = await confirmResponse.json();
addDebugLog(`handleSubscribe: confirm response=${JSON.stringify(confirmData)}`);
console.log('[Subscription] Confirm response:', confirmData); console.log('[Subscription] Confirm response:', confirmData);
if (!confirmResponse.ok || confirmData.error) {
throw new Error(confirmData.error || 'Failed to create subscription');
}
// 5. Fetch subscription status from Stripe // 5. Fetch subscription status from Stripe
const statusResponse = await fetch( const statusResponse = await fetch(
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`, `${STRIPE_API_URL}/subscription-status/${beneficiary.id}`,
@ -392,15 +435,60 @@ export default function SubscriptionScreen() {
); );
} }
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');
}
};
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Debug Panel */}
{showDebugPanel && (
<View style={styles.debugPanel}>
<View style={styles.debugHeader}>
<Text style={styles.debugTitle}>🐛 Debug Panel</Text>
<View style={styles.debugButtons}>
<TouchableOpacity onPress={copyDebugLogs} style={styles.debugCopyBtn}>
<Ionicons name="copy" size={16} color="#fff" />
<Text style={styles.debugBtnText}>Copy</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setDebugLogs([])} style={styles.debugClearBtn}>
<Text style={styles.debugBtnText}>Clear</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowDebugPanel(false)} style={styles.debugCloseBtn}>
<Ionicons name="close" size={16} color="#fff" />
</TouchableOpacity>
</View>
</View>
<ScrollView style={styles.debugScroll}>
{debugLogs.length === 0 ? (
<Text style={styles.debugEmpty}>No logs yet. Press Subscribe to see logs.</Text>
) : (
debugLogs.map((log, i) => (
<Text key={i} style={styles.debugLog}>{log}</Text>
))
)}
</ScrollView>
</View>
)}
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Subscription</Text> <Text style={styles.headerTitle}>Subscription</Text>
<View style={styles.placeholder} /> <TouchableOpacity onPress={() => setShowDebugPanel(!showDebugPanel)}>
<Ionicons name="bug" size={24} color={showDebugPanel ? AppColors.primary : AppColors.textMuted} />
</TouchableOpacity>
</View> </View>
<View style={styles.content}> <View style={styles.content}>
@ -757,4 +845,67 @@ const styles = StyleSheet.create({
color: AppColors.white, color: AppColors.white,
textAlign: 'center', 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',
},
}); });

View File

@ -354,8 +354,7 @@ router.post('/', async (req, res) => {
id: invitation.id, id: invitation.id,
code: invitation.token, code: invitation.token,
role: role, role: role,
email: email || null, email: email || null
expiresAt: invitation.expires_at
} }
}); });
@ -389,7 +388,7 @@ router.get('/beneficiary/:beneficiaryId', async (req, res) => {
// Get invitations for this beneficiary // Get invitations for this beneficiary
const { data: invitations, error } = await supabase const { data: invitations, error } = await supabase
.from('invitations') .from('invitations')
.select('id, token, role, email, label, expires_at, accepted_at, accepted_by, created_at') .select('id, token, role, email, label, accepted_at, accepted_by, created_at')
.eq('beneficiary_id', beneficiaryId) .eq('beneficiary_id', beneficiaryId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@ -417,7 +416,6 @@ router.get('/beneficiary/:beneficiaryId', async (req, res) => {
role: inv.role, role: inv.role,
email: inv.email, email: inv.email,
label: inv.label, label: inv.label,
expiresAt: inv.expires_at,
acceptedAt: inv.accepted_at, acceptedAt: inv.accepted_at,
createdAt: inv.created_at, createdAt: inv.created_at,
acceptedBy: acceptedUser ? { acceptedBy: acceptedUser ? {
@ -561,7 +559,7 @@ router.get('/', async (req, res) => {
// Get invitations // Get invitations
const { data: invitations, error } = await supabase const { data: invitations, error } = await supabase
.from('invitations') .from('invitations')
.select('id, token, role, email, label, beneficiary_id, expires_at, accepted_at, created_at') .select('id, token, role, email, label, beneficiary_id, accepted_at, created_at')
.eq('invited_by', userId) .eq('invited_by', userId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@ -585,7 +583,6 @@ router.get('/', async (req, res) => {
role: inv.role, role: inv.role,
email: inv.email, email: inv.email,
label: inv.label, label: inv.label,
expiresAt: inv.expires_at,
acceptedAt: inv.accepted_at, acceptedAt: inv.accepted_at,
createdAt: inv.created_at, createdAt: inv.created_at,
beneficiary: beneficiary ? { beneficiary: beneficiary ? {

20
package-lock.json generated
View File

@ -42,6 +42,7 @@
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-root-toast": "^4.0.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-sherpa-onnx-offline-tts": "^0.2.4", "react-native-sherpa-onnx-offline-tts": "^0.2.4",
@ -18812,6 +18813,25 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/react-native-root-siblings": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-native-root-siblings/-/react-native-root-siblings-5.0.1.tgz",
"integrity": "sha512-Ay3k/fBj6ReUkWX5WNS+oEAcgPLEGOK8n7K/L7D85mf3xvd8rm/b4spsv26E4HlFzluVx5HKbxEt9cl0wQ1u3g==",
"license": "MIT"
},
"node_modules/react-native-root-toast": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-native-root-toast/-/react-native-root-toast-4.0.1.tgz",
"integrity": "sha512-Zpz+EMKfcCAO/+KuarPeJ/BkmMrXYWRPyTeBhvUondykqUOkQqQO8DEbxqwoUv+ax7MbS3cDJW94L4qTbOFtBw==",
"license": "MIT",
"dependencies": {
"react-native-root-siblings": "^5.0.0"
},
"peerDependencies": {
"react-native": ">=0.47.0",
"react-native-safe-area-context": "*"
}
},
"node_modules/react-native-safe-area-context": { "node_modules/react-native-safe-area-context": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",

View File

@ -45,6 +45,7 @@
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-root-toast": "^4.0.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-sherpa-onnx-offline-tts": "^0.2.4", "react-native-sherpa-onnx-offline-tts": "^0.2.4",