diff --git a/CHANGELOG.md b/CHANGELOG.md index ff16fd504..6e5a5a2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run Click the value and pick a new one, or use the + at the end of a record's tags. Each change saves straight away. - Tickets: a closed ticket can no longer be changed, either from the ticket's windows or through the API. Merging is only offered between tickets of the same client. - Client portal: primary and technical contacts' Tickets page now opens on everyone's tickets at their company, with Mine to see only their own. The separate All tickets page is gone, and links to it land on Tickets. A ticket shows the client where it stands - Received, In progress, Waiting on you, On hold, Resolved or Closed - rather than your ticket statuses. +- Client portal: invoices and quotes now open in the portal, where they used to open their guest link in a new tab. The links in your emails work as before. A quote answered in the portal records the contact's name and any note they left. Pay invoices pays the invoices ticked as one card payment, and the Pay drop-down on each invoice is gone (see Client portal). +- Stripe: Refund on a Stripe payment now refunds that payment's amount, where it used to refund the whole card charge. A card payment from the client portal over several invoices is booked as a payment on each, so refund each invoice's part from that invoice. Stripe's fee for such a payment is recorded once, naming every invoice it paid. - Invoicing a ticket: - The invoice line now reads `Ticket TCK-123 - Subject - 10/14/2026`. It used to be a block of text with the agent, dates, contact, asset and time spent. You can still edit it before adding the line. - Billing a ticket goes in the ticket's history ("Billed on invoice INV-45"). It used to add an internal note to the conversation. @@ -673,14 +675,23 @@ If you are coming from 26.08 or earlier, the database update still has to be run - A cleared line's quick look says which statement it cleared on, and its Edit window keeps its amount, date and account as they are (see Breaking Changes, Accounts, transactions and transfers). - Reconciling needs write access to Financial, and undoing a reconciled statement full access. Agents with read access can look at the statements. - The demo data reconciles Operating Checking and the Business Visa to each month's statement through last month. -- **Client portal** (rebuilt: the look, home and tickets) +- **Client portal** (rebuilt: the look, home, tickets and billing) - A new look: a light top bar with your logo, or your company's initials on your theme colour, and the sections the contact can open - Home, Tickets, Billing for billing contacts, Your IT for technical contacts, and your custom links - with New ticket and their account menu. On a phone the sections sit in a bar along the bottom and a menu that slides in. Contacts can switch to dark mode from their account menu, and it stays that way. The welcome banner along the top of every page is gone. - Home shows what needs the contact first: tickets waiting on their reply or resolved for them to confirm, tasks waiting for their approval and invoices past due. Then their open tickets, what is owed with Pay invoices, the domains, certificates, licenses and warranties renewing in the next 45 days or lapsed in the last 30, the devices they use with any warranty that has run out or is about to, and your phone and email when they are set. - Tickets has tabs for Open, Waiting on you, Resolved, Closed and All, each with its count, a search by subject or number, and 20 to a page. Primary and technical contacts see their whole company's, with who raised each, and Mine for their own. - A ticket shows how far it has got - received, in progress, resolved, closed - and the conversation oldest first, with the contact's own messages on the right and the files that came with each. Tasks waiting for their approval have Approve. Alongside are the priority, category, who raised it, who is working on it, the device and how many tasks are done. - Replies take files dropped onto the form or chosen, listed before they are sent. It's fixed marks a ticket resolved once its tasks are done, a resolved ticket asks them to close it or reopen it, and a closed one asks how you did. - New Ticket asks how much it is getting in the way, what it is about and which device - their own, or any of their company's for primary and technical contacts - and takes screenshots and files. Alongside it is what happens next, and your phone number for anything urgent when it is set. - - The billing, Your IT and account pages are in the new look and work as before. They are rebuilt in the stages to come. + - Invoices open in the portal, with tabs for To pay, Paid and All, each with its count, a search by number or scope, and what is owed - and how much of it is late - under the title. An invoice shows as it prints, with the amount due and when, Pay, Download PDF and Print, the payments made with a receipt for each, and the recurring invoice it came from. When autopay will pay it, it says which card and when. Opening a sent invoice marks it viewed, with the contact's name in its history. + - Pay invoices: the contact ticks the invoices to pay - all of them, or just the one when they come from its Pay button - and pays them together as one card payment, with a saved card there and then or a new card on a checkout page. The total and the Pay button follow the ticks, and your Stripe threshold applies to the total. Each invoice gets a payment for its part with the same reference, the client gets one receipt listing them all, and your paid notification address one email. + - Quotes has tabs for Waiting for you, Accepted and All. A quote shows as it prints, with what it comes to up front and each month, quarter or year. Accept and Decline open a window for the contact's name and a note for you - a PO number, when to start, what would change their mind - and both go in the quote's history, the notification and the email to your quote notification address. Accepting asks them to confirm they can accept it for their company. An accepted quote takes a signed copy or purchase order, and one that has run out offers to ask for an updated quote. + - Payments lists every payment on the account, with one card payment over several invoices as one line. Its receipt shows what was paid, when and how, and each invoice it paid with what is still owed, ready to print. + - Statement shows what is owed by how late it is - current, then 1-30, 31-60, 61-90 and over 90 days - with the open invoices, or everything from the last 12 months with the paid ones. Download PDF is the same statement you email. + - A recurring invoice shows what it bills line by line, when the next invoice comes, its autopay card and the invoices it has made. + - Cards & autopay: the saved cards, with expired ones marked and the recurring invoices each one pays, adding a card in Stripe's form straight after agreeing to it, removing one, and autopay for each recurring invoice, all on one page. + - Home lists quotes waiting for an answer, and a past-due invoice opens that invoice. Ask us on an invoice, quote or recurring invoice starts a ticket with the subject filled in. + - Pay invoices and Cards & autopay only show when Stripe is set up. Without it, an invoice says to use the payment details on it. + - The Your IT and account pages are in the new look and work as before. They are rebuilt in the stages to come. ### Bug Fixes @@ -748,6 +759,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Client Portal: a reply of nothing but empty lines was accepted, and a new ticket could be raised with no details. - Client Portal: Pay invoices and Recurring invoices wrote PHP warnings onto the page when no payment provider was set up. - Client Portal: on most pages the browser blocked the arrows on drop-downs, the close crosses and the editor setup, as the portal's security policy didn't allow them. +- Client Portal: paying an invoice with a saved card didn't check the contact had billing access, and couldn't pay a partly paid invoice. A double click could charge the card twice. +- Client Portal: the saved cards page asked Stripe about every card each time it opened, and showed an error instead of the cards when Stripe couldn't be reached. It started a Stripe checkout on every visit too, where it now starts one when Add a card is clicked. - Client Portal: a ticket rating could be saved as any value. Only Good and Bad, the two buttons the portal shows, are accepted now. The demo data rated tickets Great and Ok, which the portal never offers, and now uses Good and Bad. - Notifications that linked to a page did not open correctly from Admin pages. - Messages showed a backslash before apostrophes and quotes in names, or showed `&` instead of `&`. @@ -1066,6 +1079,17 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `client/tickets.php` takes `tab` (`open`, `you`, `resolved`, `closed`, `all`), `scope` (`mine`), `q` and `page`. `client/ticket_view_all.php` redirects there. `add_ticket` takes `file[]` and checks the subject, details, category and asset. `client/ticket_attachment.php` refuses a file on an internal or archived reply. - Removed: `ticketPortalStatusHtml()` from `functions/ticket.php`, replaced by `portalTicketStateHtml()`. - CSS: the `.itflow-portal-*` classes, from `-header`, `-nav`, `-tabbar` and `-menu` to `-panel`, `-state-*`, `-tabs`, `-ticket`, `-steps`, `-msg`, `-event`, `-drop`, `-file-chip`, `-facts`, `-stat` and `-todo`. The black table heads on the pages not rebuilt yet are softened to sit in the light shell. +- Client portal billing: + - New pages: `client/invoice.php`, `quote.php` and `recurring_invoice.php` (`?id=`, the session's client's only, never a draft or a paused recurring invoice), `payments.php` (`page`), `receipt.php` (`?id=` any payment of the receipt), `pay.php` (the checkout) and `pdf.php` (`?invoice=` or `?quote=`). `invoices.php` takes `tab` (`open`, `paid`, `all`), `q` and `page`; `quotes.php` takes `tab` (`waiting`, `accepted`, `all`), `q` and `page`; `statement.php` takes `view=year`; `unpaid_invoices.php` takes `select=ID,ID`; `saved_payment_methods.php` takes `add=1` to open Stripe's form; `ticket_add.php` takes `subject`. + - `client/post.php`: `portal_quote_answer` (`quote_id`, `answer` accept or decline, `name`, `note`, `authorised`), `portal_quote_file` (`quote_id`, `file[]`), `portal_pay` (`invoice_ids[]`, `method` - a saved card's id or `new` - and `pay_token`, which is the Stripe idempotency key) and `portal_pay_return` (Stripe's return from the checkout). `add_payment_by_provider` is removed. `export_statement_pdf` draws `billingStatementPdf()` and takes `paid=1`. `create_stripe_customer` goes on to `saved_payment_methods.php?add=1`. + - The new `functions/billing_pay.php`: `billingStripeProvider()`, `billingStripeClient()`, `billingStripeCustomer($client_id)`, `billingOnlinePayAllowed($amount)`, `billingCardParts($description)`, `billingSavedCards($client_id)`, `billingPaySelection($client_id, $invoice_ids, &$error)`, `billingPaySplit()` and `billingPaySplitParse()`, `billingStripeIntentParams()`, `billingStripeChargeSaved()`, `billingStripeCheckoutIntent()`, `billingStripeBook($pi, $client_id, $by)`, `billingClientPayments()`, `billingPaymentReceipt($payment_id, $client_id)` and `billingPaymentMethodText()`. A portal PaymentIntent carries `itflow_invoices` (`invoice_id:cents,...`) and `itflow_client_id` in its metadata, plus `itflow_invoice_id` when it pays one invoice. `billingStripeBook()` takes a MySQL `GET_LOCK` on the PaymentIntent and books it once, a payment per invoice referenced `Stripe - pi_...`. + - `functions/billing.php`: `billingQuoteAnswer($quote_id, $accept, $by)`, `billingQuoteAddFiles($quote_id, $files, $by)` and `billingQuoteFiles($quote_id)`. `guest/guest_post.php` answers quotes and takes their uploads through them, with the same messages. `billingGuestViewed()` takes `$who` for a contact in the portal. + - `functions/billing_email.php`: `billingPaymentReceiptSend($applied, $payment, $notify)` sends one receipt for a payment over one invoice or several, and `billingPaymentSend()` calls it. `billingPaymentNotifyMany()` is the paid notification for several invoices. `billingQuoteAnsweredNotify()` takes who answered and their note. + - `client/functions.php`: `portalBillingGate()` (billing access and the accounting module), `portalStateBadge()`, `portalInvoiceState($row, $paid)`, `portalQuoteState($row)`, `portalQuoteWaitingSql()`, `portalBillingViewed($type, $row)` (once a sign-in) and `portalAskUrl($subject)`. `portalNav()` adds Payments, and only lists Pay invoices and Cards & autopay when Stripe is set up. + - A page that sets `$portal_stripe` before `includes/inc_all.php` gets the portal's Content-Security-Policy with Stripe's script, frames and API added (Stripe.js, Checkout and Link, as Stripe lists them), and Stripe.js with the new `js/portal_stripe.js` - the Payment Element on `pay.php` and the embedded Checkout for Add a card. `js/portal.js` adds `[data-portal-print]` and the Pay invoices form (`form[data-portal-pay]`, `[data-pay-cents]`, `[data-pay-all]`, `[data-pay-label]`). + - `includes/billing_paper.php` takes `client` to leave out the agent-only note on a seat line. + - `cron/nightly_tasks.php` reconciles Stripe fees by payment reference, so a card payment over several invoices gets one fee expense. `refund_payment_stripe` refunds the payment's amount. + - CSS: `.itflow-portal-amount`, `-big-amount`, `-state-late`, `-outcome`, `-note`, `-payrow`, `-paybox`, `-methods`, `-method`, `-paybar`, `-stripe-element`, `-card-brand`, `-aging`, `-table` and `-receipt`, and print rules that leave only the document. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()` and `clientOverviewPersonInitials()` diff --git a/agent/post/payment.php b/agent/post/payment.php index 6278f76a8..fe5d244dc 100644 --- a/agent/post/payment.php +++ b/agent/post/payment.php @@ -587,11 +587,13 @@ if (isset($_GET['refund_payment_stripe'])) { } // Get Stripe Payment Intent ID - $sql = mysqli_query($mysqli,"SELECT payment_reference FROM payments WHERE payment_id = $payment_id LIMIT 1"); + $sql = mysqli_query($mysqli,"SELECT payment_amount, payment_reference FROM payments WHERE payment_id = $payment_id LIMIT 1"); $row = mysqli_fetch_assoc($sql); if (preg_match('/(?pi_[A-Za-z0-9]+)/', $row['payment_reference'], $matches)) { $stripe_payment_intent_id = $matches['payment_intent']; } + // Only this payment's part - one card payment from the client portal can pay several invoices + $refund_cents = (int) round(floatval($row['payment_amount']) * 100); // Get invoice details $sql = mysqli_query($mysqli,"SELECT client_id, client_name, contact_email, contact_extension, contact_mobile, @@ -631,8 +633,9 @@ if (isset($_GET['refund_payment_stripe'])) { // Refund $refund = $stripe->refunds->create([ - 'payment_intent' => $stripe_payment_intent_id - ]); + 'payment_intent' => $stripe_payment_intent_id, + 'amount' => $refund_cents, + ]); } catch (Exception $e) { $error = $e->getMessage(); error_log("Stripe refund error - encountered exception during refund for invoice ID $invoice_id / $invoice_prefix$invoice_number: $error"); diff --git a/client/functions.php b/client/functions.php index c6bf0e7a3..6992262e2 100644 --- a/client/functions.php +++ b/client/functions.php @@ -171,14 +171,20 @@ function portalNav() { ]; if (contactCan('accounting') && $config_module_enable_accounting == 1) { + $cards = billingStripeProvider() !== null; $children = [ - ['label' => 'Invoices', 'icon' => 'fa-file-invoice-dollar', 'href' => '/client/invoices.php', 'pages' => ['invoices.php']], - ['label' => 'Pay invoices', 'icon' => 'fa-hand-holding-usd', 'href' => '/client/unpaid_invoices.php', 'pages' => ['unpaid_invoices.php']], - ['label' => 'Quotes', 'icon' => 'fa-comment-dollar', 'href' => '/client/quotes.php', 'pages' => ['quotes.php']], - ['label' => 'Recurring invoices', 'icon' => 'fa-redo-alt', 'href' => '/client/recurring_invoices.php', 'pages' => ['recurring_invoices.php']], - ['label' => 'Statement', 'icon' => 'fa-file-alt', 'href' => '/client/statement.php', 'pages' => ['statement.php']], - ['label' => 'Saved cards', 'icon' => 'fa-credit-card', 'href' => '/client/saved_payment_methods.php', 'pages' => ['saved_payment_methods.php']], + ['label' => 'Invoices', 'icon' => 'fa-file-invoice-dollar', 'href' => '/client/invoices.php', 'pages' => ['invoices.php', 'invoice.php']], ]; + if ($cards) { + $children[] = ['label' => 'Pay invoices', 'icon' => 'fa-hand-holding-usd', 'href' => '/client/unpaid_invoices.php', 'pages' => ['unpaid_invoices.php', 'pay.php']]; + } + $children[] = ['label' => 'Quotes', 'icon' => 'fa-comment-dollar', 'href' => '/client/quotes.php', 'pages' => ['quotes.php', 'quote.php']]; + $children[] = ['label' => 'Recurring invoices', 'icon' => 'fa-redo-alt', 'href' => '/client/recurring_invoices.php', 'pages' => ['recurring_invoices.php', 'recurring_invoice.php']]; + $children[] = ['label' => 'Payments', 'icon' => 'fa-receipt', 'href' => '/client/payments.php', 'pages' => ['payments.php', 'receipt.php']]; + $children[] = ['label' => 'Statement', 'icon' => 'fa-file-alt', 'href' => '/client/statement.php', 'pages' => ['statement.php']]; + if ($cards) { + $children[] = ['label' => 'Cards & autopay', 'icon' => 'fa-credit-card', 'href' => '/client/saved_payment_methods.php', 'pages' => ['saved_payment_methods.php']]; + } $nav['billing'] = ['label' => 'Billing', 'icon' => 'fa-file-invoice-dollar', 'children' => $children, 'pages' => array_merge(...array_column($children, 'pages'))]; } @@ -310,3 +316,93 @@ function portalTicketEventText($html) { return preg_match('/^Ticket (resolved|reopened|closed) by .{1,200}\.$/', $text) ? $text : ''; } +/* + * ---------- billing ---------- + * + * Invoices, quotes, recurring invoices, payments and the statement, for + * primary and billing contacts. Every query is keyed to the session's client; + * paying by card is functions/billing_pay.php. + */ + +/** Billing pages and handlers: a primary or billing contact, with the accounting module on - anyone else is sent away. */ +function portalBillingGate() { + global $config_module_enable_accounting; + enforceContactCan('accounting'); + if ($config_module_enable_accounting != 1) { + redirect('/client/index.php'); + } +} + +/** A badge from a state - label, icon and class (portalInvoiceState(), portalQuoteState()). */ +function portalStateBadge(array $state) { + return '' . escapeHtml($state['label']) . ''; +} + +/** + * Where an invoice stands for the client: invoiceState() (state, balance, + * paid, days) with a label, icon and class - paid, cancelled, overdue, or + * when it is due. + */ +function portalInvoiceState(array $row, $paid) { + $state = invoiceState($row, $paid); + $days = $state['days']; + switch ($state['state']) { + case 'paid': + return $state + ['label' => 'Paid', 'icon' => 'fa-check', 'class' => 'itflow-portal-state-resolved']; + case 'cancelled': + return $state + ['label' => 'Cancelled', 'icon' => 'fa-ban', 'class' => 'itflow-portal-state-closed']; + case 'nonbillable': + return $state + ['label' => 'No charge', 'icon' => 'fa-minus-circle', 'class' => 'itflow-portal-state-closed']; + case 'draft': + return $state + ['label' => 'Draft', 'icon' => 'fa-pencil-alt', 'class' => 'itflow-portal-state-closed']; + case 'overdue': + return $state + ['label' => -$days . ' ' . (-$days === 1 ? 'day' : 'days') . ' overdue', 'icon' => 'fa-exclamation-circle', 'class' => 'itflow-portal-state-late']; + } + if ($days === 0) { + return $state + ['label' => 'Due today', 'icon' => 'fa-clock', 'class' => 'itflow-portal-state-you']; + } + if ($days <= 7) { + return $state + ['label' => "Due in $days " . ($days === 1 ? 'day' : 'days'), 'icon' => 'fa-clock', 'class' => 'itflow-portal-state-you']; + } + return $state + ['label' => 'Due ' . date('M j', strtotime((string) $row['invoice_due'])), 'icon' => 'fa-clock', 'class' => 'itflow-portal-state-new']; +} + +/** Where a quote stands for the client - waiting for their answer, run out, accepted or declined - with a label, icon and class. */ +function portalQuoteState(array $row) { + $status = (string) $row['quote_status']; + if (in_array($status, ['Accepted', 'Invoiced'], true)) { + return ['key' => 'accepted', 'label' => 'Accepted', 'icon' => 'fa-thumbs-up', 'class' => 'itflow-portal-state-resolved']; + } + if ($status === 'Declined') { + return ['key' => 'declined', 'label' => 'Declined', 'icon' => 'fa-thumbs-down', 'class' => 'itflow-portal-state-closed']; + } + if (quoteExpired($row)) { + return ['key' => 'expired', 'label' => 'Expired', 'icon' => 'fa-hourglass-end', 'class' => 'itflow-portal-state-hold']; + } + return ['key' => 'waiting', 'label' => 'Waiting for you', 'icon' => 'fa-reply', 'class' => 'itflow-portal-state-you']; +} + +/** SQL: quotes waiting for the client's answer - sent or viewed, and not run out. */ +function portalQuoteWaitingSql() { + return "quote_status IN ('Sent', 'Viewed') AND quote_expire >= CURDATE()"; +} + +/** + * The contact opened an invoice or quote in the portal - marked viewed, in + * its history and told to the agents (billingGuestViewed()), once each time + * they sign in rather than on every look. + */ +function portalBillingViewed($type, array $row) { + global $contact; + $key = $type . ':' . intval($row[billingType($type)['col'] . '_id']); + if (!empty($_SESSION['portal_viewed'][$key])) { + return; + } + $_SESSION['portal_viewed'][$key] = 1; + billingGuestViewed($type, $row, (string) ($contact['contact_name'] ?? '')); +} + +/** The ask-us link for a document - a new ticket with its subject filled in. */ +function portalAskUrl($subject) { + return 'ticket_add.php?' . http_build_query(['subject' => $subject]); +} diff --git a/client/includes/footer.php b/client/includes/footer.php index 4002896c4..2a7431a99 100644 --- a/client/includes/footer.php +++ b/client/includes/footer.php @@ -46,5 +46,11 @@ + + + + + + diff --git a/client/includes/header.php b/client/includes/header.php index dfc3064bd..d481db830 100644 --- a/client/includes/header.php +++ b/client/includes/header.php @@ -15,11 +15,21 @@ header("X-Frame-Options: DENY"); // Legacy // A page that turned the policy on keeps it, with data: images allowed - the // arrows on drop-downs, ticks in checkboxes and the close crosses are data: -// SVGs in Bootstrap's CSS, which default-src 'self' alone blocked -foreach (headers_list() as $portal_header) { - if (stripos($portal_header, 'Content-Security-Policy:') === 0) { - header("Content-Security-Policy: default-src 'self'; img-src 'self' data:"); - break; +// SVGs in Bootstrap's CSS, which default-src 'self' alone blocked. A page +// that takes a card sets $portal_stripe first: the same policy, with +// Stripe's script, frames and API let in - Stripe's own list, from +// docs.stripe.com/security/guide (Stripe.js, Checkout and Link) +if (!empty($portal_stripe)) { + header("Content-Security-Policy: default-src 'self'; img-src 'self' data: https://*.stripe.com https://*.link.com; " + . "script-src 'self' https://js.stripe.com https://*.js.stripe.com https://checkout.stripe.com; " + . "frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com https://checkout.stripe.com https://link.com https://*.link.com; " + . "connect-src 'self' https://api.stripe.com https://checkout.stripe.com https://link.com https://*.link.com"); +} else { + foreach (headers_list() as $portal_header) { + if (stripos($portal_header, 'Content-Security-Policy:') === 0) { + header("Content-Security-Policy: default-src 'self'; img-src 'self' data:"); + break; + } } } diff --git a/client/index.php b/client/index.php index d8862ccc6..b21a41b04 100644 --- a/client/index.php +++ b/client/index.php @@ -2,7 +2,8 @@ /* * Client Portal * Home - what needs this contact: tickets waiting on their reply or for them - * to confirm, approvals, invoices past due; their open tickets, the balance, + * to confirm, approvals, invoices past due, quotes waiting for an answer; + * their open tickets, the balance, * renewals coming up and the devices they use. * * SCOPING: everything here is keyed to $session_client_id from the session, @@ -77,7 +78,7 @@ if ($can_billing) { $overdue += $left; $overdue_count++; $needs_you[] = [ - 'href' => 'unpaid_invoices.php', + 'href' => 'invoice.php?id=' . intval($invoice['invoice_id']), 'icon' => 'fa-file-invoice-dollar', 'tone' => 'is-danger', 'title' => 'Invoice ' . $invoice['invoice_prefix'] . $invoice['invoice_number'] . ' is past due', @@ -87,7 +88,21 @@ if ($can_billing) { $next_due = $invoice['invoice_due']; } } + + // Quotes waiting for their answer + $sql = mysqli_query($mysqli, "SELECT quote_id, quote_prefix, quote_number, quote_scope, quote_amount, quote_currency_code, quote_expire FROM quotes + WHERE quote_client_id = $session_client_id AND " . portalQuoteWaitingSql() . " ORDER BY quote_expire ASC LIMIT 4"); + while ($row = mysqli_fetch_assoc($sql)) { + $needs_you[] = [ + 'href' => 'quote.php?id=' . intval($row['quote_id']), + 'icon' => 'fa-comment-dollar', + 'tone' => '', + 'title' => 'Quote ' . $row['quote_prefix'] . $row['quote_number'] . ' is waiting for your answer', + 'line' => trim($row['quote_scope'] . ' · ' . billingMoney($row['quote_amount'], $row['quote_currency_code']), ' ·') . ' · good until ' . date('M j', strtotime($row['quote_expire'])), + ]; + } } +$pay_href = $can_billing && billingStripeProvider() ? 'unpaid_invoices.php' : 'invoices.php'; // Open tickets, newest first $recent = mysqli_query($mysqli, "SELECT " . portalTicketColumnsSql() . ", contact_name FROM tickets @@ -138,7 +153,7 @@ $first_name = strtok(trim((string) $contact['contact_name']), ' ') ?: '';
0) { ?> - Pay invoices +
@@ -164,7 +179,7 @@ $first_name = strtok(trim((string) $contact['contact_name']), ' ') ?: '';
- + diff --git a/client/invoice.php b/client/invoice.php new file mode 100644 index 000000000..d9b268c9d --- /dev/null +++ b/client/invoice.php @@ -0,0 +1,189 @@ + + Invoices +
+ "That invoice isn't here", 'text' => 'It may have been replaced, or the link is not quite right.', 'icon' => 'fa-file-invoice-dollar', 'filtered' => false, + 'actions' => [['label' => 'All invoices', 'href' => 'invoices.php', 'icon' => 'fa-arrow-left']]]) ?> +
+ = date('Y-m-d')) { + foreach (billingSavedCards($session_client_id) as $card) { + if (intval($card['saved_payment_id']) === intval($recurring['recurring_payment_saved_payment_id'])) { + $autopay_card = $card['card']; + } + } +} + +$paper = [ + 'type' => 'invoice', + 'row' => $invoice, + 'items' => $items, + 'totals' => $totals, + 'paid' => $state['paid'], + 'balance' => $state['balance'], + 'logo_url' => $company['logo_url'], + 'client' => true, +]; +?> + +Invoices + +
+
+

Invoice

+

+
+
+ Download PDF + +
+
+ +
+ +
+ +
+
+ +
+ +
+
Paid in full
+
+
+
+ +
+ +
+
+
Nothing is owed on this invoice.
+
+
+ +
Amount due
+
+
+ + + Due + +
+ 0) { ?> +

of paid so far.

+ + +
+ + Autopay will charge on - nothing to do, unless you would rather pay now. +
+ + + Pay + + + + +

This invoice is too large to pay by card here - please pay it another way, or ">ask us.

+ +

To pay, use the payment details on the invoice, or ">ask us how.

+ + +
+
+ + +
+
+

Payments

+
+ +
+ + + +
+ + + +
+
Billed - see what it covers
+
+ +
+
+ + +

+ Questions about this invoice? ">Ask us or call . +

+ +
+ +
+ +
+ +
+ + 0"; +$tabs = [ + 'open' => ['label' => 'To pay', 'icon' => 'fa-hourglass-half', 'where' => $open_sql], + 'paid' => ['label' => 'Paid', 'icon' => 'fa-check', 'where' => "NOT ($open_sql) AND invoice_status NOT IN ('Cancelled', 'Non-Billable')"], + 'all' => ['label' => 'All', 'icon' => 'fa-list', 'where' => '1 = 1'], +]; +$q = trim((string) ($_GET['q'] ?? '')); +$where = "invoice_client_id = $session_client_id AND invoice_status != 'Draft'"; +if ($q !== '') { + $q_sql = escapeSql($q); + $where .= " AND (CONCAT(invoice_prefix, invoice_number) LIKE '%$q_sql%' OR invoice_scope LIKE '%$q_sql%')"; +} -$invoices_sql = mysqli_query($mysqli, "SELECT invoice_amount, invoice_date, invoice_due, invoice_id, invoice_number, invoice_prefix, - invoice_scope, invoice_status, invoice_url_key FROM invoices WHERE invoice_client_id = $session_client_id AND invoice_status != 'Draft' ORDER BY invoice_date DESC"); +$count_sql = []; +foreach ($tabs as $key => $t) { + $count_sql[] = "COALESCE(SUM({$t['where']}), 0) AS `$key`"; +} +$counts = array_map('intval', mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " FROM invoices WHERE $where"))); + +// The To pay tab when there is something to pay, else all of them +$tab = isset($tabs[$_GET['tab'] ?? '']) ? $_GET['tab'] : ($counts['open'] ? 'open' : 'all'); +$total = $counts[$tab]; +$pages = max(1, (int) ceil($total / $per_page)); +$page = min(max(1, intval($_GET['page'] ?? 1)), $pages); +$offset = ($page - 1) * $per_page; + +$invoices = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_due, invoice_status, invoice_amount, + invoice_currency_code, $paid_sql AS paid + FROM invoices WHERE $where AND {$tabs[$tab]['where']} + ORDER BY " . ($tab === 'open' ? 'invoice_due ASC, invoice_number ASC' : 'invoice_date DESC, invoice_number DESC') . " + LIMIT $per_page OFFSET $offset"); + +// What is owed, over every open invoice whatever the search +$owed = 0.0; +$late = 0.0; +$currency = ''; +foreach (billingClientOpenInvoices($session_client_id) as $open) { + $left = round(floatval($open['invoice_amount']) - floatval($open['paid']), 2); + $owed += $left; + $currency = $currency ?: (string) $open['invoice_currency_code']; + if ($open['invoice_due'] < date('Y-m-d')) { + $late += $left; + } +} +$cards = billingStripeProvider() !== null; + +/** This page's address with some of its settings changed. */ +function invoicesUrl(array $changes) { + global $tab, $q; + $params = array_merge(['tab' => $tab, 'q' => $q !== '' ? $q : null], $changes); + $params = array_filter($params, function ($v) { return $v !== null && $v !== ''; }); + return 'invoices.php' . ($params ? '?' . http_build_query($params) : ''); +} ?> -

Invoices

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - +
+
+

Invoices

+

+ 0) { ?> + You owe 0) { ?> - of it past due + + Nothing is owed - thank you - -

-
#ScopeAmountDateDueStatus
"> - - - -
- - +

+
+
+ + 0 && $cards) { ?> + Pay invoices +
-
+ + +
+ + + 'invoices', 'filtered' => true, 'match_text' => 'Nothing here has “' . escapeHtml($q) . '” in its number or scope.', 'clear_url' => invoicesUrl(['q' => null, 'page' => null])]) ?> + + 'Nothing to pay', 'text' => 'You are all paid up - thank you.', 'icon' => 'fa-check', 'filtered' => false]) ?> + + 'No paid invoices yet', 'text' => 'Invoices show here once they are paid.', 'icon' => 'fa-check', 'filtered' => false]) ?> + + 'No invoices yet', 'text' => 'When we bill you, the invoice shows here.', 'icon' => 'fa-file-invoice-dollar', 'filtered' => false]) ?> + + + + 1) { ?> +
+ - of +
+ + +
+
+ + +
(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']))]) : ''); +?> + +Pay invoices + + + +
+
+

Pay by card

+
+
+
+ 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']]]) ?> +
+ + + +
+
+

Pay

+

· paid securely through Stripe

+
+
+ +
+
+
+
+

Card details

+ Secure +
+
"> +
+
Loading the card form...
+
+ + + +

Your card details go straight to Stripe and never reach us.

+
+
+
+ +
+
+
+

Paying

+ Change +
+
    + $row) { ?> +
  • + +
    +
    +
    + +
  • + +
+
+ Total + +
+
+
+
+ + + + $pages) { + $page = $pages; + $list = billingClientPayments($session_client_id, $per_page, ($page - 1) * $per_page); +} +$offset = ($page - 1) * $per_page; +?> + +
+
+

Payments

+

What you have paid us, with a receipt for each

+
+
+ +
+ + 'No payments yet', 'text' => 'Once you pay an invoice, the payment and its receipt show here.', 'icon' => 'fa-receipt', 'filtered' => false]) ?> + + + 1) { ?> +
+ - of +
+ + +
+
+ + +
+ + 0 - ORDER BY invoice_date ASC, invoice_number ASC" - ); - - if (mysqli_num_rows($statement_sql) == 0) { - flashAlert("There is nothing outstanding to put on a statement", 'error'); + if (!$statement['invoices']) { + flashAlert("There is nothing to put on a statement", 'error'); redirect("statement.php"); } - require_once("../libs/TCPDF/tcpdf.php"); - - // Start TCPDF - $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); - $pdf->SetMargins(10, 10, 10); - $pdf->setPrintHeader(false); - $pdf->setPrintFooter(false); - $pdf->AddPage(); - $pdf->SetFont('helvetica', '', 10); - - // Logo + title - $html = ' - - - - -
'; - if (!empty($company_logo) && file_exists("../uploads/settings/$company_logo")) { - $html .= ''; - } - $html .= ' - Account Statement
- As of ' . date("Y-m-d") . ' -

'; - - $html .= ' - - - - - - - - -
' . $company_name . '' . $client_name . '
' . nl2br(formatAddress($company_address, $company_city, $company_state, $company_zip, $company_country) . "\n$company_phone\n$company_website") . '

'; - - // Statement lines - $html .= ' - - - - - - - - - '; - - $statement_total = 0; - $statement_row_shade = false; - - while ($row = mysqli_fetch_assoc($statement_sql)) { - $invoice_prefix = escapeHtml($row['invoice_prefix']); - $invoice_number = intval($row['invoice_number']); - $invoice_scope = escapeHtml($row['invoice_scope']); - $invoice_date = escapeHtml($row['invoice_date']); - $invoice_due = escapeHtml($row['invoice_due']); - $invoice_amount = floatval($row['invoice_amount']); - $amount_paid = floatval($row['amount_paid']); - $invoice_balance = $invoice_amount - $amount_paid; - - $statement_total = $statement_total + $invoice_balance; - - // Same one-day grace as client/statement.php and client/invoices.php - if (strtotime($invoice_due) + 86400 < time()) { - $due_style = ' style="color:#dc3545;"'; - } else { - $due_style = ''; - } - - $row_background = $statement_row_shade ? ' bgcolor="#f2f2f2"' : ''; - $statement_row_shade = !$statement_row_shade; - - $html .= ' - - - - - - - - '; - } - - $html .= ' - - - -
InvoiceScopeDateDueAmountPaidBalance
' . $invoice_prefix . $invoice_number . '' . $invoice_scope . '' . $invoice_date . '' . $invoice_due . '' . numfmt_format_currency($currency_format, $invoice_amount, $statement_currency_code) . '' . numfmt_format_currency($currency_format, $amount_paid, $statement_currency_code) . '' . numfmt_format_currency($currency_format, $invoice_balance, $statement_currency_code) . '
Total Balance Due' . numfmt_format_currency($currency_format, $statement_total, $statement_currency_code) . '
'; - - $pdf->writeHTML($html, true, false, true, false, ''); - - $filename = toAlphanumeric($client_name) . "-Account_Statement-" . date("Y-m-d"); - - $pdf->Output("$filename.pdf", 'D'); - - exit(); + billingPdfSend(billingStatementPdf($statement), true); } @@ -781,200 +643,183 @@ if (isset($_POST['edit_contact'])) { } -if (isset($_GET['add_payment_by_provider'])) { +if (isset($_POST['portal_quote_answer'])) { validateCSRFToken(); - $invoice_id = intval($_GET['invoice_id']); - $saved_payment_id = intval($_GET['add_payment_by_provider']); + portalBillingGate(); - // Get invoice details - $sql = mysqli_query($mysqli,"SELECT client_id, client_name, contact_email, contact_extension, contact_mobile, - contact_mobile_country_code, contact_name, contact_phone, contact_phone_country_code, - invoice_amount, invoice_currency_code, invoice_number, invoice_prefix, invoice_status, - invoice_url_key FROM invoices - LEFT JOIN clients ON invoice_client_id = client_id - LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1 - WHERE invoice_id = $invoice_id AND client_id = $session_client_id" - ); - $row = mysqli_fetch_assoc($sql); - $invoice_number = intval($row['invoice_number']); - $invoice_status = escapeSql($row['invoice_status']); - $invoice_amount = floatval($row['invoice_amount']); - $invoice_prefix = escapeSql($row['invoice_prefix']); - $invoice_number = intval($row['invoice_number']); - $invoice_url_key = escapeSql($row['invoice_url_key']); - $invoice_currency_code = escapeSql($row['invoice_currency_code']); - $client_id = intval($row['client_id']); - $client_name = escapeSql($row['client_name']); - $contact_name = escapeSql($row['contact_name']); - $contact_email = escapeSql($row['contact_email']); - $contact_phone = escapeSql(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code'])); - $contact_extension = preg_replace("/[^0-9]/", '',$row['contact_extension']); - $contact_mobile = escapeSql(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code'])); + // Accept or decline a quote, signed with a name and with a note for us - billingQuoteAnswer() in functions/billing.php, + // which the quote's guest link uses too + $quote_id = intval($_POST['quote_id'] ?? 0); + $accept = ($_POST['answer'] ?? '') === 'accept'; + $name = trim(mb_substr((string) ($_POST['name'] ?? ''), 0, 200)); + $note = trim(mb_substr((string) ($_POST['note'] ?? ''), 0, 2000)); - // Get ITFlow company details - $sql = mysqli_query($mysqli,"SELECT company_address, company_city, company_country, company_email, company_name, company_phone, - company_phone_country_code, company_state, company_website, company_zip FROM companies WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql); - $company_name = escapeSql($row['company_name']); - $company_country = escapeSql($row['company_country']); - $company_address = escapeSql($row['company_address']); - $company_city = escapeSql($row['company_city']); - $company_state = escapeSql($row['company_state']); - $company_zip = escapeSql($row['company_zip']); - $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code'])); - $company_email = escapeSql($row['company_email']); - $company_website = escapeSql($row['company_website']); - - // Sanitize Config vars from get_settings.php - $config_invoice_from_name = escapeSql($config_invoice_from_name); - $config_invoice_from_email = escapeSql($config_invoice_from_email); - - // Get Client Payment Details - $sql = mysqli_query($mysqli, "SELECT payment_provider_account, payment_provider_client, payment_provider_private_key, - payment_provider_public_key, saved_payment_client_id, saved_payment_description, - saved_payment_provider_method FROM client_saved_payment_methods LEFT JOIN payment_providers ON saved_payment_provider_id = payment_provider_id LEFT JOIN client_payment_provider ON saved_payment_client_id = client_id WHERE saved_payment_id = $saved_payment_id AND saved_payment_client_id = $session_client_id LIMIT 1"); - $row = mysqli_fetch_assoc($sql); - - $public_key = escapeSql($row['payment_provider_public_key']); - $private_key = escapeSql($row['payment_provider_private_key']); - $account_id = intval($row['payment_provider_account']); - $payment_provider_client = escapeSql($row['payment_provider_client']); - $saved_payment_method = escapeSql($row['saved_payment_provider_method']); - $saved_payment_description = escapeSql($row['saved_payment_description']); - $payment_client_id = intval($row['saved_payment_client_id']); - - // Sanity checks - // Check to make invoice belongs to logged in client - if ($client_id !== $session_client_id) { - flashAlert("Invoice does not belong to you!", 'danger'); - redirect(); - } elseif ($payment_client_id !== $session_client_id) { - flashAlert("Saved Payment method does not belong to you!", 'danger'); - redirect(); - } elseif (!$payment_provider_client || !$saved_payment_method) { - flashAlert("Stripe not enabled or no client card saved", 'error'); - redirect(); - } elseif ($invoice_status !== 'Sent' && $invoice_status !== 'Viewed') { - flashAlert("Invalid invoice state (draft/partial/paid/not billable)", 'error'); - redirect(); - } elseif ($invoice_amount == 0) { - flashAlert("Invalid invoice amount", 'error'); - redirect(); + $quote = billingRow('quote', $quote_id, false); + if (!$quote || intval($quote['client_id']) !== $session_client_id || $quote['quote_status'] === 'Draft') { + flashAlert("That quote isn't here", 'error'); + redirect('quotes.php'); + } + if ($name === '' || ($accept && empty($_POST['authorised']))) { + flashAlert($name === '' ? 'Put your name to your answer' : 'Tick to confirm you can accept the quote', 'error'); + redirect("quote.php?id=$quote_id"); } - // Initialize Stripe - require_once __DIR__ . '/../includes/stripe_init.php'; - $stripe = new \Stripe\StripeClient($private_key); - - $balance_to_pay = round($invoice_amount, 2); - $pi_description = "ITFlow: $client_name payment of $invoice_currency_code $balance_to_pay for $invoice_prefix$invoice_number"; - - // Create a payment intent - try { - $payment_intent = $stripe->paymentIntents->create([ - 'amount' => intval(round($balance_to_pay * 100)), // Times by 100 as Stripe expects values in cents - 'currency' => $invoice_currency_code, - 'customer' => $payment_provider_client, - 'payment_method' => $saved_payment_method, - 'off_session' => true, - 'confirm' => true, - 'description' => $pi_description, - 'metadata' => [ - 'itflow_client_id' => $client_id, - 'itflow_client_name' => $client_name, - 'itflow_invoice_number' => $invoice_prefix . $invoice_number, - 'itflow_invoice_id' => $invoice_id, - ] - ]); - - // Get details from PI - $pi_id = escapeSql($payment_intent->id); - $pi_date = date('Y-m-d', $payment_intent->created); - $pi_amount_paid = floatval(($payment_intent->amount_received / 100)); - $pi_currency = strtoupper(escapeSql($payment_intent->currency)); - $pi_livemode = $payment_intent->livemode; - - } catch (Exception $e) { - $error = $e->getMessage(); - error_log("Stripe payment error - encountered exception during payment intent for invoice ID $invoice_id / $invoice_prefix$invoice_number: $error"); - logApp("Stripe", "error", "Exception during PI for invoice ID $invoice_id: $error"); + $answer = billingQuoteAnswer($quote_id, $accept, ['name' => $name, 'note' => $note]); + if (!$answer['ok']) { + flashAlert($answer['error'], 'error'); + redirect("quote.php?id=$quote_id"); } - if ($payment_intent->status == "succeeded" && intval($balance_to_pay) == intval($pi_amount_paid)) { + $number = escapeHtml(billingNumber('quote', $quote)); + flashAlert($accept ? "Thank you - quote $number is accepted and our team has been told" : "Quote $number declined - thank you for letting us know"); - // Update Invoice Status - mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id"); + redirect("quote.php?id=$quote_id"); - // 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 = $account_id, payment_method = 'Stripe', payment_reference = 'Stripe - $pi_id', payment_invoice_id = $invoice_id"); - mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Paid', history_description = 'Online Payment added (agent)', history_invoice_id = $invoice_id"); +} - // Email receipt - if (!empty($config_smtp_host)) { - $subject = "Payment Received - Invoice $invoice_prefix$invoice_number"; - $body = "Hello $contact_name,

We have received online payment for the amount of " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " for invoice $invoice_prefix$invoice_number. Please keep this email as a receipt for your records.

Amount Paid: " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . "

Thank you for your business!


--
$company_name - Billing Department
$config_invoice_from_email
$company_phone"; +if (isset($_POST['portal_quote_file'])) { - // Queue Mail - $data = [ - [ - 'from' => $config_invoice_from_email, - 'from_name' => $config_invoice_from_name, - 'recipient' => $contact_email, - 'recipient_name' => $contact_name, - 'subject' => $subject, - 'body' => $body, - ] - ]; + validateCSRFToken(); - // Email the internal notification address too - if (!empty($config_invoice_paid_notification_email)) { - $subject = "Payment Received - $client_name - Invoice $invoice_prefix$invoice_number"; - $body = "Hello,

This is a notification that an invoice has been paid in ITFlow. Below is a copy of the receipt sent to the client:-

--------

Hello $contact_name,

We have received online payment for the amount of " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " for invoice $invoice_prefix$invoice_number. Please keep this email as a receipt for your records.

Amount Paid: " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . "

Thank you for your business!


--
$company_name - Billing Department
$config_invoice_from_email
$company_phone"; + portalBillingGate(); - $data[] = [ - 'from' => $config_invoice_from_email, - 'from_name' => $config_invoice_from_name, - 'recipient' => $config_invoice_paid_notification_email, - 'recipient_name' => $contact_name, - 'subject' => $subject, - 'body' => $body, - ]; - } - - $mail = addToMailQueue($data); - - // Email Logging - $email_id = mysqli_insert_id($mysqli); - mysqli_query($mysqli,"INSERT INTO history SET history_status = 'Sent', history_description = 'Payment Receipt sent to mail queue ID: $email_id!', history_invoice_id = $invoice_id"); - logAudit("Invoice", "Payment", "Payment receipt for invoice $invoice_prefix$invoice_number queued to $contact_email Email ID: $email_id", $client_id, $invoice_id); - } - - // Log info - $extended_log_desc = ''; - if (!$pi_livemode) { - $extended_log_desc = '(DEV MODE)'; - } - - // Notify/log - appNotify("Invoice Paid", "Invoice $invoice_prefix$invoice_number automatically paid", "/agent/invoice.php?invoice_id=$invoice_id", $client_id); - logAudit("Invoice", "Payment", "$session_contact_name initiated Stripe payment amount of " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " added to invoice $invoice_prefix$invoice_number - $pi_id $extended_log_desc", $client_id, $invoice_id); - triggerCustomAction('invoice_pay', $invoice_id); - - flashAlert("The amount " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " paid Invoice $invoice_prefix$invoice_number"); - - redirect(); + // A signed copy or purchase order for an accepted quote - billingQuoteAddFiles() in functions/billing.php + $quote_id = intval($_POST['quote_id'] ?? 0); + $quote = billingRow('quote', $quote_id, false); + if (!$quote || intval($quote['client_id']) !== $session_client_id || $quote['quote_status'] !== 'Accepted') { + flashAlert("Files can only be sent with an accepted quote", 'error'); + redirect($quote ? "quote.php?id=$quote_id" : 'quotes.php'); + } + $upload = billingQuoteAddFiles($quote_id, $_FILES['file'] ?? [], ['name' => (string) ($contact['contact_name'] ?? '')]); + if ($upload['added'] && !$upload['failed']) { + flashAlert('Sent - thank you. ' . escapeHtml(implode(', ', $upload['added'])) . ' is with us now'); + } elseif ($upload['added']) { + flashAlert('Sent ' . escapeHtml(implode(', ', $upload['added'])) . ' - but ' . $upload['failed'] . ' could not be taken. Only PDFs can be sent', 'error'); } else { - mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Payment failed', history_description = 'Stripe pay failed due to payment error', history_invoice_id = $invoice_id"); - - logAudit("Invoice", "Payment", "Failed online payment amount of invoice $invoice_prefix$invoice_number due to Stripe payment error", $client_id, $invoice_id); - flashAlert("Payment failed", 'error'); - - redirect(); + flashAlert('That file could not be taken - only PDFs can be sent', 'error'); } + redirect("quote.php?id=$quote_id"); + +} + +if (isset($_POST['portal_pay'])) { + + validateCSRFToken(); + + portalBillingGate(); + + /* + * Pay one or more invoices by card - functions/billing_pay.php. The choice + * is checked again here: the invoices must be this client's and still + * owed, in one currency and under the card limit. A saved card is charged + * there and then; a new card goes on to the checkout page (pay.php). + */ + $invoice_ids = array_map('intval', (array) ($_POST['invoice_ids'] ?? [])); + $method = (string) ($_POST['method'] ?? 'new'); + $back = 'unpaid_invoices.php' . ($invoice_ids ? '?' . http_build_query(['select' => implode(',', $invoice_ids)]) : ''); + + if (!billingStripeProvider()) { + flashAlert('Card payments are not set up', 'error'); + redirect('invoices.php'); + } + + $selection = billingPaySelection($session_client_id, $invoice_ids, $error); + if (!$selection) { + flashAlert(escapeHtml($error), 'error'); + redirect($back); + } + + if ($method === 'new') { + $_SESSION['portal_checkout'] = ['ids' => array_keys($selection['cents']), 'pi' => $_SESSION['portal_checkout']['pi'] ?? '']; + redirect('pay.php'); + } + + // A saved card, charged now - the form's token keeps a double click to one charge + $token = (string) ($_POST['pay_token'] ?? ''); + if (!preg_match('/^[a-f0-9]{32}$/', $token)) { + flashAlert('That page was out of date - check the invoices and try again', 'error'); + redirect($back); + } + $contact_name = (string) ($contact['contact_name'] ?? ''); + $charge = billingStripeChargeSaved($selection, $session_client_id, intval($method), $token, ['name' => $contact_name]); + if (!$charge['ok']) { + flashAlert(escapeHtml($charge['error']), 'error'); + redirect($back); + } + + $booked = billingStripeBook($charge['pi'], $session_client_id, ['name' => $contact_name, 'how' => 'Saved card', 'via' => 'in the client portal']); + if (!$booked['ok']) { + $pi_id = escapeSql((string) $charge['pi']->id); + logApp("Stripe", "error", "Saved card payment $pi_id for client $session_client_id went through but could not be booked: " . $booked['error']); + appNotify("Invoice Payment", "A card payment of " . escapeSql(billingMoney($selection['total'], $selection['currency'])) . " from $session_client_name went through in the client portal but could not be recorded - $pi_id", "/agent/invoices.php?client_id=$session_client_id", $session_client_id); + flashAlert('Your card was charged, but we could not record the payment here - we have been told and will put it right', 'error'); + redirect('invoices.php'); + } + + flashAlert('Thank you - your payment of ' . escapeHtml(billingMoney($selection['total'], $selection['currency'])) . ' went through'); + redirect('receipt.php?id=' . $booked['payment_id']); + +} + +if (isset($_GET['portal_pay_return'])) { + + portalBillingGate(); + + /* + * Back from Stripe after paying on the checkout page (pay.php) - with the + * payment's id and client secret. The secret must match the payment, and + * the payment must be this client's: then it is booked, once + * (billingStripeBook() in functions/billing_pay.php). + */ + $pi_id = (string) ($_GET['payment_intent'] ?? ''); + $secret = (string) ($_GET['payment_intent_client_secret'] ?? ''); + $stripe = billingStripeClient(); + if (!$stripe || !preg_match('/^pi_[A-Za-z0-9_]+$/', $pi_id)) { + flashAlert('That payment could not be found', 'error'); + redirect('invoices.php'); + } + + try { + $pi = $stripe->paymentIntents->retrieve($pi_id); + } catch (Throwable $e) { + logApp("Stripe", "error", "Could not retrieve $pi_id on return to the client portal: " . $e->getMessage()); + flashAlert('We could not check that payment just now - if your card was charged, it will show here shortly', 'error'); + redirect('invoices.php'); + } + + if (!hash_equals((string) $pi->client_secret, $secret) || intval($pi->metadata['itflow_client_id'] ?? 0) !== $session_client_id) { + flashAlert('That payment could not be found', 'error'); + redirect('invoices.php'); + } + + if ($pi->status === 'processing') { + flashAlert('Your payment is processing - the invoices show as paid once it clears'); + redirect('invoices.php'); + } + if ($pi->status !== 'succeeded') { + flashAlert('That payment did not go through - nothing was charged. Try again, or use another card', 'error'); + redirect('pay.php'); + } + + $booked = billingStripeBook($pi, $session_client_id, ['name' => (string) ($contact['contact_name'] ?? ''), 'how' => 'Online card', 'via' => 'in the client portal']); + if (!$booked['ok']) { + $pi_id_sql = escapeSql($pi_id); + logApp("Stripe", "error", "Card payment $pi_id for client $session_client_id went through but could not be booked: " . $booked['error']); + appNotify("Invoice Payment", "A card payment from $session_client_name went through in the client portal but could not be recorded - $pi_id_sql", "/agent/invoices.php?client_id=$session_client_id", $session_client_id); + flashAlert('Your card was charged, but we could not record the payment here - we have been told and will put it right', 'error'); + redirect('invoices.php'); + } + + unset($_SESSION['portal_checkout']); + if (!$booked['already']) { + flashAlert('Thank you - your payment of ' . escapeHtml(billingMoney(intval($pi->amount_received) / 100, strtoupper((string) $pi->currency))) . ' went through'); + } + redirect('receipt.php?id=' . $booked['payment_id']); + } if (isset($_POST['create_stripe_customer'])) { @@ -1043,7 +888,9 @@ if (isset($_POST['create_stripe_customer'])) { logAudit("Stripe", "Create", "$session_contact_name created Stripe customer for $session_client_name as $stripe_customer_id and authorized future automatic payments", $session_client_id, $session_client_id); - flashAlert("Stripe customer created. Thank you for your consent."); + flashAlert("Thank you - now add your card."); + + redirect('saved_payment_methods.php?add=1'); } catch (Exception $e) { $error = $e->getMessage(); @@ -1057,7 +904,8 @@ if (isset($_POST['create_stripe_customer'])) { } } else { - flashAlert("Stripe customer already exists for your account.", 'danger'); + // Already set up - on to adding the card + redirect('saved_payment_methods.php?add=1'); } redirect('saved_payment_methods.php'); diff --git a/client/quote.php b/client/quote.php new file mode 100644 index 000000000..6339740f5 --- /dev/null +++ b/client/quote.php @@ -0,0 +1,282 @@ + + Quotes +
+ "That quote isn't here", 'text' => 'It may have been replaced, or the link is not quite right.', 'icon' => 'fa-comment-dollar', 'filtered' => false, + 'actions' => [['label' => 'All quotes', 'href' => 'quotes.php', 'icon' => 'fa-arrow-left']]]) ?> +
+ 'quote', + 'row' => $quote, + 'items' => $items, + 'totals' => $totals, + 'logo_url' => $company['logo_url'], + 'client' => true, +]; +?> + +Quotes + +
+
+

Quote

+

+
+
+ Download PDF + +
+
+ +
+ +
+ +
+
+ +
+ $group) { + if ($frequency === '' && !$group['items'] && $group['discount'] == 0) { + continue; + } ?> +
+
+
+
+ +
+ +
Quote total
+
+ + + +

+ Good until . + Let us know whether to go ahead. +

+
+ + +
+ +
+ +
+
This quote has run out
+
It was good until - prices may have moved since.
+
+
+ ">Ask for an updated quote + +
+ +
+
Accepted - thank you
+
+ . + +
+
+
+ + Invoice + + +
+ +
+
Declined
+
+ . + If something needs changing, let us know and we'll send a new one. +
+
+
+ ">Ask for a new quote + +
+
+ + +
+
+

Signed copy or PO

+
+
+ +
+ + + +
+ + +
+ + +

Need us to have the quote signed, or a purchase order? Send the PDF here.

+
+ + Drop PDFs here, or + +
+ +
+ +
+
+ + +

+ Questions about this quote? ">Ask us or call . +

+ +
+ +
+ +
+ +
+ + + + + + + + ['label' => 'Waiting for you', 'icon' => 'fa-reply', 'where' => portalQuoteWaitingSql()], + 'accepted' => ['label' => 'Accepted', 'icon' => 'fa-thumbs-up', 'where' => "quote_status IN ('Accepted', 'Invoiced')"], + 'all' => ['label' => 'All', 'icon' => 'fa-list', 'where' => '1 = 1'], +]; +$q = trim((string) ($_GET['q'] ?? '')); +$where = "quote_client_id = $session_client_id AND quote_status != 'Draft'"; +if ($q !== '') { + $q_sql = escapeSql($q); + $where .= " AND (CONCAT(quote_prefix, quote_number) LIKE '%$q_sql%' OR quote_scope LIKE '%$q_sql%')"; +} + +$count_sql = []; +foreach ($tabs as $key => $t) { + $count_sql[] = "COALESCE(SUM({$t['where']}), 0) AS `$key`"; +} +$counts = array_map('intval', mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " FROM quotes WHERE $where"))); + +// The waiting tab when something is waiting, else all of them +$tab = isset($tabs[$_GET['tab'] ?? '']) ? $_GET['tab'] : ($counts['waiting'] ? 'waiting' : 'all'); +$total = $counts[$tab]; +$pages = max(1, (int) ceil($total / $per_page)); +$page = min(max(1, intval($_GET['page'] ?? 1)), $pages); +$offset = ($page - 1) * $per_page; + +$quotes = mysqli_query($mysqli, "SELECT quote_id, quote_prefix, quote_number, quote_scope, quote_date, quote_expire, quote_status, quote_amount, quote_monthly_amount, quote_currency_code + FROM quotes WHERE $where AND {$tabs[$tab]['where']} + ORDER BY " . ($tab === 'waiting' ? 'quote_expire ASC, quote_number ASC' : 'quote_date DESC, quote_number DESC') . " + LIMIT $per_page OFFSET $offset"); + +/** This page's address with some of its settings changed. */ +function quotesUrl(array $changes) { + global $tab, $q; + $params = array_merge(['tab' => $tab, 'q' => $q !== '' ? $q : null], $changes); + $params = array_filter($params, function ($v) { return $v !== null && $v !== ''; }); + return 'quotes.php' . ($params ? '?' . http_build_query($params) : ''); +} ?> -

Quotes

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#ScopeAmountDateStatus
"> - - -
+ a month
- -
- - - -
- - +
+
+

Quotes

+

- +
+ + +
+ + + 'quotes', 'filtered' => true, 'match_text' => 'Nothing here has “' . escapeHtml($q) . '” in its number or scope.', 'clear_url' => quotesUrl(['q' => null, 'page' => null])]) ?> + + 'Nothing waiting for you', 'text' => 'When we send you a quote, it waits here for your answer.', 'icon' => 'fa-check', 'filtered' => false]) ?> + + 'No accepted quotes', 'text' => '', 'icon' => 'fa-thumbs-up', 'filtered' => false]) ?> + + 'No quotes yet', 'text' => 'Need something priced up? Raise a ticket and we will put a quote together.', 'icon' => 'fa-comment-dollar', 'filtered' => false, + 'actions' => [['label' => 'Ask for a quote', 'href' => portalAskUrl('Quote request'), 'icon' => 'fa-plus']]]) ?> + + + + 1) { ?> +
+ - of +
+ + +
+
+ + +
+ + Payments +
+ "That payment isn't here", 'text' => 'It may have been removed, or the link is not quite right.', 'icon' => 'fa-receipt', 'filtered' => false, + 'actions' => [['label' => 'All payments', 'href' => 'payments.php', 'icon' => 'fa-arrow-left']]]) ?> +
+ + +Payments + +
+
+

Receipt

+

·

+
+ +
+ +
+
+
+ + + +
+ +
+
+
Payment received
+
+
+
+ +
+
+
Received from
+
Date
+
Paid by
+ +
Reference
+ +
+ +
+ + + + + + + + + + + + + + + + + + 1) { ?> + + + + + + + + +
InvoicePaidStill owed
+ +
+
0 ? escapeHtml(billingMoney($state['balance'], $line['invoice_currency_code'])) : 'Paid' ?>
Total
+
+
+ +
+ Thank you for your payment. + +
+
+ + + Recurring invoices +
+ "That recurring invoice isn't here", 'text' => 'It may have ended, or the link is not quite right.', 'icon' => 'fa-redo-alt', 'filtered' => false, + 'actions' => [['label' => 'Recurring invoices', 'href' => 'recurring_invoices.php', 'icon' => 'fa-arrow-left']]]) ?> +
+ 'recurring', + 'row' => $recurring, + 'items' => $items, + 'totals' => $totals, + 'logo_url' => $company['logo_url'], + 'client' => true, +]; +?> + +Recurring invoices + +
+
+

+

·

+
+
+ +
+ +
+ +
+
+
Each
+
+

The next invoice comes .

+
+ +
+
+ + + + +
+ + +
+
+ + Charged on each invoice's due date, with a receipt by email. + + Add a card to have these paid automatically. + +
+
+
+ +
+ +
+
+

Invoices from it

+
+ + 'None yet', 'text' => 'The first one comes ' . escapeHtml(date('M j, Y', strtotime($recurring['recurring_invoice_next_date']))) . '.', 'icon' => 'fa-file-invoice-dollar', 'filtered' => false]) ?> + + + +
+ +

+ Something to change? ">Ask us or call . +

+ +
+ +
+ +
+ +
+ + 0, 'payment_provider_name' => '', 'payment_provider_threshold' => 0]; // none set up - nothing to pay with -$payment_provider_id = intval($row['payment_provider_id']); -$payment_provider_name = escapeHtml($row['payment_provider_name']); -$payment_provider_threshold = floatval($row['payment_provider_threshold']); + WHERE recurring_invoice_client_id = $session_client_id AND recurring_invoice_status = 1 + ORDER BY recurring_invoice_next_date ASC, recurring_invoice_id ASC"); +$cards_on = billingStripeProvider() !== null; +$cards = []; +foreach ($cards_on ? billingSavedCards($session_client_id) : [] as $card) { + $cards[intval($card['saved_payment_id'])] = $card['card']; +} ?> -

Recurring Invoices

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ScopeAmountNext Bill DateFrequencyAuto Pay
- 0) { ?> -
- - - - -
- - Add a Payment Method - -
- - +
+
+

Recurring invoices

+

What we bill you regularly

- + + Autopay +
+
+ + 'No recurring invoices', 'text' => 'Services billed each month, quarter or year show here.', 'icon' => 'fa-redo-alt', 'filtered' => false]) ?> + + + +
+ +
+
+

Cards & autopay

+
+
+
+ 'Card payments are not set up', 'text' => 'To pay, use the payment details on each invoice.', 'icon' => 'fa-credit-card', 'filtered' => false]) ?> +
+ -

Saved Payment Methods

-
-
-
- - - In order to set up automatic payments, you must create a Stripe customer record. -
- By saving your card details, you grant consent for automatic payments. - Stripe processes your information in accordance with its Privacy Policy and Terms. -
- -
- - -
- -
-
- - - - Manage saved payment methods

- - -

You currently have no saved payment methods. Please add one below.

- -

Payment methods you've authorized us to save for future payments:

-
    - paymentMethods->retrieve($stripe_pm_id, []); - $brand = escapeHtml($pm->card->brand); - $last4 = escapeHtml($pm->card->last4); - $exp_month = escapeHtml($pm->card->exp_month); - $exp_year = escapeHtml($pm->card->exp_year); - - echo "
  • $brand x$last4 | Exp. $exp_month/$exp_year"; - echo " – Remove
  • "; - } - } catch (Exception $e) { - $error = $e->getMessage(); - error_log("Stripe payment error: $error"); - logApp("Stripe", "error", "Exception retrieving payment methods: $error"); - echo "

    Unable to retrieve payment methods from Stripe.

    "; - } - ?> -
- -
-
- Add a new payment method -

If you save payment details, you grant consent for automatic payments.

-

- - - - -
- -
- - - +
+
+

Cards & autopay

+

Save a card to pay invoices in a click, and have recurring invoices paid on their own.

- +
+
+
+
+

Your cards

+
+ + 'No cards saved', 'text' => 'Save one to pay in a click, or to turn on autopay.', 'icon' => 'fa-credit-card', 'filtered' => false]) ?> + +
    + +
  • + + +
    +
    + + Expired + + Expires + + + · Pays recurring automatically + +
    +
    + Remove +
  • + +
+ +
+ + +
hidden> +

By saving a card you agree may charge it for invoices you pay with it, and for the recurring invoices you set to autopay.

+
Loading Stripe...
+ +
+
+ +
+ +

By saving a card you agree may charge it for invoices you pay with it, and for the recurring invoices you set to autopay. Stripe keeps the card details, under its privacy policy and terms.

+ +
+ +
+
+
+ +
+
+
+

Autopay

+
+ + 'Nothing to autopay', 'text' => 'Recurring invoices - monthly services and the like - can be paid automatically once you have one.', 'icon' => 'fa-sync-alt', 'filtered' => false]) ?> + +
+

Autopay charges the card on each invoice's due date, and we email you a receipt every time.

+
+
    + +
  • +
    + + / +
    +
    + + + +
    + + +
    +
    +
  • + +
+ +
+
+
+ + 0 - ORDER BY invoice_date ASC, invoice_number ASC" -); - -$statement_count = mysqli_num_rows($statement_sql); -$statement_total = 0; - +$with_paid = ($_GET['view'] ?? '') === 'year'; +$statement = billingStatement($session_client_id, $with_paid ? date('Y-m-d', strtotime('-12 months')) : '', '', $with_paid); +$currency = $statement['currency']; +$cards = billingStripeProvider() !== null; +$money = function ($amount) use ($currency) { + return escapeHtml(billingMoney($amount, $currency)); +}; ?> -
-

Account Statement

- 0) { ?> - - Download PDF - - +
+
+

Statement

+

· as of

+
+
+ + Download PDF + + 0 && $cards) { ?> + Pay invoices + +
-
+
+
+
+ + + + Total due + +
+
+
+
+ + + + Past due + +
+
+
+
+ $amount) { ?> +
+ + +
+ +
+
+
-
+ - - - - - - - - - - - - - - - - - +
+ + 'No invoices in the last 12 months', 'text' => '', 'icon' => 'fa-file-alt', 'filtered' => false]) + : emptyStateHtml(['title' => 'Nothing outstanding', 'text' => 'You are all paid up - thank you.', 'icon' => 'fa-check', 'filtered' => false]) ?> + +
+
#ScopeDateDueAmountPaidBalance
+ + + + + + + + + - - - - - - - - - - - - - - - + 0 && $row['days'] > 0; ?> + + + + + + + + + - - - - + + + + +
InvoiceDateDueAmountPaidBalance
- "> - - -
+ +
+
+ +
late
+
0 ? $money($row['balance']) : 'Paid' ?>
Total Balance Due
Total due
- - - -
- -
- +
+ + Subject *
- +
diff --git a/client/unpaid_invoices.php b/client/unpaid_invoices.php index b1f7e3e1a..69f0cef63 100644 --- a/client/unpaid_invoices.php +++ b/client/unpaid_invoices.php @@ -1,211 +1,160 @@ 0, 'payment_provider_id' => 0, 'payment_provider_threshold' => 0]; // none set up - nothing to pay with -$payment_provider_id = intval($row['payment_provider_id']); -$payment_provider_active = intval($row['payment_provider_active']); -$payment_provider_threshold = floatval($row['payment_provider_threshold']); - -// Saved Payment Methods -$sql_saved_payment_methods = mysqli_query($mysqli, " - SELECT payment_provider_name, saved_payment_description, saved_payment_id FROM client_saved_payment_methods - LEFT JOIN payment_providers - ON client_saved_payment_methods.saved_payment_provider_id = payment_providers.payment_provider_id - WHERE saved_payment_client_id = $session_client_id - AND payment_provider_active = 1; -"); - -// Get Balance -// Billing Card Queries - //Add up all the payments for the invoice and get the total amount paid to the invoice -$sql_invoice_amounts = mysqli_query($mysqli, "SELECT SUM(invoice_amount) AS invoice_amounts FROM invoices WHERE invoice_client_id = $session_client_id AND invoice_status != 'Draft' AND invoice_status != 'Cancelled' AND invoice_status != 'Non-Billable'"); -$row = mysqli_fetch_assoc($sql_invoice_amounts); - -$invoice_amounts = floatval($row['invoice_amounts']); - -$sql_amount_paid = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS amount_paid FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_client_id = $session_client_id"); -$row = mysqli_fetch_assoc($sql_amount_paid); - -$amount_paid = floatval($row['amount_paid']); - -$balance = $invoice_amounts - $amount_paid; +$provider = billingStripeProvider(); +$open = billingClientOpenInvoices($session_client_id); +$cards = $provider ? billingSavedCards($session_client_id) : []; +$usable = array_values(array_filter($cards, function ($card) { + return !$card['card']['expired']; +})); +// What is ticked to start with - all of it, or what the link asked for +$select = isset($_GET['select']) ? array_filter(array_map('intval', explode(',', (string) $_GET['select']))) : null; +$ticked = []; +$total_cents = 0; +$currency = ''; +foreach ($open as $i => $row) { + $cents = (int) round((floatval($row['invoice_amount']) - floatval($row['paid'])) * 100); + $open[$i]['cents'] = $cents; + $currency = $currency ?: strtoupper((string) $row['invoice_currency_code']); + // One payment can carry so many invoices - the oldest are ticked first + if ($select === null ? count($ticked) < BILLING_PAY_MAX_INVOICES : in_array(intval($row['invoice_id']), $select, true)) { + $ticked[intval($row['invoice_id'])] = true; + $total_cents += $cents; + } +} +$limit_cents = $provider ? (int) round(floatval($provider['payment_provider_threshold']) * 100) : 0; +$locale = str_replace('_', '-', (string) ($session_company_locale ?: 'en_US')); +$pay_token = bin2hex(random_bytes(16)); ?> -
-
-

Unpaid Invoices

-
-
- - - - +
+
+

Pay invoices

+

Tick what to pay - it goes through as one card payment.

-
-
+ - - - - - - - - - - - - - - - +
+ 'Card payments are not set up', 'text' => 'To pay, use the payment details on each invoice.', 'icon' => 'fa-credit-card', 'filtered' => false, + 'actions' => [['label' => 'Invoices to pay', 'href' => 'invoices.php?tab=open', 'icon' => 'fa-file-invoice-dollar']]]) ?> +
- - if (empty($invoice_scope)) { - $invoice_scope_display = "-"; - } else { - $invoice_scope_display = $invoice_scope; - } +
+ 'Nothing to pay', 'text' => 'You are all paid up - thank you.', 'icon' => 'fa-check', 'filtered' => false, + 'actions' => [['label' => 'Your payments', 'href' => 'payments.php', 'icon' => 'fa-receipt']]]) ?> +
- $now = time(); - if (($invoice_status == "Sent" || $invoice_status == "Partial" || $invoice_status == "Viewed") && strtotime($invoice_due) + 86400 < $now) { - $overdue_color = "text-danger fw-bold"; - } else { - $overdue_color = ""; - } + - if ($invoice_status == "Sent") { - $invoice_badge_color = "warning"; - } elseif ($invoice_status == "Viewed") { - $invoice_badge_color = "info"; - } elseif ($invoice_status == "Partial") { - $invoice_badge_color = "primary"; - } elseif ($invoice_status == "Paid") { - $invoice_badge_color = "success"; - } elseif ($invoice_status == "Cancelled") { - $invoice_badge_color = "danger"; - } else{ - $invoice_badge_color = "secondary"; - } - ?> + + + - - - - - - - + +
    + +
  • + +
  • + +
+ + - - - - -
#ScopeAmountDateDue
"> - $invoice_amount - ) - ){ ?> - -
- - -
- -
+
+
+
+

Pay with

+ Your cards +
+
+
+ $card) { + $expired = $card['card']['expired']; ?> + + + +
+
+
+
+ + +
+
0 && $total_cents >= $limit_cents ? '' : ' hidden' ?>> + Card payments are limited to under at a time - untick some invoices, or pay another way. +
+ +

Paid securely through Stripe - your card details never reach us.

+
+
+
+
+ + 0 && $expense_category_id > 0) { $sql_missing_fee = mysqli_query($mysqli, " - SELECT payment_reference, payment_date, payment_amount, invoice_prefix, invoice_number, invoice_client_id + 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\_%' @@ -731,6 +735,7 @@ if ($stripe_provider) { AND NOT EXISTS ( SELECT 1 FROM expenses WHERE LOCATE(payments.payment_reference, expenses.expense_reference) = 1 ) + GROUP BY payment_reference LIMIT 50 "); @@ -744,8 +749,7 @@ if ($stripe_provider) { $payment_reference = escapeSql($missing['payment_reference']); $payment_date = escapeSql($missing['payment_date']); $payment_amount = floatval($missing['payment_amount']); - $invoice_prefix = escapeSql($missing['invoice_prefix']); - $invoice_number = intval($missing['invoice_number']); + $invoice_numbers = escapeSql($missing['invoice_numbers']); $client_id = intval($missing['invoice_client_id']); $pi_id = str_replace('Stripe - ', '', $missing['payment_reference']); @@ -762,7 +766,7 @@ if ($stripe_provider) { 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_prefix$invoice_number payment of $payment_amount', expense_reference = '$payment_reference'"); + 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 diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 8f7ac127c..65a80b501 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -7240,6 +7240,244 @@ a.itflow-portal-todo:hover { max-height: 600px; } +/* Billing: amounts down the side of a list, a document's state, and the big + figure on an invoice, quote or receipt */ +.itflow-portal-amount { + color: var(--bs-emphasis-color); + font-weight: 600; +} +.itflow-portal-amount small { + color: var(--bs-secondary-color); + font-size: .75rem; +} +.itflow-portal-state-late { background-color: var(--bs-danger-bg-subtle); color: var(--bs-danger-text-emphasis); } +.itflow-portal-big-amount { + color: var(--bs-emphasis-color); + font-size: 1.75rem; + font-weight: 600; + line-height: 1.2; + margin: .15rem 0 .5rem; +} +.itflow-portal-outcome { + display: flex; + align-items: center; + gap: .85rem; +} +.itflow-portal-outcome-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background-color: var(--bs-secondary-bg); + color: var(--bs-secondary-color); +} +.itflow-portal-outcome-icon.is-success { + background-color: var(--bs-success-bg-subtle); + color: var(--bs-success-text-emphasis); +} +.itflow-portal-outcome-icon.is-warning { + background-color: var(--bs-warning-bg-subtle); + color: var(--bs-warning-text-emphasis); +} +.itflow-portal-note { + display: flex; + gap: .6rem; + padding: .65rem .8rem; + border-radius: var(--bs-border-radius); + background-color: var(--bs-tertiary-bg); + font-size: .85rem; +} +.itflow-portal-note i { + margin-top: .15rem; + color: var(--bs-secondary-color); +} + +/* Pay invoices: a row to tick, the ways to pay, and the total with its button */ +.itflow-portal-payrow { + cursor: pointer; + margin: 0; +} +.itflow-portal-payrow > .form-check-input { + flex: 0 0 auto; + width: 1.2rem; + height: 1.2rem; + margin: 0; +} +@media (max-width: 575.98px) { + .itflow-portal-payrow { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + column-gap: .75rem; + } + .itflow-portal-payrow > .form-check-input { + margin-top: .2rem; + } + .itflow-portal-payrow > .itflow-portal-ticket-side { + grid-column: 2; + } +} +.itflow-portal-list > li.is-selected { + background-color: rgba(var(--bs-primary-rgb), .05); +} +.itflow-portal-list > li.is-selected .itflow-portal-payrow:hover { + background-color: transparent; +} +@media (min-width: 992px) { + .itflow-portal-paybox { + position: sticky; + top: calc(var(--itflow-portal-header-height) + 1rem); + } +} +.itflow-portal-methods { + display: grid; + gap: .5rem; +} +.itflow-portal-method { + display: flex; + align-items: center; + gap: .75rem; + margin: 0; + padding: .65rem .8rem; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + cursor: pointer; +} +.itflow-portal-method .form-check-input { + flex: 0 0 auto; + margin: 0; +} +.itflow-portal-method:has(input:checked) { + border-color: var(--bs-primary); + box-shadow: inset 0 0 0 1px var(--bs-primary); +} +.itflow-portal-method.is-disabled { + cursor: not-allowed; + opacity: .6; +} +.itflow-portal-paybar { + padding: 1rem 1.1rem; + border-top: 1px solid var(--bs-border-color-translucent); + background-color: var(--bs-tertiary-bg); +} +.itflow-portal-paybar .itflow-portal-big-amount { + margin: 0; +} +.itflow-portal-stripe-element { + min-height: 3rem; +} + +/* A saved card's brand, in a grey tile */ +.itflow-portal-card-brand { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2rem; + border-radius: var(--bs-border-radius); + background-color: var(--bs-secondary-bg); + color: var(--bs-secondary-color); + font-size: 1.25rem; +} + +/* Statement: how late, in five tiles */ +.itflow-portal-aging { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: .5rem; + height: 100%; +} +.itflow-portal-aging > div { + display: flex; + flex-direction: column; + justify-content: center; + gap: .15rem; + padding: .6rem .7rem; + border: 1px solid var(--bs-border-color-translucent); + border-radius: var(--bs-border-radius-lg); + background-color: var(--bs-body-bg); + min-width: 0; +} +.itflow-portal-aging span { + color: var(--bs-secondary-color); + font-size: .72rem; + white-space: nowrap; +} +.itflow-portal-aging strong { + overflow: hidden; + color: var(--bs-emphasis-color); + font-size: .9rem; + text-overflow: ellipsis; +} +.itflow-portal-aging .is-late strong { + color: var(--bs-danger-text-emphasis); +} +@media (max-width: 575.98px) { + .itflow-portal-aging { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.itflow-portal-table > thead th { + color: var(--bs-secondary-color); + font-size: .75rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; +} +.itflow-portal-panel .table > :not(caption) > * > * { + padding-left: 1.1rem; + padding-right: 1.1rem; +} + +/* A receipt */ +.itflow-portal-receipt { + max-width: 46rem; +} +.itflow-portal-receipt-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.1rem; + border-bottom: 1px solid var(--bs-border-color-translucent); +} +.itflow-portal-receipt-head .itflow-portal-big-amount { + margin: 0; +} +.itflow-portal-receipt-foot { + padding: .9rem 1.1rem; + border-top: 1px solid var(--bs-border-color-translucent); + color: var(--bs-secondary-color); + font-size: .8rem; +} + +/* Printing an invoice, quote or receipt: the document and nothing round it */ +@media print { + .itflow-portal-header, + .itflow-portal-tabbar, + .itflow-portal-footer, + .itflow-portal-main > .alert { + display: none !important; + } + .itflow-portal { + padding-bottom: 0 !important; + background: #fff !important; + } + .itflow-portal-main { + max-width: none; + padding: 0; + } + .itflow-portal-receipt { + max-width: none; + border: 0; + } +} + /* The pages not rebuilt yet: their black table heads and borders, softened to sit in the light shell */ .itflow-portal-main .table-dark { diff --git a/functions.php b/functions.php index a66900b7b..fdc2f8c37 100644 --- a/functions.php +++ b/functions.php @@ -53,6 +53,7 @@ require_once __DIR__ . '/functions/share.php'; require_once __DIR__ . '/functions/billing.php'; require_once __DIR__ . '/functions/products.php'; require_once __DIR__ . '/functions/billing_email.php'; +require_once __DIR__ . '/functions/billing_pay.php'; require_once __DIR__ . '/functions/expenses.php'; require_once __DIR__ . '/functions/expense_import.php'; require_once __DIR__ . '/functions/trips.php'; diff --git a/functions/billing.php b/functions/billing.php index 4239182ae..0d4612e5c 100644 --- a/functions/billing.php +++ b/functions/billing.php @@ -1371,6 +1371,137 @@ function quoteMadeFrom($quote_id) { return $made; } +/** + * A client's answer to a quote, from its guest link or the client portal: + * accepted only while it waits for one (Sent or Viewed) and has not run + * out, declined only while it waits - the first answer through wins. Then + * it goes in the quote's history, the agents are told, the quote_accept or + * quote_decline custom action runs and the quote notification address + * hears. $by, for the portal: name (who answered), note (what they said). + * ['ok' => bool, 'error' => why not, for the client]. + */ +function billingQuoteAnswer($quote_id, $accept, array $by = []) { + global $mysqli; + $quote_id = intval($quote_id); + $quote = billingRow('quote', $quote_id, false); + if (!$quote) { + return ['ok' => false, 'error' => 'This quote is no longer awaiting a response']; + } + if ($accept) { + mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Accepted' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire >= CURDATE()"); + } else { + mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Declined' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed')"); + } + if (mysqli_affected_rows($mysqli) !== 1) { + $expired = $accept ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT quote_expire FROM quotes WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire < CURDATE()")) : null; + return ['ok' => false, 'error' => $expired ? "This quote ran out on " . escapeHtml($expired[0]) . " - ask for an updated one" : "This quote is no longer awaiting a response"]; + } + + $word = $accept ? 'Accepted' : 'Declined'; + $number = billingNumber('quote', $quote); + $name = trim((string) ($by['name'] ?? '')); + $note = trim((string) ($by['note'] ?? '')); + billingLog('quote', $quote_id, $word, $name !== '' ? "$word by $name in the client portal" . ($note !== '' ? "\nNote: $note" : '') : "$word by the client"); + appNotify("Quote $word", escapeSql("Quote $number has been " . strtolower($word) . " by {$quote['client_name']}" . ($name !== '' ? " ($name, in the client portal)" : '')), + "/agent/quote.php?quote_id=$quote_id", intval($quote['client_id'])); + triggerCustomAction($accept ? 'quote_accept' : 'quote_decline', $quote_id); + if ($name !== '') { + logAudit("Quote", $accept ? "Accept" : "Decline", escapeSql("$name " . strtolower($word) . " quote $number in the client portal"), intval($quote['client_id']), $quote_id); + } + + // Our team hears - functions/billing_email.php + $settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_smtp_host, config_quote_notification_email FROM settings WHERE company_id = 1")) ?: []; + if (!empty($settings['config_smtp_host']) && !empty($settings['config_quote_notification_email'])) { + billingQuoteAnsweredNotify($quote_id, $accept, $name, $note); + } + + return ['ok' => true, 'error' => '']; +} + +/** + * PDFs a client sends back with an accepted quote - a signed copy, a purchase + * order - from its guest link or the client portal. $files: the upload's + * $_FILES entry (file[]). Each goes in the client's Client Uploads folder and + * on the quote, and the agents hear. $by: name, for the portal. + * ['added' => file names, 'failed' => how many could not be taken]. + */ +function billingQuoteAddFiles($quote_id, array $files, array $by = []) { + global $mysqli; + $quote = billingRow('quote', $quote_id, false); + $result = ['added' => [], 'failed' => 0]; + if (!$quote || !isset($files['name']) || !is_array($files['name'])) { + return $result; + } + $quote_id = intval($quote['quote_id']); + $client_id = intval($quote['client_id']); + $number = billingNumber('quote', $quote); + $number_sql = escapeSql($number); + $name = trim((string) ($by['name'] ?? '')); + + $upload_file_dir = dirname(__DIR__) . "/uploads/clients/$client_id/"; + mkdirMissing($upload_file_dir); + + for ($i = 0; $i < count($files['name']); $i++) { + $single_file = [ + 'name' => $files['name'][$i], + 'type' => $files['type'][$i], + 'tmp_name' => $files['tmp_name'][$i], + 'error' => $files['error'][$i], + 'size' => $files['size'][$i], + ]; + $file_reference_name = checkFileUpload($single_file, ['pdf']); + if (!$file_reference_name) { + $result['failed']++; + continue; + } + + $file_name = escapeSql($single_file['name']); + $extarr = explode('.', $single_file['name']); + $file_extension = escapeSql(strtolower(end($extarr))); + $file_mime_type = escapeSql($single_file['type']); + $file_size = intval($single_file['size']); + + // Get/Create a top-level folder called Client Uploads + $folder = mysqli_fetch_row(mysqli_query($mysqli, "SELECT folder_id FROM folders WHERE folder_name = 'Client Uploads' AND parent_folder = 0 AND folder_client_id = $client_id LIMIT 1")); + if ($folder) { + $folder_id = intval($folder[0]); + } else { + mysqli_query($mysqli, "INSERT INTO folders SET folder_name = 'Client Uploads', parent_folder = 0, folder_location = 1, folder_client_id = $client_id"); + $folder_id = intval(mysqli_insert_id($mysqli)); + logAudit("Folder", "Create", "Automatically created folder Client Uploads", $client_id, $folder_id); + } + + if (!move_uploaded_file($single_file['tmp_name'], $upload_file_dir . $file_reference_name)) { + $result['failed']++; + continue; + } + + mysqli_query($mysqli, "INSERT INTO files SET file_reference_name = '" . escapeSql($file_reference_name) . "', file_name = '$file_name', file_description = 'Uploaded via $number_sql', file_ext = '$file_extension', file_mime_type = '$file_mime_type', file_size = $file_size, file_folder_id = $folder_id, file_client_id = $client_id"); + $file_id = intval(mysqli_insert_id($mysqli)); + mysqli_query($mysqli, "INSERT INTO quote_files SET quote_id = $quote_id, file_id = $file_id"); + + appNotify("Quote File", "$file_name was uploaded to quote $number_sql", "/agent/quote.php?quote_id=$quote_id", $client_id); + billingLog('quote', $quote_id, 'Upload', $name !== '' ? "$name uploaded file {$single_file['name']} in the client portal" : "Client uploaded file {$single_file['name']}"); + logAudit("File", "Upload", ($name !== '' ? escapeSql($name) : 'Guest') . " uploaded file $file_name to quote $number_sql" . ($name !== '' ? ' in the client portal' : ''), $client_id); + $result['added'][] = (string) $single_file['name']; + } + return $result; +} + +/** The files sent back with a quote - name and when, newest first. */ +function billingQuoteFiles($quote_id) { + global $mysqli; + $quote_id = intval($quote_id); + $rows = []; + $sql = mysqli_query($mysqli, "SELECT files.file_id, file_name, file_size, file_created_at FROM quote_files + JOIN files ON files.file_id = quote_files.file_id + WHERE quote_files.quote_id = $quote_id AND file_archived_at IS NULL ORDER BY file_created_at DESC, files.file_id DESC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row; + } + return $rows; +} + /* ------------------------------------------------------- Recurring invoices */ /** How long a missed run is still billed by the nightly run - a night or a few the cron didn't run. */ @@ -1916,32 +2047,42 @@ function billingPdfSend(array $pdf, $download = false) { /* ------------------------------------------------------------- Guest pages */ /** - * A client has opened an invoice or quote from its link: a sent one becomes - * viewed, the view goes in its history (where from, in plain text), and the - * agents hear about it while it still matters - an invoice not yet paid, a - * quote not yet answered. + * A client has opened an invoice or quote - from its link, or in the client + * portal ($who, the contact): a sent one becomes viewed, the view goes in + * its history (where from, in plain text), and the agents hear about it + * while it still matters - an invoice not yet paid, a quote not yet + * answered. */ -function billingGuestViewed($type, array $row) { +function billingGuestViewed($type, array $row, $who = '') { global $mysqli; $t = billingType($type); $c = $t['col']; $id = intval($row["{$c}_id"]); $status = (string) $row["{$c}_status"]; $number = billingNumber($type, $row); + $who = trim((string) $who); if ($status === 'Sent') { mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_status = 'Viewed' WHERE {$c}_id = $id AND {$c}_status = 'Sent'"); } - $agent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); - $where = implode(' - ', array_filter([getIP(), trim(strip_tags(getOS($agent))), trim(strip_tags(getWebBrowser($agent)))], function ($part) { - return $part !== '' && $part !== '-'; - })); - billingLog($type, $id, $status === 'Sent' ? 'Viewed' : $status, 'Viewed by the client' . ($where !== '' ? " - $where" : '')); + if ($who !== '') { + $where = ''; + $description = "Viewed in the client portal by $who"; + $by = " ($who, in the client portal)"; + } else { + $agent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); + $where = implode(' - ', array_filter([getIP(), trim(strip_tags(getOS($agent))), trim(strip_tags(getWebBrowser($agent)))], function ($part) { + return $part !== '' && $part !== '-'; + })); + $description = 'Viewed by the client' . ($where !== '' ? " - $where" : ''); + $by = $where !== '' ? " - $where" : ''; + } + billingLog($type, $id, $status === 'Sent' ? 'Viewed' : $status, $description); $matters = $type === 'invoice' ? !in_array($status, ['Paid', 'Cancelled', 'Non-Billable'], true) : in_array($status, ['Draft', 'Sent', 'Viewed'], true); if ($matters) { - appNotify($t['label'] . ' Viewed', escapeSql("$number has been viewed by {$row['client_name']}" . ($where !== '' ? " - $where" : '')), "/agent/{$t['page']}?{$t['param']}=$id", intval($row['client_id'])); + appNotify($t['label'] . ' Viewed', escapeSql("$number has been viewed by {$row['client_name']}$by"), "/agent/{$t['page']}?{$t['param']}=$id", intval($row['client_id'])); } } diff --git a/functions/billing_email.php b/functions/billing_email.php index 17e1dabd5..37772bee3 100644 --- a/functions/billing_email.php +++ b/functions/billing_email.php @@ -593,9 +593,10 @@ function billingNotifyEmail($to, $subject, array $email, $from = 'invoice') { /** * Tell our team a client answered a quote online - to the quote notification - * address in the quote settings. $accepted or declined. + * address in the quote settings. $accepted or declined; $by, who answered + * in the client portal, and the note they left with it. */ -function billingQuoteAnsweredNotify($quote_id, $accepted) { +function billingQuoteAnsweredNotify($quote_id, $accepted, $by = '', $note = '') { global $mysqli, $config_base_url; $quote = billingRow('quote', $quote_id, false); $to = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_quote_notification_email FROM settings WHERE company_id = 1"))[0] ?? ''); @@ -604,10 +605,17 @@ function billingQuoteAnsweredNotify($quote_id, $accepted) { } $number = billingNumber('quote', $quote); $word = $accepted ? 'accepted' : 'declined'; + $by = trim((string) $by); + $summary = ['Client' => $quote['client_name'], 'Quote' => $number, 'Total' => billingMoney($quote['quote_amount'], $quote['quote_currency_code'])]; + if ($by !== '') { + $summary = ['Client' => $quote['client_name'], ucfirst($word) . ' by' => $by] + $summary; + } return billingNotifyEmail($to, 'Quote ' . ucfirst($word) . " - {$quote['client_name']} - Quote $number", [ 'kind' => 'Quote ' . $word, 'tone' => $accepted ? 'success' : 'danger', 'title' => "{$quote['client_name']} $word $number", - 'intro' => ["{$quote['client_name']} $word quote $number online" . (trim((string) $quote['quote_scope']) !== '' ? " - \"{$quote['quote_scope']}\"" : '') . '.'], - 'summary' => ['Client' => $quote['client_name'], 'Quote' => $number, 'Total' => billingMoney($quote['quote_amount'], $quote['quote_currency_code'])], + 'intro' => ["{$quote['client_name']} $word quote $number " . ($by !== '' ? 'in the client portal' : 'online') . (trim((string) $quote['quote_scope']) !== '' ? " - \"{$quote['quote_scope']}\"" : '') . '.' + . (trim((string) $note) !== '' ? ' They left a note:' : '')], + 'note' => trim((string) $note), + 'summary' => $summary, 'button' => ['label' => 'Open in ITFlow', 'url' => "https://$config_base_url/agent/quote.php?quote_id=" . intval($quote['quote_id'])], ], 'quote'); } @@ -634,6 +642,60 @@ function billingPaymentNotify(array $invoice, $amount, $how) { ]); } +/** + * Tell the paid notification address about one payment over several + * invoices - the online payments the client portal takes. + */ +function billingPaymentNotifyMany(array $applied, $how) { + global $config_base_url; + $to = billingEmailFrom('notify')['email']; + if ($to === '' || !$applied) { + return; + } + $first = $applied[0]['invoice']; + $currency = (string) $first['invoice_currency_code']; + $total = array_sum(array_column($applied, 'amount')); + $rows = []; + foreach ($applied as $entry) { + $balance = invoiceState($entry['invoice'], invoicePaid($entry['invoice']['invoice_id']))['balance']; + $rows[] = [['text' => billingNumber('invoice', $entry['invoice']), 'url' => "https://$config_base_url/agent/invoice.php?invoice_id=" . intval($entry['invoice']['invoice_id'])], + billingMoney($entry['amount'], $currency), $balance > 0 ? billingMoney($balance, $currency) : 'Paid']; + } + billingNotifyEmail($to, "Payment received - {$first['client_name']} - " . count($applied) . ' invoices', [ + 'kind' => 'Payment received', 'tone' => 'success', 'title' => "{$first['client_name']} paid " . count($applied) . ' invoices', + 'intro' => ["$how payment of " . billingMoney($total, $currency) . " for {$first['client_name']}, over these invoices."], + 'summary' => ['Client' => $first['client_name'], 'Invoices' => (string) count($applied), 'Amount paid' => billingMoney($total, $currency)], + 'strong' => 'Amount paid', + 'lines' => ['head' => ['Invoice', 'Applied', 'Balance'], 'align' => ['left', 'right', 'right'], 'rows' => $rows], + ]); +} + +/** + * Send the receipt for a payment on one invoice or several - $applied as + * billingPaymentEmail() takes it - to the client's primary and billing + * contacts, and log it on each invoice. $notify also tells the paid + * notification address. The addresses it went to. + */ +function billingPaymentReceiptSend(array $applied, array $payment, $notify = false) { + if (!$applied) { + return []; + } + $first = $applied[0]['invoice']; + [$subject, $email] = billingPaymentEmail($applied, $payment + ['currency' => $first['invoice_currency_code']]); + $sent = billingEmailQueue(billingEmailRecipients($first['client_id']), $subject, $email); + if ($sent) { + foreach ($applied as $entry) { + billingLog('invoice', $entry['invoice']['invoice_id'], 'Sent', 'Payment receipt emailed to ' . implode(', ', $sent)); + } + } + if ($notify && count($applied) === 1) { + billingPaymentNotify($first, $applied[0]['amount'], $payment['how'] ?? 'Online'); + } elseif ($notify) { + billingPaymentNotifyMany($applied, $payment['how'] ?? 'Online'); + } + return $sent; +} + /** * Send a payment received email for a payment on one invoice - to its * client's primary and billing contacts - and log it on the invoice. @@ -644,15 +706,7 @@ function billingPaymentSend($invoice_id, $amount, array $payment, $notify = fals if (!$invoice) { return []; } - [$subject, $email] = billingPaymentEmail([['invoice' => $invoice, 'amount' => $amount]], $payment + ['currency' => $invoice['invoice_currency_code']]); - $sent = billingEmailQueue(billingEmailRecipients($invoice['client_id']), $subject, $email); - if ($sent) { - billingLog('invoice', $invoice['invoice_id'], 'Sent', 'Payment receipt emailed to ' . implode(', ', $sent)); - } - if ($notify) { - billingPaymentNotify($invoice, $amount, $payment['how'] ?? 'Online'); - } - return $sent; + return billingPaymentReceiptSend([['invoice' => $invoice, 'amount' => $amount]], $payment, $notify); } /* ------------------------------------------------------------ Statements */ diff --git a/functions/billing_pay.php b/functions/billing_pay.php new file mode 100644 index 000000000..b4a76bb8d --- /dev/null +++ b/functions/billing_pay.php @@ -0,0 +1,531 @@ + 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'; +} diff --git a/guest/guest_post.php b/guest/guest_post.php index d42cf7234..03e63e417 100644 --- a/guest/guest_post.php +++ b/guest/guest_post.php @@ -12,129 +12,30 @@ require_once "../includes/inc_set_timezone.php"; // Must be included after sessi $session_ip = escapeSql(getIP()); $session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT']); -if (isset($_GET['accept_quote'], $_GET['url_key'])) { +if (isset($_GET['accept_quote'], $_GET['url_key']) || isset($_GET['decline_quote'], $_GET['url_key'])) { - $quote_id = intval($_GET['accept_quote']); + // The answer and what follows it - functions/billing.php, shared with the client portal + $accept = isset($_GET['accept_quote']); + $quote_id = intval($accept ? $_GET['accept_quote'] : $_GET['decline_quote']); $url_key = escapeSql($_GET['url_key']); - // Select only the necessary fields - $sql = mysqli_query($mysqli, "SELECT quote_prefix, quote_number, client_name, client_id FROM quotes LEFT JOIN clients ON quote_client_id = client_id WHERE quote_id = $quote_id AND quote_url_key = '$url_key'"); + $sql = mysqli_query($mysqli, "SELECT quote_id FROM quotes WHERE quote_id = $quote_id AND quote_url_key = '$url_key'"); if (mysqli_num_rows($sql) == 1) { - $row = mysqli_fetch_assoc($sql); - $quote_prefix = escapeSql($row['quote_prefix']); - $quote_number = intval($row['quote_number']); - $client_name = escapeSql($row['client_name']); - $client_id = intval($row['client_id']); - // Claim the response - only a quote still awaiting one can be accepted, - // and only the first request through wins - // ...and not once it has run out - a price that has expired is asked for again - mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Accepted' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire >= CURDATE()"); + $answer = billingQuoteAnswer($quote_id, $accept); - if (mysqli_affected_rows($mysqli) !== 1) { - $expired = mysqli_fetch_row(mysqli_query($mysqli, "SELECT quote_expire FROM quotes WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire < CURDATE()")); - flashAlert($expired ? "This quote ran out on " . escapeHtml($expired[0]) . " - ask for an updated one" : "This quote is no longer awaiting a response", 'error'); + if (!$answer['ok']) { + flashAlert($answer['error'], 'error'); redirect(); } - mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Accepted', history_description = 'Accepted by the client', history_quote_id = $quote_id"); - - // Notification - appNotify("Quote Accepted", "Quote $quote_prefix$quote_number has been accepted by $client_name", "/agent/quote.php?quote_id=$quote_id", $client_id); - triggerCustomAction('quote_accept', $quote_id); - - // Internal email notification - - $sql_company = mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql_company); - $company_name = escapeSql($row['company_name']); - - $sql_settings = mysqli_query($mysqli, "SELECT config_quote_from_email, config_quote_from_name, config_quote_notification_email, - config_smtp_encryption, config_smtp_host, config_smtp_password, config_smtp_port, - config_smtp_username FROM settings WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql_settings); - $config_smtp_host = $row['config_smtp_host']; - $config_smtp_port = intval($row['config_smtp_port']); - $config_smtp_encryption = $row['config_smtp_encryption']; - $config_smtp_username = $row['config_smtp_username']; - $config_smtp_password = $row['config_smtp_password']; - $config_quote_from_name = escapeSql($row['config_quote_from_name']); - $config_quote_from_email = escapeSql($row['config_quote_from_email']); - $config_quote_notification_email = escapeSql($row['config_quote_notification_email']); - $config_base_url = escapeSql($config_base_url); - - // Our team hears - functions/billing_email.php - if (!empty($config_smtp_host) && !empty($config_quote_notification_email)) { - billingQuoteAnsweredNotify($quote_id, true); + if ($accept) { + flashAlert("Quote Accepted"); + } else { + flashAlert("Quote Declined", 'danger'); } - flashAlert("Quote Accepted"); - - redirect(); - - } else { - echo "Invalid!!"; - } - -} - -if (isset($_GET['decline_quote'], $_GET['url_key'])) { - - $quote_id = intval($_GET['decline_quote']); - $url_key = escapeSql($_GET['url_key']); - - // Select only the necessary fields - $sql = mysqli_query($mysqli, "SELECT quote_prefix, quote_number, client_name, client_id FROM quotes LEFT JOIN clients ON quote_client_id = client_id WHERE quote_id = $quote_id AND quote_url_key = '$url_key'"); - - if (mysqli_num_rows($sql) == 1) { - $row = mysqli_fetch_assoc($sql); - $quote_prefix = escapeSql($row['quote_prefix']); - $quote_number = intval($row['quote_number']); - $client_name = escapeSql($row['client_name']); - $client_id = intval($row['client_id']); - - // Claim the response - only a quote still awaiting one can be declined, - // and only the first request through wins - mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Declined' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed')"); - - if (mysqli_affected_rows($mysqli) !== 1) { - flashAlert("This quote is no longer awaiting a response", 'error'); - redirect(); - } - - mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Declined', history_description = 'Declined by the client', history_quote_id = $quote_id"); - - // Notification - appNotify("Quote Declined", "Quote $quote_prefix$quote_number has been declined by $client_name", "/agent/quote.php?quote_id=$quote_id", $client_id); - triggerCustomAction('quote_decline', $quote_id); - - // Internal email notification - - $sql_company = mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql_company); - $company_name = escapeSql($row['company_name']); - - $sql_settings = mysqli_query($mysqli, "SELECT config_quote_from_email, config_quote_from_name, config_quote_notification_email, - config_smtp_encryption, config_smtp_host, config_smtp_password, config_smtp_port, - config_smtp_username FROM settings WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql_settings); - $config_smtp_host = $row['config_smtp_host']; - $config_smtp_port = intval($row['config_smtp_port']); - $config_smtp_encryption = $row['config_smtp_encryption']; - $config_smtp_username = $row['config_smtp_username']; - $config_smtp_password = $row['config_smtp_password']; - $config_quote_from_name = escapeSql($row['config_quote_from_name']); - $config_quote_from_email = escapeSql($row['config_quote_from_email']); - $config_quote_notification_email = escapeSql($row['config_quote_notification_email']); - $config_base_url = escapeSql($config_base_url); - - // Our team hears - functions/billing_email.php - if (!empty($config_smtp_host) && !empty($config_quote_notification_email)) { - billingQuoteAnsweredNotify($quote_id, false); - } - flashAlert("Quote Declined", 'danger'); - redirect(); } else { @@ -299,84 +200,19 @@ if (isset($_POST['guest_quote_upload_file'])) { $url_key = escapeSql($_POST['url_key']); // Select only the necessary fields - $sql = mysqli_query($mysqli, "SELECT quote_prefix, quote_number, client_id FROM quotes LEFT JOIN clients ON quote_client_id = client_id WHERE quote_id = $quote_id AND quote_url_key = '$url_key'"); + $sql = mysqli_query($mysqli, "SELECT quote_id FROM quotes WHERE quote_id = $quote_id AND quote_url_key = '$url_key'"); if (mysqli_num_rows($sql) == 1) { - $row = mysqli_fetch_assoc($sql); - $quote_prefix = escapeSql($row['quote_prefix']); - $quote_number = intval($row['quote_number']); - $client_id = intval($row['client_id']); - - // Define & create directories, as required - $upload_file_dir = "../uploads/clients/$client_id/"; - mkdirMissing($upload_file_dir); - - // Store attached any file - if (!empty($_FILES)) { - - for ($i = 0; $i < count($_FILES['file']['name']); $i++) { - // Extract file details for this iteration - $single_file = [ - 'name' => $_FILES['file']['name'][$i], - 'type' => $_FILES['file']['type'][$i], - 'tmp_name' => $_FILES['file']['tmp_name'][$i], - 'error' => $_FILES['file']['error'][$i], - 'size' => $_FILES['file']['size'][$i] - ]; - - if ($file_reference_name = checkFileUpload($single_file, array('pdf'))) { - - $file_tmp_path = $_FILES['file']['tmp_name'][$i]; - - $file_name = escapeSql($_FILES['file']['name'][$i]); - $extarr = explode('.', $_FILES['file']['name'][$i]); - $file_extension = escapeSql(strtolower(end($extarr))); - - // Extract the file mime type and size - $file_mime_type = escapeSql($single_file['type']); - $file_size = intval($single_file['size']); - - // Define destination file path - $dest_path = $upload_file_dir . $file_reference_name; - - // Get/Create a top-level folder called Client Uploads - $folder_sql = mysqli_query($mysqli, "SELECT folder_id FROM folders WHERE folder_name = 'Client Uploads' AND parent_folder = 0 AND folder_client_id = $client_id LIMIT 1"); - if (mysqli_num_rows($folder_sql) == 1) { - // Get - $row = mysqli_fetch_assoc($folder_sql); - $folder_id = $row['folder_id']; - } else { - // Create - mysqli_query($mysqli,"INSERT INTO folders SET folder_name = 'Client Uploads', parent_folder = 0, folder_location = 1, folder_client_id = $client_id"); - $folder_id = mysqli_insert_id($mysqli); - logAudit("Folder", "Create", "Automatically created folder Client Uploads", $client_id, $folder_id); - } - - // Do move/upload - move_uploaded_file($file_tmp_path, $dest_path); - - // Create reference in files - mysqli_query($mysqli,"INSERT INTO files SET file_reference_name = '$file_reference_name', file_name = '$file_name', file_description = 'Uploaded via $quote_prefix$quote_number', file_ext = '$file_extension', file_mime_type = '$file_mime_type', file_size = $file_size, file_folder_id = $folder_id, file_client_id = $client_id"); - $file_id = mysqli_insert_id($mysqli); - - // Associate file with quote - mysqli_query($mysqli, "INSERT INTO quote_files SET quote_id = $quote_id, file_id = $file_id"); - - // Logging & feedback - flashAlert('File uploaded!'); - - appNotify("Quote File", "$file_name was uploaded to quote $quote_prefix$quote_number", "/agent/quote.php?quote_id=$quote_id", $client_id); - - mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Upload', history_description = 'Client uploaded file $file_name', history_quote_id = $quote_id"); - - logAudit("File", "Upload", "Guest uploaded file $file_name to quote $quote_prefix$quote_number", $client_id); - - } else { - flashAlert('Something went wrong uploading the file - please let the support team know.', 'error'); - - logApp("Guest", "error", "Error uploading file to invoice"); - } + // Each PDF to the client's Client Uploads and onto the quote - functions/billing.php, shared with the client portal + if (!empty($_FILES['file'])) { + $upload = billingQuoteAddFiles($quote_id, $_FILES['file']); + foreach ($upload['added'] as $added) { + flashAlert('File uploaded!'); + } + if ($upload['failed']) { + flashAlert('Something went wrong uploading the file - please let the support team know.', 'error'); + logApp("Guest", "error", "Error uploading file to invoice"); } } diff --git a/includes/billing_paper.php b/includes/billing_paper.php index aa39c85c3..522238b25 100644 --- a/includes/billing_paper.php +++ b/includes/billing_paper.php @@ -16,6 +16,7 @@ * notes_edit the notes save in place (agent pages) * taxes billingTaxes(), for the add-a-line row * logo_url the company logo, relative to the page + * client shown to the client (the client portal) - no agent-only notes * csrf the agent's token, for the edit controls * * Everything in it is escaped here. @@ -205,7 +206,7 @@ $paper_bill_lines = array_filter([ Per · 0 ? ' · at least ' . billingQuantity($seat['item_seat_minimum']) : '' ?> - The license is gone - it stays at + The license is gone - it stays at Now diff --git a/js/portal.js b/js/portal.js index 41333de35..246aa4e3f 100644 --- a/js/portal.js +++ b/js/portal.js @@ -8,7 +8,11 @@ * - Attachment drop zones: drop files on [data-portal-drop] and they go into * its file input, listed by name and size * - A form marked data-portal-once can't be sent twice + * - Print buttons ([data-portal-print]) - an onclick would be blocked + * - Pay invoices: the total and the Pay button follow the invoices ticked * - Tables a page left unwrapped scroll sideways on a phone instead of the page + * + * Taking a card through Stripe is js/portal_stripe.js, on those pages only. */ (function () { 'use strict'; @@ -186,6 +190,87 @@ }); }); + // ---------- Print ---------- + document.addEventListener('click', function (e) { + if (e.target.closest('[data-portal-print]')) { + e.preventDefault(); + window.print(); + } + }); + + // ---------- Paying invoices ---------- + // The ticks on Pay invoices: the total, how many, and the button's words + // follow them, and a total over the card limit is stopped here as well as + // on the server + document.querySelectorAll('form[data-portal-pay]').forEach(function (form) { + var boxes = Array.prototype.slice.call(form.querySelectorAll('input[data-pay-cents]')); + var all = form.querySelector('[data-pay-all]'); + var button = form.querySelector('[data-pay-button]'); + var limit = parseInt(form.getAttribute('data-pay-limit') || '0', 10); + var max = parseInt(form.getAttribute('data-pay-max') || '0', 10); + var money; + try { + money = new Intl.NumberFormat(form.getAttribute('data-pay-locale') || undefined, {style: 'currency', currency: form.getAttribute('data-pay-currency') || 'USD'}); + } catch (err) { + money = new Intl.NumberFormat(undefined, {style: 'currency', currency: 'USD'}); + } + + function text(selector, value) { + var el = form.querySelector(selector); + if (el) { + el.textContent = value; + } + } + + function update() { + var cents = 0; + var count = 0; + boxes.forEach(function (box) { + if (box.checked) { + cents += parseInt(box.getAttribute('data-pay-cents'), 10) || 0; + count++; + } + var row = box.closest('li'); + if (row) { + row.classList.toggle('is-selected', box.checked); + } + }); + var amount = money.format(cents / 100); + var method = form.querySelector('input[name=method]:checked'); + var over = limit > 0 && cents >= limit; + var tooMany = max > 0 && count > max; + text('[data-pay-total]', amount); + text('[data-pay-count]', count + (count === 1 ? ' invoice' : ' invoices')); + text('[data-pay-label]', !count ? 'Tick an invoice to pay' : (tooMany ? 'Pay up to ' + max + ' invoices at a time' + : (method && method.value === 'new' ? 'Continue to pay ' : 'Pay ') + amount)); + var note = form.querySelector('[data-pay-limit-note]'); + if (note) { + note.hidden = !over; + } + button.disabled = !count || over || tooMany; + if (all) { + all.checked = count === boxes.length; + all.indeterminate = count > 0 && count < boxes.length; + } + } + + if (all) { + all.addEventListener('change', function () { + boxes.forEach(function (box) { + box.checked = all.checked; + }); + update(); + }); + } + boxes.forEach(function (box) { + box.addEventListener('change', update); + }); + form.querySelectorAll('input[name=method]').forEach(function (radio) { + radio.addEventListener('change', update); + }); + update(); + }); + // ---------- Tables ---------- document.querySelectorAll('.itflow-portal-main table.table').forEach(function (table) { if (table.closest('.table-responsive')) { diff --git a/js/portal_stripe.js b/js/portal_stripe.js new file mode 100644 index 000000000..965ec0f64 --- /dev/null +++ b/js/portal_stripe.js @@ -0,0 +1,128 @@ +/* + * Client portal - taking a card through Stripe. Loaded only on the pages + * that set $portal_stripe (client/includes/footer.php), after Stripe's own + * script, and kept out of the page itself for the portal's + * Content-Security-Policy: + * + * - Checkout (client/pay.php): Stripe's card form in form[data-stripe-pay], + * on the PaymentIntent the page set up; paying sends the contact to + * Stripe and back to the return URL, which books it + * - Cards & autopay (client/saved_payment_methods.php): Add a card opens + * Stripe's embedded Checkout to save one - straight away when the page + * asks with data-autostart + * + * If Stripe's script didn't load, each says so instead of sitting there. + */ +(function () { + 'use strict'; + + var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'; + + function show(el, on) { + if (el) { + el.hidden = !on; + } + } + + function unavailable(scope) { + show(scope.querySelector('.itflow-portal-stripe-element'), false); + show(scope.querySelector('[data-stripe-loading]'), false); + show(scope.querySelector('[data-stripe-unavailable]'), true); + } + + // ---------- Checkout ---------- + var form = document.querySelector('form[data-stripe-pay]'); + if (form) { + var submit = form.querySelector('[data-stripe-submit]'); + var errorBox = form.querySelector('[data-pay-error]'); + + var busy = function (on) { + submit.disabled = on; + show(form.querySelector('[data-stripe-busy]'), on); + show(form.querySelector('[data-stripe-idle]'), !on); + }; + + if (typeof window.Stripe !== 'function') { + unavailable(form); + } else { + var stripe = window.Stripe(form.getAttribute('data-stripe-key')); + var elements = stripe.elements({ + clientSecret: form.getAttribute('data-client-secret'), + appearance: {theme: dark ? 'night' : 'stripe'} + }); + var payment = elements.create('payment', {layout: 'tabs'}); + payment.on('ready', function () { + show(form.querySelector('[data-stripe-loading]'), false); + submit.disabled = false; + }); + payment.on('loaderror', function () { + unavailable(form); + }); + payment.mount('#payment-element'); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + show(errorBox, false); + busy(true); + stripe.confirmPayment({ + elements: elements, + confirmParams: {return_url: form.getAttribute('data-return-url')} + }).then(function (result) { + // Only reached when the payment could not go ahead - otherwise + // the browser is already on its way to the return URL + if (result.error) { + errorBox.textContent = result.error.type === 'card_error' || result.error.type === 'validation_error' + ? result.error.message + : 'Something went wrong taking the payment - nothing has been charged. Try again.'; + show(errorBox, true); + } + busy(false); + }); + }); + } + } + + // ---------- Adding a card ---------- + var holder = document.querySelector('[data-stripe-add-card-holder]'); + if (holder) { + var started = false; + var start = function () { + if (started) { + return; + } + started = true; + show(holder, true); + document.querySelectorAll('[data-stripe-add-card]').forEach(function (button) { + show(button, false); + }); + if (typeof window.Stripe !== 'function') { + unavailable(holder); + return; + } + var stripe = window.Stripe(holder.getAttribute('data-stripe-key')); + stripe.initEmbeddedCheckout({ + fetchClientSecret: function () { + return fetch('/client/post.php?create_stripe_checkout', {method: 'POST', credentials: 'same-origin'}) + .then(function (response) { return response.json(); }) + .then(function (data) { + if (!data.clientSecret) { + throw new Error(data.error || 'No checkout'); + } + return data.clientSecret; + }); + } + }).then(function (checkout) { + show(holder.querySelector('[data-stripe-loading]'), false); + checkout.mount('#checkout'); + }).catch(function () { + unavailable(holder); + }); + }; + document.querySelectorAll('[data-stripe-add-card]').forEach(function (button) { + button.addEventListener('click', start); + }); + if (holder.hasAttribute('data-autostart')) { + start(); + } + } +})();