mirror of
https://github.com/itflow-org/itflow
synced 2026-08-16 04:25:13 +00:00
Backed out of Refunds which still keeps the fix to properly set the invoice status when payment is deleted
This commit is contained in:
@@ -649,25 +649,13 @@ if (isset($_GET['invoice_id'])) {
|
|||||||
$payment_reference = escapeHtml($row['payment_reference']);
|
$payment_reference = escapeHtml($row['payment_reference']);
|
||||||
$account_name = escapeHtml($row['account_name']);
|
$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>
|
<tr>
|
||||||
<td><?= $payment_date ?></td>
|
<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 class="text-right"><?= numfmt_format_currency($currency_format, $payment_amount, $payment_currency_code) ?></td>
|
||||||
<td>
|
<td><?= $payment_reference ?></td>
|
||||||
<?php if ($payment_is_refund) { ?><span class="badge badge-danger mr-1">Refund</span><?php } ?>
|
|
||||||
<?= $payment_reference ?>
|
|
||||||
</td>
|
|
||||||
<td><?= $account_name ?></td>
|
<td><?= $account_name ?></td>
|
||||||
<td class="text-center text-nowrap">
|
<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>
|
||||||
<?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>
|
|
||||||
</tr>
|
</tr>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>×</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) { ?> · <?= $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';
|
|
||||||
@@ -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
|
Apply Credit Not ready for use 2025-08-27 - JQ
|
||||||
|
|
||||||
@@ -852,34 +658,24 @@ if (isset($_GET['delete_payment'])) {
|
|||||||
);
|
);
|
||||||
$row = mysqli_fetch_assoc($sql);
|
$row = mysqli_fetch_assoc($sql);
|
||||||
$invoice_id = intval($row['payment_invoice_id']);
|
$invoice_id = intval($row['payment_invoice_id']);
|
||||||
$payment_is_refund = !is_null($row['payment_refund_of_id']);
|
|
||||||
$invoice_prefix = escapeSql($row['invoice_prefix']);
|
$invoice_prefix = escapeSql($row['invoice_prefix']);
|
||||||
$invoice_number = intval($row['invoice_number']);
|
$invoice_number = intval($row['invoice_number']);
|
||||||
$client_id = intval($row['invoice_client_id']);
|
$client_id = intval($row['invoice_client_id']);
|
||||||
|
|
||||||
enforceClientAccess();
|
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");
|
mysqli_query($mysqli,"DELETE FROM payments WHERE payment_id = $payment_id");
|
||||||
|
|
||||||
// Recalculate from what is left rather than from the pre-delete total
|
// Recalculate from what is left rather than from the pre-delete total
|
||||||
$invoice_status = updateInvoiceStatusFromPayments($invoice_id);
|
$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);
|
flashAlert("Payment deleted", 'error');
|
||||||
|
if ($config_stripe_enable) {
|
||||||
if (!$payment_is_refund && $config_stripe_enable) {
|
flashAlert("Payment deleted - Stripe payments must be manually refunded in Stripe", 'error');
|
||||||
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();
|
redirect();
|
||||||
|
|||||||
@@ -1,76 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ITFlow - Payment & refund helpers
|
* ITFlow - Payment 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* 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
|
* Draft and Cancelled invoices are left alone - those states are not a function
|
||||||
* Sent rather than being stranded on Partial. Draft and Cancelled invoices are
|
* of the payment total.
|
||||||
* left alone - those states are not a function of the payment total.
|
|
||||||
*/
|
*/
|
||||||
function updateInvoiceStatusFromPayments($invoice_id)
|
function updateInvoiceStatusFromPayments($invoice_id)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user