Stable version: Reusable BeneficiaryMenu, subscription fixes

- Created reusable BeneficiaryMenu component with Modal backdrop
- Menu closes on outside tap (proper Modal + Pressable implementation)
- Removed debug panel from subscription and beneficiary detail pages
- Fixed subscription creation and equipment status handling
- Backend improvements for Stripe integration
This commit is contained in:
Sergei 2026-01-09 13:22:56 -08:00
parent 79baf86faf
commit 24e7f057e7
10 changed files with 1002 additions and 673 deletions

View File

@ -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,
},
});

View File

@ -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(

View File

@ -326,6 +326,15 @@ export default function EquipmentStatusScreen() {
<Text style={styles.supportText}>Need help? Contact support</Text>
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
{/* Back to Loved Ones Button */}
<TouchableOpacity
style={styles.backToLovedOnesButton}
onPress={() => router.replace('/(tabs)/beneficiaries')}
>
<Ionicons name="arrow-back" size={20} color={AppColors.primary} />
<Text style={styles.backToLovedOnesText}>Back to My Loved Ones</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
@ -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,
},
});

View File

@ -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<string | null>(null);
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [showWebView, setShowWebView] = useState(false);
const [isWebViewReady, setIsWebViewReady] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(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) {
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);
} else {
toast.error('Error', response.error?.message || 'Failed to save changes.');
}
} catch (err) {
toast.error('Error', 'Failed to save changes.');
}
@ -279,83 +293,14 @@ export default function BeneficiaryDetailScreen() {
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
</View>
<View>
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
{/* Dropdown Menu */}
{isMenuVisible && (
<View style={styles.dropdownMenu}>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
handleEditPress();
}}
>
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/share`);
}}
>
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Access</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/subscription`);
}}
>
<Ionicons name="diamond-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Subscription</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dropdownItem}
onPress={() => {
setIsMenuVisible(false);
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
}}
>
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Equipment</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.dropdownItem, styles.dropdownItemDanger]}
onPress={() => {
setIsMenuVisible(false);
handleDeleteBeneficiary();
}}
>
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
<Text style={[styles.dropdownItemText, styles.dropdownItemTextDanger]}>Remove</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
{/* Backdrop to close menu */}
{isMenuVisible && (
<TouchableOpacity
style={styles.menuBackdrop}
activeOpacity={1}
onPress={() => setIsMenuVisible(false)}
<BeneficiaryMenu
beneficiaryId={id || ''}
onEdit={handleEditPress}
onRemove={handleDeleteBeneficiary}
/>
)}
</View>
{/* DEBUG PANEL */}
{/* DEBUG PANEL - commented out
{__DEV__ && (
<View style={styles.debugPanel}>
<Text style={styles.debugTitle}>DEBUG INFO (tap to copy)</Text>
@ -377,6 +322,7 @@ export default function BeneficiaryDetailScreen() {
</TouchableOpacity>
</View>
)}
*/}
{/* Dashboard Content */}
<View style={styles.dashboardContainer}>
@ -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',

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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 (
<View>
<TouchableOpacity
style={styles.menuButton}
onPress={() => setIsVisible(!isVisible)}
>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
<Modal
visible={isVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setIsVisible(false)}
>
{/* Full screen backdrop */}
<Pressable
style={styles.modalBackdrop}
onPress={() => setIsVisible(false)}
>
{/* Menu positioned at top right */}
<Pressable
style={styles.dropdownMenuContainer}
onPress={(e) => e.stopPropagation()}
>
<View style={styles.dropdownMenu}>
{menuItems.map((item) => (
<TouchableOpacity
key={item.id}
style={[
styles.dropdownItem,
item.danger && styles.dropdownItemDanger,
]}
onPress={() => handleMenuAction(item.id)}
>
<Ionicons
name={item.icon}
size={20}
color={item.danger ? AppColors.error : AppColors.textPrimary}
/>
<Text
style={[
styles.dropdownItemText,
item.danger && styles.dropdownItemTextDanger,
]}
>
{item.label}
</Text>
</TouchableOpacity>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
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,
},
});

View File

@ -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<ApiResponse<{ avatarUrl: string | null }>> {
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<string>((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<ApiResponse<{ success: boolean }>> {
const token = await this.getToken();
@ -1047,6 +1098,53 @@ class ApiService {
}
}
// Get transaction history from Stripe
async getTransactionHistory(beneficiaryId: number, limit = 10): Promise<ApiResponse<{
transactions: Array<{
id: string;
type: 'subscription' | 'one_time';
amount: number;
currency: string;
status: string;
date: string;
description: string;
invoicePdf?: string;
hostedUrl?: string;
receiptUrl?: string;
}>;
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<ApiResponse<{ success: boolean; status: string }>> {
const token = await this.getToken();