Record Stripe fees every 15 minutes instead of once a night - a new Payment Provider Fees cron job runs the fee recorder that Nightly Tasks used to, Nightly Tasks no longer does, the recorder holds a lock so the job and Record fees now can't both record the same fee, and the payment provider screens no longer say fees are recorded each night

This commit is contained in:
johnnyq
2026-11-04 14:38:32 -05:00
parent 66d08c00be
commit e472c95a11
7 changed files with 104 additions and 27 deletions

View File

@@ -170,7 +170,7 @@ ob_start();
<input type="checkbox" class="form-check-input" name="record_fees" value="1" id="paymentProviderRecordFees" <?= $records_fees ? 'checked' : '' ?> data-provider-fees-switch>
<label class="form-check-label" for="paymentProviderRecordFees">Record its fees as expenses</label>
</div>
<div class="form-text ps-5">Each night, its fee on each card payment is recorded as an expense from the account above.</div>
<div class="form-text ps-5">Its fee on each card payment is recorded as an expense from the account above, a few minutes after Stripe settles the payment.</div>
</div>
<div data-provider-fees>

View File

@@ -8,7 +8,7 @@
* and how many are still to record), its saved cards and the autopay on
* them, and its card payments in the last 30 days. Check connection asks
* the provider whose account the key is; Record fees now does what the
* nightly run does. Add and Edit are one window
* Payment Provider Fees job does every few minutes. Add and Edit are one window
* (modals/payment_provider/payment_provider_edit.php); the rest are
* admin/post/payment_provider.php.
*/
@@ -155,7 +155,7 @@ $csrf = $_SESSION['csrf_token'];
<?php } ?>
<?php if ($activity['waiting']) { ?>
<span class="badge <?= $activity['stale'] ? 'bg-warning-subtle text-warning-emphasis border border-warning-subtle' : 'text-bg-light border' ?> fw-normal mt-1" data-provider-fees-waiting="<?= $activity['waiting'] ?>"
title="<?= $activity['stale'] ? 'Some are more than two days old - the nightly run should have had them' : 'The nightly run records them once Stripe has settled them' ?>"><?= $activity['waiting'] ?> to record</span>
title="<?= $activity['stale'] ? 'Some are more than two days old - the Payment Provider Fees job in Maintenance > Cron should have had them' : 'Recorded a few minutes after Stripe settles them' ?>"><?= $activity['waiting'] ?> to record</span>
<?php } ?>
<?php } else { ?>
<span class="text-body-secondary">Not recorded</span>
@@ -187,7 +187,7 @@ $csrf = $_SESSION['csrf_token'];
<i class="fas fa-fw fa-plug text-secondary me-2"></i>Check connection
</a>
<?php if ($fees_on && $activity['waiting']) { ?>
<a class="dropdown-item" href="post.php?record_payment_provider_fees=<?= $provider_id ?>&csrf_token=<?= $csrf ?>" title="What the nightly run does - for the card payments whose fee is still to record">
<a class="dropdown-item" href="post.php?record_payment_provider_fees=<?= $provider_id ?>&csrf_token=<?= $csrf ?>" title="What the Payment Provider Fees job does every few minutes - for the card payments whose fee is still to record">
<i class="fas fa-fw fa-receipt text-secondary me-2"></i>Record fees now
</a>
<?php } ?>

View File

@@ -165,7 +165,7 @@ if (isset($_GET['check_payment_provider_connection'])) {
}
// Record fees now - what the nightly run does, for the payments whose fee is still to record
// Record fees now - what the Payment Provider Fees job does every few minutes, for the payments whose fee is still to record
if (isset($_GET['record_payment_provider_fees'])) {
validateCSRFToken();
@@ -182,6 +182,10 @@ if (isset($_GET['record_payment_provider_fees'])) {
}
$done = paymentProviderRecordFees($row);
if ($done['busy']) {
flashAlert('<strong>' . escapeHtml($name) . '</strong>\'s fees are being recorded right now by the scheduled run - look again in a minute', 'info');
redirect();
}
$said = [];
if ($done['recorded']) {
$said[] = 'Recorded its fee on ' . $done['recorded'] . ' payment' . ($done['recorded'] === 1 ? '' : 's') . ' - ' . paymentProviderMoneyText($done['amount']);

View File

@@ -758,19 +758,7 @@ while ($row = mysqli_fetch_assoc($sql_recurring_payments)) {
}
}
/*
* Stripe fee reconciliation - the fee on each recent card payment that has
* none recorded yet, as Stripe charged it, once it has settled
* (paymentProviderRecordFees(), functions/payment_provider.php - the same
* thing Record fees now on Admin > Payment Providers does). One card payment
* over several invoices has one fee, taken once. A payment can complete
* before Stripe settles it; that one is picked up on a later run.
*/
$stripe_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT payment_provider_name, payment_provider_private_key, payment_provider_account,
payment_provider_expense_vendor, payment_provider_expense_category FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
if ($stripe_provider) {
paymentProviderRecordFees($stripe_provider);
}
// Stripe fees are recorded by their own job every few minutes - cron/payment_provider_fees.php
/*
* Recurring expenses - each running one due today, or missed by up to

View File

@@ -0,0 +1,54 @@
<?php
/*
* ITFlow - Payment provider fees job
*
* Records what Stripe charged on each recent card payment as an expense, once Stripe has
* settled it - paymentProviderRecordFees() in functions/payment_provider.php, the same thing
* Record fees now on Admin > Payment Providers does. A card payment usually settles within
* seconds, so running this every few minutes has its fee on the books shortly after the
* payment instead of the next morning. One that hasn't settled yet waits for a later run.
*
* Safe on an interval: a payment whose fee is recorded is never looked at again, and the
* recorder holds a lock so this and Record fees now can't both record the same fee. With
* nothing waiting, a run is one query and no call to Stripe.
*/
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
// Ensure we're running from command line
if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "includes/cron_lock.php";
require_once "../config.php";
// Set Timezone
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
$payment_provider_fees_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1"));
if (intval($payment_provider_fees_settings['config_enable_cron'] ?? 0) == 0) {
cronJobStop("Cron: is not enabled\n");
}
$payment_provider_fees_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT payment_provider_name, payment_provider_private_key, payment_provider_account,
payment_provider_expense_vendor, payment_provider_expense_category FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
if (!$payment_provider_fees_provider) {
cronJobStop("No Stripe payment provider\n");
}
$payment_provider_fees_done = paymentProviderRecordFees($payment_provider_fees_provider);
if ($payment_provider_fees_done['busy']) {
echo "Fees are being recorded already - left to that run\n";
} else {
echo $payment_provider_fees_done['recorded'] . " fee(s) recorded, " . $payment_provider_fees_done['waiting'] . " waiting for Stripe to settle, " . $payment_provider_fees_done['failed'] . " failed\n";
}

View File

@@ -6,8 +6,8 @@
* The services clients pay invoices by card through - Stripe, for now. Each
* has its keys (live or test, as the keys say), whether it is on, the account
* its payments go into, the invoices it is too small for (the card limit), and
* whether its fees are recorded as expenses - each night, by
* cron/nightly_tasks.php. Paying through one is functions/billing_pay.php.
* whether its fees are recorded as expenses - every few minutes, by
* cron/payment_provider_fees.php. Paying through one is functions/billing_pay.php.
*
* A provider is saved only once it is right: its keys look like that
* provider's, come from the same mode, and - when they are new or changed -
@@ -411,16 +411,19 @@ function paymentProviderUnrecordedSql() {
/*
* Record the provider's fee on its recent card payments that have none yet,
* from what it actually charged - each night (cron/nightly_tasks.php), or
* now from Payment Providers. A payment it hasn't settled yet waits for the
* from what it actually charged - every few minutes (cron/payment_provider_fees.php),
* or now from Payment Providers. A payment it hasn't settled yet waits for the
* next run. Only while its fees are recorded, under a vendor and a category.
* ['recorded' => how many, 'amount' => their fees by currency, 'waiting' =>
* how many not settled yet, 'failed' => how many it wouldn't say, 'error' =>
* the last thing it said].
* the last thing it said, 'busy' => another run had it in hand, so nothing was done].
*
* One run at a time: "has no fee yet" is read before the fee is written, so two runs
* at once - the job, and someone pressing Record fees now - would each record it.
*/
function paymentProviderRecordFees(array $provider, $limit = 50) {
global $mysqli;
$result = ['recorded' => 0, 'amount' => [], 'waiting' => 0, 'failed' => 0, 'error' => ''];
$result = ['recorded' => 0, 'amount' => [], 'waiting' => 0, 'failed' => 0, 'error' => '', 'busy' => false];
$private_key = (string) ($provider['payment_provider_private_key'] ?? '');
$vendor_id = intval($provider['payment_provider_expense_vendor'] ?? 0);
$category_id = intval($provider['payment_provider_expense_category'] ?? 0);
@@ -429,6 +432,26 @@ function paymentProviderRecordFees(array $provider, $limit = 50) {
return $result;
}
$lock = mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('itflow_payment_provider_fees', 0)"));
if (intval($lock[0] ?? 0) !== 1) {
$result['busy'] = true;
return $result;
}
try {
return paymentProviderRecordFeesLocked($provider, $limit, $result);
} finally {
mysqli_query($mysqli, "SELECT RELEASE_LOCK('itflow_payment_provider_fees')");
}
}
/* paymentProviderRecordFees(), with its lock held. */
function paymentProviderRecordFeesLocked(array $provider, $limit, array $result) {
global $mysqli;
$private_key = (string) $provider['payment_provider_private_key'];
$vendor_id = intval($provider['payment_provider_expense_vendor']);
$category_id = intval($provider['payment_provider_expense_category']);
$account_id = intval($provider['payment_provider_account'] ?? 0);
$sql = mysqli_query($mysqli, "SELECT payment_reference, MIN(payment_date) AS payment_date, SUM(payment_amount) AS payment_amount,
GROUP_CONCAT(CONCAT(invoice_prefix, invoice_number) ORDER BY invoice_number SEPARATOR ', ') AS invoice_numbers,
MIN(invoice_client_id) AS invoice_client_id
@@ -477,8 +500,8 @@ function paymentProviderRecordFees(array $provider, $limit = 50) {
* What has gone through a provider in the last 30 days - 'payments' (card
* payments, a split one once), 'paid' and 'fees' (by currency), and, while
* its fees are recorded, 'waiting' (payments with no fee recorded yet) and
* 'stale' (of those, the ones more than two days old - the nightly run
* should have had them).
* 'stale' (of those, the ones more than two days old - the Payment
* Provider Fees job should have had them).
*/
function paymentProviderActivity(array $provider) {
global $mysqli;
@@ -541,7 +564,7 @@ function paymentProviderProblems() {
$activity = paymentProviderActivity($provider);
if ($activity['stale']) {
$problems[] = ['warning', 'fa-receipt', "$name's fees aren't being recorded",
$activity['stale'] . ' card payment' . ($activity['stale'] === 1 ? '' : 's') . ' more than two days old ' . ($activity['stale'] === 1 ? 'has' : 'have') . ' no fee recorded. The nightly run records them - or record them now.', 'payment_providers.php', 'Payment Providers'];
$activity['stale'] . ' card payment' . ($activity['stale'] === 1 ? '' : 's') . ' more than two days old ' . ($activity['stale'] === 1 ? 'has' : 'have') . ' no fee recorded. The Payment Provider Fees job in Maintenance > Cron records them - check it is on, or record them now.', 'payment_providers.php', 'Payment Providers'];
}
}
return $problems;

View File

@@ -75,6 +75,14 @@ function cronJobRegistry(): array
'schedule' => 'Interval',
'interval_minutes' => 1,
],
[
'name' => 'payment_provider_fees',
'label' => 'Payment Provider Fees',
'script' => 'payment_provider_fees.php',
'description' => 'Records what Stripe charged on each card payment as an expense, once Stripe has settled it - while Admin > Payment Providers records its fees.',
'schedule' => 'Interval',
'interval_minutes' => 15,
],
[
'name' => 'domain_refresher',
'label' => 'Domain Refresher',