Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions proservice-mobile-enterprise/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# ProService Mobile – Secure Donation Flow

This document describes how to run and deploy the secure donation flow that integrates Stripe payments with Supabase Edge Functions for the Solar Concept initiative.

## Overview

- Mobile app uses `@supabase/supabase-js` to fetch projects and invoke secure Edge Functions.
- Stripe Payment Intents are created server-side via Supabase functions to keep secrets outside the client.
- A hardened error handler surfaces end-user friendly messages while preserving observability in logs.
- Supabase Row Level Security (RLS) enforces access control for donation data.

## Environment Variables

Configure the following variables before building the mobile app or deploying the backend:

| Variable | Purpose | Where to set |
|----------|---------|--------------|
| `EXPO_PUBLIC_SUPABASE_URL` or `SUPABASE_URL` | Supabase project URL | Mobile app |
| `EXPO_PUBLIC_SUPABASE_ANON_KEY` or `SUPABASE_ANON_KEY` | Supabase anonymous key | Mobile app |
| `STRIPE_PUBLISHABLE_KEY` or `EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe publishable key used by the mobile client | Mobile app |
| `STRIPE_SECRET_KEY` | Secret key used by Supabase Edge Functions to talk to Stripe | Supabase secrets |
| `APP_URL` | Public URL of your frontend for Stripe redirects | Supabase secrets |
| `STRIPE_TEST_PAYMENT_METHOD` (optional) | Test payment method ID (`pm_card_visa`) for automated confirmations during QA | Supabase secrets |

> ℹ️ **React Native / Expo** – make sure environment variables are available at build time (e.g. `app.config.js`, `expo-config`, or `react-native-config`).

## Supabase Edge Functions

The repository ships with two Edge Functions under `supabase/functions`:

- `create-payment-intent`: validates donation payloads and returns a Stripe Payment Intent client secret.
- `confirm-payment`: retrieves or finalises a Payment Intent to confirm the donation outcome.

### Deploy

```bash
supabase functions deploy create-payment-intent
supabase functions deploy confirm-payment
```

### Configure Secrets

```bash
supabase secrets set STRIPE_SECRET_KEY=sk_test_...
supabase secrets set APP_URL=https://your-app.com
supabase secrets set STRIPE_TEST_PAYMENT_METHOD=pm_card_visa # optional
```

## Database Security

Apply the recommended RLS policies in the Supabase SQL editor:

```sql
ALTER TABLE solar_projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Public can view projects" ON solar_projects
FOR SELECT USING (true);

CREATE POLICY "Authenticated users can donate" ON donations
FOR INSERT TO authenticated WITH CHECK (true);

CREATE POLICY "Users see own donations" ON donations
FOR SELECT USING (auth.uid() = user_id);
```

## Mobile Integration

- Use `SecureDonationButton` from `src/components/SecureDonationButton.tsx` to trigger a protected donation flow.
- `PaymentService` (under `src/services`) abstracts Supabase function calls and amount sanitisation.
- `ErrorHandler` unifies network and payment error messaging.

### Quick Usage

```tsx
import SecureDonationButton from '../components/SecureDonationButton';

<SecureDonationButton
projectId={project.id}
projectName={project.name}
userEmail={currentUser.email}
onSuccess={({ amount, paymentIntentId }) => {
console.log('Donation confirmed', amount, paymentIntentId);
}}
/>;
```

## Testing Checklist

- [ ] Run `npm install` (or `yarn`) inside `proservice-mobile-enterprise/`.
- [ ] Provide environment variables via `.env`, `app.config.js`, or native build tooling.
- [ ] Use Stripe test cards to validate the flow (`4242 4242 4242 4242`).
- [ ] Monitor Supabase Edge Function logs (`supabase functions logs <name>`).

## Deployment Recap

1. Configure Supabase secrets (`STRIPE_SECRET_KEY`, `APP_URL`, optional test payment method).
2. Deploy `create-payment-intent` and `confirm-payment`.
3. Apply database RLS policies.
4. Rebuild the mobile app with publishable keys and Supabase credentials.
5. Verify transactions in the Stripe dashboard (test mode first, then live).
1 change: 1 addition & 0 deletions proservice-mobile-enterprise/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"react-native": "0.73.0",
"@tanstack/react-query": "^5.0.0",
"@reduxjs/toolkit": "^2.0.0",
"@supabase/supabase-js": "^2.45.4",
"react-redux": "^8.1.0",
"react-native-maps": "1.7.1",
"@stripe/stripe-react-native": "0.26.0",
Expand Down
260 changes: 260 additions & 0 deletions proservice-mobile-enterprise/src/components/SecureDonationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import React, { useMemo, useState } from 'react';
import {
Alert,
StyleSheet,
StyleProp,
Text,
TextInput,
TouchableOpacity,
View,
ViewStyle,
TextStyle
} from 'react-native';
import { PaymentService } from '../services/paymentService';
import { ErrorHandler } from '../services/errorHandler';

interface SecureDonationButtonProps {
projectId: string;
projectName: string;
userEmail: string;
defaultAmounts?: number[];
containerStyle?: StyleProp<ViewStyle>;
titleStyle?: StyleProp<TextStyle>;
onSuccess?: (payload: { amount: number; paymentIntentId: string }) => void;
}

const DEFAULT_AMOUNTS = [10, 25, 50, 100];

export const SecureDonationButton: React.FC<SecureDonationButtonProps> = ({
projectId,
projectName,
userEmail,
defaultAmounts = DEFAULT_AMOUNTS,
containerStyle,
titleStyle,
onSuccess
}) => {
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);

const formattedAmounts = useMemo(() => {
return defaultAmounts
.filter((amount) => Number.isFinite(amount) && amount > 0)
.map((amount) => Math.round(amount));
}, [defaultAmounts]);

const resolveAmount = (): number | null => {
if (selectedAmount && selectedAmount > 0) {
return selectedAmount;
}

if (customAmount.trim().length === 0) {
return null;
}

const value = parseFloat(customAmount.replace(',', '.'));
if (!Number.isFinite(value) || value <= 0) {
return null;
}

return Number(value.toFixed(2));
};

const resetState = () => {
setSelectedAmount(null);
setCustomAmount('');
};

const handleSecureDonation = async () => {
const amount = resolveAmount();

if (!amount) {
Alert.alert('Erreur', 'Veuillez saisir un montant valide');
return;
}

if (!userEmail) {
Alert.alert('Erreur', 'Adresse e-mail utilisateur manquante');
return;
}

setLoading(true);

try {
const paymentData = await ErrorHandler.safeAPIcall(
() => PaymentService.createDonationIntent(amount, projectId, userEmail),
'create-donation-intent'
);

console.log('Paiement sécurisé initié:', paymentData);

await new Promise((resolve) => setTimeout(resolve, 1500));

const confirmation = await ErrorHandler.safeAPIcall(
() => PaymentService.confirmDonation(paymentData.id),
'confirm-donation'
);

Alert.alert(
'Don sécurisé réussi !',
`Merci pour votre don de ${amount}€ pour ${projectName}`
);

if (onSuccess) {
onSuccess({ amount, paymentIntentId: confirmation.id });
}

resetState();
} catch (error) {
const message = error instanceof Error ? error.message : 'Erreur de sécurité';
Alert.alert('Erreur de sécurité', message);
} finally {
setLoading(false);
}
};

const resolvedAmountLabel = resolveAmount();

return (
<View style={[styles.container, containerStyle]}>
<Text style={[styles.title, titleStyle]}>Faire un don sécurisé</Text>

<View style={styles.amounts}>
{formattedAmounts.map((amount) => {
const isSelected = selectedAmount === amount;
return (
<TouchableOpacity
key={amount}
style={[styles.amountButton, isSelected && styles.amountButtonSelected]}
onPress={() => {
setSelectedAmount(amount);
setCustomAmount('');
}}
disabled={loading}
>
<Text style={[styles.amountText, isSelected && styles.amountTextSelected]}>
{amount}€
</Text>
</TouchableOpacity>
);
})}
</View>

<Text style={styles.customLabel}>Ou montant personnalisé :</Text>
<TextInput
style={styles.customInput}
placeholder="Montant en €"
placeholderTextColor="#94a3b8"
value={customAmount}
onChangeText={(text) => {
const sanitized = text.replace(/[^\d.,]/g, '');
setCustomAmount(sanitized);
setSelectedAmount(null);
}}
keyboardType="decimal-pad"
maxLength={8}
editable={!loading}
/>

<TouchableOpacity
style={[styles.donateButton, (!resolvedAmountLabel || loading) && styles.donateButtonDisabled]}
onPress={handleSecureDonation}
disabled={!resolvedAmountLabel || loading}
>
<Text style={styles.donateButtonText}>
{loading
? 'Traitement sécurisé...'
: resolvedAmountLabel
? `Donner ${resolvedAmountLabel}€`
: 'Sélectionnez un montant'}
</Text>
</TouchableOpacity>

<Text style={styles.securityNote}>🔒 Paiement 100% sécurisé</Text>
</View>
);
};

const styles = StyleSheet.create({
container: {
backgroundColor: '#0f172a',
borderRadius: 16,
padding: 20,
borderWidth: 1,
borderColor: '#1e293b'
},
title: {
color: '#f8fafc',
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
textAlign: 'center'
},
amounts: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
marginBottom: 12
},
amountButton: {
width: '48%',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: '#1e293b',
backgroundColor: '#1f2937',
marginBottom: 8,
alignItems: 'center'
},
amountButtonSelected: {
backgroundColor: '#2563eb',
borderColor: '#3b82f6'
},
amountText: {
color: '#f8fafc',
fontSize: 16,
fontWeight: '500'
},
amountTextSelected: {
color: '#ffffff'
},
customLabel: {
color: '#94a3b8',
fontSize: 14,
marginBottom: 8
},
customInput: {
backgroundColor: '#1f2937',
borderColor: '#1e293b',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
color: '#f8fafc',
fontSize: 16,
marginBottom: 16
},
donateButton: {
backgroundColor: '#22c55e',
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center'
},
donateButtonDisabled: {
backgroundColor: '#16a34a88'
},
donateButtonText: {
color: '#0f172a',
fontSize: 16,
fontWeight: '700'
},
securityNote: {
color: '#4ade80',
fontSize: 12,
textAlign: 'center',
marginTop: 12
}
});

export default SecureDonationButton;
Loading
Loading