1import { useEffect, useState } from 'react';
2import { useParams } from 'react-router-dom';
3import { loadStripe } from '@stripe/stripe-js';
4import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
5import './PublicInvoicePayment.css';
8 * Public Invoice Payment Page
10 * Allows customers to pay invoices via secure tokenized links without logging in.
11 * Used for email-based invoice payments.
14 * No authentication required - security via cryptographically secure payment tokens.
17function PaymentForm({ invoice, token, onSuccess, onError }) {
18 const stripe = useStripe();
19 const elements = useElements();
20 const [processing, setProcessing] = useState(false);
22 const handleSubmit = async (e) => {
25 if (!stripe || !elements) {
32 // Confirm payment with Stripe
33 const { error: submitError } = await elements.submit();
35 throw new Error(submitError.message);
38 const { error, paymentIntent } = await stripe.confirmPayment({
41 return_url: `${window.location.origin}/pay/success`,
43 redirect: 'if_required',
47 throw new Error(error.message);
50 if (paymentIntent && paymentIntent.status === 'succeeded') {
51 // Confirm payment with backend
52 const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app/api'}/public/pay/${token}/confirm-payment`, {
54 headers: { 'Content-Type': 'application/json' },
55 body: JSON.stringify({ paymentIntentId: paymentIntent.id }),
59 const data = await response.json();
60 throw new Error(data.error || 'Failed to confirm payment');
66 console.error('Payment error:', err);
73 <form onSubmit={handleSubmit} className="payment-form">
77 disabled={!stripe || processing}
78 className="submit-payment-btn"
80 {processing ? 'Processing...' : `Pay ${invoice.currency} $${parseFloat(invoice.total).toFixed(2)}`}
86export default function PublicInvoicePayment() {
87 const { token } = useParams();
88 const [invoice, setInvoice] = useState(null);
89 const [loading, setLoading] = useState(true);
90 const [error, setError] = useState('');
91 const [clientSecret, setClientSecret] = useState('');
92 const [publishableKey, setPublishableKey] = useState('');
93 const [stripeAccount, setStripeAccount] = useState('');
94 const [stripePromise, setStripePromise] = useState(null);
95 const [paymentSuccess, setPaymentSuccess] = useState(false);
98 // Fetch invoice details
99 const fetchInvoice = async () => {
101 const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app/api'}/public/pay/${token}`);
104 const data = await response.json();
105 if (data.alreadyPaid) {
106 setError('This invoice has already been paid. Thank you!');
108 setError(data.error || 'Failed to load invoice');
114 const invoiceData = await response.json();
115 setInvoice(invoiceData);
117 if (!invoiceData.canPay) {
118 setError('This invoice cannot be paid at this time');
123 // Create payment intent
124 const paymentResponse = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app/api'}/public/pay/${token}/create-payment`, {
126 headers: { 'Content-Type': 'application/json' },
129 if (!paymentResponse.ok) {
130 const data = await paymentResponse.json();
131 throw new Error(data.error || 'Failed to initialize payment');
134 const paymentData = await paymentResponse.json();
135 setClientSecret(paymentData.clientSecret);
136 setPublishableKey(paymentData.publishableKey);
137 setStripeAccount(paymentData.stripeAccount);
140 const stripe = await loadStripe(paymentData.publishableKey, {
141 stripeAccount: paymentData.stripeAccount,
143 setStripePromise(stripe);
147 console.error('Error loading invoice:', err);
148 setError(err.message || 'Failed to load payment page');
158 const handlePaymentSuccess = () => {
159 setPaymentSuccess(true);
162 const handlePaymentError = (errorMessage) => {
163 setError(errorMessage);
168 <div className="public-payment-page">
169 <div className="payment-container">
170 <div className="loading-spinner">
171 <div className="spinner"></div>
172 <p>Loading invoice...</p>
179 if (paymentSuccess) {
181 <div className="public-payment-page">
182 <div className="payment-container">
183 <div className="success-message">
184 <div className="success-icon">✓</div>
185 <h1>Payment Successful!</h1>
186 <p>Thank you for your payment.</p>
187 <p className="invoice-ref">Invoice #{invoice.invoice_id}</p>
188 <p className="amount-paid">
189 Paid: {invoice.currency} ${parseFloat(invoice.total).toFixed(2)}
191 <p className="receipt-info">
192 A receipt will be sent to your email shortly.
202 <div className="public-payment-page">
203 <div className="payment-container">
204 <div className="error-message">
205 <h1>Unable to Process Payment</h1>
214 <div className="public-payment-page">
215 <div className="payment-container">
216 <div className="invoice-summary">
217 <h1>Invoice Payment</h1>
218 <div className="invoice-details">
219 <div className="detail-row">
220 <span className="label">Invoice #:</span>
221 <span className="value">{invoice.invoice_id}</span>
223 <div className="detail-row">
224 <span className="label">Customer:</span>
225 <span className="value">{invoice.customer_name}</span>
227 {invoice.description && (
228 <div className="detail-row">
229 <span className="label">Description:</span>
230 <span className="value">{invoice.description}</span>
236 {invoice.items && invoice.items.length > 0 && (
237 <div className="invoice-items">
249 {invoice.items.map((item, idx) => (
251 <td>{item.description}</td>
252 <td>{item.quantity}</td>
253 <td>{invoice.currency} ${parseFloat(item.unit_price).toFixed(2)}</td>
254 <td>{invoice.currency} ${parseFloat(item.total).toFixed(2)}</td>
263 <div className="invoice-totals">
264 <div className="total-row">
265 <span>Subtotal (ex GST):</span>
266 <span>{invoice.currency} ${parseFloat(invoice.subtotal || 0).toFixed(2)}</span>
268 <div className="total-row">
269 <span>GST ({parseFloat(invoice.tax_rate || 10).toFixed(0)}%):</span>
270 <span>{invoice.currency} ${parseFloat(invoice.tax_amount || 0).toFixed(2)}</span>
272 <div className="total-row total-amount">
273 <span>TOTAL (inc GST):</span>
274 <span>{invoice.currency} ${parseFloat(invoice.total).toFixed(2)}</span>
280 {clientSecret && stripePromise && (
281 <div className="payment-section">
282 <h2>Payment Details</h2>
284 stripe={stripePromise}
290 colorPrimary: '#2563eb',
298 onSuccess={handlePaymentSuccess}
299 onError={handlePaymentError}
302 <div className="secure-badge">
303 🔒 Secure payment powered by Stripe