Payment Providers, when it is turned on and * has its keys - id, keys, threshold and the account payments go to. Null * when clients can't pay by card. */ function billingStripeProvider() { global $mysqli; static $provider = false; if ($provider === false) { $provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT payment_provider_id, payment_provider_public_key, payment_provider_private_key, payment_provider_threshold, payment_provider_account FROM payment_providers WHERE payment_provider_name = 'Stripe' AND payment_provider_active = 1 LIMIT 1")) ?: null; if ($provider && (trim((string) $provider['payment_provider_public_key']) === '' || trim((string) $provider['payment_provider_private_key']) === '')) { $provider = null; } } return $provider; } /** A Stripe client on the provider's secret key, or null when Stripe isn't set up. */ function billingStripeClient() { $provider = billingStripeProvider(); if (!$provider) { return null; } require_once __DIR__ . '/../includes/stripe_init.php'; return new \Stripe\StripeClient((string) $provider['payment_provider_private_key']); } /** The client's Stripe customer id ('' when they have none yet). */ function billingStripeCustomer($client_id) { global $mysqli; $provider = billingStripeProvider(); if (!$provider) { return ''; } $client_id = intval($client_id); $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT payment_provider_client FROM client_payment_provider WHERE client_id = $client_id AND payment_provider_id = " . intval($provider['payment_provider_id']) . " LIMIT 1")); return (string) ($row[0] ?? ''); } /** * Whether an amount can be paid by card - Stripe's threshold (Admin > Payment * Providers) caps what is offered at checkout; none set means no cap. */ function billingOnlinePayAllowed($amount) { $provider = billingStripeProvider(); if (!$provider) { return false; } $threshold = floatval($provider['payment_provider_threshold']); return $threshold == 0 || $threshold > round(floatval($amount), 2); } /* ------------------------------------------------------------- Saved cards */ /** * A saved card's description ("visa - 4242 | Exp 9/2028", as saved) in * parts: brand, last4, exp ("09/28"), expired, icon and label ("Visa •••• * 4242"). One saved some other way keeps its description as the label. */ function billingCardParts($description) { $description = trim((string) $description); $brands = [ 'visa' => ['Visa', 'fab fa-cc-visa'], 'mastercard' => ['Mastercard', 'fab fa-cc-mastercard'], 'amex' => ['American Express', 'fab fa-cc-amex'], 'american express' => ['American Express', 'fab fa-cc-amex'], 'discover' => ['Discover', 'fab fa-cc-discover'], 'diners' => ['Diners Club', 'fab fa-cc-diners-club'], 'jcb' => ['JCB', 'fab fa-cc-jcb'], 'unionpay' => ['UnionPay', 'fas fa-credit-card'], ]; if (!preg_match('/^(.+?)\s*-\s*(\d{4})\s*\|\s*Exp\s*(\d{1,2})\/(\d{2,4})$/i', $description, $m)) { return ['brand' => '', 'last4' => '', 'exp' => '', 'expired' => false, 'icon' => 'fas fa-credit-card', 'label' => $description !== '' ? $description : 'Card']; } $key = strtolower(trim($m[1])); [$brand, $icon] = $brands[$key] ?? [ucwords($key), 'fas fa-credit-card']; $month = intval($m[3]); $year = intval($m[4]); $year = $year < 100 ? 2000 + $year : $year; // A card is good to the end of the month it runs out $expired = $year * 100 + $month < intval(date('Ym')); return [ 'brand' => $brand, 'last4' => $m[2], 'exp' => sprintf('%02d/%02d', $month, $year % 100), 'expired' => $expired, 'icon' => $icon, 'label' => "$brand •••• {$m[2]}", ]; } /** * The client's cards saved with Stripe, oldest first - each row with its * billingCardParts() under 'card' and how many active recurring invoices * pay with it under 'autopay'. */ function billingSavedCards($client_id) { global $mysqli; $provider = billingStripeProvider(); if (!$provider) { return []; } $client_id = intval($client_id); $rows = []; $sql = mysqli_query($mysqli, "SELECT saved_payment_id, saved_payment_description, saved_payment_provider_method, saved_payment_created_at, (SELECT COUNT(*) FROM recurring_payments JOIN recurring_invoices ON recurring_invoice_id = recurring_payment_recurring_invoice_id WHERE recurring_payment_saved_payment_id = saved_payment_id AND recurring_invoice_status = 1) AS autopay FROM client_saved_payment_methods WHERE saved_payment_client_id = $client_id AND saved_payment_provider_id = " . intval($provider['payment_provider_id']) . " ORDER BY saved_payment_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['card'] = billingCardParts($row['saved_payment_description']); $row['autopay'] = intval($row['autopay']); $rows[] = $row; } return $rows; } /* ------------------------------------------------------- Choosing and paying */ /** * The invoices a client chose to pay, checked: theirs, still open with * something left on them, in one currency, no more than a card payment can * carry. ['invoices' => [id => row, with balance], 'cents' => [id => * cents], 'total', 'currency', 'numbers'] - or null, with $error for the * client. */ function billingPaySelection($client_id, array $invoice_ids, &$error = '') { $invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids)))); if (!$invoice_ids) { $error = 'Tick the invoices you want to pay'; return null; } if (count($invoice_ids) > BILLING_PAY_MAX_INVOICES) { $error = 'Pay up to ' . BILLING_PAY_MAX_INVOICES . ' invoices at a time'; return null; } $open = []; foreach (billingClientOpenInvoices($client_id) as $row) { $open[intval($row['invoice_id'])] = $row; } $selection = ['invoices' => [], 'cents' => [], 'total' => 0.0, 'currency' => '', 'numbers' => []]; foreach ($invoice_ids as $id) { if (!isset($open[$id])) { $error = 'One of those invoices has been paid or changed since the page loaded - check the list and try again'; return null; } $row = $open[$id]; $cents = (int) round((floatval($row['invoice_amount']) - floatval($row['paid'])) * 100); $currency = strtoupper((string) $row['invoice_currency_code']); if ($selection['currency'] !== '' && $currency !== $selection['currency']) { $error = 'Those invoices are in different currencies - pay each currency on its own'; return null; } $selection['currency'] = $currency; $row['balance'] = $cents / 100; $selection['invoices'][$id] = $row; $selection['cents'][$id] = $cents; $selection['numbers'][] = billingNumber('invoice', $row); } $selection['total'] = array_sum($selection['cents']) / 100; if (!billingOnlinePayAllowed($selection['total'])) { $error = 'Card payments are limited to under ' . billingMoney(floatval(billingStripeProvider()['payment_provider_threshold'] ?? 0), $selection['currency']) . ' at a time - pay fewer invoices at once, or pay another way'; return null; } return $selection; } /** A selection's split, as it goes in the payment's metadata - "id:cents,id:cents". */ function billingPaySplit(array $selection) { $parts = []; foreach ($selection['cents'] as $id => $cents) { $parts[] = intval($id) . ':' . intval($cents); } return implode(',', $parts); } /** A split back from a payment's metadata - [invoice_id => cents], or [] when it doesn't read. */ function billingPaySplitParse($split) { $out = []; foreach (explode(',', (string) $split) as $part) { if (!preg_match('/^(\d+):(\d+)$/', trim($part), $m)) { return []; } $out[intval($m[1])] = intval($m[2]); } return $out; } /** * What every PaymentIntent for a selection carries - amount, currency, * description and the metadata billingStripeBook() books from. $by: the * contact paying (name). */ function billingStripeIntentParams(array $selection, $client_id, array $by = []) { global $mysqli; $client_id = intval($client_id); $client_name = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id"))[0] ?? ''); $numbers = implode(', ', $selection['numbers']); $metadata = [ 'itflow_client_id' => $client_id, 'itflow_client_name' => mb_substr($client_name, 0, 450), 'itflow_invoices' => billingPaySplit($selection), 'itflow_invoice_number' => mb_substr($numbers, 0, 450), 'itflow_paid_by' => mb_substr((string) ($by['name'] ?? ''), 0, 200), 'itflow_source' => 'client_portal', ]; if (count($selection['cents']) === 1) { // The one-invoice key the guest and autopay payments carry $metadata['itflow_invoice_id'] = intval(array_key_first($selection['cents'])); } return [ 'amount' => array_sum($selection['cents']), 'currency' => strtolower($selection['currency']), 'description' => mb_substr("ITFlow: $client_name payment of {$selection['currency']} " . number_format($selection['total'], 2, '.', '') . " for $numbers", 0, 1000), 'metadata' => $metadata, 'payment_method_types' => ['card'], ]; } /** * Charge one of the client's saved cards for a selection, there and then. * $idempotency_key keeps a form sent twice to one charge. ['ok' => true, * 'pi' => the PaymentIntent] or ['ok' => false, 'error' => for the client]. */ function billingStripeChargeSaved(array $selection, $client_id, $saved_payment_id, $idempotency_key, array $by = []) { global $mysqli; $provider = billingStripeProvider(); $client_id = intval($client_id); $saved_payment_id = intval($saved_payment_id); $customer = billingStripeCustomer($client_id); $card = $provider ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT saved_payment_provider_method, saved_payment_description FROM client_saved_payment_methods WHERE saved_payment_id = $saved_payment_id AND saved_payment_client_id = $client_id AND saved_payment_provider_id = " . intval($provider['payment_provider_id']) . " LIMIT 1")) : null; if (!$provider || !$card || $customer === '') { return ['ok' => false, 'error' => 'That card is no longer saved - choose another']; } if (billingCardParts($card['saved_payment_description'])['expired']) { return ['ok' => false, 'error' => 'That card has expired - choose another or pay with a new card']; } $params = billingStripeIntentParams($selection, $client_id, $by) + [ 'customer' => $customer, 'payment_method' => (string) $card['saved_payment_provider_method'], 'off_session' => true, 'confirm' => true, ]; try { $pi = billingStripeClient()->paymentIntents->create($params, ['idempotency_key' => 'itflow-portal-' . $idempotency_key]); } catch (\Stripe\Exception\CardException $e) { $code = (string) $e->getStripeCode(); logApp("Stripe", "warning", "Saved card payment declined for client $client_id: " . $e->getMessage()); return ['ok' => false, 'error' => $code === 'authentication_required' ? 'Your bank wants to confirm this payment - choose A new card and enter the card details to approve it' : 'Your card was declined: ' . rtrim($e->getMessage(), '.') . '. Try another card']; } catch (\Throwable $e) { logApp("Stripe", "error", "Saved card payment failed for client $client_id: " . $e->getMessage()); return ['ok' => false, 'error' => 'The payment could not be made just now - nothing was charged. Try again in a moment']; } if ($pi->status !== 'succeeded') { return ['ok' => false, 'error' => 'The payment did not go through - nothing was charged. Try another card']; } return ['ok' => true, 'pi' => $pi]; } /** * The PaymentIntent the checkout page takes a new card with - $reuse_id's, * when it is still waiting for a card and asks for the same amount and * split, else a new one (and the old one cancelled). Throws on Stripe errors. */ function billingStripeCheckoutIntent(array $selection, $client_id, array $by = [], $reuse_id = '') { $stripe = billingStripeClient(); $params = billingStripeIntentParams($selection, $client_id, $by); $customer = billingStripeCustomer($client_id); if ($customer !== '') { $params['customer'] = $customer; } if (preg_match('/^pi_[A-Za-z0-9_]+$/', (string) $reuse_id)) { try { $old = $stripe->paymentIntents->retrieve($reuse_id); $waiting = in_array($old->status, ['requires_payment_method', 'requires_confirmation', 'requires_action'], true); if ($waiting && intval($old->amount) === intval($params['amount']) && ($old->metadata['itflow_invoices'] ?? '') === $params['metadata']['itflow_invoices'] && intval($old->metadata['itflow_client_id'] ?? 0) === intval($client_id)) { return $old; } if ($waiting) { $stripe->paymentIntents->cancel($reuse_id); } } catch (\Throwable $e) { // Gone or not cancellable - a new one is made below either way } } return $stripe->paymentIntents->create($params); } /* ------------------------------------------------------------------ Booking */ /** * Book a card payment that went through: one payment per invoice in its * split, dated the day it was made, into Stripe's account, referenced * "Stripe - pi_...", and each invoice's status from its payments. The money * has been taken, so an invoice paid some other way meanwhile (now overpaid) * or no longer open is booked all the same and flagged to the agents. Booked * once: another call for the same payment finds it and books nothing. * * $pi: the PaymentIntent (or the same as an array). $client_id: whose it * must be. $by: name (who paid), how ('Online card', 'Saved card'), via * (words for the history - "in the client portal"). * * ['ok', 'error', 'applied' => [['invoice' => billingRow(), 'amount']], * 'payment_id' (the first), 'already' (booked before)] */ function billingStripeBook($pi, $client_id, array $by = []) { global $mysqli; $pi = $pi instanceof \Stripe\StripeObject ? $pi->toArray() : (array) json_decode(json_encode($pi), true); $client_id = intval($client_id); $pi_id = (string) ($pi['id'] ?? ''); $fail = function ($error) { return ['ok' => false, 'error' => $error, 'applied' => [], 'payment_id' => 0, 'already' => false]; }; if (!preg_match('/^pi_[A-Za-z0-9_]+$/', $pi_id)) { return $fail('not a payment'); } if (($pi['status'] ?? '') !== 'succeeded') { return $fail('not succeeded'); } $received = intval($pi['amount_received'] ?? 0); if ($received <= 0 || $received !== intval($pi['amount'] ?? -1)) { return $fail('amount not received in full'); } $metadata = (array) ($pi['metadata'] ?? []); if (intval($metadata['itflow_client_id'] ?? 0) !== $client_id) { return $fail('not this client'); } $split = billingPaySplitParse($metadata['itflow_invoices'] ?? ''); if (!$split && intval($metadata['itflow_invoice_id'] ?? 0)) { $split = [intval($metadata['itflow_invoice_id']) => $received]; } if (!$split || array_sum($split) !== $received) { return $fail('split does not add up'); } $currency = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) ($pi['currency'] ?? ''))); $invoices = []; foreach ($split as $invoice_id => $cents) { $invoice = billingRow('invoice', $invoice_id, false); if (!$invoice || intval($invoice['client_id']) !== $client_id || strtoupper((string) $invoice['invoice_currency_code']) !== $currency || $cents <= 0) { return $fail("invoice $invoice_id does not fit"); } $invoices[$invoice_id] = $invoice; } $reference = escapeSql("Stripe - $pi_id"); $lock = escapeSql('itflow_pi_' . $pi_id); if (intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('$lock', 15)"))[0] ?? 0) !== 1) { return $fail('busy'); } $booked = mysqli_fetch_row(mysqli_query($mysqli, "SELECT payment_id FROM payments JOIN invoices ON invoice_id = payment_invoice_id WHERE payment_reference = '$reference' AND invoice_client_id = $client_id ORDER BY payment_id ASC LIMIT 1")); if ($booked) { mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')"); $applied = []; foreach ($invoices as $invoice_id => $invoice) { $applied[] = ['invoice' => billingRow('invoice', $invoice_id, false), 'amount' => $split[$invoice_id] / 100]; } return ['ok' => true, 'error' => '', 'applied' => $applied, 'payment_id' => intval($booked[0]), 'already' => true]; } $provider = billingStripeProvider(); $account_id = intval($provider['payment_provider_account'] ?? 0); $date = date('Y-m-d', intval($pi['created'] ?? time()) ?: time()); $total = $received / 100; $name = trim((string) ($by['name'] ?? '')); $via = trim((string) ($by['via'] ?? '')); $many = count($invoices) > 1; $dev = empty($pi['livemode']) ? ' (DEV MODE)' : ''; $applied = []; $first_payment_id = 0; foreach ($invoices as $invoice_id => $invoice) { $amount = $split[$invoice_id] / 100; $status_before = (string) $invoice['invoice_status']; mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$date', payment_amount = $amount, payment_currency_code = '" . escapeSql($currency) . "', payment_account_id = $account_id, payment_method = 'Stripe', payment_reference = '$reference', payment_invoice_id = $invoice_id"); $first_payment_id = $first_payment_id ?: intval(mysqli_insert_id($mysqli)); $status = updateInvoiceStatusFromPayments($invoice_id); $invoice = billingRow('invoice', $invoice_id, false); $number = billingNumber('invoice', $invoice); $money = billingMoney($amount, $currency); billingLog('invoice', $invoice_id, $status, "Paid $money online by card" . ($name !== '' ? " - $name" : '') . ($via !== '' ? " $via" : '') . ($many ? ', in one payment of ' . billingMoney($total, $currency) . ' for ' . count($invoices) . ' invoices' : '')); $state = invoiceState($invoice, invoicePaid($invoice_id)); if (!in_array($status_before, invoiceOpenStatuses(), true) || $state['balance'] < 0) { appNotify("Invoice Payment", escapeSql("Check $number for {$invoice['client_name']}: a card payment of $money went on it" . ($state['balance'] < 0 ? ' and it is now overpaid by ' . billingMoney(-$state['balance'], $currency) : " while it was $status_before")), "/agent/invoice.php?invoice_id=$invoice_id", $client_id); } if ($status === 'Paid') { triggerCustomAction('invoice_pay', $invoice_id); } logAudit("Invoice", "Payment", escapeSql(($name !== '' ? $name : 'Client') . " paid $money on invoice $number by card" . ($via !== '' ? " $via" : '') . " - $pi_id$dev"), $client_id, $invoice_id); $applied[] = ['invoice' => $invoice, 'amount' => $amount]; } mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')"); $client_name = (string) $applied[0]['invoice']['client_name']; $who = $name !== '' ? " ($name" . ($via !== '' ? ", $via" : '') . ')' : ''; if ($many) { appNotify("Invoice Paid", escapeSql("$client_name paid " . count($applied) . ' invoices online - ' . billingMoney($total, $currency) . $who), "/agent/invoices.php?client_id=$client_id", $client_id); } else { $number = billingNumber('invoice', $applied[0]['invoice']); appNotify("Invoice Paid", escapeSql("Invoice $number has been paid by $client_name$who"), "/agent/invoice.php?invoice_id=" . intval($applied[0]['invoice']['invoice_id']), $client_id); } if (billingEmailReady()) { billingPaymentReceiptSend($applied, ['date' => $date, 'method' => 'Card', 'reference' => "Stripe - $pi_id", 'how' => $by['how'] ?? 'Online card'], true); } return ['ok' => true, 'error' => '', 'applied' => $applied, 'payment_id' => $first_payment_id, 'already' => false]; } /* --------------------------------------------------------- Payment history */ /** SQL that keys the payments made together - one card payment over several invoices shares a date, method and reference. */ function billingPaymentGroupKeySql() { return "IF(COALESCE(payment_reference, '') <> '', CONCAT(payment_date, '|', payment_method, '|', payment_reference, '|', payment_currency_code), CONCAT('#', payment_id))"; } /** * A client's payments, newest first, the parts of one payment together: * id (the first), date, method, reference, currency, total, count and * numbers (the invoices). ['rows' => ..., 'total' => how many payments]. */ function billingClientPayments($client_id, $limit = 20, $offset = 0) { global $mysqli; $client_id = intval($client_id); $limit = max(1, intval($limit)); $offset = max(0, intval($offset)); $key = billingPaymentGroupKeySql(); $from = "FROM payments JOIN invoices ON invoice_id = payment_invoice_id WHERE invoice_client_id = $client_id AND payment_archived_at IS NULL"; $total = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT $key) $from"))[0] ?? 0); $rows = []; $sql = mysqli_query($mysqli, "SELECT MIN(payment_id) AS payment_id, MAX(payment_date) AS payment_date, MAX(payment_method) AS payment_method, MAX(payment_reference) AS payment_reference, MAX(payment_currency_code) AS payment_currency_code, SUM(payment_amount) AS amount, COUNT(*) AS invoices, GROUP_CONCAT(CONCAT(invoice_prefix, invoice_number) ORDER BY invoice_date, invoice_number SEPARATOR ', ') AS numbers $from GROUP BY $key ORDER BY MAX(payment_date) DESC, MIN(payment_id) DESC LIMIT $limit OFFSET $offset"); while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } return ['rows' => $rows, 'total' => $total]; } /** * One payment as a receipt - the payment $payment_id belongs to, with the * other invoices it paid: date, method, reference, currency, total and * lines (each payment with its invoice). Null when it isn't the client's. */ function billingPaymentReceipt($payment_id, $client_id) { global $mysqli; $payment_id = intval($payment_id); $client_id = intval($client_id); $key = billingPaymentGroupKeySql(); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT $key AS group_key FROM payments JOIN invoices ON invoice_id = payment_invoice_id WHERE payment_id = $payment_id AND invoice_client_id = $client_id AND payment_archived_at IS NULL")); if (!$row) { return null; } $group = escapeSql($row['group_key']); $lines = []; $sql = mysqli_query($mysqli, "SELECT payment_id, payment_date, payment_amount, payment_currency_code, payment_method, payment_reference, payment_created_at, invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_amount, invoice_status, invoice_due, invoice_currency_code, (SELECT COALESCE(SUM(p2.payment_amount), 0) FROM payments p2 WHERE p2.payment_invoice_id = invoice_id) AS paid FROM payments JOIN invoices ON invoice_id = payment_invoice_id WHERE invoice_client_id = $client_id AND payment_archived_at IS NULL AND $key = '$group' ORDER BY invoice_date ASC, invoice_number ASC"); while ($line = mysqli_fetch_assoc($sql)) { $lines[] = $line; } if (!$lines) { return null; } return [ 'id' => intval($lines[0]['payment_id']), 'date' => $lines[0]['payment_date'], 'method' => (string) $lines[0]['payment_method'], 'reference' => (string) $lines[0]['payment_reference'], 'currency' => (string) $lines[0]['payment_currency_code'], 'total' => round(array_sum(array_map('floatval', array_column($lines, 'payment_amount'))), 2), 'lines' => $lines, ]; } /** How a payment was made, in words for the client - "Card", "Check", "Bank transfer". */ function billingPaymentMethodText($method, $reference = '') { $method = trim((string) $method); if (strcasecmp($method, 'Stripe') === 0 || stripos((string) $reference, 'Stripe - pi_') === 0) { return 'Card'; } return $method !== '' ? $method : 'Payment'; }