Files
itflow/client/pay.php
johnnyq e29369926f Client portal stage 4: accounts - Your account rebuilt with details and an ask-us link, phone numbers, ticket email choices, password change needing the current password and 8 characters with an email notice, two-step sign-in with an authenticator app set up from a QR code, remembered browsers with Forget them, phone PIN and recent sign-ins with the device; activity rebuilt with signing-in and changes tabs and paging; sign-in asks portal users with two-step sign-in for a code, remembers the browser on request and locks an account after five wrong codes in ten minutes, matching portal users by contact_user_id; Admin > Client Portal can require two-step sign-in, sending anyone without it to set it up first, and agents can turn it off for a contact from the contact page; password links stored as SHA-256 and running out after an hour for Forgot password and a week for invites, the nightly run clearing only run-out links, Forgot password limited to one email per account every five minutes and ten requests per address in ten minutes, the reset page rebuilt with the password typed twice and a confirmation email, fixing the empty-token check that let anyone trigger the reset email; contacts choose every ticket email or replies and fixes only, and primary and technical contacts can hear about new tickets at their company or every update, honoured by agent, portal, email parser and recurring ticket emails; announcements for every client or one with tone and start and end dates on the portal home page, managed in Admin > Client Portal; View as contact opens the portal read only as a contact from the agent contact page, audited, with billing shown only when the agent's role reads sales and financial, nothing written or paid while viewing; agent contact page shows two-step status, last sign-in and ticket email choices; database 3.1.0 adds portal_announcements, contact ticket email columns and config_client_portal_require_mfa and hashes outstanding links; new functions/portal.php; changelog updated
2026-10-17 12:05:55 -04:00

134 lines
6.4 KiB
PHP

<?php
/*
* Client Portal
* Checkout - paying the invoices picked on Pay invoices with a new card, in
* Stripe's card form on this page (js/portal_stripe.js). Stripe sends the
* contact back to post.php?portal_pay_return, which books the payment and
* shows the receipt.
*
* SCOPING: portal_pay keeps the choice in the session, and it is checked
* again here (billingPaySelection()) before the payment is set up - an
* invoice paid some other way in between drops the page back to the list.
*/
$portal_stripe = true; // Stripe's script and card form - see includes/header.php
require_once "includes/inc_all.php";
portalBillingGate();
// Paying starts a card payment with Stripe - never while an agent views as the contact
if (portalViewingAs()) {
flashAlert("Paying isn't open while you view the portal as someone - nothing can be changed", 'error');
redirect('unpaid_invoices.php');
}
$checkout = $_SESSION['portal_checkout'] ?? null;
$error = '';
$selection = null;
$pi = null;
$provider = billingStripeProvider();
if (!$provider) {
$error = 'Card payments are not set up';
} elseif (!$checkout || empty($checkout['ids'])) {
$error = 'Choose the invoices to pay first';
} else {
$selection = billingPaySelection($session_client_id, (array) $checkout['ids'], $error);
}
if ($selection) {
try {
$pi = billingStripeCheckoutIntent($selection, $session_client_id, ['name' => (string) ($contact['contact_name'] ?? '')], (string) ($checkout['pi'] ?? ''));
$_SESSION['portal_checkout']['pi'] = (string) $pi->id;
} catch (Throwable $e) {
logApp("Stripe", "error", "Could not set up a card payment for client $session_client_id: " . $e->getMessage());
$error = 'The card form could not be set up just now - nothing has been charged. Try again in a moment';
$selection = null;
}
}
$back = 'unpaid_invoices.php' . ($checkout && !empty($checkout['ids']) ? '?' . http_build_query(['select' => implode(',', array_map('intval', (array) $checkout['ids']))]) : '');
?>
<a class="itflow-portal-back" href="<?= escapeHtml($back) ?>"><i class="fas fa-chevron-left"></i>Pay invoices</a>
<?php if (!$selection) { ?>
<div class="itflow-portal-page-head">
<div>
<h1>Pay by card</h1>
</div>
</div>
<section class="itflow-portal-panel">
<?= emptyStateHtml(['title' => escapeHtml($error), 'text' => 'Nothing has been charged.', 'icon' => 'fa-credit-card', 'filtered' => false,
'actions' => [['label' => 'Back to Pay invoices', 'href' => $back, 'icon' => 'fa-arrow-left']]]) ?>
</section>
<?php } else {
$amount = billingMoney($selection['total'], $selection['currency']);
$count = count($selection['invoices']); ?>
<div class="itflow-portal-page-head">
<div>
<h1>Pay <?= escapeHtml($amount) ?></h1>
<p><?= $count ?> <?= $count === 1 ? 'invoice' : 'invoices' ?> · paid securely through Stripe</p>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<section class="itflow-portal-panel">
<div class="itflow-portal-panel-head">
<h2><i class="fas fa-credit-card"></i>Card details</h2>
<span class="small text-body-secondary"><i class="fas fa-lock me-1"></i>Secure</span>
</div>
<form class="itflow-portal-panel-body" id="portalPayForm" data-stripe-pay
data-stripe-key="<?= escapeHtml($provider['payment_provider_public_key']) ?>"
data-client-secret="<?= escapeHtml((string) $pi->client_secret) ?>"
data-return-url="<?= escapeHtml("https://$config_base_url/client/post.php?portal_pay_return=1") ?>">
<div class="itflow-portal-stripe-element" id="payment-element">
<div class="text-body-secondary small" data-stripe-loading><span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Loading the card form...</div>
</div>
<div class="alert alert-warning small mt-3 mb-0" data-stripe-unavailable hidden>
The card form did not load - check your connection and reload the page. Nothing has been charged.
</div>
<div class="alert alert-danger small mt-3 mb-0" data-pay-error hidden></div>
<button type="submit" class="btn btn-success btn-lg w-100 mt-3" data-stripe-submit disabled>
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true" data-stripe-busy hidden></span><i class="fas fa-lock me-2" data-stripe-idle></i>Pay <?= escapeHtml($amount) ?>
</button>
<p class="small text-body-secondary text-center mt-2 mb-0">Your card details go straight to Stripe and never reach us.</p>
</form>
</section>
</div>
<div class="col-lg-5">
<section class="itflow-portal-panel">
<div class="itflow-portal-panel-head">
<h2><i class="fas fa-file-invoice-dollar"></i>Paying</h2>
<a href="<?= escapeHtml($back) ?>">Change</a>
</div>
<ul class="itflow-portal-list">
<?php foreach ($selection['invoices'] as $id => $row) { ?>
<li class="itflow-portal-todo">
<span class="itflow-portal-todo-main">
<div><?= escapeHtml($row['invoice_prefix'] . $row['invoice_number']) ?></div>
<div><?= trim((string) $row['invoice_scope']) !== '' ? escapeHtml($row['invoice_scope']) : 'Due ' . escapeHtml(date('M j, Y', strtotime($row['invoice_due']))) ?></div>
</span>
<span class="itflow-money"><?= escapeHtml(billingMoney($selection['cents'][$id] / 100, $selection['currency'])) ?></span>
</li>
<?php } ?>
</ul>
<div class="itflow-portal-paybar d-flex justify-content-between align-items-baseline">
<span class="text-body-secondary">Total</span>
<span class="itflow-portal-big-amount itflow-money"><?= escapeHtml($amount) ?></span>
</div>
</section>
</div>
</div>
<?php } ?>
<?php
require_once "includes/footer.php";