2 * @file Invoice Refunds API Routes
3 * @module routes/refunds
5 * Handles invoice refund operations through Stripe. Supports full invoice refunds and
6 * granular line-item refunds with inventory restocking. Restricted to tenant administrators.
9 * - Full: Refund entire invoice amount via Stripe
10 * - Line Item: Refund specific quantities of specific items
13 * - Stripe Connected Account refund processing
14 * - Automatic inventory restocking
15 * - Partial refund support (multiple refunds per invoice)
16 * - Refund history tracking
17 * - Audit trail with user who processed refund
20 * - Tenant admin role required (admin/msp/root)
21 * - Tenant isolation enforced
22 * - Payment validation before refund
23 * - Prevents over-refunding
26 * @requires ../services/db
27 * @requires ../middleware/auth
28 * @requires ../middleware/tenantAdmin
31const express = require('express');
32const router = express.Router();
33const pool = require('../services/db');
35// Initialize Stripe with correct key based on mode
36const stripeMode = process.env.STRIPE_MODE || 'test';
37const stripeSecretKey = stripeMode === 'live'
38 ? process.env.STRIPE_LIVE_SECRET_KEY
39 : process.env.STRIPE_TEST_SECRET_KEY;
40const stripe = require('stripe')(stripeSecretKey);
42console.log(`[Stripe] Refunds initialized in ${stripeMode.toUpperCase()} mode`);
43const authenticateToken = require('../middleware/auth');
44const requireTenantAdmin = require('../middleware/tenantAdmin');
45const { getTenantFilter } = require('../middleware/tenant');
48 * @api {post} /invoices/:id/refund/full Full Invoice Refund
51 * @apiDescription Processes a full refund for a paid invoice through Stripe.
52 * Refunds the entire payment amount, restocks all inventory items, and updates
53 * invoice status to refunded.
56 * 1. Validate invoice is paid and belongs to tenant
57 * 2. Check invoice hasn't already been fully refunded
58 * 3. Get original payment details from Stripe
59 * 4. Create refund through Stripe Connected Account
60 * 5. Restock all inventory items
61 * 6. Update invoice payment_status to 'refunded'
62 * 7. Record refund in database with audit trail
64 * **Stripe Integration:**
65 * - Uses original payment_intent_id
66 * - Processes through Connected Account
67 * - Handles application fees automatically
68 * - Includes metadata for tracking
71 * - Restocks all items with is_stock=true
72 * - Updates product stock_quantity
73 * - Transaction-safe (rolls back on failure)
75 * @apiHeader {string} Authorization Bearer JWT token (tenant admin required)
76 * @apiHeader {string} Content-Type application/json
77 * @apiParam {number} id Invoice ID (URL parameter)
78 * @apiParam {string} [reason] Refund reason (duplicate, fraudulent, requested_by_customer, other)
79 * @apiParam {string} [notes] Internal notes about the refund
81 * @apiSuccess {boolean} success Always true
82 * @apiSuccess {string} message Success message
83 * @apiSuccess {number} refundId Database refund record ID
84 * @apiSuccess {string} stripeRefundId Stripe refund ID (re_xxx)
85 * @apiSuccess {number} amount Refunded amount in cents
86 * @apiSuccess {string} currency Currency code
87 * @apiSuccess {string} status Refund status (succeeded, pending, failed)
89 * @apiError {number} 401 Authentication required
90 * @apiError {number} 403 Tenant admin access required
91 * @apiError {number} 404 Invoice not found or not in tenant
92 * @apiError {number} 400 Invoice not paid / already refunded / invalid state
93 * @apiError {number} 500 Stripe error or database failure
95 * @apiExample {curl} Example Request:
97 * -H "Authorization: Bearer eyJhbGc..." \
98 * -H "Content-Type: application/json" \
99 * -d '{"reason": "requested_by_customer", "notes": "Customer requested full refund due to service issue"}' \
100 * "https://api.everydaytech.au/invoices/123/refund/full"
102 * @apiExample {json} Success Response:
106 * "message": "Full refund processed successfully",
108 * "stripeRefundId": "re_1AbC2DeFgHiJkL3M",
111 * "status": "succeeded",
112 * "itemsRestocked": 3
115router.post('/:id/refund/full', authenticateToken, requireTenantAdmin, async (req, res) => {
116 const invoiceId = req.params.id;
117 const { reason = 'requested_by_customer', notes = '' } = req.body;
118 const userId = req.user?.user_id;
120 const client = await pool.connect();
122 await client.query('BEGIN');
124 // Get invoice with tenant filtering
125 const { clause, params } = getTenantFilter(req, 'c');
126 const invoiceResult = await client.query(`
130 p.amount as payment_amount,
131 p.stripe_payment_intent_id,
133 p.currency as payment_currency
135 LEFT JOIN customers c ON i.customer_id = c.customer_id
136 LEFT JOIN payments p ON i.invoice_id = p.invoice_id AND p.status = 'succeeded'
137 WHERE i.invoice_id = $1 ${clause ? 'AND ' + clause : ''}
139 `, [invoiceId, ...params]);
141 if (invoiceResult.rows.length === 0) {
142 await client.query('ROLLBACK');
143 return res.status(404).json({ error: 'Invoice not found' });
146 const invoice = invoiceResult.rows[0];
148 // Validate invoice can be refunded
149 if (invoice.payment_status !== 'paid') {
150 await client.query('ROLLBACK');
151 return res.status(400).json({
152 error: 'Invoice must be paid to process refund',
153 currentStatus: invoice.payment_status
157 if (invoice.refund_status === 'full') {
158 await client.query('ROLLBACK');
159 return res.status(400).json({ error: 'Invoice already fully refunded' });
162 if (!invoice.stripe_payment_intent_id) {
163 await client.query('ROLLBACK');
164 return res.status(400).json({ error: 'No Stripe payment found for this invoice' });
167 // Calculate refund amount (total paid minus any previous refunds)
168 const alreadyRefunded = parseInt(invoice.amount_refunded || 0);
169 const totalPaid = parseInt(invoice.payment_amount);
170 const refundAmount = totalPaid - alreadyRefunded;
172 if (refundAmount <= 0) {
173 await client.query('ROLLBACK');
174 return res.status(400).json({ error: 'Invoice already fully refunded' });
177 // Process refund through Stripe
178 console.log(`[Refund] Processing full refund for invoice ${invoiceId}: ${refundAmount} cents`);
180 const stripeRefund = await stripe.refunds.create({
181 payment_intent: invoice.stripe_payment_intent_id,
182 amount: refundAmount,
185 invoice_id: invoiceId.toString(),
186 tenant_id: invoice.tenant_id,
188 processed_by: userId?.toString() || 'unknown'
191 stripeAccount: invoice.stripe_account_id
194 console.log(`[Refund] Stripe refund created: ${stripeRefund.id}, status: ${stripeRefund.status}`);
197 const itemsResult = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [invoiceId]);
198 let itemsRestocked = 0;
200 for (const it of itemsResult.rows) {
201 if (!it.product_id) continue;
202 const productResult = await client.query(
203 'SELECT product_id, is_stock FROM products WHERE product_id = $1',
206 if (productResult.rows.length && productResult.rows[0].is_stock) {
208 'UPDATE products SET stock_quantity = stock_quantity + $1 WHERE product_id = $2',
209 [Number(it.quantity || 0), it.product_id]
215 // Create refund record
216 const refundResult = await client.query(`
217 INSERT INTO refunds (
232 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CURRENT_TIMESTAMP)
239 invoice.stripe_account_id,
242 invoice.payment_currency || 'AUD',
254 payment_status = 'refunded',
255 refund_status = 'full',
256 amount_refunded = COALESCE(amount_refunded, 0) + $1,
257 updated_at = CURRENT_TIMESTAMP
258 WHERE invoice_id = $2
259 `, [refundAmount, invoiceId]);
261 // Update payment record
266 amount_refunded = COALESCE(amount_refunded, 0) + $1,
267 refunded_at = CURRENT_TIMESTAMP,
269 updated_at = CURRENT_TIMESTAMP
270 WHERE payment_id = $3
271 `, [refundAmount, reason, invoice.payment_id]);
273 await client.query('COMMIT');
277 message: 'Full refund processed successfully',
278 refundId: refundResult.rows[0].refund_id,
279 stripeRefundId: stripeRefund.id,
280 amount: refundAmount,
281 currency: invoice.payment_currency || 'AUD',
282 status: stripeRefund.status,
287 await client.query('ROLLBACK');
288 console.error('[Refund] Error processing full refund:', err);
290 if (err.type === 'StripeInvalidRequestError') {
291 return res.status(400).json({
292 error: 'Stripe refund failed',
297 res.status(500).json({
298 error: 'Failed to process refund',
307 * @api {post} /invoices/:id/refund/line-items Line Item Refund
308 * @apiName LineItemRefund
310 * @apiDescription Processes a partial refund for specific line items with quantities.
311 * Allows refunding individual items or partial quantities while leaving other items intact.
314 * 1. Validate invoice is paid and belongs to tenant
315 * 2. Calculate total refund amount from line items
316 * 3. Check refund doesn't exceed remaining refundable amount
317 * 4. Create refund through Stripe
318 * 5. Restock specified quantities
319 * 6. Record line item details
320 * 7. Update invoice refund status
322 * @apiHeader {string} Authorization Bearer JWT token (tenant admin required)
323 * @apiHeader {string} Content-Type application/json
324 * @apiParam {number} id Invoice ID (URL parameter)
325 * @apiParam {Object[]} items Line items to refund
326 * @apiParam {number} items.item_id Invoice item ID
327 * @apiParam {number} items.quantity Quantity to refund (can be partial)
328 * @apiParam {string} [reason] Refund reason
329 * @apiParam {string} [notes] Internal notes
331 * @apiSuccess {boolean} success Always true
332 * @apiSuccess {string} message Success message
333 * @apiSuccess {number} refundId Database refund record ID
334 * @apiSuccess {string} stripeRefundId Stripe refund ID
335 * @apiSuccess {number} amount Total refunded amount in cents
336 * @apiSuccess {number} itemsRefunded Number of line items refunded
337 * @apiSuccess {number} itemsRestocked Number of items restocked
339 * @apiError {number} 400 Invalid line items / over-refunding
340 * @apiError {number} 404 Invoice not found
342 * @apiExample {curl} Example Request:
344 * -H "Authorization: Bearer eyJhbGc..." \
345 * -H "Content-Type: application/json" \
346 * -d '{"items": [{"item_id": 45, "quantity": 2}, {"item_id": 46, "quantity": 1}], "reason": "requested_by_customer"}' \
347 * "https://api.everydaytech.au/invoices/123/refund/line-items"
349router.post('/:id/refund/line-items', authenticateToken, requireTenantAdmin, async (req, res) => {
350 const invoiceId = req.params.id;
351 const { items = [], reason = 'requested_by_customer', notes = '' } = req.body;
352 const userId = req.user?.user_id;
354 if (!items || items.length === 0) {
355 return res.status(400).json({ error: 'At least one line item required for refund' });
358 const client = await pool.connect();
360 await client.query('BEGIN');
362 // Get invoice with tenant filtering
363 const { clause, params } = getTenantFilter(req, 'c');
364 const invoiceResult = await client.query(`
368 p.amount as payment_amount,
369 p.stripe_payment_intent_id,
371 p.currency as payment_currency
373 LEFT JOIN customers c ON i.customer_id = c.customer_id
374 LEFT JOIN payments p ON i.invoice_id = p.invoice_id AND p.status = 'succeeded'
375 WHERE i.invoice_id = $1 ${clause ? 'AND ' + clause : ''}
377 `, [invoiceId, ...params]);
379 if (invoiceResult.rows.length === 0) {
380 await client.query('ROLLBACK');
381 return res.status(404).json({ error: 'Invoice not found' });
384 const invoice = invoiceResult.rows[0];
386 if (invoice.payment_status !== 'paid' && invoice.payment_status !== 'refunded') {
387 await client.query('ROLLBACK');
388 return res.status(400).json({
389 error: 'Invoice must be paid to process refund',
390 currentStatus: invoice.payment_status
394 if (!invoice.stripe_payment_intent_id) {
395 await client.query('ROLLBACK');
396 return res.status(400).json({ error: 'No Stripe payment found for this invoice' });
399 // Get all invoice items
400 const invoiceItemsResult = await client.query(
401 'SELECT * FROM invoice_items WHERE invoice_id = $1',
404 const invoiceItemsMap = new Map(invoiceItemsResult.rows.map(it => [it.item_id, it]));
406 // Calculate refund amount and validate items
407 let refundAmountCents = 0;
408 const refundLineItems = [];
410 for (const refundItem of items) {
411 const invoiceItem = invoiceItemsMap.get(refundItem.item_id);
413 await client.query('ROLLBACK');
414 return res.status(400).json({
415 error: `Invoice item ${refundItem.item_id} not found`
419 const quantityToRefund = parseFloat(refundItem.quantity);
420 const originalQuantity = parseFloat(invoiceItem.quantity);
422 if (quantityToRefund <= 0 || quantityToRefund > originalQuantity) {
423 await client.query('ROLLBACK');
424 return res.status(400).json({
425 error: `Invalid quantity for item ${refundItem.item_id}. Must be between 0 and ${originalQuantity}`
429 // Calculate refund amount for this line (unit_price * quantity + 10% GST)
430 const unitPrice = parseFloat(invoiceItem.unit_price);
431 const lineSubtotal = unitPrice * quantityToRefund;
432 const lineGST = lineSubtotal * 0.10;
433 const lineTotal = lineSubtotal + lineGST;
434 const lineTotalCents = Math.round(lineTotal * 100);
436 refundAmountCents += lineTotalCents;
437 refundLineItems.push({
438 item_id: refundItem.item_id,
439 quantity: quantityToRefund,
440 unit_price: unitPrice,
441 amount: lineTotalCents,
442 product_id: invoiceItem.product_id,
443 description: invoiceItem.description
447 // Validate refund amount doesn't exceed remaining refundable amount
448 const alreadyRefunded = parseInt(invoice.amount_refunded || 0);
449 const totalPaid = parseInt(invoice.payment_amount);
450 const remainingRefundable = totalPaid - alreadyRefunded;
452 if (refundAmountCents > remainingRefundable) {
453 await client.query('ROLLBACK');
454 return res.status(400).json({
455 error: 'Refund amount exceeds remaining refundable amount',
456 requested: refundAmountCents,
457 available: remainingRefundable
461 // Process refund through Stripe
462 console.log(`[Refund] Processing line item refund for invoice ${invoiceId}: ${refundAmountCents} cents (${items.length} items)`);
464 const stripeRefund = await stripe.refunds.create({
465 payment_intent: invoice.stripe_payment_intent_id,
466 amount: refundAmountCents,
469 invoice_id:invoiceId.toString(),
470 tenant_id: invoice.tenant_id,
471 refund_type: 'line_item',
472 items_count: items.length.toString(),
473 processed_by: userId?.toString() || 'unknown'
476 stripeAccount: invoice.stripe_account_id
479 console.log(`[Refund] Stripe refund created: ${stripeRefund.id}, status: ${stripeRefund.status}`);
481 // Create refund record
482 const refundResult = await client.query(`
483 INSERT INTO refunds (
498 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CURRENT_TIMESTAMP)
505 invoice.stripe_account_id,
508 invoice.payment_currency || 'AUD',
516 const refundId = refundResult.rows[0].refund_id;
518 // Insert refund line items and restock
519 let itemsRestocked = 0;
520 for (const lineItem of refundLineItems) {
521 // Insert refund line item record
523 INSERT INTO refund_line_items (
531 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
536 lineItem.description,
542 // Restock if product has inventory tracking
543 if (lineItem.product_id) {
544 const productResult = await client.query(
545 'SELECT product_id, is_stock FROM products WHERE product_id = $1',
546 [lineItem.product_id]
548 if (productResult.rows.length && productResult.rows[0].is_stock) {
550 'UPDATE products SET stock_quantity = stock_quantity + $1 WHERE product_id = $2',
551 [lineItem.quantity, lineItem.product_id]
558 // Update invoice refund tracking
559 const newAmountRefunded = alreadyRefunded + refundAmountCents;
560 const fullyRefunded = newAmountRefunded >= totalPaid;
565 payment_status = CASE WHEN $1 THEN 'refunded' ELSE 'partial' END,
566 refund_status = CASE WHEN $1 THEN 'full' ELSE 'partial' END,
567 amount_refunded = $2,
568 updated_at = CURRENT_TIMESTAMP
569 WHERE invoice_id = $3
570 `, [fullyRefunded, newAmountRefunded, invoiceId]);
572 // Update payment record
577 amount_refunded = $2,
578 refunded_at = CURRENT_TIMESTAMP,
580 updated_at = CURRENT_TIMESTAMP
581 WHERE payment_id = $4
582 `, [fullyRefunded, newAmountRefunded, reason, invoice.payment_id]);
584 await client.query('COMMIT');
588 message: 'Line item refund processed successfully',
590 stripeRefundId: stripeRefund.id,
591 amount: refundAmountCents,
592 currency: invoice.payment_currency || 'AUD',
593 status: stripeRefund.status,
594 itemsRefunded: items.length,
599 await client.query('ROLLBACK');
600 console.error('[Refund] Error processing line item refund:', err);
602 if (err.type === 'StripeInvalidRequestError') {
603 return res.status(400).json({
604 error: 'Stripe refund failed',
609 res.status(500).json({
610 error: 'Failed to process refund',
619 * @api {get} /invoices/:id/refunds List Invoice Refunds
620 * @apiName ListRefunds
622 * @apiDescription Lists all refunds for a specific invoice, including full and partial refunds.
624 * @apiHeader {string} Authorization Bearer JWT token
625 * @apiParam {number} id Invoice ID (URL parameter)
627 * @apiSuccess {Object[]} refunds Array of refund records
628 * @apiSuccess {number} refunds.refund_id Refund ID
629 * @apiSuccess {string} refunds.refund_type Type (full, partial, line_item)
630 * @apiSuccess {number} refunds.amount Amount in cents
631 * @apiSuccess {string} refunds.currency Currency code
632 * @apiSuccess {string} refunds.status Status (pending, succeeded, failed, canceled)
633 * @apiSuccess {string} refunds.reason Refund reason
634 * @apiSuccess {string} refunds.notes Internal notes
635 * @apiSuccess {string} refunds.created_at Timestamp
636 * @apiSuccess {string} refunds.processed_by_name User who processed refund
638 * @apiError {number} 404 Invoice not found
640router.get('/:id/refunds', authenticateToken, async (req, res) => {
641 const invoiceId = req.params.id;
644 // Get invoice with tenant filtering
645 const { clause, params } = getTenantFilter(req, 'c');
646 const invoiceCheck = await pool.query(`
649 LEFT JOIN customers c ONi.customer_id = c.customer_id
650 WHERE i.invoice_id = $1 ${clause ? 'AND ' + clause : ''}
651 `, [invoiceId, ...params]);
653 if (invoiceCheck.rows.length === 0) {
654 return res.status(404).json({ error: 'Invoice not found' });
657 // Get all refunds for this invoice
658 const refundsResult = await pool.query(`
661 u.name as processed_by_name,
662 u.email as processed_by_email
664 LEFT JOIN users u ON r.created_by = u.user_id
665 WHERE r.invoice_id = $1
666 ORDER BY r.created_at DESC
669 res.json({ refunds: refundsResult.rows });
672 console.error('[Refund] Error listing refunds:', err);
673 res.status(500).json({ error: 'Failed to retrieve refunds' });
677module.exports = router;