Overview
Customers can now pay invoices without logging in using secure tokenized payment links sent via email.
How It Works
1. Payment Token Generation
- Every invoice automatically gets a unique 64-character payment token
- Tokens are cryptographically secure (256-bit entropy via crypto.randomBytes)
- Tokens are stored in invoices.payment_token column
2. Payment Flow
1. Send invoice email to customer with payment link
↓
2. Customer clicks link → /pay/{token}
↓
3. Public payment page loads (no login required)
↓
4. Customer enters payment details
↓
5. Stripe processes payment on connected account
↓
6. Invoice marked as paid, payment recorded
3. Security Features
- ✅ No authentication required - token-based access
- ✅ Tokens are unique per invoice
- ✅ Cannot guess tokens (2^256 combinations)
- ✅ Automatic validation (invoice exists, can be paid)
- ✅ Prevents payment of void/already-paid invoices
Payment Link Format
https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/{payment_token}
Example:
https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/b7b0249982974e6e24e492e9c7f628c0f16163ae24c73c4c22c4bc9e34c72615
API Endpoints
1. GET /api/public/pay/:token
Fetches invoice details for payment page
Response:
{
"invoice_id": 502,
"currency": "AUD",
"subtotal": 75.00,
"tax_rate": 10.00,
"tax_amount": 7.50,
"total": 82.50,
"payment_status": "unpaid",
"status": "sent",
"customer_name": "The Vacuum Shop",
"items": [...],
"canPay": true
}
2. POST /api/public/pay/:token/create-payment
Creates Stripe payment intent
Response:
{
"clientSecret": "pi_xxx_secret_yyy",
"publishableKey": "pk_test_xxx",
"stripeAccount": "acct_xxx",
"amount": 8250,
"currency": "aud"
}
3. POST /api/public/pay/:token/confirm-payment
Confirms successful payment
Request:
{
"paymentIntentId": "pi_xxx"
}
Response:
{
"success": true,
"message": "Payment confirmed",
"invoice_id": 502
}
Database Schema
ALTER TABLE invoices ADD COLUMN payment_token VARCHAR(64) UNIQUE;
CREATE INDEX idx_invoices_payment_token ON invoices(payment_token) WHERE payment_token IS NOT NULL;
Usage Examples
Get Payment Token for Invoice
const invoice = await pool.query(
'SELECT invoice_id, payment_token FROM invoices WHERE invoice_id = $1',
[invoiceId]
);
const paymentUrl = `https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/${invoice.rows[0].payment_token}`;
Email Template (HTML)
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
.invoice-email { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #2563eb; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.pay-button {
display: inline-block;
background: #10b981;
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
margin: 20px 0;
}
.invoice-details { margin: 20px 0; }
.total { font-size: 24px; font-weight: bold; color: #1a202c; }
</style>
</head>
<body>
<div class="invoice-email">
<div class="header">
<h1>Invoice #${invoice.invoice_id}</h1>
</div>
<div class="content">
<p>Dear ${customer.name},</p>
<p>Thank you for your business. Please find your invoice details below:</p>
<div class="invoice-details">
<p><strong>Invoice Number:</strong> #${invoice.invoice_id}</p>
<p><strong>Issue Date:</strong> ${invoice.issued_date}</p>
<p><strong>Due Date:</strong> ${invoice.due_date}</p>
<hr>
<p><strong>Subtotal (ex GST):</strong> ${invoice.currency} $${invoice.subtotal}</p>
<p><strong>GST (${invoice.tax_rate}%):</strong> ${invoice.currency} $${invoice.tax_amount}</p>
<p class="total"><strong>Total:</strong> ${invoice.currency} $${invoice.total}</p>
</div>
<center>
<a href="${paymentUrl}" class="pay-button">Pay Invoice Online</a>
</center>
<p style="font-size: 12px; color: #64748b; margin-top: 30px;">
This payment link is secure and expires when the invoice is paid.
</p>
</div>
</div>
</body>
</html>
Sample Payment Links (Test)
Invoice #502 - The Vacuum Shop - AUD $82.50
https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/b7b0249982974e6e24e492e9c7f628c0f16163ae24c73c4c22c4bc9e34c72615
Invoice #501 - The Vacuum Shop - AUD $165.00
https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/0afaa6a843a66491f15cc2eb7e5f96c5cc6cb316ff0c8e03a9e2bc6b3a661038
Invoice #500 - The Vacuum Shop - AUD $550.00
https://rmm-psa-dashboard-5jyun.ondigitalocean.app/pay/74aa23f60234 0c2a3a0cefa9b82480786fa60283ce4b32fb0cb34f968c62fc96
Testing
Use Stripe test cards:
- Success: 4242 4242 4242 4242 (any future date, any CVC)
- Decline: 4000 0000 0000 0002
- 3D Secure: 4000 0025 0000 3155
Token Regeneration
const crypto = require('crypto');
// Generate new token for invoice
const newToken = crypto.randomBytes(32).toString('hex');
await pool.query(
'UPDATE invoices SET payment_token = $1 WHERE invoice_id = $2',
[newToken, invoiceId]
);
Migration Status
- ✅ All 502 invoices have payment tokens
- ✅ New invoices auto-generate tokens on creation
- ✅ Public payment endpoints deployed
- ✅ Payment page UI deployed
- ✅ Stripe integration configured for AUD
- ✅ GST calculations included
Next Steps (Optional)
- Email Integration: Build email sender for invoices
- PDF Attachments: Attach invoice PDF to emails
- Payment Receipts: Auto-email receipt after payment
- Token Expiry: Add token expiration (e.g., 30 days)
- Payment Reminders: Schedule reminder emails for overdue invoices
- Payment Plans: Allow partial payments with payment schedules
Deployment Date: March 17, 2026
Backend: https://rmm-psa-backend-t9f7k.ondigitalocean.app
Frontend: https://rmm-psa-dashboard-5jyun.ondigitalocean.app
Status: ✅ LIVE