diff --git a/admin/database_updates/2.4.9.php b/admin/database_updates/2.4.9.php new file mode 100644 index 00000000..8d896664 --- /dev/null +++ b/admin/database_updates/2.4.9.php @@ -0,0 +1,16 @@ + - - + "> + + Refund + + - + + 0) { ?> + + + + + + +
+ + + + + + +
+ + $refundable_cents) { + flashAlert("Refund can not be more than the " . numfmt_format_currency($currency_format, $refundable_amount, $payment_currency_code) . " remaining on this payment", 'error'); + redirect(); + } + + $amount = round($amount, 2); + $stripe_pi_id = getStripePaymentIntentId($original_reference); + $refund_reference = $reference; + $stripe_refunded = false; + + // Refund through Stripe when the payment came in through Stripe and the agent asked for it + if ($refund_stripe && $stripe_pi_id) { + + $stripe_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1")); + $provider_private_key = $stripe_provider['payment_provider_private_key'] ?? ''; + + if (empty($provider_private_key)) { + flashAlert("Stripe is not configured - refund not issued", 'error'); + redirect(); + } + + require_once __DIR__ . '/../../includes/stripe_init.php'; + $stripe = new \Stripe\StripeClient($provider_private_key); + + try { + // The idempotency key is minted once per rendered modal, so a double submit + // of the same form returns the original refund instead of sending the money twice + $stripe_refund = $stripe->refunds->create( + [ + 'payment_intent' => $stripe_pi_id, + 'amount' => intval(round($amount * 100)), // Stripe expects cents + 'metadata' => [ + 'itflow_client_id' => $client_id, + 'itflow_invoice_id' => $invoice_id, + 'itflow_invoice_number' => $invoice_prefix . $invoice_number, + 'itflow_payment_id' => $payment_id, + ] + ], + $idempotency_key ? ['idempotency_key' => "itflow_refund_$idempotency_key"] : [] + ); + } catch (Exception $e) { + $error = $e->getMessage(); + error_log("Stripe refund error - payment ID $payment_id / $stripe_pi_id on invoice $invoice_prefix$invoice_number: $error"); + logApp("Stripe", "error", "Refund failed for payment ID $payment_id ($stripe_pi_id): $error"); + flashAlert("Stripe refund failed: " . escapeHtml($error) . " - nothing was recorded", 'error'); + redirect(); + } + + if ($stripe_refund->status === 'failed' || $stripe_refund->status === 'canceled') { + logApp("Stripe", "error", "Refund for payment ID $payment_id ($stripe_pi_id) came back as {$stripe_refund->status}"); + flashAlert("Stripe reported the refund as {$stripe_refund->status} - nothing was recorded", 'error'); + redirect(); + } + + $stripe_refund_id = escapeSql($stripe_refund->id); + $refund_reference = "Stripe Refund - $stripe_refund_id"; + $stripe_refunded = true; + + // An idempotent replay hands back the same refund object - do not book it twice + $sql_existing = mysqli_query($mysqli, "SELECT payment_id FROM payments WHERE payment_reference = '$refund_reference' LIMIT 1"); + if (mysqli_num_rows($sql_existing) > 0) { + flashAlert("That refund has already been recorded", 'error'); + redirect(); + } + + } + + // Refunds are stored as a negative payment - every SUM(payment_amount) in the app + // then reports the right invoice balance and account balance with no other changes + $refund_amount_signed = -1 * $amount; + + mysqli_query($mysqli, "INSERT INTO payments SET + payment_date = '$date', + payment_amount = $refund_amount_signed, + payment_currency_code = '$payment_currency_code', + payment_account_id = $account, + payment_method = '$payment_method', + payment_reference = '$refund_reference', + payment_invoice_id = $invoice_id, + payment_refund_of_id = $payment_id" + ); + + $invoice_status = updateInvoiceStatusFromPayments($invoice_id); + + $refund_type = ($amount_cents === $refundable_cents && $refundable_cents === (int) round($payment_amount * 100)) ? 'Refund' : 'Partial refund'; + $refund_channel = $stripe_refunded ? ' via Stripe' : ''; + + mysqli_query($mysqli, "INSERT INTO history SET history_status = '$invoice_status', history_description = '$refund_type issued$refund_channel', history_invoice_id = $invoice_id"); + + logAudit("Invoice", "Refund", "$refund_type of " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . " issued$refund_channel against payment ID $payment_id on invoice $invoice_prefix$invoice_number", $client_id, $invoice_id); + + // Email the client a refund notification + if ($email_receipt == 1 && !empty($config_smtp_provider) && !empty($contact_email)) { + + $sql = mysqli_query($mysqli, "SELECT * FROM companies WHERE company_id = 1"); + $row = mysqli_fetch_assoc($sql); + $company_name = escapeSql($row['company_name']); + $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); + + $config_invoice_from_name = escapeSql($config_invoice_from_name); + $config_invoice_from_email = escapeSql($config_invoice_from_email); + + $subject = "Refund Issued - Invoice $invoice_prefix$invoice_number"; + $body = "Hello $contact_name,

A refund of " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . " has been issued against invoice $invoice_prefix$invoice_number.

Refund Amount: " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . "
Original Payment Method: $payment_method

Card refunds usually appear on your statement within 5-10 business days.


--
$company_name - Billing Department
$config_invoice_from_email
$company_phone"; + + $email_data = [ + [ + 'from' => $config_invoice_from_email, + 'from_name' => $config_invoice_from_name, + 'recipient' => $contact_email, + 'recipient_name' => $contact_name, + 'subject' => $subject, + 'body' => $body + ] + ]; + + addToMailQueue($email_data); + + $email_id = mysqli_insert_id($mysqli); + + mysqli_query($mysqli, "INSERT INTO history SET history_status = '$invoice_status', history_description = 'Refund notification sent to mail queue ID: $email_id!', history_invoice_id = $invoice_id"); + logAudit("Invoice", "Refund", "Refund notification for invoice $invoice_prefix$invoice_number queued to $contact_email Email ID: $email_id", $client_id, $invoice_id); + + } + + flashAlert("$refund_type of " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . " issued$refund_channel"); + + redirect(); + +} + /* Apply Credit Not ready for use 2025-08-27 - JQ @@ -649,49 +844,42 @@ if (isset($_GET['delete_payment'])) { $payment_id = intval($_GET['delete_payment']); - $sql = mysqli_query($mysqli,"SELECT * FROM payments WHERE payment_id = $payment_id"); + // payments has no client column - the client comes from the invoice the payment sits on + $sql = mysqli_query($mysqli,"SELECT * FROM payments + LEFT JOIN invoices ON payment_invoice_id = invoice_id + WHERE payment_id = $payment_id + LIMIT 1" + ); $row = mysqli_fetch_assoc($sql); $invoice_id = intval($row['payment_invoice_id']); - $deleted_payment_amount = floatval($row['payment_amount']); - $client_id = intval($row['payment_client_id']); + $payment_is_refund = !is_null($row['payment_refund_of_id']); + $invoice_prefix = escapeSql($row['invoice_prefix']); + $invoice_number = intval($row['invoice_number']); + $client_id = intval($row['invoice_client_id']); enforceClientAccess(); - //Add up all the payments for the invoice and get the total amount paid to the invoice - $sql_total_payments_amount = mysqli_query($mysqli,"SELECT SUM(payment_amount) AS total_payments_amount FROM payments WHERE payment_invoice_id = $invoice_id"); - $row = mysqli_fetch_assoc($sql_total_payments_amount); - $total_payments_amount = floatval($row['total_payments_amount']); - - // Get the invoice total and details - $sql = mysqli_query($mysqli,"SELECT * FROM invoices WHERE invoice_id = $invoice_id"); - $row = mysqli_fetch_assoc($sql); - $invoice_prefix = escapeSql($row['invoice_prefix']); - $invoice_number = intval($row['invoice_number']); - $invoice_amount = floatval($row['invoice_amount']); - - //Calculate the Invoice balance - $invoice_balance = $invoice_amount - $total_payments_amount + $deleted_payment_amount; - - //Determine if invoice has been paid - if ($invoice_balance == 0) { - $invoice_status = "Paid"; - } else { - $invoice_status = "Partial"; + // Deleting a payment that has been refunded would leave the refund rows dangling + if (!$payment_is_refund && getPaymentRefundedTotal($payment_id) > 0) { + flashAlert("This payment has been refunded - delete the refund first", 'error'); + redirect(); } - //Update Invoice Status - mysqli_query($mysqli,"UPDATE invoices SET invoice_status = '$invoice_status' WHERE invoice_id = $invoice_id"); - - //Add Payment to History - mysqli_query($mysqli,"INSERT INTO history SET history_status = '$invoice_status', history_description = 'Payment deleted', history_invoice_id = $invoice_id"); - mysqli_query($mysqli,"DELETE FROM payments WHERE payment_id = $payment_id"); - logAudit("Invoice", "Edit", "$session_name deleted Payment on Invoice $invoice_prefix$invoice_number", $client_id, $invoice_id); + // Recalculate from what is left rather than from the pre-delete total + $invoice_status = updateInvoiceStatusFromPayments($invoice_id); - flashAlert("Payment deleted", 'error'); - if ($config_stripe_enable) { - flashAlert("Payment deleted - Stripe payments must be manually refunded in Stripe", 'error'); + $deleted_description = $payment_is_refund ? 'Refund deleted' : 'Payment deleted'; + + mysqli_query($mysqli,"INSERT INTO history SET history_status = '$invoice_status', history_description = '$deleted_description', history_invoice_id = $invoice_id"); + + logAudit("Invoice", "Edit", "$session_name deleted $deleted_description on Invoice $invoice_prefix$invoice_number", $client_id, $invoice_id); + + if (!$payment_is_refund && $config_stripe_enable) { + flashAlert("Payment deleted - deleting a payment does not refund it. Use Refund to send the money back through Stripe", 'error'); + } else { + flashAlert("$deleted_description", 'error'); } redirect(); diff --git a/db.sql b/db.sql index aeb726c3..2b95bd88 100644 --- a/db.sql +++ b/db.sql @@ -1534,7 +1534,9 @@ CREATE TABLE `payments` ( `payment_archived_at` datetime DEFAULT NULL, `payment_account_id` int(11) NOT NULL, `payment_invoice_id` int(11) NOT NULL DEFAULT 0, - PRIMARY KEY (`payment_id`) + `payment_refund_of_id` int(11) DEFAULT NULL, + PRIMARY KEY (`payment_id`), + KEY `payment_refund_of_id` (`payment_refund_of_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -2997,4 +2999,4 @@ CREATE TABLE `vendors` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-07-27 18:38:36 +-- Dump completed on 2026-07-28 21:33:31 diff --git a/functions.php b/functions.php index 5abd11e9..5183cccc 100644 --- a/functions.php +++ b/functions.php @@ -16,3 +16,4 @@ require_once __DIR__ . '/functions/auth.php'; require_once __DIR__ . '/functions/logging.php'; require_once __DIR__ . '/functions/app.php'; require_once __DIR__ . '/functions/db.php'; +require_once __DIR__ . '/functions/payments.php'; diff --git a/functions/payments.php b/functions/payments.php new file mode 100644 index 00000000..7a8cb82f --- /dev/null +++ b/functions/payments.php @@ -0,0 +1,114 @@ += $total_cents) { + $new_status = 'Paid'; + } else { + $new_status = 'Partial'; + } + + mysqli_query($mysqli, "UPDATE invoices SET invoice_status = '$new_status' WHERE invoice_id = $invoice_id"); + + return $new_status; +}