Easy Invoice — complete changelog
The readme summarises each release; this file keeps every entry.

= 2.4.1 - September 15, 2026 =
* Fix: on phones the public invoice and quote pages clipped the line-item table's Total column and, in the Modern, Creative and Professional designs, the totals box; every design now fits a 360px screen (narrower screens scroll the table sideways).
* Performance: the payment script (and the jQuery it needs) loaded on every front-end page of the site; it now loads on the public invoice page only. Filter `easy_invoice_load_payment_assets` to add pages.
* Housekeeping: the changelog in this file now summarises each release; the complete, itemised history lives in changelog.txt inside the plugin folder and on GitHub.


= 2.4.0 - September 11, 2026 =
* Fix: on phones the public invoice and quote pages clipped the line-item table's Total column and, in the Modern, Creative and Professional designs, the totals box; the sheet now uses phone margins, item names wrap and the figures stay on one line, so every design fits a 360px screen (narrower screens scroll the table sideways).
* Performance: the payment script (and the jQuery it needs) loaded on every page of the site; it now loads on the public invoice page only. Filter `easy_invoice_load_payment_assets` to add pages.
* Change: a link emailed before access keys existed no longer dead-ends at "not found": it shows a page (no document data) offering to email a fresh keyed link to the address the document was issued to — one click for the customer, nothing for the merchant to do. Rate-limited; drafts are never offered.
* Fix: the quote list's header cards (total quotes, value by currency, accepted, pending) counted only the 20 rows on screen; they now cover the whole store, from stored quote totals.
* Fix: searching the quote list by quote number, client name or email returned nothing; only title text matched.
* Performance: the invoice list's Overdue filter, the Add Payment form's invoice picker, the payments list totals and the site-wide outstanding/revenue figures are answered from single SQL passes (10,000-invoice stores: Overdue filter 19 s → 3 s, Add Payment 2 s → 0.4 s).
* Fix: two builders open at the same time could save two invoices (or quotes) with the same number — the number the page showed on load overwrote the one claimed on save. Quotes were affected on every concurrent save.
* Fix: quote builder client search ignored a name typed while the initial "all clients" list was still loading.
* Fixed - **"Apply Tax to This Invoice" could never be switched off.** The builder omits an unchecked box from its request and the form processor treated "absent" as "unchanged", so with tax enabled globally every new invoice came back taxed and unticking the box on an existing one was silently ignored. Absent now means unchecked, as the browser intends.
* Fixed - **A discount could make an invoice or quote total negative.** A percentage above 100 or a fixed amount larger than the subtotal was accepted and produced a negative total (and a negative balance due). Both builders now refuse a percentage over 100 or a negative discount with a clear message, the message itself is shown in the toast instead of "Validation failed", and the models cap any discount at the amount it is taken from.
* Performance - **Invoice totals are now stored.** Until now a total only existed while a model was in memory, so the invoice list header, the dashboard, the clients page and the reports loaded every open and every paid invoice as a full model on each view — 20 s and 88 MB with 1,200 invoices, and out of memory at a few thousand. `InvoiceTotalsCache` persists the computed total on save, backfills older invoices in the background (a one-off cron job; nothing to run by hand), drops a total when its inputs change (items, discount, tax fields, the global tax settings) and rebuilds everything when the arithmetic itself changes. Those screens now sum in SQL, with the site-wide figures memoised until the next invoice, payment or credit note write. Verified on 2,300 invoices added through the builder: list 20 s → under a second of plugin time, 88 MB → 12 MB.
* Fixed - The invoice list's "Total Invoices" card and "All (n)" tab showed the size of the current page (20) instead of the real count, and counted trashed and draft invoices by loading every one of them.
* Fixed - **Overdue and partially paid invoices could not be paid from their public page.** The payment panel only offered methods while the status was "available" or "unpaid", so an overdue invoice — the one a client most needs to settle — showed "Payment methods are not configured". Every status that still carries a balance now offers the configured methods (`easy_invoice_payable_statuses` filter). The panel also no longer throws a script error when no method is available.
* Fixed - After saving a brand-new invoice or quote the address bar never gained the document id, so a refresh reopened a blank builder and a second Save created a duplicate. The id is now written into the URL as soon as the save succeeds.
* Performance - Payments → Add New Payment built a full model for every published invoice on the site to fill its drop-down; on a store with thousands of invoices that was tens of seconds and hundreds of megabytes. It now lists open invoices only (newest 300, filterable, `easy_invoice_payment_form_invoice_limit`), shows the balance still due, and preselects `?invoice_id=`.
* Performance - The invoice and quote builders loaded every client on the site into a hidden mirror `<select>` on each open (one model and one user lookup per client). Only the document's own client is rendered now; the picker searches over AJAX as before, and its empty-query "show some clients" list builds 50 models instead of all of them.
* Fixed - **Opening the invoice builder for an invoice that no longer exists white-screened.** A stale bookmark, a browser back-button after deleting, or a quote id pasted into the invoice builder URL rendered an empty editor and then crashed the live preview. Both builders now stop with "That invoice/quote does not exist or has been deleted" and a back link.
* Fixed - CSV import on PHP 8.4 logged a deprecation for every row read (`fgetcsv()` without an explicit escape character); the reader now passes the same escape it always used, so the import output is unchanged and the log is quiet.
* Fixed - Server-side PDF rendering raises the PHP memory limit the same way WordPress does for image editing before it starts, so a PDF downloaded from a public invoice page on a host with the 40 MB front-end default no longer risks a memory error.
* Fixed - **The `customer` role was never registered on sites without WooCommerce.** Its registration was hooked to `init` at priority 1 from a function that itself runs at `init` priority 10, so it never fired: clients had no role, the Clients screen fell back to a generic label, and Pro's Client Portal — which recognises a client by that role — could not see them. The role is registered as soon as the plugin loads; existing clients regain it on the next page load and still have no backend access.
* Fixed - **The admin was never told when a client submitted a manual payment.** The only listener for `easy_invoice_manual_payment_submitted` lived in a class nothing instantiates, and the offline check in the payment handler tested gateway ids that do not exist. A bank-transfer proof upload, a cash payment or a manual payment now emails the admin "Payment awaiting verification" with the method, amount and the client's note; Pro's bank-transfer and cheque gateways keep sending their own.
* Fixed - The invoice list's `?subscription=` filter was read and ignored; it now filters, for Pro's Subscription Invoices.
* Added - `easy_invoice_quote_total` filter on the quote total (the invoice model has had its counterpart since 1.0), `easy_invoice_quote_to_invoice_data` on the data a converted invoice is created from, `easy_invoice_client_created` after a client and their user are created, and `easy_invoice_help_link_url` on the sidebar Documentation link; the community link honours `easy_invoice_show_upgrade_prompts`.
* Removed - Dead code that could only mislead: two admin classes nothing instantiated (one registered a "Manual Payment Verification" meta box for a post type that has no edit screen), a "create sample" and an "update existing quotes" handler whose buttons no longer exist, and the scripts that called them.
* Removed - Four classes nothing referenced (`InvoiceService`, `QuoteService`, `MigrationStatus`, `ClientDisplayHelper`), three listeners on hooks nothing fires, and list-column hooks for a `easy-payment` post type that does not exist.
* Fixed - **Reports: the summary cards ignored the From / To range and counted credited invoices as revenue.** "Total revenue" added up every paid invoice the site ever had, including ones later credited in full, while the sections under it were dated; "Top clients by revenue" summed drafts and unpaid invoices too and sorted by USD only. The cards and the top-client list now cover the chosen period, revenue is paid invoices net of credit notes, invoice and client counts are for the period, and the two revenue figures say what they measure.
* Fixed - **Duplicate invoice and quote numbers under concurrent use.** The number lock serialised requests correctly, but each request then read the counter from WordPress's options cache, loaded before it queued for the lock — twelve simultaneous saves all took `INV-000194`. Quotes created outside the builder never took a number at all (the form default only peeked at the next one). And two people opening "New invoice" at the same moment both saved the pre-filled number. The counter is re-read from the database under the lock; a posted number is kept only while nobody holds it and the counter moves past it; a taken number is replaced by a fresh one. Verified with 12 parallel creations (12 distinct numbers, twice over) and the double-prefill case.
* Fixed - The Addons screen links an addon's Settings only when the installed Pro registers that page (an older Pro next to a newer free plugin led to a 403).
* Fixed - **Admin screens took 30–40 seconds on a site with a few thousand invoices.** Every invoice model built on a list evaluated the "next free number" default (three queries each), the dashboard loaded every invoice three times, the invoice list loaded every trashed and every draft invoice just to count them, the reports loaded everything and filtered in PHP, and each formatted amount rebuilt the whole settings tree. Measured on 3,000 invoices: dashboard 38 s → 0.8 s, invoice list 37 s → 1.5 s, reports 36 s → 0.8 s, a 4,500-line client statement 12.6 s → 1.5 s. Field definitions and settings are resolved once per request, counts are SQL counts, only open invoices are loaded for balances, and paid/credited totals are fetched for a whole set in two queries.
* Fixed - **Printed lines did not always add up to the printed subtotal.** Line amounts, discount, tax and total were kept as unrounded floats and rounded only for display, each on its own — 3 × 33.335 + 0.015 + 7 × 1.005 printed as 100.01, 0.02 and 7.03 under a subtotal of 107.05. Money is now rounded to cents per line at calculation time (invoices, quotes, both builders' JavaScript use the same half-up rule), so lines, subtotal, tax and total are consistent on the screen, the PDF, the e-invoice and the charge.
* Fixed - **Issue, due and expiry dates defaulted to the UTC date, not the site's.** New invoices, quotes, duplicates, recurring children, imports and payment forms used `gmdate()`, so a document created at 3 a.m. in Kathmandu (or any evening in the Americas) was dated the previous day. Sixty call sites now use the site's timezone; "overdue" was already judged on the local date.
* Fixed - **Two credit notes issued at the same moment could together exceed the invoice.** The remaining-creditable check and the credit note that consumes it ran unlocked, so three simultaneous 60.00 credits against a 100.00 invoice all went through. Credit-note creation is serialised per invoice; the second and third now get "only 40.00 of this invoice is left to credit".
* Fixed - Credit-note numbers read the counter from the options cache under their lock, the same race as invoice numbers; re-read from the database.
* Security - Payment proofs were saved under a time-based name (`payment_proof_<uniqid>`) in a public folder; a receipt carries bank details. Names are now random, and the folder gets an index file and `Options -Indexes`.
* Security - **Stored XSS from a client's profile into the invoice and quote builders.** The client-search dropdown built its rows by string interpolation, so a name or company a client had set through the portal ran as script when an administrator searched for clients; the selected client's address was rendered the same way. Rows are now built as DOM nodes with text, and the address is escaped before its line breaks are added. Found by planting payloads through every client-facing input and driving every admin screen.
* Security - The migration "skip" link wrote an option from a plain GET; it now carries a nonce and requires `manage_options`.
* Security - Release hardening pass: every internal redirect goes through `wp_safe_redirect`; the builder's live-preview endpoint requires the invoice capabilities (it renders the whole document by id and only asked for a nonce); request parameters read on the Settings, licence and payments screens are sanitised at the read. PHPCompatibility 7.4–8.5 and the WordPress security sniff set were run across the plugin, and every privileged AJAX / admin-post action was exercised as a subscriber with nonces bypassed to confirm the capability checks stand on their own.
* Fixed - **The invoice and quote builders could not add a line item** — the item row's form markup was printed as text, so "Add Another Item" produced a block of HTML instead of fields. Also un-escaped: the Pro licence screen, the Template Builder canvas and preview, CSV exports (quotes turned into `&quot;`), and the toast script payload. Notices and gateway descriptions may carry links again.
* Changed - **Every invoice and quote design tidied.** The "Adjust (%)" column appears only when a line actually uses it; the footer block is printed only when there is footer text (the Legacy designs no longer print a hard-coded English footer with a placeholder address); "Notes" and "Terms & Conditions" headings lose their trailing colons; an empty line-item table says so instead of showing a bare header; attachments and the quote signature block now sit inside the document card — so they are part of the PDF — through the new `easy_invoice_document_body_after` action fired by every design; and the designs stack cleanly on phones (header, addresses and totals go single-column, the item table scrolls sideways instead of pushing the page wider).
* Fixed - **"Delete client only" deleted the client's documents.** The dialog posts the literal string "false", which a boolean cast read as true, so the choice meant to keep invoices and quotes removed them along with the client. Both choices now do what they say.
* Added - A search box on the Clients screen filters the list by name, company, email or phone as you type.
* Fixed - A renamed payment method (Settings → Payment Methods → Display Name) was saved but the field always showed the default again.
* Fixed - **New line items were not taxable.** The "Taxable" box on a new line rendered unchecked despite its default, so with tax switched on every invoice built in the builder came out without tax unless each line was ticked by hand. New lines are taxable; stored lines keep their saved state.
* Fixed - **The "Live preview" in the builder was not live** — it only redrew when the design changed and always showed the last saved state. It now re-renders as you type, add or remove lines, pick a client or change a discount, with the totals the saved invoice will have.
* Fixed - **The invoice list's status filter did nothing** (a single meta clause was handed to WP_Query unwrapped), and it offered "Pending", a status no invoice has, while omitting "Available" — the status of every new invoice. Filters: Available, Paid, Partially paid, Unpaid, Overdue, Draft, Cancelled.
* Added - **Convert to invoice** on each quote row (Easy Invoice → Quotes): a draft invoice with the quote's lines, the quote marked accepted and linked to it; once converted the row links to the invoice. Fires `easy_invoice_quote_converted_manually`. Until now a quote became an invoice only when the client pressed Accept.
* Changed - The admin invoice preview accepts `?id=` as well as `?invoice_id=`.
* Fixed - **"Add New Payment" could not record a payment.** The admin form posted to the customer checkout endpoint, which runs a gateway; its first option, "Manual", named a gateway that does not exist, so every attempt ended in "Selected payment gateway is not available". The form now books a completed payment (cash, cheque, bank transfer, other) against the invoice and settles it — paid when covered, partially paid otherwise — and fires `easy_invoice_payment_recorded`. The invoice list labels partially paid invoices.
* Fixed - The payment panel's method icons and the lock beside "Your payment is secure" were icon-font class names on a page that loads no icon font, so they rendered as empty boxes; they are inline icons now.
* Fixed - Settings field descriptions may carry links and code; number fields' min/max/step attributes were being HTML-escaped into their values.
* Fixed - **Team members could not open any screen.** Every admin page demanded `manage_options`, so a colleague given an EI role by the Pro Team Roles addon was refused everywhere before the plugin's own capability checks ran. Pages now register with a filterable capability (`easy_invoice_menu_capability`), the Dashboard, Reports, Clients and preview guards check the matching `ei_*` capability (and no longer crash printing a WP_Error), the sidebar draws only entries the member can open, and list screens hide create / edit / delete / send controls the member does not hold.
* Fixed - **Settings screen: every image field shared the id "0"** — a cast in the escaping pass turned field ids into 0, so the logo, watermark and every other control had duplicate ids and the "Change" button could write to the wrong field. Ids are the option names again. Empty image previews no longer request the page URL as an image.
* Fixed - **Admin colours that never rendered.** The views use Tailwind's amber, emerald, teal and orange palettes, which the bundled Tailwind v2 does not define — every amber warning box and emerald badge was unstyled. A small stylesheet now defines them; controls given only a border colour draw a border.
* Fixed - **Lists that scrolled sideways on a laptop.** The invoice and quote rows' action links refused to wrap and pushed the Status, Amount and Actions columns off screen at 1440 px; the Clients table hid its last column. Actions wrap, titles wrap, and Username / Role columns step aside under 1700 px.
* Fixed - **A part-paid or credited invoice asked the client for the full amount again.** The public page's Pay Now charged the invoice total whatever had been received, was hidden on partially paid invoices, and ignored credit notes entirely — an invoice credited in full still showed its whole total due. What is owed is now one figure everywhere (total less payments and credit notes): the Total Due in every design, a "Credit note CN-…" row and Balance due in the totals block, on the page, the PDF and the email attachment; the payment panel says "Amount Due" and charges only that; Pay Now stays available while anything is owed; recording a payment or a credit counts both towards settlement; and an unpaid invoice credited in full is cancelled. The credit note screen shows amounts in the invoice's currency.
* Fixed - A payment marked failed or refunded after completing put an issued invoice back to Draft; it now reads Partially paid or Available. An invoice with some money received is marked Partially paid as soon as the gateway confirms it. New filter `easy_invoice_allow_partial_payment_amount` lets an addon permit an instalment smaller than the balance.
* Fixed - **"Overdue" is now a state, not a status nobody sets.** An invoice still owed after its due date reads Overdue in the invoice list, the Overdue filter, the dashboard, and a client's page; the dashboard's Unpaid and Overdue tiles counted the literal statuses `unpaid` and `overdue` (always 0) and now count open invoices with a balance, with the amounts outstanding and past due. The list's "Pending Invoices" tile becomes Outstanding, and its Draft tab looked for draft *posts* (always 0) rather than draft invoices.
* Fixed - **Quotes never expired.** The daily check named a post type that does not exist, and would have expired accepted quotes had it run. Open quotes past their date are now marked expired; an expired, accepted or declined quote cannot be accepted or declined again — the public page says why instead of showing the buttons — and the quote list reads Expired as soon as the date passes.
* Added - Settings → Text: a separate **Line total** label for the item table's amount column; renaming "Total" no longer renames that column heading as well. Amounts on the payment panel, statements, dashboard and reports follow the currency settings (symbol position, separators, precision) like the documents do.
* Fixed - **Receipts and payment notices were wrong for anything but a single full payment.** The client receipt showed `{{payment_40}}` and `{{cash}}` in place of the amount and method (raw payment data was being used as replacement text), named the invoice total instead of the payment that settled it, and went out only when the invoice was fully paid. Each payment — instalments included — now gets a receipt with the amount, method, date and balance remaining; the admin notice states the amount received and what is still owed. New action `easy_invoice_payment_received` for payments that leave a balance.
* Fixed - `getMeta()` / `setMeta()` / `getMetaData()` on the invoice and quote models were not real methods: they fell through to the magic getter for a field called "meta" and returned nothing, so every caller (payment method and transaction id on the invoice, addon settings read back into the builder) silently got null. They are real methods now.
* Fixed - Trashing marked a paid invoice cancelled and restoring made it Available; restore now returns the status the invoice had before it was trashed (bulk trash cancels on the way too, as the single action did).
* Fixed - The list's "Draft" action set the WordPress post to draft, which made the invoice vanish from every list and turned its link into a 404. It now sets the invoice status back to draft (the invoice stays listed with its number) and is not offered on paid or part-paid invoices.
* Changed - Emailing a draft invoice issues it (status Available) and emailing a quote marks it Sent — a document the client holds no longer reads Draft in your lists.
* Changed - **The stock emails read like business correspondence.** Invoice, quote, payment-received, reminder and quote-response emails lose the emoji headings, the hard-coded "Payment Terms: Due on receipt" line, the "late fees or service interruptions" warning and the shortcode line, and gain a "View and pay invoice" button and an `{{amount_due}}` placeholder (the balance after payments and credits). Sites still using the previous stock text are moved to the new one once; an edited template is left alone.
* Fixed - **The Reports page's Invoice Report showed most invoices at $0.00 and every one as "Unpaid".** It read a stored total and a status key that a normally saved invoice never has, ignored the date range, and counted trashed invoices. It now lists the invoices issued in the period with the totals the documents themselves show and their real status (paid, partially paid, unpaid, overdue, draft, cancelled); the status chart keeps a fixed colour per status and draws when its tab is opened instead of coming up blank.
* Fixed - Search boxes with an inset icon (Templates, lists) printed the placeholder under the icon: the shared input style overrode the box's own left padding.
* Fixed - Emails signed off with the site's name instead of the business name from Settings → Company; `{{company_name}}` now uses the company name and falls back to the site name only when none is set.
* Fixed - The dashboard's "Active clients" was always 0 (the lookup used a meta key nothing writes); the Reports page's "Active clients" counted every client on file and "Average payment time" was a hard-coded 30 days — both are computed now, top clients are named by business rather than email, and the list stops padding with $0.00 rows.
* Fixed - Reports linked invoice rows to a route that does not exist and to public URLs for trashed invoices; they open the builder. The builder's "Edit client" link is hidden when the document has no client. The Time Tracking, Expense Tracking and deposit screens linked invoices to the stock post editor, which cannot open them.
* Fixed - A payment with a non-numeric amount fataled the payment view; the stock "Payments" post-type editor is hidden (it created nameless auto-drafts). The addon cards' Docs / Settings links carried a stray backslash in their title attribute.
* Changed - The builder's live preview is scaled to fit its pane instead of reflowing the A4 design at half width; the "Create New Template" deep link opens the builder rather than the Dashboard underneath it. Creating an invoice or quote with an invalid client id no longer fatals.
* Changed - Statements are titled with the business name and print amounts with the currency symbol.
* Added - **Every invoice design says what is owed.** A "Total Due" line in the header (the total less payments received) and, once something has been paid, **Paid** and **Balance due** rows under the total on the page as well as the PDF. `easy_invoice_amount_due` and `easy_invoice_totals_show_payments` filter them; the Legacy design's Total Due now also subtracts payments.
* Changed - Multi-page "Download as PDF" files carry "INV-000123 · 2 / 3" at the foot of every page, in a strip kept clear of content.
* Fixed - **Removing the last attachment on an invoice or quote did not stick**: the emptied field was dropped before the save, so the file came back on reload.
* Fixed - **Converting a quote to an invoice produced nameless lines that added up to zero** — a saved quote stores its lines as title/total and the conversion read name/amount. Lines are now read through the model.
* Fixed - A company logo that fails to load no longer leaves a broken-image icon on the document; the logo block is rendered only when a logo is set.
* Changed - **Every design was reset, one by one.** Standard is ink on white with one rule per section and nothing boxed; Classic keeps its dark serif ledger row but loses the cell grid and the grey panels; Minimal and the Minimal / Minimalist quote designs are hairlines only (Minimalist centres its header); Modern and Professional drop the panel-inside-a-panel header and the heavy shadows and sit their sections closer; Legacy keeps its ruled grid with softer rules. Each document now carries a small "INVOICE" / "QUOTE" line above its title, so a document titled "Web Design" still says what it is.
* Changed - **The designs look like invoices from a firm, not from a plugin.** Corporate opens with a navy masthead across the width of the sheet and closes with a navy total; Elegant is set in a serif with hairlines, small capitals and a bronze total instead of tinted panels; Creative carries one violet-to-fuchsia gradient and zebra-striped rows — the three used to be the same layout in three colours. Every design now right-aligns its Qty, Rate and Total headings over the figures, sets numbers in tabular figures so they line up, keeps amounts from wrapping, and prints the totals labels without colons.
* Added - **The "To" block names the contact person, phone and VAT number.** A business client's contact appears under the company name, the client's phone is listed with the email, and a customer VAT number entered on the invoice is printed — as EU B2B invoices require. Your own tax ID (Settings → Company) is printed under your details on every design. A quote or invoice with nobody to address no longer shows an empty "To" box. `easy_invoice_client_block_html` filters the block; `easy_invoice_tax_id_label` renames the "VAT" prefix.
* Fixed - The Legacy invoice design never printed Terms & Conditions, unlike every other design; the Legacy quote design's heading was not translatable; the Modern quote design's table header was painted in four separate gradients, one per column.
* Fixed - A tax statement (reverse charge, export) added to the totals printed above the subtotal rather than between the tax line and the grand total.
* Fixed - Server-generated PDFs of the Professional, Modern and Creative designs ran to a second page for a two-line invoice; the sheet is set at a print scale now and each fits one page.
* Fixed - **Totals rows read in the order the figures are computed.** A discount taken off the taxed total is listed below the tax line; one taken before tax stays above it. Previously the discount always came first, so the rows did not add up on the page.
* Fixed - New invoices and quotes created without an explicit discount timing (imports, API, payment links) now discount **before tax**, matching the first choice in the builder; an empty setting used to fall into the after-tax arithmetic. Existing documents are not recalculated.
* Changed - The default quote footer reads "Thank you for your business." instead of naming the plugin.
* Changed - **The design is an A4 sheet, on screen and in the PDF.** The public invoice and quote page lays the chosen design out as a 210 × 297 mm sheet (growing taller only when the document needs more than one page), and "Download as PDF" captures that sheet edge to edge, one A4 page per sheet, breaking between rows. The capture used to be the wide transparent box around the card, so the PDF showed the card small with its rounded corners and top bar clipped.
* Changed - **"Download as PDF" captures the page exactly as shown again** — the design, a custom template, the watermark — as it did before 2.4.0. It is now a setting (Settings → Email → "Download as PDF" button): browser capture (default) or a server-generated file with real text. Emails, the REST API and e-invoicing always use the server.
* Added - **Server-generated PDFs reproduce the selected design.** Every built-in invoice and quote design (Modern, Classic, Corporate, Creative, Elegant, Minimal, Professional, Standard, Legacy) and Pro Template Builder canvases render on the server with their own colours, typography and layout — as real text — instead of one neutral layout. Choose the plain layout under Settings → Email → "Server-generated PDF layout" if you prefer it. A design that cannot be rendered falls back to the plain layout on its own.
* Fixed - The Minimal quote design left its totals block unclosed, nesting the notes, terms and footer inside it.
* Fixed - Request fields that were not sent (an optional client field, a missing nonce) raised PHP notices; every request read is guarded now.
* Fixed - **An invoice to a person showed no name.** The client's business name was the only thing copied onto the invoice, so a client without one had an empty "To" block. The person's name is used when there is no business name, and a client's details are filled in the moment an invoice or quote is created for them rather than on the next load.
* Fixed - The PDF now honours the Subtotal, Tax and Discount labels renamed under Settings → Text, as the page already did; and an invoice with money received shows **Paid** and **Balance due** under the total.
* Added - `easy_invoice_presented_access_token` lets another proof of access stand in for the `?ik=` / `?qk=` token — the Pro Secure Links addon uses it so a signed link can pay, accept and decline.
* Changed - `easy_invoice_has_pro()` is true only when Easy Invoice Pro actually loaded, not merely when its file is present — Pro 2.3 declines to run against an older free plugin, and the admin shell must not then call into it.
* Added - `easy_invoice_reports_after_summary` fires on the Reports page between the charts and the detailed reports; the Pro Reports addon puts its profit & loss statement there.
* Added - Document labels saved under Settings now pass through `easy_invoice_text_setting`, and email templates through `easy_invoice_email_template_data` (with `easy_invoice_email_finished` once a send is over), so a document can be produced in the client's language — the Pro Client Language addon uses both.
* Added - **Attachments.** Pick files from the media library on an invoice or quote — a timesheet, a signed contract, delivery photos. They are listed on the document page and the PDF, and sent with the email (`easy_invoice_email_document_attachments` turns that off; `easy_invoice_email_attachments` filters the list).
* Added - The public quote page can carry a signature pad when an addon asks for it, and `easy_invoice_quote_accepted` now fires with the acceptance details (signature, signer name, address, time). `easy_invoice_pdf_after_notes` fires on the PDF after notes and terms.
* Added - **Import from Sliced Invoices, Sprout Invoices and CSV.** A new Import screen reads Sliced's and Sprout's posts directly on the same site — clients, quotes, invoices with every line item, and payments — with a count of what it will bring in before you run it. Original numbers and dates are kept, a client who already exists here is reused (matched by email), and running an import twice adds nothing. CSV covers FreshBooks, Wave, Zoho, Invoice Ninja and spreadsheets: columns are matched by header name, so most exports work unchanged; comma, semicolon and tab delimiters and both "1,234.56" and "1.234,56" are understood. Template files are provided.
* Added - **Know when the client has opened it.** Each invoice and quote records the first and latest time the client viewed it and how many times, shown beside the number in the lists ("Viewed 2× · last 3 hours ago") and returned by the REST API. Staff previews are not counted; the viewer's address is stored with its last octet removed. Fires `easy_invoice_document_viewed`, which the Pro Webhooks addon turns into `invoice.viewed` / `quote.viewed` events.
* Fixed - **The committed dependency tree could not have run.** The autoloader shipped from the repository referenced `thecodingmachine/safe`, a package that was never committed, so a build straight from the tree would have fataled on every request. The CSS parser is now pinned to a version without that dependency, which also removes several hundred PHP 8.4 deprecation notices per request on sites running with debugging on.
* Changed - Every output in the plugin is now escaped at the point it is printed (about 700 sites), and every `date()` is `gmdate()`; the plugin passes WordPress.org's Plugin Check with no errors.
* Changed - Plugin Check pass: direct-access guards on every PHP file, sanitizers on all registered settings, escaped exception messages, `wp_rand()` and `wp_handle_upload()` in place of PHP's own functions, translator comments and ordered placeholders on translatable strings, and `esc_html_e()` for four hundred plain-text labels. Four strings used the Pro text domain by mistake.
* Changed - **The public invoice and quote pages are now real WordPress pages, and themes can override them.** Both used to be self-contained HTML documents with hand-written script tags and no `wp_head()`/`wp_footer()`, which meant nothing enqueued for them ever loaded (the bank-transfer submit script among them) and nothing a site adds through the normal hooks — analytics, consent banners — appeared. One shared page now renders both documents through the standard pipeline, with its own stylesheet and script instead of 1,200 lines of inline code, and the theme's assets kept off it by default (`easy_invoice_document_isolate_assets`, `easy_invoice_document_allowed_handles`). Copy any template to `{theme}/easy-invoice/…` to override it: the page (`document/single.php`), any design (`invoice-templates/modern.php`), the PDFs (`pdf/invoice.php`). Print uses the browser's print dialog with print styles rather than a pop-up window. The old `templates/invoices/single.php` and `templates/quotes/single.php` remain as thin redirects.
* Fixed - **A client paying by bank transfer, cheque or cash from their emailed link was refused with "Invalid invoice".** The payment form never forwarded the link's access token on submit, so the authorisation added to the payment endpoint could not see it. The instructions loader on the same page did forward it, which is why the details appeared but the submission failed.
* Fixed - Per-document Additional CSS containing quotes or `>` was mangled on reload, because it was HTML-escaped inside a `<style>` element where entities are not decoded. It is now written the way core writes Customizer CSS.
* Fixed - **A PayPal payment verified by IPN left the invoice unpaid.** Checkout wrote a pending payment record; the IPN handler verified the payment with PayPal and then asked whether the invoice was paid by summing *completed* records — of which there were none — so every PayPal invoice stayed unpaid until someone marked it by hand. The verified IPN now completes that record (or books one, if the customer paid from a link with no pending record) with PayPal's transaction id and the amount actually paid, and only then decides the status. A short payment shows as partial rather than paid, and PayPal's IPN retries are recognised by transaction id and ignored.
* Added - **Credit notes.** An issued invoice cannot be edited or deleted — it is a tax record — so until now there was no lawful way to undo one. Each invoice row now offers *Credit note*: credit the whole invoice or part of it, with a reason that appears on the document. Credit notes have their own CN- sequence, reference the invoice they correct, render as PDFs, and appear on the client's statement. Over-crediting is refused with the amount still available; crediting a draft is refused because a draft should simply be edited.
* Changed - **An issued invoice can no longer be deleted permanently.** Every tax authority requires issued invoices kept with an unbroken sequential number; deleting one destroys evidence and leaves the gap an auditor looks for. Deleting drafts, moving issued invoices to trash (reversible, and marks them cancelled) and everything about quotes are unchanged. Enforced at the data layer, so the invoice screen, bulk actions, the REST route, client deletion, wp-admin and WP-CLI all behave the same. The `easy_invoice_allow_issued_invoice_deletion` filter exists for a site with a genuine reason — deliberately a filter, not a setting.
* Added - **Statements of account.** *Statement* on each client row shows every invoice, payment and credit note that moved the balance, in date order with a running total, filterable by date and downloadable as a PDF. Only settled payments move the balance; documents before a chosen window are brought forward rather than dropped.
* Fixed - **Payments were never found for a client.** Payments record only the invoice they settle, not a client, so a client's payments are reachable only through their invoices. Four places instead queried a meta key nothing has ever written and silently matched nothing — the client-delete confirmation told every user there were no payments, whatever the truth. The count is now right. Deletion still leaves payment records in place, on purpose and now stated in the dialog: a payment is the evidence money changed hands.
* Added - **VAT numbers checked against VIES.** A *Check with VIES* control beside the customer's VAT number on the invoice asks the EU register whether the number is actually registered — the question that decides whether a reverse-charge supply is valid. "Not registered" and "could not check" are kept strictly apart: an outage, a non-EU country or a member state that did not answer never reports a number as invalid. Greece is queried as EL, as VIES requires. On demand, never on save, so an invoice never fails to save because a foreign register is down.
* Changed - **Download PDF now produces a real, text-based PDF by default.** The server-side renderer added in this release is now the primary path — the same invoice is 25 KB of selectable text instead of a 183 KB screenshot. The existing download links, including ones already in customers' inboxes, all upgrade automatically; if the renderer is unavailable the browser path still works.
* Fixed - **Quotes could not be rendered server-side at all** — the quote PDF template did not exist, so every quote silently fell back to the browser renderer. Both documents now share one layout and differ only in the four labels and one date that actually differ.
* Fixed - **Customer name, email and address were dropped when an invoice was saved.** They are not form fields, so persistence — driven by the form's field list — never wrote them; an invoice for someone who is not a client had nowhere to keep them. The per-invoice tax override suffered the same way on sites with tax switched off site-wide. Both now round-trip. Nothing is invented: an invoice with a client still takes its details from the client.
* Changed - **The admin now loads 47 KB of CSS instead of 2.8 MB on every screen.** The shipped Tailwind bundle was the full CDN dump. It is purged in place — every rule that survives is byte-identical to before — and was verified by pixel-diffing 41 admin screens with the full and purged files: zero differing pixels. Three ways back to the full file if a custom template needs it; see `tools/tailwind-build/README.md`.
* Fixed - **Composer dependencies were resolved against the build machine's PHP.** The lock file demanded PHP 8.4 while the plugin advertises 7.4, which fatals the whole site on any host in between. Dependencies are now pinned to the advertised minimum.
* Added - **A hook after a client's details** (`easy_invoice_client_view_after_details`), **a filter for credit-note actions** (`easy_invoice_credit_note_actions`), and **a filter for pages that render themselves** (`easy_invoice_self_rendering_pages`) — the last one because the admin dispatcher rendered the Dashboard underneath any page it did not recognise.
* Added - **A REST API at `easy-invoice/v1`.** The plugin had 83 admin-ajax handlers and no REST surface, and the documentation told integrators to POST at admin-ajax.php with a nonce — which only works from inside a logged-in browser session, ruling out mobile apps, headless front-ends and anything authenticating with an application password. Routes cover listing, reading, creating and trashing invoices, reading quotes, listing clients, and downloading an invoice as a PDF.
* Added - Three extension points Pro addons had been registering against for years without anything applying them: `easy_invoice_email_footer_html` (the footer block of every email), `easy_invoice_email_html` (replace the whole email layout) and `easy_invoice_pdf_footer_html` (the footer of every rendered document — page, PDF and attachment, through one helper all fourteen designs now share). `EmailManager::headers($kind, $document)` gives addons the From and Reply-To headers Settings → Email defines, so Pro reminders no longer go out as "WordPress".
* Fixed - The shared confirmation dialog tried to submit a link as if it were a form button when the link sat inside a bulk-action form (Time Tracking's Delete), which threw an error and did nothing; links follow their address, buttons submit their form.
* Fixed - The Reports overview's "Invoice Status" chart read a date the model never had (so the date range did nothing) and only knew the statuses paid / unpaid / draft / cancelled — every Available or Partially paid invoice was missing from it and Overdue was only counted for a status nothing sets. It now uses the same derived statuses as the invoice list, filters by issue date, and colours each slice by status rather than by position.
* Fixed - **A quote's acceptance and decline details were never saved.** The accept and decline handlers set the date, who decided and the decline reason on the model, but only builder fields are written to the database, so the quote log was the only record and exports read an empty "accepted date". They are stored now, and the public page tells a client who reopens an accepted or declined quote what happened ("You accepted this quote on 12 September 2026") instead of a document with no buttons and no explanation.
* Fixed - Adding a client with an email or username that is already taken said "Failed to create client"; it now gives the reason. The client list's Delete had two competing handlers; one remains (keep or remove the client's documents).
* Fixed - **Trashing, restoring or deleting a payment left the invoice's status untouched** — the bulk actions on the Payments screen wrote the new status to a meta key the invoice never reads, so a paid invoice stayed "Paid" after its only payment was removed. The invoice is now re-checked against its remaining payments and credit notes: paid, partially paid, or awaiting payment.
* Fixed - About 140 strings on the admin screens (column headings, bulk actions, report titles, empty-state messages) were plain text rather than translatable; they go through the `easy-invoice` text domain now, so the Pro Translations page and language packs reach them.
* Added - **Deleting the plugin keeps your data unless you say otherwise.** There was no uninstall routine at all, so deleting left every option, scheduled task and addon table behind with no way to clear them. Deleting now unschedules the plugin's tasks and, only if Settings → Advanced → "Remove all data when the plugin is deleted" is on, removes invoices, quotes, payments, credit notes, client records, settings, roles and the addons' tables. Off by default: invoices are tax records.
* Fixed - A quote converted by the client accepting it (Settings → Quote → "Accept creates an invoice") was not linked back from the quote, so the quote list showed no invoice and "Convert" could make a second one. Both conversion paths link the quote and invoice the same way.
* Fixed - The builder's live preview taxed lines whose "Taxable" box was unticked (the preview is rendered from the serialised form, where an unticked box is simply absent, and absent was read as taxable), so the preview total disagreed with the saved invoice and the public page.
* Fixed - **A quote's own tax setting was lost while the site's global tax was off**: the quote repository never stored the per-quote "Apply tax" switch, so the quote calculated without tax and the invoice made from it did too. Quotes keep their tax switch, rate and prices-include-tax like invoices do, and conversion carries the switch across.
* Fixed - Settings → Tax → "Work out reverse charge and exports automatically" could not be switched on: the box was drawn from a display-only list of extra tax fields that the save handler never read, and it always reappeared unticked. Extra tax fields save and display like the rest.
* Fixed - **"Test Template" under Settings → Email did nothing useful**: the button never told the server which template it belonged to, so every click was refused ("Template type is required"). Each of the three buttons now sends its own template with sample data for every placeholder, in the site's currency.
* Fixed - The stock invoice and quote emails said "is attached" whether or not a PDF was; the sentence is reworded when nothing is attached. Quote emails carry the PDF under the same "Attach a PDF copy" setting as invoices (it only covered invoices).
* Fixed - **Credit note PDFs read like invoices**: "INVOICE", an Invoice Number, a Due Date, a "Total Due" and "Payment is due within 30 days". Every design and the plain layout now print "CREDIT NOTE", a Credit Note Number and Date, "Total Credited", and no due date or payment terms.
* Fixed - A part-paid invoice whose remaining balance was then covered by a credit note stayed "Partially paid"; it is marked paid, as nothing more is owed.
* Note - **Every REST route is authenticated and capability-checked**, using the same `ei_*` capabilities as the admin screens, so an EI Viewer gets read access and nothing more and the Team Roles addon keeps working unchanged. There are no public routes. Responses are assembled field by field rather than dumping post meta, specifically so the per-document access key — which acts as a bearer credential for the public payment page — is never exposed; verified that it appears nowhere in a response.
* Fixed - **`POST /easy-invoice/v1/invoices` now creates a real invoice.** It wrote a post and its items directly, so the result had no status (it showed as blank in the list and could not be sent), skipped the client record, and fired none of the creation hooks — the Pro Webhooks, Recurring and Reminders addons never learned it existed. Creation now goes through the same repository as the invoice builder: the invoice is a draft (or `available` on request), numbered from the sequence, filled from `client_id` when given (an unknown client is a 400), and accepts `customer_name`, `customer_email`, `currency` and `terms` alongside the fields it already took.
* Added - Invoice responses from the REST API carry `totals.paid`, `totals.credited` and `totals.due` next to `total`, so an integration does not have to work out what is still owed from the payment and credit-note records.
* Added - **Reverse charge and export handling for cross-border invoices.** A German agency invoicing a French business must not charge VAT — the customer accounts for it — and the invoice has to say so. The plugin could previously only apply its single rate or switch tax off entirely, and switching it off leaves no statement on the document, which makes the invoice invalid rather than zero-rated. Enable it under Settings → Tax, add your VAT number and country under Company Information and the customer's on the invoice, and cross-border EU B2B supplies are treated as a reverse charge while supplies outside the EU are zero-rated as exports. The required legal wording appears with the totals on screen, in the PDF and in emails.
* Added - **VAT identifiers for both parties.** Your VAT number and country under Company Information; the customer's on each invoice. Both are required fields of EN 16931, the standard behind Factur-X, XRechnung and Peppol BIS, so this is also the groundwork for structured e-invoicing.
* Added - **EN 16931 tax categories** — standard, zero-rated, exempt, reverse charge, intra-community, export and out-of-scope — using the standard's own codes so they can be written directly into a structured document. Zero-rated, exempt and reverse-charge all show 0.00 in the tax column and mean entirely different things to a tax authority; the plugin can now tell them apart, and knows which ones must carry a stated reason.
* Note - **Existing invoices are unaffected.** Automatic determination is off by default, and even when switched on it resolves to the ordinary rate unless both parties' countries and VAT numbers are known. It can only ever remove tax, never add it, and an explicit category set by you always overrides it. Verified that an existing invoice's total is unchanged before and after.
* Added - **Server-side PDF rendering.** Until now every PDF was made in the visitor's browser: html2canvas screenshotted the page and jsPDF wrapped the bitmap. The result was a picture — no selectable text, no search, no accessibility, and around 180 KB for a one-page invoice. The same invoice now renders on the server at 25 KB with real, selectable text and no embedded image. This is the change the rest of the roadmap depends on: the server never held a document before, which is why nothing could be attached to an email, why scheduled runs could not carry one, and why structured e-invoicing (which needs XML embedded inside a PDF/A-3 file) was impossible.
* Added - **Invoice emails can attach a PDF copy.** Off by default under Settings → Email → General, because attaching a document changes what every customer receives and makes messages much larger than some SMTP relays accept. `easy_invoice_attach_pdf_to_email` filters it. The attachment is written, sent and deleted within the request.
* Added - **PDF paper size setting** — A4, Letter or Legal, defaulting to A4. Verified to produce exactly 595×842pt and 612×792pt respectively.
* Changed - **dompdf now ships with the plugin**, taking the package from 2.3 MB to 6.5 MB. It was previously present in the tree but not registered with Composer, not tracked in version control, and excluded from the build — effectively dead weight. The bundled DejaVu fonts are most of that size and cannot be dropped: the core PDF fonts are WinAnsi-only and cannot render Polish or several French characters, which are exactly the markets where e-invoicing is now mandatory.
* Note - PDF output uses its own template at `templates/pdf/invoice.php`, overridable with the `easy_invoice_pdf_template_path` filter. The on-screen designs could not be reused: they are laid out with flexbox, and dompdf implements CSS 2.1 and ignores it, which turns a two-column header into two stacked rows. The PDF template is table-based for that reason.
* Fixed - **Deleting a client failed with a critical error, whichever option you chose.** Both branches of the delete handler called `$this->log()`, a method that does not exist on that class or any trait it uses, so the request died the moment an administrator confirmed. Neither "Delete Client Only" nor "Delete All" could complete. They now log the way the rest of the plugin does. Verified afterwards through the Clients screen: deleting a client with an invoice and a payment attached reports "Client and all associated documents (2 total) deleted successfully", the count matches what is actually removed, and no orphaned payment rows are left behind.
* Fixed - **Accepting a quote crashed for the customer on any site running the Team Roles addon.** The quote-to-invoice conversion fires `save_post_easy_invoice` itself, and it passed two arguments where WordPress core's `save_post_{post_type}` passes three. Team Roles' audit logger declares all three as required, so the conversion raised an ArgumentCountError and the customer saw "There has been a critical error on this website" — after the invoice had already been created, leaving a stray draft behind. Both dispatch sites now pass the third argument, matching core. Verified by accepting a quote as an anonymous customer through the real page: the quote moves to accepted, the invoice is created with the quote's items and total, and it links back to the quote.
* Added - **`easy_invoice_email_headers` is now applied when an invoice, quote or receipt email is built**, with the email type and the document passed alongside the headers. Easy Invoice Pro's Email Enhancements addon has always registered against this filter; nothing ever applied it, so its per-document-type Reply-To never took effect.
* Fixed - **Corrected the PDF Toolkit description.** It advertised "custom headers and footers" and "color and font controls"; neither is implemented, and neither is offered anywhere in the addon's settings, which expose watermark options only. The handlers for them hang off six `easy_invoice_pdf_*` filters that nothing in either plugin fires, because PDFs are rendered in the browser from the document view rather than by a server-side pipeline. The watermark is real and is now what the addon says it does.
* Security - **The payment-callback endpoint let anyone attach forged cheque details, and a file, to any invoice.** `easy_invoice_payment_callback` is registered for logged-out access and the only thing in front of it was the shared, page-wide payment nonce, which is rendered on every public invoice page — so anyone able to open a single invoice could take one and call this for any id. The id was handed to the gateway without even checking it was an invoice, and the cheque gateway's callback writes: it stores the cheque number, bank name, date and an uploaded image against whatever id it receives, so forged payment records could be attached to any invoice on the site, or meta written onto any post at all. The endpoint now validates the invoice and applies the same access-key, administrator or bound-client check as every other payment path. No JavaScript in either plugin calls it, and PayPal's IPN could never have satisfied its nonce check, so nothing legitimate was relying on the old behaviour.
* Security - **The PDF download nonces were not tied to a document.** `easy_invoice_generate_pdf` and its quote equivalent accepted a nonce created for the action name alone, so a nonce obtained for a document you were allowed to see could be replayed against any other id. The document content stayed protected — the redirect target enforces access and returns a 404 — but the response still disclosed the permalink, and therefore the existence and title, of documents outside the caller's reach. Both nonces are now bound to the specific invoice or quote id.
* Fixed - **The search box inside the multi-select settings fields had no accessible name.** Select2 builds that input itself and gives it neither a label nor an `aria-label`, so a screen reader announced it as an unnamed text field on the email-reminder and other multiselect settings. It now takes the name of the field it belongs to.
* Security - **Every Pro card gateway bypassed the new payment authorisation check entirely.** The check sat below the `easy_invoice_before_process_payment` filter, which is a dispatch point that answers and ends the request — and Easy Invoice Pro attaches Stripe, Authorize.Net, Moneris and Partial Payments to it, so for those four the access key was never examined. Authorisation now runs before the filter, and the invoice is validated first.
* Fixed - **Client card payments would have broken on sites running an older Easy Invoice Pro.** Pro only began sending the per-invoice access key in 2.2.7, and the two plugins update through different channels, so a newer free against an older Pro is normal for a while after release. On Stripe the charge is confirmed before it is recorded here, so a refusal would have taken the customer's money and left the invoice unpaid. Older Pro builds are now accepted through the 2.3.8 authorisation, only while such a build is installed, only when no key is presented, and only for Pro's own gateways; it lapses automatically when Pro is updated.
* Fixed - **Addon screens that were not an addon's main page showed raw WordPress notices, clipped by the sidebar.** Suppression was built from each addon's settings URL, so a second screen such as Accounting Sync's "Sync Log" was missed. It now matches on the page slug prefix, and any notice that does render is indented clear of the sidebar.
* Fixed - **The Bulk Operations addon had no link to its Export screen, and opening it painted the Dashboard first**, with the export form stacked underneath. Its registry entry now points at the screen.
* Fixed - **Choosing an invoice or quote design on a document you had not saved yet crashed.** The template loader passed a null document and a null currency formatter into the design templates, which call methods on both without checking — so the request died mid-render and the picker returned a broken response instead of a preview. Both are now given an empty document and a matching formatter, so an unsaved invoice or quote previews as a blank version of the chosen design. All ten invoice designs and all six quote designs were exercised against both a new and an existing document.
* Fixed - Saving settings raised an "Undefined array key" warning before its nonce check could run, on any request that arrived without the nonce field.
* Added - **A one-time notice after updating, explaining the access-key change.** WordPress updates a plugin by replacing files and does not run the activation hook, so without this the first sign a site owner would have that older invoice links stopped working is a customer telling them. The notice appears on the Dashboard and Plugins screens for sites that already have invoices or quotes, explains that previously-sent links need re-sending, and dismisses permanently. Fresh installs never see it, since they have no old links to break.
* Fixed - **Two admin hooks pointed at methods that do not exist**, in `AdminController` and `InvoiceAdmin`. Neither class is ever instantiated, so nothing fired them, but they are the same latent-fatal pattern found and removed elsewhere in this release and are documented as dead rather than left to mislead.
* Security - **Two more unauthenticated payment endpoints did not check who was asking.** `easy_invoice_get_payment_instructions` guarded guests with a "published invoices only" test that could never fail, since every invoice is stored as published — so any caller with the page's shared nonce could read the rendered payment instructions for an arbitrary invoice id. `easy_invoice_process_payment` had no per-invoice check at all and would start a payment against any id, returning its amount and gateway detail. Both now require the same per-document access key, administrator, or bound-client check as the rest of the plugin, and a key issued for one invoice cannot be used against another.
* Fixed - **The invoice page did not pass its access key to the payment scripts.** `templates/invoices/single.php` builds its own HTML document and defines `easy_invoice_vars` inline, which overwrote the copy `PaymentController` localises — so the key from the emailed link never reached the payment requests. Left unfixed alongside the authorisation change above, clients following a legitimate link would have been able to open their invoice but not pay it. The key is now included in that object and forwarded by the online-payment and payment-instruction requests, matching what the manual-payment script already did.
* Security - **Invoices and quotes were readable by anyone with the URL, including drafts. All sites should update.** Both post types are stored with `post_status` `publish` regardless of their workflow status (the workflow status lives in post meta), and both are registered public and publicly queryable — but nothing checked authorisation before rendering. Any unauthenticated visitor who guessed or was forwarded a link, or who walked `?p=<id>`, could read the full document: customer name, email, address, invoice number, line items, prices, notes and totals. Access is now checked before any output, on every front-end entry point — permalink, `?p=<id>`, feeds, embeds and the PDF download path — using the same rules the rest of the plugin already applied to payment and accept/decline: a valid per-document access key, an administrator, or the signed-in client the document is bound to. Unauthorised requests get a plain 404 so the response does not reveal whether an invoice number exists.
* Security - **Invoices and quotes could be enumerated even without opening them.** Site search, `?post_type=easy_invoice` and the type feeds returned archive listings of document titles and permalinks to anonymous visitors. Both post types are now excluded from search, and listing-style front-end queries for them return nothing.
* Security - **Two public AJAX endpoints did not really check authorisation.** `easy_invoice_download_invoice_pdf` and the public invoice/quote email resends gated non-administrators with a "published only" repository lookup, which was no protection at all because every document is stored as published — so the lookup returned the same record for any caller. Both endpoints are registered for logged-out access, so a caller holding a nonce could pull PDF data for an arbitrary invoice, or make the site email one out. They now use the same access check as the rest of the plugin.
* Note - **Invoice and quote links emailed before per-document access keys existed do not carry one**, so their recipients will now see a 404 until you resend the document — resending issues a fresh link that works. Administrators are unaffected, and links sent by recent versions already carry a key. A site that needs the previous open-by-URL behaviour can restore it with the `easy_invoice_require_document_authorisation` filter, but doing so re-exposes customer data to anyone holding a URL.
* Fixed - **The admin was unusable on phones and small tablets.** The sidebar was a fixed 256px column with no responsive rules and the content area reserved 256px of padding unconditionally, so on a 390px screen roughly 134px was left for the actual page — stat cards became unreadable slivers, tables collapsed to a column of checkboxes, and 17 of 25 screens scrolled sideways. The sidebar is now an off-canvas drawer below 1024px, with a backdrop, Escape-to-close and close-on-navigation; from 1024px up nothing changes. The previous mobile menu button could never have worked: it bound to the first element in the page matching `button[aria-expanded="false"]`, toggled a `hidden` class the sidebar never had, and only appeared below 640px.
* Fixed - **The Dashboard's "Edit" and "View" links were broken.** Both pointed at `page=easy-invoice-invoices`, which is not a registered admin page, so every Recent Invoices action returned a permissions error. Edit now opens the invoice builder and View opens the public invoice, matching the invoice list. The same dead page slug also broke the redirect after verifying a manual payment.
* Fixed - **Row actions could not be reached by keyboard or on touch devices.** View / Edit / Draft / Trash / Send Email / Download PDF / Duplicate were revealed only on mouse hover, and because they were hidden with `visibility: hidden` they were also skipped by tab navigation entirely. They now appear on keyboard focus, and are always visible on touch devices and narrow screens.
* Fixed - **New invoices could not be saved on the first attempt.** Issue Date and Due Date are required but shipped with no default, so a new invoice always failed validation — while the live preview beside the form already displayed dates, making the error look wrong. Issue Date now defaults to today and Due Date to the payment term configured in Settings (30 days by default). Quote forms already worked this way.
* Fixed - The Quotes list scrolled the whole page sideways. Its table card was missing the `overflow-hidden` the invoice list has, so a table wider than the card leaked out through every parent.
* Fixed - "Add New Client" was rendered twice in the invoice builder under the same element id. Every script bound to the first copy — the older markup, missing the dialog role and labelling the newer copy had — so the accessible version was dead markup.
* Fixed - Bulk-action menus, select-all and per-row checkboxes, and the invoice/quote template pickers had no accessible names, so screen readers announced them as unlabelled controls.
* Fixed - Empty states on the invoice list were hardcoded English and could not be translated. The payments list offered only "No payments found." with no way forward; it now links to Add New Payment.
* Improved - "Total Revenue" on the dashboard showed the sentence "No revenue recorded" where every neighbouring tile shows a number; it now shows a formatted zero. The two client tiles also rendered at roughly double the width of the other four and now share the same grid.
* Improved - The "Flush Rewrite Rules" maintenance button no longer sits beside the primary action on the invoice and quote lists. It is a troubleshooting tool, and is now shown only when `WP_DEBUG` is on or a site opts in via the `easy_invoice_show_maintenance_tools` filter.
* Fixed - The License screen restyled `.easy-invoice-admin` — a global class used by every Easy Invoice page — from its own page template, which pushed that screen 49px wider than the viewport on phones.
* Fixed - **All third-party CDN assets are now bundled with the plugin.** jsPDF and html2canvas were loaded from cdnjs and Chart.js from jsDelivr, across eight places — including a runtime `<script>` injector inside `document-pdf.js` and a hardcoded tag on the reports screen. This broke the WordPress.org rule that all plugin assets ship inside the plugin, and made PDF generation and reporting depend on a third-party host staying up. jsPDF 2.5.1 and html2canvas 1.4.1 now ship in `assets/js/vendors/`; Chart.js was already bundled locally and the CDN copies were simply redundant.
* Fixed - **Removed an admin endpoint that fatally errored on use.** `admin_post_fix_easy_invoice_quote_slugs` was registered against a handler method that does not exist anywhere in the plugin.
* Fixed - **Duplicate AJAX registrations resolved.** `easy_invoice_delete_client` was registered by both `EasyInvoiceAjax` and `ClientController`, and `easy_invoice_create_new_invoice` was registered twice inside `InvoiceController`. In each case WordPress held two handlers for one action and which implementation answered depended on load order — the same class of bug already tombstoned for `easy_invoice_search_clients`. The stricter handler now owns each action: the client-delete path that checks capabilities, refuses administrator accounts, and cleans up related documents.
* Fixed - **Client deletion queried two different meta keys** for the same thing — the "this will delete N payments" count and the deletion itself disagreed. Unified. Note that payment cleanup was, and remains, inert: payments carry no client ID of their own and are linked only through their invoice. Making deletion actually remove payment records will destroy data it currently leaves alone, so it is deliberately left for a change of its own.
* Performance - **Stopped rebuilding the rewrite rules on every request.** Post-type registration ran an unconditional `flush_rewrite_rules(true)` on every `init`, regenerating the entire rule set and rewriting the `rewrite_rules` option on every page load — while a throttled flush sat directly above it doing the job properly. Activation still flushes explicitly.
* Fixed - **Corrected the Email Enhancements description.** It advertised "PDF attached" and "CC / BCC"; neither is implemented — no email in either plugin passes an attachment, and the only BCC is the free "BCC admin" toggle. PDF attachment is blocked until PDFs are generated server-side rather than in the browser. The addon's real features (branded HTML email templates and per-template Reply-To) are described accurately now, in the plugin, both readmes, and the documentation.
* Internal - The build no longer ships the Tailwind purge harness in `tools/`, or the unused dompdf / masterminds / sabberworm Composer packages (~11 MB, referenced by zero lines of code).

= 2.3.8 - August 21, 2026 =
* Compatibility - **Tested and confirmed compatible with WordPress 7.1.** "Tested up to" bumped from 7.0. The release was audited against the 7.1 codebase rather than smoke-tested: every WordPress function the plugin calls was resolved against the 7.1 symbol table (no removed or renamed API is used), the plugin's global function, class and constant names were diffed against the 92 functions and 5 classes 7.1 introduces (no redeclaration collisions), and the full plugin tree was scanned with PHPCompatibility for PHP 7.4 through 8.4 (zero issues).
* Compatibility - **jQuery UI 1.14.2** ships in WordPress 7.1 (up from 1.13.3). The payment-gateway drag-to-reorder list on Settings -> Payment uses `.sortable()` and `.disableSelection()`; both remain available because core enables `jQuery.uiBackCompat` and still bundles the disable-selection module. The four APIs 1.14 removed (`$.fn._form`, `$.ui.ie`, `$.ui.safeActiveElement`, `$.ui.safeBlur`) are not used anywhere in the plugin.
* Compatibility - **The enforced iframed post editor in 7.1 does not affect Easy Invoice.** Invoices and quotes are edited through the plugin's own admin screens; their post types register with `show_ui => false` and the one UI-visible post type does not declare `editor` support, so no plugin JavaScript or CSS reaches across the editor document boundary.
* Compatibility - Admin styling verified against 7.1 markup: the `#adminmenu`, `#adminmenuwrap`, `#adminmenuback`, `#wpcontent`, `#wpbody-content` and `#wpfooter` containers the plugin restyles are all still emitted by `wp-admin/admin-header.php` and `menu-header.php` in 7.1, and the persistent admin toolbar change touches `#wpadminbar`, which the plugin does not style.
* Compatibility - The REST and media changes in 7.1 (image dimension validation on the sideload endpoint, size-aware encoding quality in attachment responses) are not applicable: the plugin's REST routes are its own (no media, sideload or attachment endpoints) and it calls no attachment or sideload APIs.
* Fixed - **Payment-gateway drag-to-reorder relied on incidental script ordering.** The `easy-invoice-settings` script handle is registered in two different places, and WordPress keeps whichever registration runs first while silently discarding the other's dependency array. The registration that wins did not list `jquery-ui-sortable`, so `settings.js` was printed ahead of jQuery UI Sortable and only worked because its code runs inside a DOM-ready callback. The jQuery UI handles are now declared explicitly on that registration, so `wp_scripts` resolves the load order instead of it falling out of enqueue sequence - worth tightening now that 7.1 ships a new jQuery UI build.

= 2.3.7 - June 29, 2026 =
* Fixed - **Invoice and Quote listing "Download PDF" button** could leave the user stranded on a blank `admin-ajax.php?action=easy_invoice_generate_pdf...` page instead of downloading the PDF. Root causes on affected sites included page-cache layers (WP Rocket, LiteSpeed, Cloudflare) replaying stale responses of the intermediate admin-ajax URL, security plugins / WAFs stripping the redirect body, and cross-tab session-cookie behaviour (Safari ITP, `SameSite=Strict`) dropping the WP session between the click and the new tab. Two coordinated changes address this:
  1. **The button no longer routes through admin-ajax.** The anchor's native href already points directly at `<invoice-permalink>?auto_download_pdf=1`, and the single-page JS renders the PDF from there. Removing the click interceptor collapses three server round-trips into one and sidesteps every intermediate-hop failure mode. No security posture change — the destination is the same public permalink the hop was going to anyway.
  2. **The server-side download handlers are hardened for any external caller.** `generateInvoicePdf` and `generateQuotePdf` now accept an admin session or a valid per-document access key (`?ik=` / `?qk=`) as alternate authorisation paths when the per-request nonce fails, emit explicit `Cache-Control: no-store` headers to defeat intermediate caching, and fall back to a client-side redirect (`<meta refresh>` + `window.location.replace`) when the server-side redirect can't fire because upstream output already flushed the response headers. Email download links, dashboard widgets, and any other integration calling these endpoints directly benefit from the same hardening.

= 2.3.6 - June 26, 2026 =
* Security - Tightened the capability check on the AJAX payment-update endpoint so only users with the dedicated payment-management permission can change payment records or invoice status. **All sites should update.**
* Security - The `[easy_invoice_url]` and `[easy_quote_url]` shortcodes no longer generate per-document access keys for arbitrary visitors. Keys are now produced only when an invoice or quote email is sent; the shortcodes attach an existing key only when the current viewer is the site admin, the bound client, or already holds the key in the page URL.
* Fixed - The bound-client authorisation path on quote Accept / Decline and invoice manual-payment submission silently never succeeded for logged-in customers. The guard relied on PHP's `method_exists()` which returns false for `__call`-resolved methods, and both the Invoice and Quote models resolve `getClientId()` that way. Logged-in clients whose email matches the document's bound client are now correctly recognised. (Admin and emailed-link paths were unaffected.)
* Note - Emailed `{{invoice_url}}` and `{{quote_url}}` links continue to work unchanged for the legitimate recipient. The shortcode change is invisible for admin embeds on admin-context pages; on public pages the shortcode now renders a plain permalink for visitors who don't already hold a valid key (they can still view the document — only the Accept / Decline / submit-manual-payment paths require the key).

= 2.3.5 - June 26, 2026 =
* Security - Hardened authorisation on the manual-payment submission flow. **All sites should update.**
* Improved - Invoice share links emailed to clients (`{{invoice_url}}` in email templates, `[easy_invoice_url]` shortcode) now carry a per-invoice access key. Recipients of these links can submit "I paid by bank transfer / cheque" entries as before — no extra steps for the customer.
* Note - Invoice links generated **before** this update will still load and display the invoice and accept online gateway payments (Stripe / PayPal / etc.). The manual-payment submission form appears only after the admin resends the invoice (which produces a new link) or after the client logs in to the WordPress account whose email matches the invoice's bound client. Admins are unaffected — the form is always available from the admin UI.
* Fixed - Race condition under high concurrency where two simultaneous invoice (or quote) creates could be assigned the same number. The counter read-check-write is now serialised via a MySQL named lock; the lock auto-releases on connection close so it cannot leak across requests.
* Added - License recognition for the new Professional Lifetime and Agency Lifetime SKUs so customers on those plans are correctly placed in their tier and see the matching variant label on the License page.

= 2.3.4 - June 12, 2026 =
* Security - Hardened authorisation on the quote Accept and Decline flows. **All sites running 2.3.3 or earlier should update.**
* Improved - Quote share links emailed to clients (`{{quote_url}}` in email templates, `[easy_quote_url]` shortcode) now carry a per-quote access key. Recipients of these links can Accept / Decline as before — no extra steps for the customer.
* Note - Quote links generated **before** this update will still load and display the quote, but the Accept / Decline buttons will appear only after the admin resends the quote (which produces a new link) or after the client logs in to the WordPress account whose email matches the quote's bound client. Admins are unaffected — Accept / Decline from the admin UI continues to work for every quote.

= 2.3.3 - June 06, 2026 =
* Fixed - **Client role badge inconsistent across sites: "Client" on some, "Customer" on others.** Root cause was a four-year-old hook-timing bug in `ClientServiceProvider::register()` — it called `add_action('after_setup_theme', ...)` from inside a callback that itself only runs on `init:10`, so by the time the `add_action` ran, `after_setup_theme` had already fired and the registration silently never executed. On sites with WooCommerce, WC registered `customer` so the badge showed "Customer"; on sites without WC, the role was never registered, `WP_User::roles` filtered it out as unknown, and the Clients page fell back to a "Client" label. Hook moved to `init:1` so it actually runs.
* Fixed - **New clients created from Easy Invoice no longer have backend access.** Previously, new clients were assigned WooCommerce's `customer` role (or our copy of it) which carries the `read` capability — letting them log in to wp-admin and see a stripped-down dashboard. New clients now receive a per-user `read => false` capability override on creation: they keep the `customer` role (so Pro Client Portal's role check still recognises them) but `WP_User::has_cap('read')` returns false, blocking wp-admin access. Login still works (auth is cap-free) so the Pro Client Portal frontend remains accessible.
* Added - **`easy_invoice_can_edit_invoice` filter hook** in the invoice save AJAX handler and in `Invoice::saveItems()`. Defaults to `true` (no change for sites without addons listening). Pro's Partial Payments addon hooks into this to block edits on paid deposit invoices.
* Added - **Locked-banner UI in the invoice editor.** When an invoice's `easy_invoice_can_edit_invoice` resolves to `false`, the editor renders an amber notice at the top: *"This deposit invoice is locked. A payment was recorded on [date]. To add new charges, open the linked balance invoice instead."* with a button that deep-links to the paired balance invoice. The hidden `<form>` gets a `data-ei-locked="1"` attribute so themes/addons can extend the lock visually.
* Important - **Existing users' access is preserved.** The `customer` role is now registered with **no** capabilities (`[]`) so that users on sites that previously had the broken registration don't suddenly gain a `read` cap on the upgrade. On sites where WooCommerce has already registered `customer` with its full cap set, our `add_role()` is a no-op (the role already exists) — WC customers retain WC's caps unchanged. The new per-user `read => false` only applies to clients created from 2.3.3 onward.

= 2.3.2 - May 24, 2026 =
* Fixed - **Client search now reliably finds WooCommerce customers.** Three compounding bugs were producing intermittent / empty search results in the invoice and quote client-search dropdown:
  - Three handlers were registered for the same `wp_ajax_easy_invoice_search_clients` action (in `EasyInvoiceAjax`, `InvoiceController`, and `QuoteController`). WordPress fires hooks in registration order; the first to call `wp_send_json_success()` died the request, and which one won shifted between requests based on bootstrap order. Removed the two duplicate registrations — `EasyInvoiceAjax::searchClients` is now the sole owner.
  - `EasyInvoiceAjax::sendSuccess()` was injecting a `toast` key into the response data unconditionally. For the search endpoint that returns a numeric-indexed array of clients, adding a string key mutated the array shape so `json_encode` emitted a JSON object instead of an array. The frontend check `response.data.length > 0` then evaluated `undefined > 0` and silently rendered the empty state. `sendSuccess` now only injects the toast key when `$data` is associative or empty — list responses pass through unchanged.
  - `searchClients` itself now bypasses `sendSuccess` entirely (via `wp_send_json_success` direct) so the "Operation completed successfully" toast no longer appears on every keystroke.
* Fixed - **WooCommerce customers display with their real name everywhere.** The `Client` model loaded only Easy-Invoice-specific meta keys, so WooCommerce-imported customers rendered as their bare `user_login` in the Clients listing, the invoice/quote search dropdown, and the invoice header — even though their real name sits in `billing_first_name` / `billing_last_name`. Extended `Client::loadFromUserMeta()` with a unified fallback chain: EI meta -> WP standard meta -> WooCommerce `billing_*` meta. Covers `first_name`, `last_name`, `email`, `phone`, and `business_client_name` (from `billing_company`). Single change propagates to every display point through the model's magic getters.
* Improved - **Client search now covers phone numbers across both client pools.** `ClientRepository::search()` already searched `billing_phone` so WooCommerce customers were searchable by phone, but it was missing the Easy-Invoice-native phone meta key. Added `ClientFields::PHONE` so EI-native clients are searchable by phone on equal footing with first name, last name, email, and business name.
* Fixed - **Item Library buttons hidden on invoice/quote forms after addon migration.** The invoice and quote item rows render "Select from Item Library" and "Save to Item Library" icon buttons gated on the Item Library service being available. The gate was a stale `class_exists('EasyInvoicePro\\Services\\ItemLibraryService')` check left over from before the addon-system migration — the class moved to `EasyInvoicePro\Addons\ItemLibrary\Services\ItemLibraryService` so the old check always returned false, hiding the buttons even when the addon was active. Replaced with the canonical `\EasyInvoice\Addons\AddonManager::shouldLoad('item_library')` check used everywhere else in the codebase.
* Fixed - **Clients table row alignment after Add New Client.** The table has 8 columns (ID, Business Name, Client Name, Email, Phone, Username, Role, Actions) but the JS row template only rendered 6 cells, so every column from "Client Name" onward landed one position to the left of where it should — email landed in the Client Name column, role badge landed in the Phone column, action buttons landed in the Username column. Restored the missing Business Name and Phone cells and aligned the `colspan` for the "No clients found" placeholder across PHP (was 7) and JS (was 6) to the correct 8.
* Fixed - **No success toast after Add Client.** The success branch was calling a non-existent `showToast()` function behind a `typeof showToast === 'function'` guard, so the toast never fired. Switched to `EasyInvoiceToast.success` / `.error` (the same API used by every other handler in the file). Kept the `suppress_global_toast` flag so we don't get a duplicate toast now that the manual one works.
* Fixed - **Role badge flickered "Client" -> "Customer" after refresh.** New clients are created with the WP `customer` role, but the JS new-row template hardcoded "Client" — producing a one-shot mismatch the user saw on the next page load. The `addClient` AJAX response now returns the actual assigned role (`role` + `role_label`) and the JS template renders from that. Single source of truth in PHP, no more flicker.
* Improved - **Short description shortened to fit the WordPress.org 150-char limit** (was 157 chars and got truncated by the importer with a warning visible to plugin authors). New version keeps the "WordPress invoice plugin" keyword up front and packs the four primary capabilities into a clean two-sentence snippet.

= 2.3.1 - May 21, 2026 =
* Compatibility - **Tested and confirmed compatible with WordPress 7.0.** "Tested up to" bumped from 6.9.
* Fixed - **Client search excluded WooCommerce customers.** `ClientRepository::search()` gated all results on an EXISTS check against Easy Invoice's own meta keys, so users who exist in WordPress (or were imported by WooCommerce) but had no prior Easy Invoice activity were filtered out of every client autocomplete and selector. Replaced with a two-pass query: a user-table search across `user_login`, `user_email`, `display_name`, plus a meta search across `first_name`, `last_name`, `nickname`, `billing_first_name`, `billing_last_name`, `billing_email`, `billing_company` and Easy Invoice's own meta keys. Result limit raised from 10 to 50.
* Fixed - **License Plan Resolver: Personal license showed as Professional.** `PRICE_ID_MAP` mis-mapped EDD `price_id` 1 to Professional. Corrected mapping to: 1=Personal, 2=Professional, 3=Agency, 4=Personal Lifetime, 5=Personal Lifetime (Unlimited). Also fixed the name-based fallback that was matching "Easy Invoice **Pro**" against "Professional" via `strpos` — order is now agency → professional → personal so the parent name no longer poisons the match.
* Added - **Plan variant labels.** Personal Lifetime and Personal Lifetime (Unlimited) now surface as "Personal Lifetime" on the License page chip instead of generic "Personal".
* Fixed - **License page activation count** showed "1 / 0" for unlimited-activation keys (EDD returns `license_limit: 0` for unlimited). Now renders "1 / Unlimited" when limit is `0` / empty / the string "unlimited".
* Improved - **License page UI**: variant badge inline with the Plan chip, "12 months" expiry shown inline, container widened and centered on the page, removed full-screen gray background that bled across all admin pages (`.easy-invoice-admin` now uses `bg-white`).
* Added - **"Docs" link on every Addons-page card state.** Previously only Locked / Available cards linked to docs; the Active state now also exposes the Docs link so users can find documentation for addons they're already using.
* Improved - **Privacy & GDPR settings section** renamed from "Account Settings" — clearer naming that matches the addon that owns the surface. Section is hidden entirely when the Privacy & GDPR Tools addon is not active.
* Internal - One-time cleanup script removed 4,315 duplicate Client Portal pages created by a regression in the page-existence check. New code defers portal-page setup to a manual admin notice rather than auto-creating on every request.

= 2.3.0 - May 17, 2026 =
* Added - **Addon system for Easy Invoice Pro**. Every Pro feature is now an independently-toggleable addon under **Easy Invoice → Addons**. Disabled addons contribute zero PHP, zero database queries, zero hooks.
* Added - **Personal tier is now free with the Pro plugin** (no license key required). Eleven addons unlock automatically when you install Easy Invoice Pro: Recurring Invoices, Partial Payments & Deposits, Client Portal, PDF Toolkit, Bulk Email & Export, Item Library, Custom Invoice & Quote Templates, Additional Tax Lines, Email Enhancements, Privacy & GDPR Tools, and Reports & Analytics. A license key now only unlocks Professional and Agency tiers.
* Added - **Reports addon** (Personal tier) — gating the Reports page on the new addon flag so users can disable it if not needed.
* Added - **Per-addon sidebar navigation** below the Addons menu in the Easy Invoice in-app sidebar — every enabled addon with a primary page gets its own sidebar entry automatically.
* Added - **WP admin notice suppression** now extended to every addon page (dynamic — picks up new addons automatically from the registry).
* Added - **One-time migration** (`MIGRATION_VERSION = '2'`) auto-enables every Personal-tier addon for existing Pro installs based on detected feature usage, so nothing breaks on upgrade. New installs get addons disabled by default — opt-in only.
* Improved - **Free vs Pro page** rewritten as a data-driven layout (680 lines → 200 lines) with the new four-tier plan model (Free / Personal / Professional / Agency) and concrete addon names per row.
* Improved - **Addons page UI**: every card shows the addon's full description (line-clamped to 3 lines), tier-color rail removed in favour of plan pills, addon categories regrouped into 8 meaningful buckets (billing, branding, productivity, clients, team, integrations, compliance, analytics).
* Improved - **Addon card descriptions** rewritten for every addon — benefit-led, concrete, complete sentences with no ellipsis truncation.
* Improved - **License banner** on the Addons page now correctly reflects the new Personal-tier-is-free model: free users see "Get Easy Invoice Pro", Pro-installed-without-license users see "Activate License to unlock Professional / Agency".
* Improved - **Item Library** + **Template Builder** pages now appear only in the in-app sidebar (under the Addons menu) — the duplicate WordPress admin submenu entries were hidden so users don't see the same link twice.
* Improved - **Export All** buttons on invoice, quote, payment, and client list screens are now gated on the `bulk_operations` addon. Free users still see the upsell button; Pro-without-addon hides it cleanly.
* Improved - **Templates moved** into per-addon folders (`addons/{slug}/templates/`) so each addon owns its UI alongside its logic.
* Internal - Five settings controllers had bottom-of-file `register()` calls that fired on autoload regardless of addon state. Removed; registration now happens only inside the addon wrapper.
* Internal - **AddonRegistry** is now the single source of truth for all 16 addons; the Free vs Pro page, addons grid, sidebar, and notice-suppression list all derive from it.

= 2.2.1 - May 15, 2026 =
* Fixed - **PDF download quality**. Invoice / quote PDFs now render with crisp text and sharp table borders instead of soft, slightly-blurry output. Specifically:
  - html2canvas capture scale bumped from 1.5x to 2x (matches retina pixel density).
  - Switched the embedded bitmap from JPEG @ 0.92 to lossless PNG so text edges and table grid lines no longer suffer JPEG colour-fringe artefacts.
  - Locked the capture viewport to the document's own layout width so responsive CSS can't shrink columns mid-capture.
  - Enabled jsPDF FlateDecode compression and the slow / higher-quality addImage compression flag — file sizes stay reasonable despite the higher resolution.
* Internal - JS cache buster bumped so browsers pick up the new `document-pdf.js` on first reload after upgrade.

= 2.2.0 - May 13, 2026 =
* Added - **Export Selected (Pro)** option in the Bulk Actions dropdown on both Invoice and Quote listings — visible to all users so the feature is discoverable. Pairs with the existing **Send Email (Pro)** option.
* Added - When Easy Invoice Pro is inactive, picking either Pro-gated bulk action now opens the project's existing Upgrade-to-Pro dialog with feature-specific copy (handled by the new `bulk-send-email-teaser.js`). When Pro is active, Pro's own handlers take over and actually run the action behind a Confirm dialog.
* Improved - All Pro-gated submit interceptors moved to native **capture-phase** event listeners so they win against the listing template's own inline jQuery submit handlers (which previously raced our handlers and showed a generic "Confirm Action" prompt on the Quote listing).
* Improved - Settings → Payment now shows a clean "Unlock more payment gateways" teaser block when Pro is inactive, listing every Pro gateway with one-click upgrade links.
* Fixed - Quote listing search form posted to a non-existent page slug (`easy-invoice-quotes-all`); now correctly posts to `easy-quote-all`, so picking a value from the client filter no longer redirects to "you are not allowed".
* Fixed - Default Invoice / Quote email body shipped a leftover `<strong>Shortcode:</strong> <code>[easy_(invoice|quote)_url …]</code>` reference line that got emailed to clients as raw text. The line is removed from the defaults, and a one-time migration strips it from already-saved options.

= 2.1.22 - May 13, 2026 =
* Added - **Filter by Client** dropdown on both **All Invoices** and **All Quotes** listing pages. Persists through view-tab switches, search, pagination, and status-filter chips so you can drill into a single client's history in two clicks.
* Improved - Listing controllers now emit the available clients list and the selected `client_id` to the template, exposing a clean extension point for Pro modules.

= 2.1.21 - May 11, 2026 =
* Added - "Documentation" link in the Easy Invoice plugin sidebar (under Join Community) that opens the new official documentation site at https://easy-invoice.matrixaddons.com/docs/
* Updated - All in-plugin documentation references now point to the new dedicated docs site (https://easy-invoice.matrixaddons.com/docs/) instead of the legacy URL
* Updated - readme.txt documentation links refreshed across Description, Installation, FAQs and Support sections

= 2.1.20 - April 29, 2026 =
* Security: Manual payment proof uploads now validate file contents with WordPress APIs (`wp_check_filetype_and_ext`) and store only a safe extension; client-supplied multipart MIME types and filenames are not trusted.
* Security: Quote accept and decline (AJAX and POST forms) use a per-quote nonce and load published quotes for non-administrators, preventing cross-quote reuse of a nonce and blocking actions on non-published quotes for guests.
* Security: Unauthenticated payment-instructions AJAX only applies to published invoices.
* Security: Send invoice and send quote email AJAX use a single handler with published-document checks for guests; removed duplicate AJAX registration that could double-send or bypass publication checks.

= 2.1.19 - April 27, 2026 =
* Minor UI issue fixed
* Minor bug fixed on both plugins

= 2.1.18 - April 21, 2026 =
* Maintenance - Version bump release

= 2.1.17 - April 17, 2026 =
* Fixed - Client PDF download: long invoice/quote HTML no longer produces blank or truncated PDFs (JPEG/PNG mismatch fixed; browser canvas limits handled; tall content captured in slices and split across A4 pages)
* Fixed - PDF page breaks align to table row and summary block boundaries so line items are not cut in half across pages (row-aware cropping)

= 2.1.16 - April 16, 2026 =
* Fixed - Quote builder item total now correctly applies Adjust (%) in real-time calculation
* Fixed - Tax now only applies to items with the Taxable checkbox checked (both Invoice and Quote builders)
* Fixed - Discount Calculation Method (Before Tax / After Tax) now works correctly in both builders
* Fixed - Price Includes Tax setting is now respected in all tax calculations
* Fixed - Calculation Method field correctly reads discount_calculation_method instead of incorrect radio selector
* Fixed - Invoice builder payment manager fallback now correctly finds quote items (.quote-item class)
* Fixed - Invoice discount type None now correctly returns zero discount
* Fixed - Division by zero guard added in tax discount ratio calculation
* Fixed - InvoiceRepository now correctly saves discount_calculation_method from form submission
* Improved - Quote form event listeners now respond to Taxable checkbox, Calculation Method, and Price Includes Tax changes
* Fixed - Invoice adjust field setting now properly controls the adjust field display in Invoice Builder
* Fixed - Corrected method calls to use shouldShowInvoiceAdjustField() instead of shouldShowQuoteAdjustField()
* Improved - Invoice Builder now correctly responds to Show/Hide Adjust Field setting
* Fixed - Adjust field calculations now completely ignored when Show/Hide Adjust Field setting is disabled
* Fixed - JavaScript calculations now respect adjust field setting via localized showAdjustField property
* Fixed - Invoice and Quote models now check adjust field setting before applying adjustment calculations
* Fixed - QuoteItem::getAmount() now respects shouldShowQuoteAdjustField() setting for real-time live preview
* Fixed - InvoiceItem::getAmount() now respects shouldShowInvoiceAdjustField() setting for real-time live preview

= 2.1.15 - April 16, 2026 =
* Fixed - Invoice adjust field setting now properly controls the adjust field display in Invoice Builder
* Fixed - Corrected method calls to use shouldShowInvoiceAdjustField() instead of shouldShowQuoteAdjustField()
* Improved - Invoice Builder now correctly responds to Show/Hide Adjust Field setting

= 2.1.14 - March 31, 2026 =
* Improved - Invoice and quote builder experience: Editor/Preview shell tabs, localized send-email confirmation, header title aligned with the document title field, dismissible mobile hint, and unsaved-changes warning when leaving the page
* Added - Accessible labels on invoice and quote form section navigation

* Improved - Table sorting functionality to handle new column structure
* Fixed - Client name display logic for better clarity and organization

= 2.1.13 - March 22, 2026 =
* Improved - User experience with better visual hierarchy
* Fixed - Minor styling issues in pricing display

= 2.1.12 - March 13, 2026 =
* Fixed - Minor issues fixed
* Added - Promotional notice

= 2.1.11 - February 22, 2026 =
* Fixed - Minor issues fixed

= 2.1.9 - February 15, 2026 =
* Fixed vendor dependency issue affecting plugin initialization
* Improved compatibility with latest WordPress version

= 2.1.8 - February 15, 2026 =
* Added custom CSS section for each template
* Enhanced template flexibility for developers

= 2.1.7 - December 15, 2025 =
* Fixed quote accept and decline functionality
* Improved quote workflow reliability

= 2.1.6 - December 11, 2025 =
* Tested and confirmed compatibility with WordPress 6.9
* Performance optimizations

= 2.1.5 - November 20, 2025 =
* Fixed template loading issue
* Improved template rendering performance

= 2.1.4 - November 11, 2025 =
* Fixed email sending issues with SMTP configurations
* Improved HTML tag support in textarea fields
* Fixed empty data saving issue in description fields

= 2.1.3 - November 3, 2025 =
* Added review notice
* Added community link for support


= 2.1.2 - October 30, 2025 =
* Fixed phone number formatting and validation
* Fixed shortcode rendering issues
* Fixed fatal error during email sending

= 2.1.0 - October 13, 2025 =
* Enhanced data escaping for security
* Strengthened input sanitization

= 2.0.9 - August 30, 2025 =
* Fixed quote and invoice auto-increment number generation
* Added regeneration feature for quote and invoice numbers


= 2.0.8 - August 30, 2025 =
* Updated checkout link for improved payment flow

= 2.0.7 - August 17, 2025 =
* Fixed minor UI and functionality issues
* Fixed trash button behavior

= 2.0.6 - August 27, 2025 =
* Fixed quote acceptance workflow


= 2.0.5 - August 24, 2025 =
* Fixed date format display issues
* Fixed various minor bugs

= 2.0.4 - August 21, 2025 =
* Fixed currency symbol display issues
* Fixed quote line items calculation

= 2.0.3 - August 19, 2025 =
* Fixed data migration from version 1.x to 2.0

= 2.0.2 - August 19, 2025 =
* Various bug fixes
* Added legacy template support

= 2.0.1 - August 16, 2025 =
* Fixed currency formatting and display
* Fixed PDF size and rendering
* Fixed email sending functionality
* Added {{quote_url}} and {{invoice_url}} smart tags

[View complete changelog](https://matrixaddons.com/plugins/easy-invoice/#changelog)
