2 * @file routes/public-invoice-payment.js
3 * @module routes/public-invoice-payment
4 * @description Public invoice payment endpoints that don't require authentication.
5 * Allows customers to pay invoices via secure tokenized links sent by email.
8 * - No authentication required (uses payment_token instead)
9 * - Tokens are cryptographically secure (64-char hex, 256-bit entropy)
10 * - One-time use recommended (can generate new token after payment)
11 * - Rate limiting should be applied at reverse proxy level
14 * 1. Customer receives email with payment link: /pay/{token}
15 * 2. Frontend fetches invoice details via GET /api/public/pay/:token
16 * 3. Customer enters payment details (Stripe Elements)
17 * 4. Frontend creates payment intent via POST /api/public/pay/:token/create-payment
18 * 5. Stripe confirms payment
19 * 6. Frontend confirms payment via POST /api/public/pay/:token/confirm-payment
24 * @requires ../services/db
25 * @requires ../services/stripeService
28const express = require('express');
29const router = express.Router();
30const crypto = require('crypto');
31const pool = require('../services/db');
33// Initialize Stripe with correct key based on mode
34const stripeMode = process.env.STRIPE_MODE || 'test';
35const stripeSecretKey = stripeMode === 'live'
36 ? process.env.STRIPE_LIVE_SECRET_KEY
37 : process.env.STRIPE_TEST_SECRET_KEY;
38const stripe = require('stripe')(stripeSecretKey);
40console.log(`[Stripe] Public Invoice Payment initialized in ${stripeMode.toUpperCase()} mode`);
43 * @api {get} /public/pay/:token Get Invoice by Payment Token
44 * @apiName GetPublicInvoice
45 * @apiGroup PublicPayment
46 * @apiDescription Fetches invoice details using a secure payment token.
47 * No authentication required. Returns invoice data needed for payment page.
49 * @apiParam {string} token 64-character hex payment token from email link
51 * @apiSuccess {Object} invoice Invoice object
52 * @apiSuccess {number} invoice.invoice_id Invoice ID
53 * @apiSuccess {string} invoice.currency Currency code (AUD, USD, etc.)
54 * @apiSuccess {number} invoice.subtotal Subtotal amount (ex tax)
55 * @apiSuccess {number} invoice.tax_rate Tax rate percentage
56 * @apiSuccess {number} invoice.tax_amount Tax amount
57 * @apiSuccess {number} invoice.total Total amount (inc tax)
58 * @apiSuccess {string} invoice.payment_status Payment status (unpaid, paid, etc.)
59 * @apiSuccess {string} invoice.status Invoice status (draft, sent, paid, void)
60 * @apiSuccess {string} invoice.customer_name Customer name
61 * @apiSuccess {Object[]} invoice.items Line items array
62 * @apiSuccess {boolean} canPay Whether invoice can be paid (not void/paid)
64 * @apiError {Number} 404 Invoice not found or token invalid
65 * @apiError {Number} 400 Invoice already paid or voided
67router.get('/pay/:token', async (req, res) => {
68 const { token } = req.params;
71 // Validate token format (64 hex chars)
72 if (!/^[0-9a-f]{64}$/i.test(token)) {
73 return res.status(400).json({ error: 'Invalid payment token format' });
76 // Fetch invoice by payment token
77 const result = await pool.query(`
94 c.name as customer_name,
95 c.email as customer_email,
96 c.phone as customer_phone,
97 c.billing_address as customer_address
99 LEFT JOIN customers c ON i.customer_id = c.customer_id
100 WHERE i.payment_token = $1
103 if (result.rows.length === 0) {
104 return res.status(404).json({ error: 'Invoice not found' });
107 const invoice = result.rows[0];
109 // Check if invoice can be paid
110 if (invoice.status === 'void') {
111 return res.status(400).json({ error: 'This invoice has been voided and cannot be paid' });
114 if (invoice.payment_status === 'paid') {
115 return res.status(400).json({
116 error: 'This invoice has already been paid',
122 const itemsResult = await pool.query(
123 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_id',
127 invoice.items = itemsResult.rows;
128 invoice.canPay = invoice.status !== 'void' && invoice.payment_status !== 'paid';
130 // Don't expose sensitive customer data beyond what's needed
131 const publicInvoice = {
132 invoice_id: invoice.invoice_id,
133 currency: invoice.currency,
134 subtotal: invoice.subtotal,
135 tax_rate: invoice.tax_rate,
136 tax_amount: invoice.tax_amount,
137 total: invoice.total,
138 amount: invoice.amount,
139 status: invoice.status,
140 payment_status: invoice.payment_status,
141 issued_date: invoice.issued_date,
142 due_date: invoice.due_date,
143 description: invoice.description,
144 customer_name: invoice.customer_name,
145 items: invoice.items,
146 canPay: invoice.canPay
149 res.json(publicInvoice);
152 console.error('Error fetching public invoice:', error);
153 res.status(500).json({ error: 'Server error', details: error.message });
158 * @api {post} /public/pay/:token/create-payment Create Payment Intent (Public)
159 * @apiName CreatePublicPaymentIntent
160 * @apiGroup PublicPayment
161 * @apiDescription Creates a Stripe payment intent for public invoice payment.
162 * No authentication required - uses payment token for security.
164router.post('/pay/:token/create-payment', async (req, res) => {
165 const { token } = req.params;
168 // Validate token format
169 if (!/^[0-9a-f]{64}$/i.test(token)) {
170 return res.status(400).json({ error: 'Invalid payment token format' });
174 const result = await pool.query(`
185 c.name as customer_name,
188 LEFT JOIN customers c ON i.customer_id = c.customer_id
189 LEFT JOIN stripe_config sc ON i.tenant_id = sc.tenant_id
190 WHERE i.payment_token = $1
193 if (result.rows.length === 0) {
194 return res.status(404).json({ error: 'Invoice not found' });
197 const invoice = result.rows[0];
199 // Validate invoice can be paid
200 if (invoice.status === 'void') {
201 return res.status(400).json({ error: 'Invoice has been voided' });
204 if (invoice.payment_status === 'paid') {
205 return res.status(400).json({ error: 'Invoice already paid' });
208 // Get Stripe connected account
209 const stripeAccountId = invoice.stripe_account_id;
210 if (!stripeAccountId) {
211 return res.status(400).json({ error: 'Payment processing not configured for this invoice' });
214 // Calculate amount in cents
215 const amountInCents = Math.round(parseFloat(invoice.total || invoice.amount || 0) * 100);
217 if (amountInCents <= 0) {
218 return res.status(400).json({ error: 'Invalid invoice amount' });
221 // Create payment intent on connected account
222 const paymentIntent = await stripe.paymentIntents.create({
223 amount: amountInCents,
224 currency: (invoice.currency || 'AUD').toLowerCase(),
225 description: `Invoice #${invoice.invoice_id}${invoice.description ? ' - ' + invoice.description : ''}`,
227 invoice_id: invoice.invoice_id.toString(),
228 customer_id: invoice.customer_id?.toString() || '',
229 tenant_id: invoice.tenant_id,
230 customer_name: invoice.customer_name || '',
231 payment_type: 'public_invoice_payment'
233 application_fee_amount: Math.round(amountInCents * 0.03), // 3% platform fee
235 stripeAccount: stripeAccountId,
239 clientSecret: paymentIntent.client_secret,
240 publishableKey: process.env.STRIPE_TEST_PUBLISHABLE_KEY,
241 stripeAccount: stripeAccountId,
242 amount: amountInCents,
243 currency: (invoice.currency || 'AUD').toLowerCase(),
247 console.error('Error creating public payment intent:', error);
248 res.status(500).json({ error: error.message || 'Failed to create payment intent' });
253 * @api {post} /public/pay/:token/confirm-payment Confirm Payment (Public)
254 * @apiName ConfirmPublicPayment
255 * @apiGroup PublicPayment
256 * @apiDescription Confirms successful payment and updates invoice status.
257 * Called after Stripe payment succeeds on the frontend.
259router.post('/pay/:token/confirm-payment', async (req, res) => {
260 const { token } = req.params;
261 const { paymentIntentId } = req.body;
263 const client = await pool.connect();
267 if (!/^[0-9a-f]{64}$/i.test(token)) {
268 return res.status(400).json({ error: 'Invalid payment token format' });
271 if (!paymentIntentId) {
272 return res.status(400).json({ error: 'Payment intent ID required' });
275 await client.query('BEGIN');
278 const invoiceResult = await client.query(`
290 LEFT JOIN stripe_config sc ON i.tenant_id = sc.tenant_id
291 WHERE i.payment_token = $1
295 if (invoiceResult.rows.length === 0) {
296 await client.query('ROLLBACK');
297 return res.status(404).json({ error: 'Invoice not found' });
300 const invoice = invoiceResult.rows[0];
302 // Retrieve payment intent from Stripe
303 const paymentIntent = await stripe.paymentIntents.retrieve(
305 { stripeAccount: invoice.stripe_account_id }
308 if (paymentIntent.status !== 'succeeded') {
309 await client.query('ROLLBACK');
310 return res.status(400).json({ error: 'Payment not completed' });
313 // Update invoice status
318 payment_status = 'paid',
319 payment_date = CURRENT_TIMESTAMP,
320 updated_at = CURRENT_TIMESTAMP
321 WHERE invoice_id = $1
322 `, [invoice.invoice_id]);
326 INSERT INTO payments (
327 invoice_id, customer_id, tenant_id,
328 amount, currency, status,
329 stripe_payment_intent_id, stripe_account_id,
330 payment_method_type, card_brand, card_last4,
331 application_fee_amount, net_amount,
332 created_at, updated_at
333 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
338 paymentIntent.amount,
339 paymentIntent.currency,
340 paymentIntent.status,
342 invoice.stripe_account_id,
343 paymentIntent.payment_method_types?.[0] || 'card',
344 paymentIntent.charges?.data?.[0]?.payment_method_details?.card?.brand || null,
345 paymentIntent.charges?.data?.[0]?.payment_method_details?.card?.last4 || null,
346 paymentIntent.application_fee_amount,
347 paymentIntent.amount - (paymentIntent.application_fee_amount || 0)
350 await client.query('COMMIT');
354 message: 'Payment confirmed',
355 invoice_id: invoice.invoice_id
359 await client.query('ROLLBACK');
360 console.error('Error confirming public payment:', error);
361 res.status(500).json({ error: error.message || 'Failed to confirm payment' });
367module.exports = router;