diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 0dfefe4..64f779f 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -190,7 +190,8 @@ async def process_square_payment( source_id=payment_request.source_id, idempotency_key=payment_request.idempotency_key, reference_id=reference_id, - note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}" + note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}", + billing_details=payment_request.billing_details ) if not square_result.get('success'): diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 983ea24..a90dba5 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -171,6 +171,7 @@ class SquarePaymentRequest(BaseModel): amount: float = Field(..., gt=0, description="Payment amount in GBP") idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments") note: Optional[str] = Field(None, description="Optional payment note") + billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS") class SquarePaymentResponse(BaseModel): diff --git a/backend/app/services/square_service.py b/backend/app/services/square_service.py index 7e4f44f..c30f5c6 100644 --- a/backend/app/services/square_service.py +++ b/backend/app/services/square_service.py @@ -35,7 +35,8 @@ class SquareService: idempotency_key: Optional[str] = None, customer_id: Optional[str] = None, reference_id: Optional[str] = None, - note: Optional[str] = None + note: Optional[str] = None, + billing_details: Optional[Dict] = None ) -> Dict: """ Create a payment using Square @@ -47,6 +48,7 @@ class SquareService: customer_id: Optional Square customer ID reference_id: Optional reference ID for internal tracking note: Optional note about the payment + billing_details: Optional billing address and cardholder name for AVS Returns: Dict with payment result including payment_id, status, and details @@ -60,19 +62,49 @@ class SquareService: # For GBP, this is pence amount_in_pence = int(amount_money * 100) - # Create payment - pass parameters directly as keyword arguments - result = self.client.payments.create( - source_id=source_id, - idempotency_key=idempotency_key, - amount_money={ + # Build payment parameters + payment_params = { + 'source_id': source_id, + 'idempotency_key': idempotency_key, + 'amount_money': { 'amount': amount_in_pence, 'currency': 'GBP' }, - location_id=self.location_id, - customer_id=customer_id if customer_id else None, - reference_id=reference_id if reference_id else None, - note=note if note else None - ) + 'location_id': self.location_id + } + + # Add billing address for AVS if provided + if billing_details: + # Add buyer email if available + if billing_details.get('email'): + payment_params['buyer_email_address'] = billing_details.get('email') + + # Build billing address for AVS + billing_address = {} + if billing_details.get('address_line_1'): + billing_address['address_line_1'] = billing_details.get('address_line_1') + if billing_details.get('address_line_2'): + billing_address['address_line_2'] = billing_details.get('address_line_2') + if billing_details.get('city'): + billing_address['locality'] = billing_details.get('city') + if billing_details.get('postal_code'): + billing_address['postal_code'] = billing_details.get('postal_code') + if billing_details.get('country'): + billing_address['country'] = billing_details.get('country') + + if billing_address: + payment_params['billing_address'] = billing_address + + # Add optional parameters + if customer_id: + payment_params['customer_id'] = customer_id + if reference_id: + payment_params['reference_id'] = reference_id + if note: + payment_params['note'] = note + + # Create payment - pass parameters directly as keyword arguments + result = self.client.payments.create(**payment_params) if result.errors: # Payment failed - extract user-friendly error messages diff --git a/frontend/index.html b/frontend/index.html index cdc0723..074fc49 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,7 @@ SASA Membership Portal - - +
diff --git a/frontend/src/components/SquarePayment.tsx b/frontend/src/components/SquarePayment.tsx index d7cc13c..72d5656 100644 --- a/frontend/src/components/SquarePayment.tsx +++ b/frontend/src/components/SquarePayment.tsx @@ -17,6 +17,14 @@ const SquarePayment: React.FC = ({ const [card, setCard] = useState(null); const [payments, setPayments] = useState(null); const [squareConfig, setSquareConfig] = useState(null); + + // Billing details state + const [cardholderName, setCardholderName] = useState(''); + const [addressLine1, setAddressLine1] = useState(''); + const [addressLine2, setAddressLine2] = useState(''); + const [city, setCity] = useState(''); + const [postalCode, setPostalCode] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { loadSquareConfig(); @@ -28,6 +36,31 @@ const SquarePayment: React.FC = ({ } }, [squareConfig]); + const loadSquareSDK = (environment: string): Promise => { + return new Promise((resolve, reject) => { + // Check if Square SDK is already loaded + if (window.Square) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.type = 'text/javascript'; + + // Load the correct SDK based on environment + if (environment?.toLowerCase() === 'sandbox') { + script.src = 'https://sandbox.web.squarecdn.com/v1/square.js'; + } else { + script.src = 'https://web.squarecdn.com/v1/square.js'; + } + + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Square SDK')); + + document.head.appendChild(script); + }); + }; + const loadSquareConfig = async () => { try { const response = await fetch('/api/v1/payments/config/square', { @@ -36,6 +69,12 @@ const SquarePayment: React.FC = ({ } }); const config = await response.json(); + console.log('Square config received:', config); + + // Load the appropriate Square SDK based on environment + await loadSquareSDK(config.environment); + console.log('Square SDK loaded for environment:', config.environment); + setSquareConfig(config); } catch (error) { console.error('Failed to load Square config:', error); @@ -53,13 +92,42 @@ const SquarePayment: React.FC = ({ } try { + // Determine environment - default to production if not explicitly set to sandbox + const environment = squareConfig.environment?.toLowerCase() === 'sandbox' ? 'sandbox' : 'production'; + console.log('Initializing Square with environment:', environment); + console.log('Application ID:', squareConfig.application_id); + console.log('Location ID:', squareConfig.location_id); + const paymentsInstance = window.Square.payments( squareConfig.application_id, - squareConfig.location_id + squareConfig.location_id, + { + environment: environment + } ); setPayments(paymentsInstance); - const cardInstance = await paymentsInstance.card(); + // Initialize card without postal code (we collect it separately in billing form) + const cardInstance = await paymentsInstance.card({ + style: { + '.input-container': { + borderColor: '#E0E0E0', + borderRadius: '4px' + }, + '.input-container.is-focus': { + borderColor: '#4CAF50' + }, + '.message-text': { + color: '#999' + }, + '.message-icon': { + color: '#999' + }, + 'input': { + fontSize: '14px' + } + } + }); await cardInstance.attach('#card-container'); setCard(cardInstance); setIsLoading(false); @@ -76,14 +144,33 @@ const SquarePayment: React.FC = ({ return; } + // Validate billing details + if (!cardholderName.trim()) { + onPaymentError('Please enter cardholder name'); + return; + } + if (!addressLine1.trim()) { + onPaymentError('Please enter address line 1'); + return; + } + if (!city.trim()) { + onPaymentError('Please enter city'); + return; + } + if (!postalCode.trim()) { + onPaymentError('Please enter postal code'); + return; + } + setIsLoading(true); + setIsProcessing(true); try { - // Tokenize the payment method + // Tokenize the payment method with billing details const result = await card.tokenize(); if (result.status === 'OK') { - // Send the token to your backend + // Send the token to your backend with billing details const response = await fetch('/api/v1/payments/square/process', { method: 'POST', headers: { @@ -94,7 +181,15 @@ const SquarePayment: React.FC = ({ source_id: result.token, amount: amount, tier_id: tierId, - note: `Membership payment - £${amount.toFixed(2)}` + note: `Membership payment - £${amount.toFixed(2)}`, + billing_details: { + cardholder_name: cardholderName, + address_line_1: addressLine1, + address_line_2: addressLine2 || undefined, + city: city, + postal_code: postalCode, + country: 'GB' + } }) }); @@ -124,33 +219,149 @@ const SquarePayment: React.FC = ({ onPaymentError(error.message || 'Payment processing failed'); } finally { setIsLoading(false); + setIsProcessing(false); } }; return ( -
-
+
+

Card Payment

Amount: £{amount.toFixed(2)}

-
+ {/* Cardholder Name */} +
+ + setCardholderName(e.target.value)} + placeholder="Name as it appears on card" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isLoading || !card} + /> +
+ + {/* Address Line 1 */} +
+ + setAddressLine1(e.target.value)} + placeholder="Street address" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isLoading || !card} + /> +
+ + {/* Address Line 2 */} +
+ + setAddressLine2(e.target.value)} + placeholder="Apartment, suite, etc. (optional)" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isLoading || !card} + /> +
+ + {/* City and Postal Code */} +
+
+ + setCity(e.target.value)} + placeholder="City" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isLoading || !card} + /> +
+
+ + setPostalCode(e.target.value.toUpperCase())} + placeholder="SW1A 1AA" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isLoading || !card} + /> +
+
+ + {/* Card Details */} +
+ +
+
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 094135b..5313699 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -36,6 +36,9 @@ const Dashboard: React.FC = () => { location: '', max_attendees: '' }); + const [showRSVPModal, setShowRSVPModal] = useState(false); + const [selectedEventForRSVP, setSelectedEventForRSVP] = useState(null); + const [eventRSVPList, setEventRSVPList] = useState([]); useEffect(() => { if (!authService.isAuthenticated()) { @@ -410,6 +413,24 @@ const Dashboard: React.FC = () => { setEditingEvent(null); }; + const handleViewRSVPs = async (event: Event) => { + setSelectedEventForRSVP(event); + try { + const rsvps = await eventService.getEventRSVPs(event.id); + setEventRSVPList(rsvps); + setShowRSVPModal(true); + } catch (error) { + console.error('Failed to load RSVPs:', error); + alert('Failed to load RSVPs. Please try again.'); + } + }; + + const handleCloseRSVPModal = () => { + setShowRSVPModal(false); + setSelectedEventForRSVP(null); + setEventRSVPList([]); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-GB', { day: 'numeric', @@ -818,7 +839,11 @@ const Dashboard: React.FC = () => { {allEvents.map(event => ( - + handleViewRSVPs(event)} + >
{event.title} @@ -854,7 +879,10 @@ const Dashboard: React.FC = () => {
)} + + {/* RSVP List Modal */} + {showRSVPModal && selectedEventForRSVP && ( +
+
e.stopPropagation()} + > +
+

RSVPs for {selectedEventForRSVP.title}

+ +
+ +
+

Date: {formatDate(selectedEventForRSVP.event_date)}

+ {selectedEventForRSVP.event_time && ( +

Time: {selectedEventForRSVP.event_time}

+ )} + {selectedEventForRSVP.location && ( +

Location: {selectedEventForRSVP.location}

+ )} +
+ + {eventRSVPList.length > 0 ? ( + <> +
+
+ Attending: {eventRSVPList.filter(r => r.status === 'attending').length} +
+
+ Maybe: {eventRSVPList.filter(r => r.status === 'maybe').length} +
+
+ Not Attending: {eventRSVPList.filter(r => r.status === 'not_attending').length} +
+
+ +
+ + + + + + + + + + + {eventRSVPList.map(rsvp => { + const rsvpUser = allUsers.find(u => u.id === rsvp.user_id); + return ( + + + + + + + ); + })} + +
NameEmailRSVPDate
+ {rsvpUser ? `${rsvpUser.first_name} ${rsvpUser.last_name}` : `User #${rsvp.user_id}`} + + {rsvpUser?.email || 'N/A'} + + + {rsvp.status === 'attending' ? 'ATTENDING' : + rsvp.status === 'maybe' ? 'MAYBE' : + 'NOT ATTENDING'} + + + {rsvp.created_at ? formatDate(rsvp.created_at) : 'N/A'} +
+
+ + ) : ( +

No RSVPs yet for this event.

+ )} +
+
+ )} ); };