Prevent duplicate Stripe payment bookings and overlapping cron runs

This commit is contained in:
johnnyq
2026-07-27 15:22:16 -04:00
parent 815802fb7c
commit 65de8c35e6
3 changed files with 58 additions and 2 deletions

View File

@@ -8,6 +8,14 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Only one run at a time. Autopay charges cards, so an overlapping run (the previous
// run still going when the next one fires) could bill the same invoice twice. The
// handle is held for the life of the process and released when it exits.
$cron_lock_handle = fopen(sys_get_temp_dir() . '/itflow_cron.lock', 'c');
if ($cron_lock_handle === false || !flock($cron_lock_handle, LOCK_EX | LOCK_NB)) {
die("Cron is already running - exiting.\n");
}
require_once "../config.php";
// Set Timezone

View File

@@ -223,8 +223,15 @@ if (isset($_GET['invoice_id'], $_GET['url_key']) && !isset($_GET['payment_intent
exit(WORDING_PAYMENT_FAILED);
}
// Update Invoice Status
mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
// Claim the invoice - the conditional UPDATE is the lock, and the row lock it takes
// is what serialises concurrent requests carrying the same payment intent. A request
// that loses the race matches 0 rows and must not book the payment a second time.
mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id AND invoice_status NOT IN ('Draft', 'Paid', 'Cancelled')");
if (mysqli_affected_rows($mysqli) !== 1) {
error_log("Stripe payment - invoice $invoice_id was already settled by a concurrent request; skipping duplicate booking of $pi_id");
header('Location: //' . $config_base_url . '/guest/guest_view_invoice.php?invoice_id=' . $invoice_id . '&url_key=' . $invoice_url_key);
exit();
}
// Add Payment to History
mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$pi_date', payment_amount = $pi_amount_paid, payment_currency_code = '$pi_currency', payment_account_id = $stripe_account, payment_method = 'Stripe', payment_reference = 'Stripe - $pi_id', payment_invoice_id = $invoice_id");

View File

@@ -0,0 +1,41 @@
diff --git a/cron/cron.php b/cron/cron.php
index 04d879d..e1942c5 100644
--- a/cron/cron.php
+++ b/cron/cron.php
@@ -8,6 +8,14 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
+// Only one run at a time. Autopay charges cards, so an overlapping run (the previous
+// run still going when the next one fires) could bill the same invoice twice. The
+// handle is held for the life of the process and released when it exits.
+$cron_lock_handle = fopen(sys_get_temp_dir() . '/itflow_cron.lock', 'c');
+if ($cron_lock_handle === false || !flock($cron_lock_handle, LOCK_EX | LOCK_NB)) {
+ die("Cron is already running - exiting.\n");
+}
+
require_once "../config.php";
// Set Timezone
diff --git a/guest/guest_pay_invoice_stripe.php b/guest/guest_pay_invoice_stripe.php
index d7c8c07..dcbe88a 100644
--- a/guest/guest_pay_invoice_stripe.php
+++ b/guest/guest_pay_invoice_stripe.php
@@ -223,8 +223,15 @@ if (isset($_GET['invoice_id'], $_GET['url_key']) && !isset($_GET['payment_intent
exit(WORDING_PAYMENT_FAILED);
}
- // Update Invoice Status
- mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
+ // Claim the invoice - the conditional UPDATE is the lock, and the row lock it takes
+ // is what serialises concurrent requests carrying the same payment intent. A request
+ // that loses the race matches 0 rows and must not book the payment a second time.
+ mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id AND invoice_status NOT IN ('Draft', 'Paid', 'Cancelled')");
+ if (mysqli_affected_rows($mysqli) !== 1) {
+ error_log("Stripe payment - invoice $invoice_id was already settled by a concurrent request; skipping duplicate booking of $pi_id");
+ header('Location: //' . $config_base_url . '/guest/guest_view_invoice.php?invoice_id=' . $invoice_id . '&url_key=' . $invoice_url_key);
+ exit();
+ }
// Add Payment to History
mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$pi_date', payment_amount = $pi_amount_paid, payment_currency_code = '$pi_currency', payment_account_id = $stripe_account, payment_method = 'Stripe', payment_reference = 'Stripe - $pi_id', payment_invoice_id = $invoice_id");