EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
PublicInvoicePayment.jsx
Go to the documentation of this file.
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';
6
7/**
8 * Public Invoice Payment Page
9 *
10 * Allows customers to pay invoices via secure tokenized links without logging in.
11 * Used for email-based invoice payments.
12 *
13 * URL: /pay/:token
14 * No authentication required - security via cryptographically secure payment tokens.
15 */
16
17function PaymentForm({ invoice, token, onSuccess, onError }) {
18 const stripe = useStripe();
19 const elements = useElements();
20 const [processing, setProcessing] = useState(false);
21
22 const handleSubmit = async (e) => {
23 e.preventDefault();
24
25 if (!stripe || !elements) {
26 return;
27 }
28
29 setProcessing(true);
30
31 try {
32 // Confirm payment with Stripe
33 const { error: submitError } = await elements.submit();
34 if (submitError) {
35 throw new Error(submitError.message);
36 }
37
38 const { error, paymentIntent } = await stripe.confirmPayment({
39 elements,
40 confirmParams: {
41 return_url: `${window.location.origin}/pay/success`,
42 },
43 redirect: 'if_required',
44 });
45
46 if (error) {
47 throw new Error(error.message);
48 }
49
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`, {
53 method: 'POST',
54 headers: { 'Content-Type': 'application/json' },
55 body: JSON.stringify({ paymentIntentId: paymentIntent.id }),
56 });
57
58 if (!response.ok) {
59 const data = await response.json();
60 throw new Error(data.error || 'Failed to confirm payment');
61 }
62
63 onSuccess();
64 }
65 } catch (err) {
66 console.error('Payment error:', err);
67 onError(err.message);
68 setProcessing(false);
69 }
70 };
71
72 return (
73 <form onSubmit={handleSubmit} className="payment-form">
74 <PaymentElement />
75 <button
76 type="submit"
77 disabled={!stripe || processing}
78 className="submit-payment-btn"
79 >
80 {processing ? 'Processing...' : `Pay ${invoice.currency} $${parseFloat(invoice.total).toFixed(2)}`}
81 </button>
82 </form>
83 );
84}
85
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);
96
97 useEffect(() => {
98 // Fetch invoice details
99 const fetchInvoice = async () => {
100 try {
101 const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app/api'}/public/pay/${token}`);
102
103 if (!response.ok) {
104 const data = await response.json();
105 if (data.alreadyPaid) {
106 setError('This invoice has already been paid. Thank you!');
107 } else {
108 setError(data.error || 'Failed to load invoice');
109 }
110 setLoading(false);
111 return;
112 }
113
114 const invoiceData = await response.json();
115 setInvoice(invoiceData);
116
117 if (!invoiceData.canPay) {
118 setError('This invoice cannot be paid at this time');
119 setLoading(false);
120 return;
121 }
122
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`, {
125 method: 'POST',
126 headers: { 'Content-Type': 'application/json' },
127 });
128
129 if (!paymentResponse.ok) {
130 const data = await paymentResponse.json();
131 throw new Error(data.error || 'Failed to initialize payment');
132 }
133
134 const paymentData = await paymentResponse.json();
135 setClientSecret(paymentData.clientSecret);
136 setPublishableKey(paymentData.publishableKey);
137 setStripeAccount(paymentData.stripeAccount);
138
139 // Load Stripe
140 const stripe = await loadStripe(paymentData.publishableKey, {
141 stripeAccount: paymentData.stripeAccount,
142 });
143 setStripePromise(stripe);
144 setLoading(false);
145
146 } catch (err) {
147 console.error('Error loading invoice:', err);
148 setError(err.message || 'Failed to load payment page');
149 setLoading(false);
150 }
151 };
152
153 if (token) {
154 fetchInvoice();
155 }
156 }, [token]);
157
158 const handlePaymentSuccess = () => {
159 setPaymentSuccess(true);
160 };
161
162 const handlePaymentError = (errorMessage) => {
163 setError(errorMessage);
164 };
165
166 if (loading) {
167 return (
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>
173 </div>
174 </div>
175 </div>
176 );
177 }
178
179 if (paymentSuccess) {
180 return (
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)}
190 </p>
191 <p className="receipt-info">
192 A receipt will be sent to your email shortly.
193 </p>
194 </div>
195 </div>
196 </div>
197 );
198 }
199
200 if (error) {
201 return (
202 <div className="public-payment-page">
203 <div className="payment-container">
204 <div className="error-message">
205 <h1>Unable to Process Payment</h1>
206 <p>{error}</p>
207 </div>
208 </div>
209 </div>
210 );
211 }
212
213 return (
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>
222 </div>
223 <div className="detail-row">
224 <span className="label">Customer:</span>
225 <span className="value">{invoice.customer_name}</span>
226 </div>
227 {invoice.description && (
228 <div className="detail-row">
229 <span className="label">Description:</span>
230 <span className="value">{invoice.description}</span>
231 </div>
232 )}
233 </div>
234
235 {/* Line Items */}
236 {invoice.items && invoice.items.length > 0 && (
237 <div className="invoice-items">
238 <h3>Items</h3>
239 <table>
240 <thead>
241 <tr>
242 <th>Description</th>
243 <th>Qty</th>
244 <th>Price</th>
245 <th>Total</th>
246 </tr>
247 </thead>
248 <tbody>
249 {invoice.items.map((item, idx) => (
250 <tr key={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>
255 </tr>
256 ))}
257 </tbody>
258 </table>
259 </div>
260 )}
261
262 {/* Totals */}
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>
267 </div>
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>
271 </div>
272 <div className="total-row total-amount">
273 <span>TOTAL (inc GST):</span>
274 <span>{invoice.currency} ${parseFloat(invoice.total).toFixed(2)}</span>
275 </div>
276 </div>
277 </div>
278
279 {/* Payment Form */}
280 {clientSecret && stripePromise && (
281 <div className="payment-section">
282 <h2>Payment Details</h2>
283 <Elements
284 stripe={stripePromise}
285 options={{
286 clientSecret,
287 appearance: {
288 theme: 'stripe',
289 variables: {
290 colorPrimary: '#2563eb',
291 },
292 },
293 }}
294 >
295 <PaymentForm
296 invoice={invoice}
297 token={token}
298 onSuccess={handlePaymentSuccess}
299 onError={handlePaymentError}
300 />
301 </Elements>
302 <div className="secure-badge">
303 🔒 Secure payment powered by Stripe
304 </div>
305 </div>
306 )}
307 </div>
308 </div>
309 );
310}