From 0c0b5fbefe072af3188f0787f8955b5e553854ea Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 26 Apr 2026 09:16:12 +0000 Subject: [PATCH] Square payment fixes Co-authored-by: Copilot --- docker-compose.yml | 58 ++- frontend/src/components/MembershipSetup.tsx | 4 +- frontend/src/components/SquarePayment.tsx | 23 +- frontend/src/components/SquarePaymentNew.tsx | 443 +++++++++++++++++++ frontend/src/contexts/FeatureFlagContext.tsx | 6 +- 5 files changed, 495 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/SquarePaymentNew.tsx diff --git a/docker-compose.yml b/docker-compose.yml index c8e440e..50ee333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,38 +55,34 @@ services: # mysql: # condition: service_healthy - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - target: development - restart: unless-stopped - environment: - - VITE_HOST_CHECK=false - - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS} - ports: - - "8050:3000" # Expose frontend to host - volumes: - - ./frontend/src:/app/src - - ./frontend/public:/app/public - - ./frontend/vite.config.ts:/app/vite.config.ts - depends_on: - - backend - - #frontend-prod: - # build: - # context: ./frontend - # dockerfile: Dockerfile - # target: production - # container_name: membership_frontend_prod - # restart: unless-stopped - # ports: - # - "8050:80" # Nginx default port - # depends_on: - # - backend - # networks: - # - membership_private + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # target: development + # restart: unless-stopped + # environment: + # - VITE_HOST_CHECK=false + # - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS} + # ports: + # - "8050:3000" # Expose frontend to host + # volumes: + # - ./frontend/src:/app/src + # - ./frontend/public:/app/public + # - ./frontend/vite.config.ts:/app/vite.config.ts + # depends_on: + # - backend + frontend-prod: + build: + context: ./frontend + dockerfile: Dockerfile + target: production + restart: unless-stopped + ports: + - "8050:80" # Nginx default port + depends_on: + - backend volumes: # mysql_data: diff --git a/frontend/src/components/MembershipSetup.tsx b/frontend/src/components/MembershipSetup.tsx index 8ff884a..df16f81 100644 --- a/frontend/src/components/MembershipSetup.tsx +++ b/frontend/src/components/MembershipSetup.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService'; import { useFeatureFlags } from '../contexts/FeatureFlagContext'; -import SquarePayment from './SquarePayment'; +import SquarePaymentNew from './SquarePaymentNew'; interface MembershipSetupProps { onMembershipCreated: () => void; @@ -244,7 +244,7 @@ const MembershipSetup: React.FC = ({ onMembershipCreated, {paymentMethod === 'square' && selectedTier && (
- = ({ 'input': { fontSize: '14px' } - } + }, + includeInputLabels: false }); await cardInstance.attach('#card-container'); setCard(cardInstance); @@ -166,8 +167,24 @@ const SquarePayment: React.FC = ({ setIsProcessing(true); try { - // Tokenize the payment method with billing details - const result = await card.tokenize(); + // Tokenize the payment method with complete verification details as per Square best practices + const verificationDetails = { + amount: amount.toFixed(2), + currencyCode: 'GBP', + intent: 'CHARGE', + billingContact: { + givenName: cardholderName.split(' ')[0] || cardholderName, + familyName: cardholderName.split(' ').slice(1).join(' ') || '', + addressLines: [addressLine1, addressLine2].filter(Boolean), + city: city, + postalCode: postalCode, + countryCode: 'GB' + }, + customerInitiated: true, + sellerKeyedIn: false + }; + + const result = await card.tokenize(verificationDetails); if (result.status === 'OK') { // Send the token to your backend with billing details diff --git a/frontend/src/components/SquarePaymentNew.tsx b/frontend/src/components/SquarePaymentNew.tsx new file mode 100644 index 0000000..ae047eb --- /dev/null +++ b/frontend/src/components/SquarePaymentNew.tsx @@ -0,0 +1,443 @@ +import React, { useEffect, useState } from 'react'; + +interface SquarePaymentNewProps { + amount: number; + onPaymentSuccess: (paymentResult: any) => void; + onPaymentError: (error: string) => void; + tierId: number; +} + +const SquarePaymentNew: React.FC = ({ + amount, + onPaymentSuccess, + onPaymentError, + tierId +}) => { + const [isLoading, setIsLoading] = useState(true); + const [card, setCard] = useState(null); + const [payments, setPayments] = useState(null); + const [squareConfig, setSquareConfig] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [cardholderName, setCardholderName] = useState(''); + const [addressLine1, setAddressLine1] = useState(''); + const [addressLine2, setAddressLine2] = useState(''); + const [city, setCity] = useState(''); + const [postalCode, setPostalCode] = useState(''); + + useEffect(() => { + loadSquareConfig(); + }, []); + + useEffect(() => { + if (squareConfig && !payments && !isLoading) { + // Only initialize after component is fully rendered (not loading) + setTimeout(() => { + initializeSquare(); + }, 100); + } + }, [squareConfig, isLoading]); + + const loadSquareSDK = (environment: string): Promise => { + return new Promise((resolve, reject) => { + if (window.Square) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.type = 'text/javascript'; + + 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', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + const config = await response.json(); + console.log('Square config received:', config); + + await loadSquareSDK(config.environment); + console.log('Square SDK loaded for environment:', config.environment); + + setSquareConfig(config); + setIsLoading(false); // Set loading to false here so DOM renders + } catch (error) { + console.error('Failed to load Square config:', error); + onPaymentError('Failed to load payment configuration'); + setIsLoading(false); + } + }; + + const initializeSquare = async () => { + if (!window.Square) { + console.error('Square.js failed to load'); + onPaymentError('Payment system failed to load. Please refresh the page.'); + setIsLoading(false); + return; + } + + try { + const environment = squareConfig.environment?.toLowerCase() === 'sandbox' ? 'sandbox' : 'production'; + console.log('Initializing Square with environment:', environment); + + const paymentsInstance = window.Square.payments( + squareConfig.application_id, + squareConfig.location_id, + { + environment: environment + } + ); + setPayments(paymentsInstance); + + // Initialize card with minimal styling - let Square handle address collection + const cardInstance = await paymentsInstance.card({ + style: { + '.input-container': { + borderColor: '#E0E0E0', + borderRadius: '4px' + }, + '.input-container.is-focus': { + borderColor: '#4CAF50' + }, + '.message-text': { + color: '#666' + }, + '.message-icon': { + color: '#666' + }, + 'input': { + color: '#333' + } + } + }); + + // Wait for element to be available in DOM + const waitForElement = (selector: string, maxAttempts = 20): Promise => { + return new Promise((resolve, reject) => { + let attempts = 0; + console.log(`Looking for element: ${selector}`); + const checkElement = () => { + const element = document.querySelector(selector); + console.log(`Attempt ${attempts + 1}: Element ${selector} found:`, !!element); + if (element) { + console.log('Element found, resolving'); + resolve(element); + } else if (attempts < maxAttempts) { + attempts++; + setTimeout(checkElement, 200); + } else { + console.error(`Element ${selector} not found after ${maxAttempts} attempts`); + console.log('Available elements:', document.querySelectorAll('div[id*="square"]')); + reject(new Error(`Element ${selector} not found after ${maxAttempts} attempts`)); + } + }; + checkElement(); + }); + }; + + console.log('About to wait for square-card-element'); + await waitForElement('#square-card-element'); + console.log('Element found, attaching card instance'); + await cardInstance.attach('#square-card-element'); + setCard(cardInstance); + } catch (error) { + console.error('Failed to initialize Square:', error); + onPaymentError('Failed to initialize payment form'); + } + }; + + const handlePayment = async () => { + if (!card || !payments) { + onPaymentError('Payment form not initialized'); + return; + } + + 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; + } + + setIsProcessing(true); + + try { + const [givenName, ...familyNameParts] = cardholderName.trim().split(/\s+/); + + const verificationDetails = { + amount: amount.toFixed(2), + currencyCode: 'GBP', + intent: 'CHARGE', + billingContact: { + givenName: givenName || cardholderName.trim(), + familyName: familyNameParts.join(' '), + addressLines: [addressLine1.trim(), addressLine2.trim()].filter(Boolean), + city: city.trim(), + postalCode: postalCode.trim(), + countryCode: 'GB' + }, + customerInitiated: true, + sellerKeyedIn: false + }; + + const result = await card.tokenize(verificationDetails); + + if (result.status === 'OK') { + const response = await fetch('/api/v1/payments/square/process', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + source_id: result.token, + amount: amount, + tier_id: tierId, + note: `Membership payment - £${amount.toFixed(2)}`, + billing_details: { + cardholder_name: cardholderName.trim(), + address_line_1: addressLine1.trim(), + address_line_2: addressLine2.trim() || undefined, + city: city.trim(), + postal_code: postalCode.trim(), + country: 'GB' + } + }) + }); + + const paymentResult = await response.json(); + + if (response.ok && paymentResult.success) { + onPaymentSuccess(paymentResult); + } else { + let errorMessage = 'Payment failed. Please try again.'; + + if (paymentResult.errors && Array.isArray(paymentResult.errors) && paymentResult.errors.length > 0) { + errorMessage = paymentResult.errors[0]; + } else if (paymentResult.detail) { + errorMessage = paymentResult.detail; + } + + onPaymentError(errorMessage); + } + } else { + const errors = result.errors?.map((e: any) => e.message).join(', ') || 'Card validation failed'; + onPaymentError(errors); + } + } catch (error: any) { + console.error('Payment error:', error); + onPaymentError(error.message || 'Payment processing failed'); + } finally { + setIsProcessing(false); + } + }; + + if (isLoading) { + return ( +
+
Loading secure payment form...
+
+ ); + } + + return ( +
+
+

Secure Payment

+

+ £{amount.toFixed(2)} +

+

Enter billing details to verify your card securely.

+
+ +
+ + setCardholderName(e.target.value)} + placeholder="Name as shown on card" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isProcessing} + /> +
+ +
+ + 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={isProcessing} + /> +
+ +
+ + 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={isProcessing} + /> +
+ +
+
+ + setCity(e.target.value)} + placeholder="City" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isProcessing} + /> +
+
+ + setPostalCode(e.target.value)} + placeholder="Postcode" + style={{ + width: '100%', + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #E0E0E0', + borderRadius: '4px', + boxSizing: 'border-box' + }} + disabled={isProcessing} + /> +
+
+ +
+ +
+
+ + + +
+

🔒 Secure payment powered by Square

+

Your payment information is encrypted and secure

+ {squareConfig?.environment === 'sandbox' && ( +

+ TEST MODE: Use test card 4111 1111 1111 1111 with any future expiry date +

+ )} +
+
+ ); +}; + +// Extend Window interface for TypeScript +declare global { + interface Window { + Square: any; + } +} + +export default SquarePaymentNew; \ No newline at end of file diff --git a/frontend/src/contexts/FeatureFlagContext.tsx b/frontend/src/contexts/FeatureFlagContext.tsx index fe7b0d2..69d87b9 100644 --- a/frontend/src/contexts/FeatureFlagContext.tsx +++ b/frontend/src/contexts/FeatureFlagContext.tsx @@ -38,10 +38,10 @@ export const FeatureFlagProvider: React.FC = ({ childr } catch (err: any) { console.error('Failed to load feature flags:', err); setError('Failed to load feature flags'); - // Set default flags on error + // Set default flags on error - conservative defaults for production setFlags({ flags: { - CASH_PAYMENT_ENABLED: true, + CASH_PAYMENT_ENABLED: false, // Default to disabled for production safety EMAIL_NOTIFICATIONS_ENABLED: true, EVENT_MANAGEMENT_ENABLED: true, AUTO_RENEWAL_ENABLED: false, @@ -50,7 +50,7 @@ export const FeatureFlagProvider: React.FC = ({ childr ADVANCED_REPORTING_ENABLED: false, API_RATE_LIMITING_ENABLED: true, }, - enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED'] + enabled_flags: ['EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED'] }); } finally { setLoading(false);