Backed out of Refunds which still keeps the fix to properly set the invoice status when payment is deleted

This commit is contained in:
johnnyq
2026-07-28 22:03:35 -04:00
parent 31921ee99b
commit 1494d2cb5e
4 changed files with 11 additions and 512 deletions

View File

@@ -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);
?>
<tr>
<td><?= $payment_date ?></td>
<td class="text-right <?php if ($payment_is_refund) { echo "text-danger"; } ?>"><?= numfmt_format_currency($currency_format, $payment_amount, $payment_currency_code) ?></td>
<td>
<?php if ($payment_is_refund) { ?><span class="badge badge-danger mr-1">Refund</span><?php } ?>
<?= $payment_reference ?>
</td>
<td class="text-right"><?= numfmt_format_currency($currency_format, $payment_amount, $payment_currency_code) ?></td>
<td><?= $payment_reference ?></td>
<td><?= $account_name ?></td>
<td class="text-center text-nowrap">
<?php if ($payment_refundable > 0) { ?>
<button type="button" class="btn btn-light text-warning ajax-modal" data-modal-url="modals/payment/payment_refund.php?id=<?= $payment_id ?>" title="Refund"><i class="fa fa-undo"></i></button>
<?php } ?>
<a class="btn btn-light text-danger confirm-link" href="post.php?delete_payment=<?= $payment_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" title="Delete"><i class="fa fa-times"></i></a>
</td>
<td class="text-center"><a class="btn btn-light text-danger confirm-link" href="post.php?delete_payment=<?= $payment_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-times"></i></a></td>
</tr>
<?php
}

View File

@@ -1,223 +0,0 @@
<?php
require_once '../../../includes/modal_header.php';
$payment_id = intval($_GET['id']);
$sql = mysqli_query(
$mysqli,
"SELECT * FROM payments
LEFT JOIN invoices ON payment_invoice_id = invoice_id
LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1
WHERE payment_id = $payment_id
LIMIT 1"
);
$row = mysqli_fetch_assoc($sql);
if (!$row) {
exit("Payment not found");
}
$payment_date = escapeHtml($row['payment_date']);
$payment_amount = floatval($row['payment_amount']);
$payment_currency_code = escapeHtml($row['payment_currency_code']);
$payment_method = escapeHtml($row['payment_method']);
$payment_reference = escapeHtml($row['payment_reference']);
$payment_account_id = intval($row['payment_account_id']);
$payment_refund_of_id = $row['payment_refund_of_id'];
$invoice_id = intval($row['invoice_id']);
$invoice_prefix = escapeHtml($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['client_id']);
$contact_name = escapeHtml($row['contact_name']);
$contact_email = escapeHtml($row['contact_email']);
enforceClientAccess();
// A refund row cannot itself be refunded
if (!is_null($payment_refund_of_id) || $payment_amount <= 0) {
exit("This entry is a refund and cannot be refunded");
}
$refunded_amount = getPaymentRefundedTotal($payment_id);
$refundable_amount = round($payment_amount - $refunded_amount, 2);
// Stripe payments carry the PaymentIntent in their reference - that is what makes
// them refundable through the gateway rather than only on paper
$stripe_pi_id = getStripePaymentIntentId($row['payment_reference']);
$stripe_available = false;
if ($stripe_pi_id) {
$stripe_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
if ($stripe_provider && !empty($stripe_provider['payment_provider_private_key'])) {
$stripe_available = true;
}
}
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-undo mr-2"></i><?= "$invoice_prefix$invoice_number" ?>: Refund Payment</h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="payment_id" value="<?= $payment_id ?>">
<input type="hidden" name="idempotency_key" value="<?= bin2hex(random_bytes(16)) ?>">
<div class="modal-body">
<?php if ($refundable_amount <= 0) { ?>
<div class="alert alert-warning mb-0">
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>This payment has already been refunded in full.
</div>
<?php } else { ?>
<div class="callout callout-info">
<div class="row">
<div class="col-md-6">
<small class="text-muted d-block">Original Payment</small>
<span class="text-bold"><?= numfmt_format_currency($currency_format, $payment_amount, $payment_currency_code) ?></span>
on <?= $payment_date ?>
</div>
<div class="col-md-6">
<small class="text-muted d-block">Available to Refund</small>
<span class="text-bold"><?= numfmt_format_currency($currency_format, $refundable_amount, $payment_currency_code) ?></span>
<?php if ($refunded_amount > 0) { ?>
<small class="text-muted">(<?= numfmt_format_currency($currency_format, $refunded_amount, $payment_currency_code) ?> already refunded)</small>
<?php } ?>
</div>
</div>
<?php if ($payment_method || $payment_reference) { ?>
<div class="mt-2">
<small class="text-muted"><?= $payment_method ?><?php if ($payment_reference) { ?> &middot; <?= $payment_reference ?><?php } ?></small>
</div>
<?php } ?>
</div>
<div class="form-row">
<div class="col-md">
<div class="form-group">
<label>Refund Date <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
</div>
<input type="date" class="form-control" name="date" max="2999-12-31" value="<?= date("Y-m-d") ?>" required>
</div>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label>Refund Amount <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-dollar-sign"></i></span>
</div>
<input type="text" class="form-control" inputmode="decimal" pattern="[0-9]*\.?[0-9]{0,2}" name="amount" value="<?= number_format($refundable_amount, 2, '.', '') ?>" placeholder="0.00" required>
</div>
<small class="form-text text-muted">Lower this to issue a partial refund.</small>
</div>
</div>
</div>
<div class="form-group">
<label>Refund From Account <strong class="text-danger">*</strong></label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-piggy-bank"></i></span>
</div>
<select class="form-control select2" name="account" required>
<option value="">- Select an Account -</option>
<?php
$sql = mysqli_query($mysqli, "SELECT * FROM accounts WHERE account_archived_at IS NULL ORDER BY account_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$account_id = intval($row['account_id']);
$account_name = escapeHtml($row['account_name']);
$account_currency = escapeHtml($row['account_currency_code']);
?>
<option <?php if ($payment_account_id == $account_id) { echo "selected"; } ?>
value="<?= $account_id ?>">
<?= $account_name ?>
</option>
<?php
}
?>
</select>
</div>
<small class="form-text text-muted">Defaults to the account the payment was deposited into.</small>
</div>
<div class="form-group">
<label>Reason / Reference</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-file-alt"></i></span>
</div>
<input type="text" class="form-control" name="reference" placeholder="Overpayment, service credit, etc" maxlength="150">
</div>
</div>
<?php if ($stripe_available) { ?>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="refundViaStripe" name="refund_stripe" value="1" checked>
<label class="custom-control-label" for="refundViaStripe">Refund this amount through Stripe</label>
</div>
<small class="form-text text-muted">
Sends the money back to the card used for <?= escapeHtml($stripe_pi_id) ?>. Leave unchecked to record the refund in ITFlow only.
Stripe does not return the processing fee on a refund, so the gateway fee expense is left in place.
</small>
</div>
<?php } elseif ($stripe_pi_id) { ?>
<div class="alert alert-warning">
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>This was a Stripe payment, but no Stripe secret key is configured. The refund will be recorded in ITFlow only and must be issued manually in the Stripe dashboard.
</div>
<?php } ?>
<?php if (!empty($config_smtp_provider) && !empty($contact_email)) { ?>
<div class="form-group">
<label>Email Notification</label>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="refundEmailReceipt" name="email_receipt" value="1">
<label class="custom-control-label" for="refundEmailReceipt"><?= $contact_email ?></label>
</div>
</div>
<?php } ?>
<?php } ?>
</div>
<div class="modal-footer">
<?php if ($refundable_amount > 0) { ?>
<button type="submit" name="refund_payment" class="btn btn-danger text-bold"><i class="fas fa-undo mr-2"></i>Issue Refund</button>
<?php } ?>
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fas fa-times mr-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -205,200 +205,6 @@ if (isset($_POST['edit_payment'])) {
}
if (isset($_POST['refund_payment'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 3);
enforceUserPermission('module_financial', 3);
$payment_id = intval($_POST['payment_id']);
$date = escapeSql($_POST['date']);
$amount = floatval($_POST['amount']);
$account = intval($_POST['account']);
$reference = escapeSql($_POST['reference']);
$refund_stripe = intval($_POST['refund_stripe'] ?? 0);
$email_receipt = intval($_POST['email_receipt'] ?? 0);
$idempotency_key = preg_replace('/[^a-f0-9]/', '', $_POST['idempotency_key'] ?? '');
// Payment, invoice and client in one read - payments has no client column, so the
// client always comes from the invoice
$sql = mysqli_query($mysqli, "SELECT * FROM payments
LEFT JOIN invoices ON payment_invoice_id = invoice_id
LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1
WHERE payment_id = $payment_id
LIMIT 1"
);
$row = mysqli_fetch_assoc($sql);
if (!$row) {
flashAlert("Payment not found", 'error');
redirect();
}
$payment_amount = floatval($row['payment_amount']);
$payment_currency_code = escapeSql($row['payment_currency_code']);
$payment_method = escapeSql($row['payment_method']);
$payment_refund_of_id = $row['payment_refund_of_id'];
$original_reference = $row['payment_reference'];
$invoice_id = intval($row['invoice_id']);
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$invoice_url_key = escapeSql($row['invoice_url_key']);
$client_id = intval($row['client_id']);
$client_name = escapeSql($row['client_name']);
$contact_name = escapeSql($row['contact_name']);
$contact_email = escapeSql($row['contact_email']);
enforceClientAccess();
// Sanity checks - a refund row is not itself refundable
if (!is_null($payment_refund_of_id) || $payment_amount <= 0) {
flashAlert("That entry is a refund and cannot be refunded", 'error');
redirect();
}
$refundable_amount = round($payment_amount - getPaymentRefundedTotal($payment_id), 2);
// Compare in whole cents so a partial refund cannot be rounded past the cap
$amount_cents = (int) round($amount * 100);
$refundable_cents = (int) round($refundable_amount * 100);
if ($amount_cents <= 0) {
flashAlert("Refund amount must be greater than zero", 'error');
redirect();
} elseif ($amount_cents > $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,<br><br>A refund of " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . " has been issued against invoice <a href=\\'https://$config_base_url/guest/guest_view_invoice.php?invoice_id=$invoice_id&url_key=$invoice_url_key\\'>$invoice_prefix$invoice_number</a>.<br><br>Refund Amount: " . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . "<br>Original Payment Method: $payment_method<br><br>Card refunds usually appear on your statement within 5-10 business days.<br><br><br>--<br>$company_name - Billing Department<br>$config_invoice_from_email<br>$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 <strong>" . numfmt_format_currency($currency_format, $amount, $payment_currency_code) . "</strong> 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();

View File

@@ -1,76 +1,14 @@
<?php
/*
* ITFlow - Payment & refund helpers
*
* Refunds are stored in the payments table as negative rows, linked back to the
* payment they reverse via payment_refund_of_id. Every balance calculation in the
* app is a SUM(payment_amount), so a negative row reduces the invoice's amount
* paid and the account balance without any of those call sites needing to change.
* ITFlow - Payment helpers
*/
/**
* Total amount already refunded against a single payment (returned positive).
*/
function getPaymentRefundedTotal($payment_id)
{
global $mysqli;
$payment_id = intval($payment_id);
$sql = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS refunded FROM payments WHERE payment_refund_of_id = $payment_id");
$row = mysqli_fetch_assoc($sql);
return abs(floatval($row['refunded']));
}
/**
* How much of a payment can still be refunded.
*/
function getPaymentRefundableAmount($payment_id)
{
global $mysqli;
$payment_id = intval($payment_id);
$sql = mysqli_query($mysqli, "SELECT payment_amount, payment_refund_of_id FROM payments WHERE payment_id = $payment_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
if (!$row || !is_null($row['payment_refund_of_id'])) {
return 0.00;
}
$payment_amount = floatval($row['payment_amount']);
if ($payment_amount <= 0) {
return 0.00;
}
return round($payment_amount - getPaymentRefundedTotal($payment_id), 2);
}
/**
* Pull the Stripe PaymentIntent ID out of a payment reference.
*
* All four Stripe booking sites write 'Stripe - pi_xxx' into payment_reference,
* which is the only place the PI is recorded. Returns null for anything else,
* which is how a manually entered payment is identified as non-refundable via Stripe.
*/
function getStripePaymentIntentId($payment_reference)
{
if (preg_match('/^Stripe - (pi_[A-Za-z0-9]+)$/', trim($payment_reference), $matches)) {
return $matches[1];
}
return null;
}
/**
* Recalculate and store an invoice's status from the sum of its payments.
*
* Refund rows are negative, so an invoice that is refunded in full drops back to
* Sent rather than being stranded on Partial. Draft and Cancelled invoices are
* left alone - those states are not a function of the payment total.
* Draft and Cancelled invoices are left alone - those states are not a function
* of the payment total.
*/
function updateInvoiceStatusFromPayments($invoice_id)
{