diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d76383ce..6cce6dafb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1044,6 +1044,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- The audit log says what changed, from what to what - "Card limit No limit → Under $2,500.00", "Mode test → live" - with the keys only as changed.
- Saved Cards: each card a client has saved, as Visa •••• 4242 with when it runs out, whose it is (to their client page), and the recurring invoices that autopay with it and when each next does. Tabs for the cards on autopay and the ones that have expired - an expired card on autopay says its next autopay will be declined, and the Expired tab turns red while there is one. Filter by client, or by provider from the Payment Providers list; search by the provider's customer or payment method ID; sort by when they run out.
- Removing a card says first which recurring invoices stop autopaying with it. It is taken off Stripe as well, each of those recurring invoices says in its history that its autopay stopped and why, and when Stripe can't be reached the card is still removed and you are told it is saved there.
+ - What goes through it: each provider shows its card payments in the last 30 days and when the last came, what its fees came to, and how many are still to record - flagged once they are more than two days old, as the nightly run should have had them. Record fees now does what the nightly run does, and says what it recorded, what Stripe hasn't settled yet and what it wouldn't say. Check connection asks Stripe whose account the key is, and says so - or why it isn't connected.
+ - Settings > Invoices and billing says how card payments stand, under Paid online: none set up, turned off, or on - with live or test keys, where payments go, the card limit and how many came in the last 30 days. The Settings overview flags test keys, card payments going into an archived account, and Stripe's fees not being recorded, and the Invoices and billing tile says whether cards are on.
### Bug Fixes
@@ -1771,6 +1773,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Settings: Notifications, Security, Client portal, Single sign-on (`admin/identity_providers.php`), Mail and Logs use the frame. `settingsSearch($query, $limit)` scores settings as Find a setting does, leaving out pages for a module that is off; `agent/ajax.php?global_search` returns them as `settings` for admins and `js/global_search.js` shows them under Settings. Single sign-on's fields are renamed to their columns (`config_azure_client_id`, `config_azure_client_secret`). A field marked `quiet` in `settingsFields()` is logged as changed without its value (the login key). The Logs handler is unchanged.
- Settings: Mail's Sending, Receiving, OAuth and From addresses tabs use the frame, and every Mail column is in `settingsFields()`. Its handlers save through `settingsSave()` and `settingsWrite()`, logging from -> to; `edit_mail_smtp_settings` and `edit_mail_imap_settings` share one check, and both come back to their tab when refused. A password, secret or refresh token posted empty keeps what is saved; an empty OAuth client ID forgets its secret and refresh token; a changed app or refresh token clears `config_mail_oauth_access_token` and its expiry, so it is fetched again. The access token is no longer a field. `oauth_connect_microsoft_mail` saves the same way before going to Microsoft, and `admin/oauth_microsoft_mail_callback.php` now stores only the tokens. New in `functions/settings.php`: `settingsMailProviders()`, `settingsMailEncryption()` and `settingsEnterSaves($button)` - a hidden Save placed first in a form whose first button would otherwise be another (Mail's OAuth tab, Logs). `settingsRefuse()` takes the page to go back to; `settingsInput()`, `settingsSelect()` and `settingsTextarea()` take `id`; `settingsWrite()` writes a `secret` field exactly as given, rather than through `escapeSql()`, which trims and strips tags.
- Payment Providers: new `functions/payment_provider.php` - `paymentProviderKinds()` (the providers ITFlow takes payments through and what their keys look like), `paymentProviderMode()`, `paymentProviderRow()`, `paymentProviderTies()` and `paymentProviderTiesText()`, `paymentProviderLimitText()`, `paymentProviderCheck($post, $before)`, `paymentProviderKeysChanged()`, `paymentProviderAsk($name, $secret_key)` (Stripe's account, from the secret key), `paymentProviderChangeLines()` and `paymentProviderWrite()`, which saves the keys exactly as given. `check_payment_provider` answers in JSON - `ok`, `errors` by field and the Stripe `account` - and the window asks it before saving; a key it has had taken isn't asked about again on the save, for ten minutes. New handlers `turn_payment_provider_off` and `turn_payment_provider_on` (GET with the CSRF token). `payment_provider_add.php` opens `payment_provider_edit.php` with no id. Field `record_fees` replaces `enable_expense`; `active` and `description` are new. `admin/post/payment_provider_model.php` is gone.
+- Payment Providers: the nightly Stripe fee reconciliation is `paymentProviderRecordFees($provider)` now, which Record fees now (`record_payment_provider_fees`, GET with the CSRF token) runs too - recorded, waiting and failed counts, and the fees by currency. Also `paymentProviderUnrecordedSql()`, `paymentProviderActivity($provider)` (card payments and fees in 30 days, and the payments still to record), `paymentProviderMoneyText()`, `paymentProviderProblems()` (the Settings overview's card payment items) and `paymentProviderSummaryHtml()` (Paid online). `check_payment_provider_connection` (GET with the CSRF token) runs `paymentProviderAsk()` on the saved key. The overview's items take `.itflow-settings-attention-text`, so the button stays beside the text where there is room.
- Saved Cards (`admin/saved_payment_methods.php`): `paymentCardExpirySql()` (a saved card's expiry as yyyymm, from its description), `paymentCardAutopay($saved_ids)` and `paymentCardRemove($saved_payment_id, $by)`, which detaches it at Stripe (a card Stripe no longer has counts as done), writes `history` for each recurring invoice that loses its autopay, and deletes it - the autopay cascades. Filters `provider`, `client` and `show` (`autopay`, `expired`). The audit log entry is type Payment Provider, with the client.
- Removed, as nothing reached them or they couldn't run: `admin/settings_custom_fields.php` (it required a modal that no longer existed) with `admin/post/custom_field.php`, `admin/post/custom_field_model.php` and `admin/modals/custom_field/`; `admin/post/settings_ai.php` and `admin/post/settings_online_payment.php`, which wrote the `config_ai_*` and `config_stripe_*` columns dropped in 2.3.2; `admin/post/settings_online_payment_clients.php`, and `agent/client_autopay.php`, which read the dropped Stripe columns and was linked from nowhere. The `custom_fields` and `custom_values` tables stay as they are, and the client export still includes asset custom field values.
- Mail Queue: new `functions/mail_queue.php` - `MAIL_QUEUE_TRIES` (4), `MAIL_QUEUE_RETRY_MINUTES` (30) and `MAIL_QUEUE_KEEP_DAYS` (90), which `cron/mail_queue.php`'s retry pass and the nightly clean-up now use too; `mailQueueTabs()`, `mailQueueState($row)`, `mailQueueCan($row, $action)`, `mailQueueAct($action, $ids)` and `mailQueueHealth()`. `email_status` 4 is Cancelled - never selected by the worker. Retry now sets status 0, no attempts and no failure time. Handlers: `retry_mail`, `cancel_mail`, `delete_mail` (GET with the CSRF token) and `bulk_retry_emails`, `bulk_cancel_emails`, `bulk_delete_emails`; `send_failed_mail` still works. The worker sets `email_failed_at` on the bad sender, bad recipient and no-MX paths.
diff --git a/admin/payment_providers.php b/admin/payment_providers.php
index 4b16cd598..226dd7b18 100644
--- a/admin/payment_providers.php
+++ b/admin/payment_providers.php
@@ -4,10 +4,13 @@
* Admin > Payment Providers - the services clients pay invoices by card
* through (functions/payment_provider.php). Each row says whether it is on,
* live or test (as its keys say), the account its payments go into, its card
- * limit, whether its fees are recorded as expenses, its saved cards and the
- * autopay on them, and when a card payment last came through it. Add and
- * Edit are one window (modals/payment_provider/payment_provider_edit.php);
- * turning one off, on and deleting it are admin/post/payment_provider.php.
+ * limit, whether its fees are recorded as expenses (and what they came to,
+ * 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
+ * (modals/payment_provider/payment_provider_edit.php); the rest are
+ * admin/post/payment_provider.php.
*/
// Default Column Sortby Filter
@@ -94,7 +97,7 @@ $csrf = $_SESSION['csrf_token'];
diff --git a/admin/settings_invoice.php b/admin/settings_invoice.php
index cc09f7248..b8a51b14b 100644
--- a/admin/settings_invoice.php
+++ b/admin/settings_invoice.php
@@ -68,6 +68,9 @@ echo settingsPageStart('invoice');
= settingsSectionEnd() ?>
= settingsSectionStart('paid', 'Paid online', 'When a client pays an invoice by card - in the client portal or from the link in its email - through a provider in Payment providers.') ?>
+
+
= paymentProviderSummaryHtml() ?>
+
= settingsInput('config_invoice_paid_notification_email', $config_invoice_paid_notification_email, ['icon' => 'fa-bell', 'type' => 'email', 'maxlength' => 200,
'placeholder' => 'billing@company.com', 'help' => 'A shared mailbox or list works best. Empty, nobody is emailed - the payment is still recorded.']) ?>
= settingsSectionEnd() ?>
diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php
index 685e77ccd..fb4006517 100644
--- a/cron/nightly_tasks.php
+++ b/cron/nightly_tasks.php
@@ -712,74 +712,17 @@ while ($row = mysqli_fetch_assoc($sql_recurring_payments)) {
}
/*
- * Stripe fee reconciliation
- * A payment can complete before Stripe attaches the balance transaction,
- * in which case the fee expense is skipped at payment time. Find recent
- * Stripe payments with no matching fee expense and record the actual fee
- * now that the balance transaction exists. One card payment over several
- * invoices (the client portal's Pay invoices) is a payment row per invoice
- * with the same reference - it has one fee, so it is taken once.
+ * 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 * FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
-
+$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) {
-
- $provider_private_key = $stripe_provider['payment_provider_private_key'];
- $expense_vendor_id = intval($stripe_provider['payment_provider_expense_vendor']);
- $expense_category_id = intval($stripe_provider['payment_provider_expense_category']);
- $expense_account_id = intval($stripe_provider['payment_provider_account']);
-
- if ($provider_private_key && $expense_vendor_id > 0 && $expense_category_id > 0) {
-
- $sql_missing_fee = 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
- FROM payments
- LEFT JOIN invoices ON payment_invoice_id = invoice_id
- WHERE payment_reference LIKE 'Stripe - pi\_%'
- AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
- AND NOT EXISTS (
- SELECT 1 FROM expenses WHERE LOCATE(payments.payment_reference, expenses.expense_reference) = 1
- )
- GROUP BY payment_reference
- LIMIT 50
- ");
-
- if ($sql_missing_fee && mysqli_num_rows($sql_missing_fee) > 0) {
-
- require_once __DIR__ . '/../includes/stripe_init.php';
- $stripe = new \Stripe\StripeClient($provider_private_key);
-
- while ($missing = mysqli_fetch_assoc($sql_missing_fee)) {
-
- $payment_reference = escapeSql($missing['payment_reference']);
- $payment_date = escapeSql($missing['payment_date']);
- $payment_amount = floatval($missing['payment_amount']);
- $invoice_numbers = escapeSql($missing['invoice_numbers']);
- $client_id = intval($missing['invoice_client_id']);
-
- $pi_id = str_replace('Stripe - ', '', $missing['payment_reference']);
-
- try {
- $payment_intent = $stripe->paymentIntents->retrieve($pi_id, ['expand' => ['latest_charge.balance_transaction']]);
- } catch (Exception $e) {
- logApp("Stripe", "warning", "Fee reconciliation - could not retrieve $pi_id: " . $e->getMessage());
- continue;
- }
-
- // Actual fee from the balance transaction (null until Stripe attaches it - retried next run)
- $balance_transaction = $payment_intent->latest_charge->balance_transaction ?? null;
- if ($balance_transaction && !is_string($balance_transaction)) {
- $gateway_fee = round($balance_transaction->fee / 100, 2);
- $gateway_fee_currency = escapeSql(strtoupper($balance_transaction->currency));
- mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$payment_date', expense_amount = $gateway_fee, expense_currency_code = '$gateway_fee_currency', expense_account_id = $expense_account_id, expense_vendor_id = $expense_vendor_id, expense_client_id = $client_id, expense_category_id = $expense_category_id, expense_description = 'Stripe fee for Invoice $invoice_numbers payment of $payment_amount', expense_reference = '$payment_reference'");
- logApp("Stripe", "info", "Fee reconciliation - recorded Stripe fee of $gateway_fee for $pi_id");
- }
- // Still-missing balance transactions get picked up on the next run
- }
- }
- }
+ paymentProviderRecordFees($stripe_provider);
}
/*
diff --git a/css/itflow_custom.css b/css/itflow_custom.css
index bd06292d4..bd8f49858 100644
--- a/css/itflow_custom.css
+++ b/css/itflow_custom.css
@@ -8287,6 +8287,11 @@ a.itflow-portal-request:focus-visible {
width: 2rem;
height: 2rem;
}
+/* The overview's what-needs-looking-at - the text takes the room, the button goes under it only when there is none */
+.itflow-settings-attention-text {
+ flex: 1 1 18rem;
+ min-width: 0;
+}
/* A theme colour's dot */
.itflow-settings-dot {
display: inline-block;
diff --git a/functions/payment_provider.php b/functions/payment_provider.php
index 4ce539061..8c3c82f5b 100644
--- a/functions/payment_provider.php
+++ b/functions/payment_provider.php
@@ -393,3 +393,183 @@ function paymentCardRemove($saved_payment_id, $by, &$error = '') {
return ['row' => $row, 'label' => $label, 'stopped' => $stopped, 'told' => $told, 'provider_error' => $provider_error];
}
+
+/* ------------------------------------------------------------- What goes through it */
+
+/*
+ * The card payments a provider's fee hasn't been recorded for yet, in SQL -
+ * its payments in the last 30 days (one card payment over several invoices
+ * is a payment row each, with the same reference, and one fee) with no
+ * expense of that reference. FROM and WHERE, for Stripe.
+ */
+function paymentProviderUnrecordedSql() {
+ return "FROM payments LEFT JOIN invoices ON payment_invoice_id = invoice_id
+ WHERE payment_reference LIKE 'Stripe - pi\\_%'
+ AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND NOT EXISTS (SELECT 1 FROM expenses WHERE LOCATE(payments.payment_reference, expenses.expense_reference) = 1)";
+}
+
+/*
+ * 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
+ * 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].
+ */
+function paymentProviderRecordFees(array $provider, $limit = 50) {
+ global $mysqli;
+ $result = ['recorded' => 0, 'amount' => [], 'waiting' => 0, 'failed' => 0, 'error' => ''];
+ $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);
+ $account_id = intval($provider['payment_provider_account'] ?? 0);
+ if (($provider['payment_provider_name'] ?? '') !== 'Stripe' || $private_key === '' || !$vendor_id || !$category_id) {
+ return $result;
+ }
+
+ $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
+ " . paymentProviderUnrecordedSql() . "
+ GROUP BY payment_reference
+ LIMIT " . intval($limit));
+ if (!$sql || !mysqli_num_rows($sql)) {
+ return $result;
+ }
+
+ require_once __DIR__ . '/../includes/stripe_init.php';
+ $stripe = new \Stripe\StripeClient($private_key);
+
+ while ($missing = mysqli_fetch_assoc($sql)) {
+ $pi_id = str_replace('Stripe - ', '', $missing['payment_reference']);
+ try {
+ $payment_intent = $stripe->paymentIntents->retrieve($pi_id, ['expand' => ['latest_charge.balance_transaction']]);
+ } catch (\Throwable $e) {
+ logApp("Stripe", "warning", "Fee reconciliation - could not retrieve $pi_id: " . $e->getMessage());
+ $result['failed']++;
+ $result['error'] = $e->getMessage();
+ continue;
+ }
+
+ // Its actual fee, from the balance transaction - none until Stripe settles it, then the next run
+ $balance_transaction = $payment_intent->latest_charge->balance_transaction ?? null;
+ if (!$balance_transaction || is_string($balance_transaction)) {
+ $result['waiting']++;
+ continue;
+ }
+ $fee = round($balance_transaction->fee / 100, 2);
+ $currency = strtoupper((string) $balance_transaction->currency);
+ $payment_amount = floatval($missing['payment_amount']);
+ $reference = escapeSql($missing['payment_reference']);
+ mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '" . escapeSql($missing['payment_date']) . "', expense_amount = $fee, expense_currency_code = '" . escapeSql($currency) . "',
+ expense_account_id = $account_id, expense_vendor_id = $vendor_id, expense_client_id = " . intval($missing['invoice_client_id']) . ", expense_category_id = $category_id,
+ expense_description = '" . escapeSql("Stripe fee for Invoice {$missing['invoice_numbers']} payment of $payment_amount") . "', expense_reference = '$reference'");
+ logApp("Stripe", "info", "Fee reconciliation - recorded Stripe fee of $fee for $pi_id");
+ $result['recorded']++;
+ $result['amount'][$currency] = round(($result['amount'][$currency] ?? 0) + $fee, 2);
+ }
+ return $result;
+}
+
+/*
+ * 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).
+ */
+function paymentProviderActivity(array $provider) {
+ global $mysqli;
+ $name = (string) $provider['payment_provider_name'];
+ $prefix = mysqli_real_escape_string($mysqli, str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $name) . ' - ') . '%';
+ $activity = ['payments' => 0, 'paid' => [], 'fees' => [], 'waiting' => 0, 'stale' => 0];
+ $sql = mysqli_query($mysqli, "SELECT payment_currency_code, COUNT(DISTINCT payment_reference), SUM(payment_amount) FROM payments
+ WHERE payment_archived_at IS NULL AND payment_reference LIKE '$prefix' AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ GROUP BY payment_currency_code");
+ while ($row = mysqli_fetch_row($sql)) {
+ $activity['payments'] += intval($row[1]);
+ $activity['paid'][(string) $row[0]] = round(floatval($row[2]), 2);
+ }
+ $sql = mysqli_query($mysqli, "SELECT expense_currency_code, SUM(expense_amount) FROM expenses
+ WHERE expense_archived_at IS NULL AND expense_reference LIKE '$prefix' AND expense_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ GROUP BY expense_currency_code");
+ while ($row = mysqli_fetch_row($sql)) {
+ $activity['fees'][(string) $row[0]] = round(floatval($row[1]), 2);
+ }
+ if ($name === 'Stripe' && intval($provider['payment_provider_expense_vendor']) && intval($provider['payment_provider_expense_category'])) {
+ $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT payment_reference), COUNT(DISTINCT CASE WHEN payment_date < DATE_SUB(CURDATE(), INTERVAL 2 DAY) THEN payment_reference END) "
+ . paymentProviderUnrecordedSql()));
+ $activity['waiting'] = intval($row[0] ?? 0);
+ $activity['stale'] = intval($row[1] ?? 0);
+ }
+ return $activity;
+}
+
+/* Sums by currency, as they read - "$4,210.00", or "$4,210.00 and €120.00". */
+function paymentProviderMoneyText(array $by_currency) {
+ $parts = [];
+ foreach ($by_currency as $currency => $amount) {
+ $parts[] = billingMoney($amount, $currency);
+ }
+ return $parts ? implode(' and ', $parts) : billingMoney(0);
+}
+
+/*
+ * What is wrong with taking card payments, for the Settings overview - each
+ * [level, icon, title, what it means, where to put it right, button]: a
+ * provider on with test keys, or paying into an account that is archived or
+ * gone, or whose fees haven't been recorded for days.
+ */
+function paymentProviderProblems() {
+ global $mysqli;
+ $problems = [];
+ $sql = mysqli_query($mysqli, "SELECT payment_provider_id FROM payment_providers ORDER BY payment_provider_name ASC");
+ while ($id = mysqli_fetch_row($sql)) {
+ $provider = paymentProviderRow($id[0]);
+ $name = $provider['payment_provider_name'];
+ if (intval($provider['payment_provider_active'])) {
+ if (paymentProviderMode($provider['payment_provider_private_key']) === 'test') {
+ $problems[] = ['warning', 'fa-credit-card', "Card payments are in test mode", "$name has test keys, so card payments move no real money. Put in its live keys when you are ready.", 'payment_providers.php', 'Payment Providers'];
+ }
+ if (!$provider['account_name'] || $provider['account_archived_at']) {
+ $problems[] = ['danger', 'fa-piggy-bank', "Card payments go into an account that is " . ($provider['account_name'] ? 'archived' : 'gone'),
+ "$name's payments are recorded into " . ($provider['account_name'] ? $provider['account_name'] . ', which is archived' : 'an account that no longer exists') . ' - pick another for them.', 'payment_providers.php', 'Payment Providers'];
+ }
+ }
+ $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'];
+ }
+ }
+ return $problems;
+}
+
+/*
+ * How card payments stand, in a sentence for Settings > Invoices and billing
+ * (Paid online) - escaped HTML: none set up, turned off, or on - live or
+ * test, where they go, the card limit and how many in the last 30 days.
+ */
+function paymentProviderSummaryHtml() {
+ global $mysqli;
+ $id = mysqli_fetch_row(mysqli_query($mysqli, "SELECT payment_provider_id FROM payment_providers ORDER BY payment_provider_active DESC, payment_provider_name ASC LIMIT 1"));
+ if (!$id) {
+ return 'No payment provider is set up, so clients can\'t pay by card. Set up Stripe.';
+ }
+ $provider = paymentProviderRow($id[0]);
+ $name = escapeHtml($provider['payment_provider_name']);
+ if (!intval($provider['payment_provider_active'])) {
+ return '' . $name . ' is turned off, so clients can\'t pay by card. Payment Providers.';
+ }
+ $mode = paymentProviderMode($provider['payment_provider_private_key']);
+ $activity = paymentProviderActivity($provider);
+ $limit = round(floatval($provider['payment_provider_threshold']), 2);
+ return ($mode === 'test' ? '' : '')
+ . $name . ' is on, with ' . ($mode === 'test' ? 'test keys - no real money moves' : 'live keys')
+ . ' - payments go into ' . escapeHtml((string) ($provider['account_name'] ?? 'no account'))
+ . ($limit > 0 ? ', for invoices under ' . escapeHtml(billingMoney($limit)) : '') . '. '
+ . ($activity['payments'] ? $activity['payments'] . ' card payment' . ($activity['payments'] === 1 ? '' : 's') . ' in the last 30 days.' : 'No card payments in the last 30 days.')
+ . ' Payment Providers.';
+}