Square payment fixes

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
James Pattinson
2026-04-26 09:16:12 +00:00
parent e1659c07ea
commit 0c0b5fbefe
5 changed files with 495 additions and 39 deletions
+20 -24
View File
@@ -55,38 +55,34 @@ services:
# mysql: # mysql:
# condition: service_healthy # condition: service_healthy
frontend: # 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: # build:
# context: ./frontend # context: ./frontend
# dockerfile: Dockerfile # dockerfile: Dockerfile
# target: production # target: development
# container_name: membership_frontend_prod
# restart: unless-stopped # restart: unless-stopped
# environment:
# - VITE_HOST_CHECK=false
# - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS}
# ports: # ports:
# - "8050:80" # Nginx default port # - "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: # depends_on:
# - backend # - backend
# networks:
# - membership_private
frontend-prod:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
restart: unless-stopped
ports:
- "8050:80" # Nginx default port
depends_on:
- backend
volumes: volumes:
# mysql_data: # mysql_data:
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService'; import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
import { useFeatureFlags } from '../contexts/FeatureFlagContext'; import { useFeatureFlags } from '../contexts/FeatureFlagContext';
import SquarePayment from './SquarePayment'; import SquarePaymentNew from './SquarePaymentNew';
interface MembershipSetupProps { interface MembershipSetupProps {
onMembershipCreated: () => void; onMembershipCreated: () => void;
@@ -244,7 +244,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
{paymentMethod === 'square' && selectedTier && ( {paymentMethod === 'square' && selectedTier && (
<div> <div>
<SquarePayment <SquarePaymentNew
amount={selectedTier.annual_fee} amount={selectedTier.annual_fee}
tierId={selectedTier.id} tierId={selectedTier.id}
onPaymentSuccess={handleSquarePaymentSuccess} onPaymentSuccess={handleSquarePaymentSuccess}
+20 -3
View File
@@ -126,7 +126,8 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
'input': { 'input': {
fontSize: '14px' fontSize: '14px'
} }
} },
includeInputLabels: false
}); });
await cardInstance.attach('#card-container'); await cardInstance.attach('#card-container');
setCard(cardInstance); setCard(cardInstance);
@@ -166,8 +167,24 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
setIsProcessing(true); setIsProcessing(true);
try { try {
// Tokenize the payment method with billing details // Tokenize the payment method with complete verification details as per Square best practices
const result = await card.tokenize(); 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') { if (result.status === 'OK') {
// Send the token to your backend with billing details // Send the token to your backend with billing details
@@ -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<SquarePaymentNewProps> = ({
amount,
onPaymentSuccess,
onPaymentError,
tierId
}) => {
const [isLoading, setIsLoading] = useState(true);
const [card, setCard] = useState<any>(null);
const [payments, setPayments] = useState<any>(null);
const [squareConfig, setSquareConfig] = useState<any>(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<void> => {
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<Element> => {
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 (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ fontSize: '16px', color: '#666' }}>Loading secure payment form...</div>
</div>
);
}
return (
<div style={{ maxWidth: '400px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px', textAlign: 'center' }}>
<h3 style={{ marginBottom: '8px', color: '#333' }}>Secure Payment</h3>
<p style={{ fontSize: '18px', fontWeight: 'bold', color: '#4CAF50' }}>
£{amount.toFixed(2)}
</p>
<p style={{ fontSize: '14px', color: '#666', margin: '8px 0' }}>Enter billing details to verify your card securely.</p>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500', color: '#333' }}>
Cardholder Name *
</label>
<input
type="text"
value={cardholderName}
onChange={(e) => 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}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500', color: '#333' }}>
Address Line 1 *
</label>
<input
type="text"
value={addressLine1}
onChange={(e) => 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}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500', color: '#333' }}>
Address Line 2
</label>
<input
type="text"
value={addressLine2}
onChange={(e) => 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}
/>
</div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
<div style={{ flex: '1' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500', color: '#333' }}>
City *
</label>
<input
type="text"
value={city}
onChange={(e) => 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}
/>
</div>
<div style={{ flex: '1' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500', color: '#333' }}>
Postal Code *
</label>
<input
type="text"
value={postalCode}
onChange={(e) => 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}
/>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: '500',
color: '#333'
}}>
Payment Details
</label>
<div
id="square-card-element"
style={{
minHeight: '200px',
border: '1px solid #E0E0E0',
borderRadius: '8px',
backgroundColor: '#fff'
}}
/>
</div>
<button
onClick={handlePayment}
disabled={!card || isProcessing}
style={{
width: '100%',
padding: '16px',
fontSize: '16px',
fontWeight: '600',
color: 'white',
backgroundColor: isProcessing ? '#ccc' : '#4CAF50',
border: 'none',
borderRadius: '8px',
cursor: isProcessing ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s'
}}
>
{isProcessing ? 'Processing Payment...' : `Pay £${amount.toFixed(2)}`}
</button>
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>
<p>🔒 Secure payment powered by Square</p>
<p>Your payment information is encrypted and secure</p>
{squareConfig?.environment === 'sandbox' && (
<p style={{ color: '#ff9800', marginTop: '8px', fontWeight: 'bold' }}>
TEST MODE: Use test card 4111 1111 1111 1111 with any future expiry date
</p>
)}
</div>
</div>
);
};
// Extend Window interface for TypeScript
declare global {
interface Window {
Square: any;
}
}
export default SquarePaymentNew;
+3 -3
View File
@@ -38,10 +38,10 @@ export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ childr
} catch (err: any) { } catch (err: any) {
console.error('Failed to load feature flags:', err); console.error('Failed to load feature flags:', err);
setError('Failed to load feature flags'); setError('Failed to load feature flags');
// Set default flags on error // Set default flags on error - conservative defaults for production
setFlags({ setFlags({
flags: { flags: {
CASH_PAYMENT_ENABLED: true, CASH_PAYMENT_ENABLED: false, // Default to disabled for production safety
EMAIL_NOTIFICATIONS_ENABLED: true, EMAIL_NOTIFICATIONS_ENABLED: true,
EVENT_MANAGEMENT_ENABLED: true, EVENT_MANAGEMENT_ENABLED: true,
AUTO_RENEWAL_ENABLED: false, AUTO_RENEWAL_ENABLED: false,
@@ -50,7 +50,7 @@ export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ childr
ADVANCED_REPORTING_ENABLED: false, ADVANCED_REPORTING_ENABLED: false,
API_RATE_LIMITING_ENABLED: true, 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 { } finally {
setLoading(false); setLoading(false);