diff --git a/agent/invoice.php b/agent/invoice.php index 9998f8ee..fe1031c2 100644 --- a/agent/invoice.php +++ b/agent/invoice.php @@ -649,25 +649,13 @@ if (isset($_GET['invoice_id'])) { $payment_reference = escapeHtml($row['payment_reference']); $account_name = escapeHtml($row['account_name']); - // Refunds are negative rows linked back to the payment they reverse - $payment_is_refund = !is_null($row['payment_refund_of_id']); - $payment_refundable = $payment_is_refund ? 0.00 : round($payment_amount - getPaymentRefundedTotal($payment_id), 2); - ?> - "> - - 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 @@ -852,34 +658,24 @@ if (isset($_GET['delete_payment'])) { ); $row = mysqli_fetch_assoc($sql); $invoice_id = intval($row['payment_invoice_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(); - // 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(); - } - mysqli_query($mysqli,"DELETE FROM payments WHERE payment_id = $payment_id"); // Recalculate from what is left rather than from the pre-delete total $invoice_status = updateInvoiceStatusFromPayments($invoice_id); - $deleted_description = $payment_is_refund ? 'Refund deleted' : 'Payment deleted'; + mysqli_query($mysqli,"INSERT INTO history SET history_status = '$invoice_status', history_description = 'Payment deleted', history_invoice_id = $invoice_id"); - 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 Payment on Invoice $invoice_prefix$invoice_number", $client_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'); + flashAlert("Payment deleted", 'error'); + if ($config_stripe_enable) { + flashAlert("Payment deleted - Stripe payments must be manually refunded in Stripe", 'error'); } redirect(); diff --git a/functions/payments.php b/functions/payments.php index 7a8cb82f..81fc5dfd 100644 --- a/functions/payments.php +++ b/functions/payments.php @@ -1,76 +1,14 @@