﻿=== Saga Payments for WooCommerce ===
Contributors: SagaPay
Tags: woocommerce, payment gateway, vipps, klarna, mobilepay
Requires at least: 5.8
Tested up to: 7.0
Stable tag: 4.0.718
Requires PHP: 7.4
WC requires at least: 7.0
WC tested up to: 10.7
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Nordic payment gateway for WooCommerce: Card, Vipps, Klarna, Apple Pay, Google Pay, Swish, MobilePay. Subscriptions, refunds, Blocks, HPOS.

== Description ==

Saga Payments for WooCommerce is a **full-featured payment gateway** built for Nordic merchants. Accept secure payments through multiple payment methods with enterprise-grade features:

= Payment Methods =

* **Card Payments** - Visa, Mastercard, American Express, Discover
* **Vipps** - Norway's most popular mobile payment app
* **Klarna** - Buy now, pay later
* **Apple Pay** - Fast checkout for Apple users
* **Google Pay** - Fast checkout for Android users
* **Swish** - Sweden's most popular mobile payment app
* **MobilePay** - Denmark and Finland's popular mobile payment app

= Features =

* **Easy Setup** - Just enter your Merchant ID, Terminal ID, and Public Key
* **Saved Payment Methods** - Customers can save cards for faster checkout
* **WooCommerce Subscriptions** - Full support for recurring payments with automatic renewals
* **WooCommerce Pre-Orders** - Charge customers when pre-ordered products become available
* **Authorize & Capture** - Authorize payments and capture later, or capture immediately
* **Auto-Capture** - Automatically capture when order status changes to Processing/Completed
* **Auto-Void** - Automatically void authorizations when orders are cancelled
* **Express Checkout** - Apple Pay/Google Pay buttons on product pages and cart
* **WooCommerce Blocks** - Full support for Blocks Checkout including saved cards
* **HPOS Compatible** - Full support for High-Performance Order Storage
* **Refunds** - Full and partial refunds directly from WooCommerce admin
* **Professional Design** - Clean, responsive payment widget

= Plugin Integrations =

* WooCommerce Subscriptions - Automatic recurring payments
* WooCommerce Pre-Orders - Charge on release
* WooCommerce Blocks - Full Blocks checkout support
* WooCommerce HPOS - High-Performance Order Storage
* **Saga Product Sync** - Bidirectional product catalogue synchronisation
* Compatible with all major WordPress themes

= Product Sync (Saga) =

Automatic bidirectional synchronisation between your WooCommerce store and Saga:

* **Saga is master** - pull products, prices, images, VAT rates and stock from Saga into WooCommerce
* **Push changes back** - WooCommerce product edits are automatically pushed to Saga in real-time
* **Inventory sync** - relative stock deltas (STOCK_DOWN / STOCK_UP) on order completion, refund and cancellation
* **Auto-retry** - failed inventory adjustments are queued and retried every 5 minutes (up to 20 attempts)
* **Full field mapping** - prices (Ã¸re <-> kroner), VAT rates (25 / 15 / 12 / 0 %), units, barcodes, cost price, images
* **Admin panel** - WooCommerce -> Saga Product Sync with connection test, manual sync, and status dashboard
* Configure via WooCommerce -> Saga Product Sync

= Requirements =

* WordPress 5.8 or later
* WooCommerce 7.0 or later
* PHP 7.4 or later
* SSL certificate (HTTPS)
* Saga Payments merchant account

== Installation ==

1. Upload the `saga-payments` folder to the `/wp-content/plugins/` directory
2. Activate the plugin through the 'Plugins' menu in WordPress
3. Go to WooCommerce > Settings > Payments > Saga Payments
4. Enter your **Merchant ID** and **Store ID** (from your Saga Payments dashboard at https://dashboard.sagapay.no) and click **Save changes**
5. The plugin will automatically provision your **Terminal ID** and **Public Key** on save - no manual key handling required
6. Enable the payment method and save

== External services ==

This plugin relies on the following external services to process payments. A Saga Payments merchant account is required.

= Saga Payments API =

All payment operations (order creation, payment verification, refunds, captures, voids, and subscription management) are handled through the Saga Payments API proxy server. When a customer initiates a checkout, order data including amount, currency, line items, and customer billing/shipping information is sent to this service via server-side HTTP requests.

* Service URL: https://sagapay-api-v3.kristoffer-afc.workers.dev/api
* Provider: Saga Payments (Cloudflare Worker proxy to Surfboard Payments)
* Website: https://sagapay.no
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Saga Payments Merchant Dashboard and Documentation =

The plugin settings screen links store administrators to Saga Payments dashboard and documentation pages for API key, webhook, and product catalogue setup. These links open only when an administrator clicks them. No customer or payment data is sent automatically by these documentation links.

* Dashboard URL: https://dashboard.sagapay.no
* API key documentation URL: https://docs.sagapay.no/guides/getting-started/opprett-api-nokler
* Webhook documentation URL: https://docs.sagapay.no/guides/getting-started/webhooks
* Product catalogue documentation URL: https://docs.sagapay.no/api-reference/api-reference/product-catalogue/
* Provider: Saga Payments
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Saga OTP Verification Service =

When a guest customer pays with a previously saved card, the plugin sends a one-time password (OTP) verification email via a Cloudflare Worker. Only the customer's email address, a generated OTP code, the store name, and the cart total are sent in the request. No card data is transmitted. The OTP email is delivered via the worker using the Resend email service.

* Service URL: https://saga-otp-worker.kristoffer-afc.workers.dev
* Provider: Saga Payments (Cloudflare Worker)
* Website: https://sagapay.no
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Saga Regnskap (Accounting) Integration =

When the store owner completes merchant OTP verification (or clicks the "Koble til regnskap" button in the gateway settings), the plugin sends a one-time registration request to the Saga Regnskap accounting service so the merchant's bookkeeping can be reconciled against the store. Only store-level facts are sent: the Surfboard merchant ID, the store URL, the activation reference, plugin/WordPress/WooCommerce versions, currency, tax display setting and which gift card system is installed. No customer data, order contents or payment credentials are transmitted. After registration, the accounting service reads order and refund TOTALS (amounts, tax rates, Saga payment references â€” never names, emails, phones, addresses or IPs) from a read-only, token-authenticated REST endpoint served by this plugin at /wp-json/saga-accounting/v1/. This applies only to merchants with a Saga Regnskap agreement; stores without an agreement store nothing.

* Service URL: https://regnskap.sagapay.no (fallback: https://saga-regnskap-api.kristoffer-afc.workers.dev)
* Provider: Saga Payments (Cloudflare Worker)
* Website: https://sagapay.no
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Surfboard Online SDK =

The payment form displayed on your checkout page is rendered by the Surfboard Online SDK. This JavaScript library is loaded from an external server into the customer's browser and handles the secure payment UI including card input fields, Vipps/MobilePay popups, Klarna widget, and Apple Pay / Google Pay buttons. Card data is entered directly into Surfboard-hosted iframes and never touches your server.

* SDK URL: https://thorium.surfgw.com/OnlineSDK.js
* Provider: Surfboard Payments
* Website: https://www.surfboardpayments.com
* Terms of Service: https://www.surfboardpayments.com/terms-and-conditions
* Privacy Policy: https://www.surfboardpayments.com/privacy-policy

= Surfboard Hosted Payment Page =

When the plugin is configured in redirect mode, or for certain subscription flows, the customer's browser is redirected to a hosted payment page operated by Surfboard Payments. The order amount, currency, and return URLs are passed via the redirect. All payment data is entered on the hosted page.

* Service URL: https://pay.withsurfboard.com
* Base URL used by redirects: https://pay.withsurfboard.com/
* Provider: Surfboard Payments
* Terms of Service: https://www.surfboardpayments.com/terms-and-conditions
* Privacy Policy: https://www.surfboardpayments.com/privacy-policy

= Surfboard Vipps/MobilePay Intermediary =

For Vipps and MobilePay payment flows, the Surfboard SDK loads an intermediary page in an iframe to handle the mobile payment authorization. This is managed automatically by the Surfboard Online SDK and serves as the bridge between your store and the Vipps/MobilePay apps.

* Service URL: https://vipps.withsurfboard.com
* Provider: Surfboard Payments
* Terms of Service: https://www.surfboardpayments.com/terms-and-conditions
* Privacy Policy: https://www.surfboardpayments.com/privacy-policy

= Google Pay API =

When Google Pay is enabled as a payment method, the Google Pay JavaScript SDK is loaded in the customer's browser to render the Google Pay button and handle the payment sheet. Payment token data is returned to your store and forwarded to the Saga Payments API for processing.

* SDK URL: https://pay.google.com/gp/p/js/pay.js
* Provider: Google
* Website: https://pay.google.com
* Terms of Service: https://payments.google.com/payments/apis-secure/get_legal_document?ldo=0&ldt=googlepaytos
* Privacy Policy: https://policies.google.com/privacy

= QR Code Generation =

For Swish payments, if the payment gateway does not return a pre-rendered QR code image, the plugin generates a QR code locally using the bundled qrcode-generator library (MIT license, by Kazuhiko Arase). No external service is called for QR code generation.

= Saga Checkout SDK (Diagnostics Only) =

The plugin diagnostics page displays the configured SDK endpoint for admin troubleshooting purposes. The actual payment SDK loaded during checkout is the Surfboard Online SDK documented above.

* Provider: Saga Payments
* Website: https://sagapay.no
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Saga Product Sync API =

If the optional product synchronization feature is enabled, the plugin sends product data (name, description, price, SKU, images, categories) from your WooCommerce catalog to the Saga Payments product API for catalogue management. This is an opt-in feature configured in the plugin settings.

* Service URL: https://api.sagapay.no
* Provider: Saga Payments
* Terms of Service: https://www.sagapay.no/terms-of-service
* Privacy Policy: https://www.sagapay.no/personvernserklaering

= Payment Method Logos =

Payment method logos (Visa, Mastercard, Amex, Vipps, Klarna, Apple Pay, Google Pay, Swish, MobilePay) are bundled locally within the plugin in SVG format. No external CDN is used for logo display.
SVG files and inline SVG markup use the standard SVG namespace URI http://www.w3.org/2000/svg and xlink namespace URI http://www.w3.org/1999/xlink as static XML identifiers; these are not external network requests.

== Screenshots ==

1. Checkout page with inline payment form showing card, Vipps, Klarna, Apple Pay, Google Pay, Swish, and MobilePay
2. WooCommerce payment gateway settings with easy configuration
3. Order management with capture, void, and refund controls

== Frequently Asked Questions ==

= How do I get a Saga Payments account? =

Contact Saga Payments at support@sagapayments.com to set up your merchant account.

= Does this support test mode? =

Yes! Enable test mode in the plugin settings to test without processing real payments.

= Which currencies are supported? =

The plugin supports NOK (Norwegian Krone), SEK (Swedish Krona), DKK (Danish Krone), EUR and other currencies supported by Saga Payments.

= Can customers save their cards? =

Yes! When "Enable Saved Cards" is turned on in settings, logged-in customers can save their cards for faster future checkout.

= Does this work with WooCommerce Subscriptions? =

Yes! Full integration with WooCommerce Subscriptions for automatic recurring payments, payment method changes, and subscription lifecycle management.

= Can I authorize and capture later? =

Yes! Set "Capture Mode" to "Manual" in settings. Payments will be authorized at checkout and you can capture from the order page when ready to ship.

== Changelog ==

= 4.0.718 =
* Sales documents now carry the reverse-charge marking when it applies: the exact words bokf.forskr. 5-1-1 nr. 7 prescribes, plus the buyer's VAT registration number. The automatic test is deliberately narrow - no VAT charged, a buyer VAT number recorded, and a buyer in another country - because printing the marking on a sale it does not cover is as wrong as leaving it off. A VAT plugin can decide it either way through the saga_sales_document_reverse_charge filter, which is also how a domestic reverse-charge sale is declared.

= 4.0.717 =
* Internal naming fix with a real hazard behind it: the "who issues" setting stored the value "saga" for "this plugin issues", while the accounting engine's equivalent setting uses "saga" for "the accounting engine issues". The same word pointed opposite ways in the two systems, and anyone later syncing the two lists by matching like word to like would have inverted both. The value here is now "plugin", which cannot be misread. A shop that had already stored the old value is read as "plugin" rather than being quietly reset to automatic. The wire field that tells the accounting integration who issued a given document is a separate contract and is unchanged.

= 4.0.716 =
* The shop now chooses who issues its sales documents, instead of the plugin guessing. Automatic detection still picks the default - if WooCommerce PDF Invoices & Packing Slips, YITH PDF Invoice or a couple of others are active, they issue and Saga Payments keeps out of the way - but the list of invoice plugins can never be complete, and guessing wrong fails silently in both directions. An invoice plugin we do not recognise would mean both issue: two documents with two number series for one sale, and spent numbers cannot be cleaned up. One we do recognise but which only prints packing slips would mean nobody issues and nothing says so. The setting is under Saga Payments, and it states plainly what the current choice will do.
* When another plugin is the issuer, Saga Payments attaches nothing to the customer's e-mail and issues nothing; the accounting integration is served that plugin's document instead. If it has not produced one, the answer is that there is none - never a substitute of ours.
* Fix in the accounting API: `orderCountTotal` in /status counted every order status while /orders deliberately omits checkout drafts, which are abandoned carts rather than orders. A reader comparing the two saw orders it could never fetch and had no way to tell whether its own paging had lost them - the gap was 200 on the shop where this was found. The count now matches what the list returns, and the excluded drafts are reported separately as `checkoutDraftCount`.

= 4.0.715 =
* The sales-document series can now be corrected. A document issued with a wrong VAT rate, buyer or amount can be annulled and replaced: the annulled snapshot stays with its reason (nothing is deleted - five-year retention, and a spent number that can be explained), the replacement gets a new number and prints which document it supersedes, and any credit note that pointed at the annulled document is annulled and reissued against the standing one. A reason is mandatory; without it the annulment is refused, because a number that stops supporting anything with no stated cause leaves the series unexplainable.
* The number series is now keyed on the seller's organisation number and the year, not on the site. A change of organisation number is almost always a different legal entity - a restructure, a sale of the business - and one counter per site would have carried one company's series straight on into another's, leaving neither able to show a complete record of its own invoiced sales. This had to be settled before the first document was issued: no migration can split a series that was already shared. Numbers now read 2026-000001.
* The feature still ships switched off and cannot be switched on. What remains: the seller is typed by hand rather than resolved from the Surfboard merchant, a limited-company document is missing the "Foretaksregisteret" marking and a guaranteed head-office address, and reverse charge is not implemented.

= 4.0.714 =
* The sales-document feature added in 4.0.711 now ships switched off and cannot be switched on. It is not finished, and three of the things it is missing cannot be repaired afterwards: there is no way to annul and replace a document that was issued with an error, the seller is typed by hand instead of resolved from the Surfboard merchant (so this plugin and Saga Regnskap could name two different sellers for the same sale), and a limited-company seller's document is missing the word "Foretaksregisteret" and a guaranteed head-office address. A document number that has been used cannot be reused, so the series is not put into use until those are closed. The settings panel says exactly this instead of offering a checkbox.
* Legal references corrected after checking them against the regulation rather than quoting from memory. The requirement that the document be sent to the buyer unprompted is bokf.forskr. § 5-2-1 fjerde ledd and is now quoted where the e-mail setting lives. The seller's organisation number is § 5-1-1 nr. 2 jf. § 5-1-2 annet ledd. § 5-1-3 was cited for buyer identification, which it does not cover - it is about numbering and dating - and no longer is.

= 4.0.713 =
* The Saga Payment Actions box now says what actually happened to a cancelled or failed order, instead of one fixed sentence. Found by opening real orders on a live shop: an order that had been authorised and then voided showed its payment id, "Status: VOIDED", and "No payment was recorded for this order" - three statements on one screen contradicting each other. Such an order now reads "The authorisation on this order was released, so no money was collected", a declined payment says it was declined, and a payment that was started and never finished says that. An order with no trace of a payment still says nothing was recorded.
* The status line reads "Not completed" rather than "No payment recorded" when a payment id exists, so it no longer denies the id printed directly above it.
* Every variant ends with the same sentence - "There is nothing to capture, void or refund" - because that is the part the merchant needs.

= 4.0.712 =
* Fix (misleading, not dangerous): a cancelled or failed order showed "This payment method is auto-captured by the provider... use the WooCommerce Refund button instead" in the Saga Payment Actions box, on orders where no money had been taken at all. One condition was answering two different questions - it returns false both when the method genuinely auto-captures and when no payment method was ever recorded, which is the normal state of an order that failed before a payment existed - so "we do not know what this was" came out as a confident claim about how it was captured. Such an order now says plainly that no payment was recorded and there is nothing to capture, void or refund, and the status reads "No payment recorded" instead of "Unknown".
* The auto-capture notice is now only shown when the payment method is actually known; a redirect that the customer never completed no longer produces a claim about it.
* An order that was cancelled while still holding an authorisation is unaffected: the funds are reserved, so the box still shows the Void button that releases them.

= 4.0.711 =
* New: the shop can now issue the sales document itself. WooCommerce sends an order confirmation, but that carries an order number, not the sequential document number a sales document needs, so a shop without an invoice plugin has never actually issued one. Turn "Utsted salgsdokument" on under Saga Payments and every paid order gets a numbered PDF: it is attached to the customer's order email, downloadable again from "min konto" and from the order page, shown on the admin order screen, and available to Saga Regnskap as the voucher itself.
* Returns produce a credit note in the same number series, referencing the document it credits.
* Invoices and credit notes share one continuous series so completeness is simple to verify. Numbers are allocated with a single atomic database statement, so two simultaneous orders can never receive the same one, and a number that gets allocated but never becomes a document is written to a void log rather than leaving an unexplained gap.
* The document is frozen when issued: the PDF is rendered from a snapshot taken at that moment, so editing the order afterwards cannot rewrite a document the customer already holds.
* If the shop already runs an invoice plugin, that plugin keeps its number series and Saga Payments passes its document through instead of issuing a competing one.
* No document is issued until the organisation number is filled in, because a numbered document without it would not be valid - the settings panel says so rather than quietly issuing anyway.
* The PDF is generated in-plugin with no third-party library, so this adds no new dependency to a payment gateway.
* New for the accounting integration: `GET /wp-json/saga-accounting/v1/orders/{id}/document` and `.../refunds/{id}/document` return the document as application/pdf, and every order and refund in the list now carries `document_number`, `document_type` and `document_url`.

= 4.0.710 =
* CRITICAL fix (a host that cannot take database locks would have taken payments and recorded none): the plugin asks the database for an advisory lock before completing a payment, and treated "no lock" as "another process is already completing this one" - which is correct when the lock is genuinely held by someone else, and catastrophic when the database simply cannot provide one. Connection multiplexers used by several managed hosts (AWS RDS Proxy, ProxySQL and similar) put that lock mechanism out of reach entirely. On such a host EVERY payment completion would have returned early: money taken at the payment system, no order marked paid, and nothing in the shop saying why. The two cases are now separated - real contention still backs off, an unavailable lock falls back to the completion marker the plugin has always kept on the order. On any normal host the behaviour is unchanged.
* Same fix applied to the scheduled subscription renewal and the pre-order release, where yesterday's lock hardening had introduced the same trap: on a host without lock support every renewal and every release would have been skipped on every pass, silently and forever. Both now fall back to the transient and order-meta guards that shipped before the lock existed.
* The lock probe is a single cheap query per request with a zero-second timeout, so it can never itself wait on anything, and a database that refuses it is treated as "no locks available" rather than being allowed to break the site.

= 4.0.709 =
* Hardening (the two automated charge paths were guarded by something that can vanish): the scheduled subscription renewal and the pre-order release each held a per-order lock stored as a WordPress transient. On a shop with a persistent object cache a transient lives in memory and is evicted under memory pressure or dropped entirely by any plugin that flushes the cache - so on a busy store the lock protecting an unattended charge could simply not be there. The safety net behind it does not close the gap on its own, because it reads the last-payment date, and that date is only written after the charge returns; two passes racing inside those few seconds both see "not charged recently". Both paths now also take a MySQL advisory lock, which no cache can evict and which the database releases by itself if the PHP process dies mid-charge. The merchant-facing charge buttons have worked this way since 4.0.470 - it was the automated paths, the ones nobody is watching, that had only the transient.
* The transient check is kept in front of it as a cheap first pass, and the last-charge safety window is kept behind it. Three layers, and only the middle one now depends on the object cache.
* Every exit path releases the new lock, including a fatal mid-charge, so a double-charge guard cannot turn into a stuck subscription.

= 4.0.708 =
* Fix (the "Charge Customer" button had the same half-guard the renewal button had): 4.0.706 taught it to recognise a charge whose confirmation timed out and stop calling that a failure - but it only recorded the payment system's order id and nothing ever read it back. So the merchant was told to check before charging again, and if they pressed again anyway the plugin charged straight over a charge whose outcome nobody knew. Every path to a stored card now resolves an unconfirmed earlier charge with the payment system first: if it went through it is written into the books as its own order, if it definitively failed a fresh charge is allowed, and if nobody knows yet nothing is charged.
* Fix: a manual charge discovered on reconciliation now creates the same WooCommerce order an immediate success would, with the same fee line, totals and payment id. Previously a charge that resolved late could have moved money with no order behind it - money the merchant could not see, refund or account for.
* Internal: the amount of a submitted-but-unconfirmed manual charge is now recorded alongside its id, because the order that records it cannot be created without knowing how much moved.

= 4.0.707 =
* Fix (a subscriber could be charged twice, found by driving a real renewal): 4.0.706 taught the "Test Renewal Now" button to recognise a charge that was submitted but never confirmed, park it, and tell the merchant "It will be reconciled automatically before any new attempt - the card will not be charged twice." That promise was only true of the scheduled renewal. The button calls the payment API directly and never passes through the scheduled path, so the next press charged straight over the parked charge without ever asking what became of it. Measured on a live test subscription: one press parked an unconfirmed charge, the next press billed the card again. The reconciliation now lives in one place that every path to the card goes through, so the note and the behaviour agree.
* Fix: the "force" checkbox on the same button skipped the double-charge safety net as well as the is-it-due check. A monthly subscription was measurably charged twice within four minutes. Force still overrides the due date - that is what it is for - but a subscription charged inside the same billing cycle is now refused whichever checkbox is ticked. To take a deliberate extra payment, use "Charge Customer", which is an explicit ad-hoc charge with its own amount.
* This was the first renewal ever charged against a real card in testing. Everything else about it held: one press produced exactly one renewal order, the renewal carried its own payment id distinct from the purchase, and an immediate second press was refused.

= 4.0.706 =
* Fix (the same double-charge risk in the two places a merchant triggers a charge by hand): after fixing the pre-order release in 4.0.705, every remaining place that charges a card on file was audited for the same fault. Two more had it. The "charge saved card" button reported a plain "Payment failed" when the charge had in fact been submitted and only its confirmation had timed out - and a merchant who reads "failed" clicks it again. It now says the charge was submitted, names the Saga order so the merchant can check the portal, and tells them not to charge again until they have. The "test renewal" button routed the same case into the retry-scheduling failure handler, which is a retry on a card that may already have been debited; it now parks the charge for automatic reconciliation and schedules nothing.
* All six places in the plugin that charge a stored card now treat "we did not get an answer" as its own outcome, never as a decline. A new test finds those places in the source rather than working from a list, so a seventh one cannot be added without the same handling.

= 4.0.705 =
* Fix (a pre-order could charge the same card twice): releasing a pre-order charges a stored card with nobody watching. When the charge was submitted but its confirmation never arrived - a verification timeout, not a decline - the order was marked "failed" while the money may already have left the customer's account. WooCommerce Pre-Orders lets a merchant retry a failed pre-order, and that retry was a second charge on top of one that had already gone through. A timeout is now recognised as its own outcome: the Saga order id is kept, the order goes on hold rather than failed, and the next release attempt asks the payment system what actually happened to that charge before the card is touched again. If it went through, it is recorded as the payment and nothing is charged; if its fate is still unknown, the release waits rather than gambling. The subscription renewal engine has worked this way since 4.0.471 - the pre-order release had never been given the same treatment.
* Fix: a pre-order release could also be fired more than once - a double-clicked "complete", a retried request, a batch run twice - and nothing checked whether the card had already been charged. An order already holding a completed payment is now never charged again, and two releases arriving together can no longer both get past that check.
* No change for pre-orders that release normally, and a genuinely declined card still fails the order exactly as before.

= 4.0.704 =
* Diagnostics: when the payment system refuses an order update, the reason it gave is now written to the debug log. The server has always sent it - which code, which error, which order - and the browser was throwing it away and logging only the sentence shown to the customer, so a real trace read "Could not update the order" and nothing about why. No change to what the customer sees.

= 4.0.703 =
* Fix (4.0.702 could cancel an Apple Pay payment while the customer was still paying): yesterday's fix released a stuck checkout after 25 seconds. A payment sheet is exactly where people take their time, and elapsed time cannot tell a dead wallet from an unhurried customer - so a customer standing in the Apple Pay sheet had their payment cancelled underneath them.
* Nothing is cancelled automatically any more. If a wallet payment goes quiet, the customer is simply offered a way out - the same Avbryt betaling button Klarna has always shown - and they decide. Somebody mid-payment ignores it and finishes; somebody stuck clicks it and gets their checkout back. Only that click resets anything, and it still asks the server first, so a payment that went through a second earlier still completes.

= 4.0.702 =
* Fix (an Apple Pay attempt could leave the checkout dead): if the Apple Pay sheet opened and then closed without the payment system reporting back, WooCommerce's Place Order button kept spinning and the shopper could neither pay nor try again until they reloaded the page. Reported from a real attempt, where the payment system crashed inside its own cancel handler and so never told the shop anything. The checkout now releases the shopper on its own if a wallet payment goes quiet.
* It still cannot tell a paying customer they have not paid: an Apple Pay or Google Pay sheet closes on success as well as on cancel, so the release asks the server whether the payment landed before concluding anything, and waits long enough for Face ID, a slow network and a late confirmation to finish first. A payment that did go through still completes normally.

= 4.0.701 =
* SECURITY / DOUBLE-SHIPMENT FIX: one shopper's payment could mark a different shopper's order as paid. A shopper whose card had been declined, whose checkout tab was still open, could have their unpaid order completed by the next shopper's payment - leaving two orders marked paid for one payment. A shop with automated fulfilment would ship two parcels and be paid for one. Reproduced end to end on the test store before the fix and refused after it.
* The cause: several endpoints checked that the caller owned the ORDER but never that they owned the PAYMENT they named. Those are two different questions, and only one of them was being asked. Every endpoint that accepts both an order id and a payment id now verifies with the payment system that the payment belongs to that order before touching it - the same check the finalise endpoint has had since 4.0.493, now applied to all five of its siblings.
* No change to normal payments: a shopper's browser sends the payment id its own order already carries, which matches, and nothing extra is requested. The verification only runs when the two disagree.

= 4.0.700 =
* The same guard added in 4.0.699 now also applies where the Blocks checkout reports which method was used. That value comes from the browser and lands in the payment method title that accounting exports group on, so it gets checked the same way as the one the payment system reports. No change to normal payments - this closes the other half of the same gap.

= 4.0.699 =
* Fix (the order list showed "LOAD" instead of the payment method): the payment system reports the type of a transaction that is still open in the same field a finished payment reports its method in, and the plugin took it at face value. Two Klarna orders whose flow did not finish came out labelled LOAD - on one of them, where it should have said Klarna - and the same value went into the payment method title that accounting exports group on. Tokens that are not payment methods are now refused and logged, and what the order already knew is kept. A payment method the plugin has not heard of yet still displays as before, so a genuinely new one is not swallowed.
* Fix (shoppers who never used a card were told to check their card details): every failed payment ended with "You have not been charged. Check your card details or choose another payment method." A shopper refused by Klarna on credit has no card details to check, and the one thing they needed to read - choose another way to pay - sat behind advice they could not act on. The same applied to Vipps, Swish, MobilePay, Apple Pay and Google Pay. The wording now fits the method actually used, and an unknown method gets wording that is true whichever it was.

= 4.0.698 =
* Fix (one payment was writing two "payment completed" lines in the order history): an ordinary card purchase confirms through more than one route - the SDK callback settles it, and the browser's completion poll lands a moment later and settles it again - and each route wrote its own note. On two test purchases, one on the classic checkout and one on Blocks, every order came out carrying "Payment completed via VISA (last four)" and "Payment completed via CARD (Wallet)" for the same payment id. A merchant looking at a disputed order could not tell that from two payment attempts. There is now one note per payment, whichever route confirms it, naming the route so support can still see what settled it.
* Fix (card payments were labelled as wallet payments): the completion note said "(Wallet)" on every inline card purchase, because the endpoint that writes it is named for wallets for historical reasons but serves every payment method. That label is gone.
* The card brand and last four digits now appear on the note for both checkouts. Which route confirms a payment first differs between the classic checkout and Blocks, and previously that decided whether the note said "VISA (last four)" or just "CARD"; it is now read from the order instead, so it is the same either way.

= 4.0.697 =
* Removed the developer switch that offered to enable part captures on Vipps. Vipps does not support them - the provider itself refused a real attempt - so a switch claiming otherwise is a promise the payment system will not keep, and anyone turning it on would get a failed capture on an order they then have to unpick. Part captures on Vipps are now simply refused, with the amounts named and what to do instead. There is no merchant setting for this and never was.

= 4.0.696 =
* Fix (partial refunds on Vipps were blocked, and they should not have been): since 4.0.666 the plugin refused to refund part of a Vipps order and displayed a warning above the refund box stating that Vipps can only be refunded in full. That rule had never been put to the provider. It has now: on a live shop, kr 2.00 was collected with Vipps and kr 1.00 refunded against it, and the refund was accepted and settled. The restriction took a capability away from every merchant and told anyone wanting to give half an order back to refund all of it and take a new payment instead. Part refunds now work on Vipps like any other method, and the incorrect warning is gone. A merchant whose own Vipps agreement forbids part refunds can restore the restriction with the filter saga_vipps_allow_partial_refund.
* Part CAPTURES on Vipps remain blocked - that one the provider genuinely does refuse, and it answered so on a real attempt.

= 4.0.694 =
* The two Vipps restrictions - no part refunds, no part captures - can now be lifted by a filter, so they can be tested against the provider rather than only assumed. The behaviour is unchanged: both still refuse by default. This exists because "Vipps only supports full refunds" had never actually been put to the provider, and if that changes, a rule nobody can switch off quietly takes a capability away from every merchant. Developers: saga_vipps_allow_partial_refund and saga_vipps_allow_partial_capture.

= 4.0.693 =
* Fix (a Vipps refund was recorded as a Klarna one): where the provider settles a refund immediately, the plugin wrote "KLARNA_AUTO_REFUND" as the reference and "Klarna refund processed automatically" as the note - for Vipps, Swish and MobilePay too, because that branch serves all of them and only Klarna was named. Seen on a real Vipps refund. No money was ever wrong, but a merchant reconciling a Vipps refund had to know that Klarna meant Vipps, and the wrong name was carried into the accounting export. The refund now names the method that actually paid.

= 4.0.692 =
* Fix (collecting the whole amount was sent as if it were part of it): the plugin always told Saga how much to collect, and Saga reads any capture that names an amount as a partial one. Vipps refuses those outright, so capturing an entire Vipps payment failed with "SVIPPS does not support partial capture" - on a payment where every krone was being collected. Collecting everything now says so, by not naming an amount at all, which is how the payment system expresses it. Collecting part of a payment is unchanged for the methods that allow it.
* And a genuine part-capture on Vipps is refused before anything is sent, naming both amounts and what to do instead - restore the order and capture in full, or release the reservation and take a new payment for the lower amount - rather than returning an unknown error on an order the merchant then cannot fix.

= 4.0.691 =
* Fix (money paid with Vipps could neither be collected nor released): a Vipps payment was reserved on the customer's account and then stranded there. The order said the funds would be taken when it was marked Processing; marking it Processing took nothing. There was no Capture button and no Void button, because the plugin held that Vipps is always collected by the provider straight away. And a refund was refused - correctly, since nothing had been collected - with advice to use a button that was not there. The merchant was paid nothing, the customer's money stayed on hold until it expired, and neither could do anything about it. Measured on a real payment on a live shop. Vipps now offers Capture and Void like card and Klarna. Swish and MobilePay are unchanged: nobody has put a real payment through either, so they keep the previous behaviour until someone has.

= 4.0.690 =
* Fix (Vipps failed outright on the block checkout): pressing the pay button with Vipps selected produced nothing at all - the payment stopped with "Invalid NONCE" and the checkout reset itself. Before starting a Vipps payment the plugin asks for a brand new payment order, because the provider fixes the phone number permanently to an order once it has been set. When the shop already had a payment order for that basket, the plugin's own duplicate protection handed the existing one back instead - correctly, so that no customer ends up with two orders - but the Vipps code took it for a new one and reloaded the payment system with credentials that did not belong to it. Vipps now checks what it was given, and simply continues on the order that already exists. The duplicate protection is unchanged; nothing was weakened to make this work.

= 4.0.689 =
* Fix (customers could not pay a payment link): if the order carried the customer's email address, WooCommerce hid the whole payment form behind "log in or confirm the email address on this order". Measured on a live shop, on exactly the flow this plugin is sold on - the merchant raises an order by hand and sends the customer its link: no payment form at all, and the email had to be deleted from the order before it could be paid. A customer asked to prove who they are before they may settle an invoice sent to them personally will often just give up, and the merchant never learns why it went unpaid. The link's own order key is what identifies the customer, exactly as payment links work everywhere else, so it is now accepted as sufficient - but only to pay, and only while the order still needs paying. The order confirmation page keeps the check, and a link to an order that has already been paid keeps it too, so a forwarded link cannot be used to read someone's order afterwards.

= 4.0.688 =
* Diagnostics: when logging is on, the plugin now records why it needs the second call it makes to Saga before charging a card, and what the first call had already sent. Measured from pressing "Bekreft ordre", eleven of fifteen seconds pass before the card is charged at all, across three round trips - two of which are the plugin's own. An attempt to drop the middle one was written and then removed the same evening: it could not fire safely, because the checkout is already marked as changed by that point, and overriding that to save four seconds is how a wrong amount gets charged. The measurement and the reason are now recorded in the code so the next attempt starts from data rather than belief. No behaviour changed for customers.

= 4.0.685 =
* Fix (the checkout was unusable after a Klarna attempt): the empty full-screen element Klarna leaves behind sits on top of the page and swallowed every click, so a customer who came back from Klarna - approved or not - could not press anything on the checkout at all. It is Klarna's element and their code may still be using it, so it is not removed; clicks are let through while there is nothing in it, and that is reversed the moment Klarna puts something back on screen.

= 4.0.684 =
* Fix (the checkout was left under a dead Klarna layer): Klarna leaves an empty full-screen element behind on the page for good after its window opens. 4.0.676 read that element as "Klarna is on screen" and stood the plugin's own progress panel aside whenever it was there - which, since it is never removed, meant the panel stayed hidden for the rest of the visit. A customer coming back from Klarna was therefore left looking at a blank layer with no message and no way to cancel. The plugin now checks whether Klarna actually has something on screen rather than whether the element exists, so the progress panel and its cancel button come back as soon as Klarna is done.
* Fix (a customer Klarna would not approve was told nothing at all): Klarna ends its own window with "we could not approve you for this purchase", the customer closes it - and the shop said nothing and kept waiting, on a page that still looked busy. Klarna's payment system never reports that outcome to the shop, so nothing was there to react to. The plugin now notices that the customer has come back from Klarna without a payment, checks with Saga whether one arrived after all, and only then tells them the payment was not completed and that they can try again or choose another method. A Klarna payment that did go through still wins, so nobody who has paid is told otherwise. The wording deliberately does not say "rejected": from the shop's side, Klarna refusing someone and the customer simply closing the window look exactly the same.

= 4.0.682 =
* Fix (the checkout could be changed while a payment was being processed): after the order was created the "processing" screen disappeared, and for roughly half a minute - until the payment window actually opened - the customer could still switch payment method or apply a discount code. That changes the amount after the payment for the old amount is already under way, which leaves the charge and the order describing different purchases. The processing screen now stays up for as long as the payment is running, and the controls that can change the amount are inert while it does. If the payment needs the customer's attention - a card number to correct, for example - the screen still steps aside as before so they can fix it and try again.

= 4.0.681 =
* Fix (express checkout could take payment for something the shop did not have): buying a whole cart with an express button never checked stock. The single-product buttons have always checked, but the cart route validated nothing - it took whatever was in the cart, created the order and charged it. WooCommerce's normal checkout blocks this; express goes around the checkout, so nothing stood in the way. With a wallet payment the customer is charged before anyone can look, and the merchant is left to find it, refund it and explain it. Every line is now checked before an order exists, and quantities are added up per product so two lines of the same item cannot slip past together.
* Fix (express buttons were shown on sold-out products): "pay now" was offered on products that could not be bought, so tapping it opened a payment sheet and then failed. The buttons are now hidden when the product cannot be bought - including via the shortcode. A product with some variations left, or one on backorder, still shows them, because those can genuinely be bought.

= 4.0.680 =
* Fix (a refused card emptied the shopper's basket): a customer whose card was declined was told the payment failed and then found their basket gone, with no way to try again. WooCommerce checks on every page load whether the order it is holding for the shopper still looks unpaid, and empties the basket when it does not. The plugin was handing it an order parked in a preparation state that WooCommerce does not recognise as unpaid, so WooCommerce concluded the payment had gone through and cleared the basket. The order is now published only under the state that matches how it is being used. Verified on the test store: after a declined card the basket is still there and the customer can pay with another card.

= 4.0.679 =
* Diagnostics: when logging is switched on, the plugin now records what emptied the shopping cart and from where. A declined card on the classic checkout was leaving shoppers with an empty basket and no way to try again, and nothing in this plugin does that - so the log now names the caller instead of leaving it to guesswork.

= 4.0.678 =
* Fix (the same "payment completed" line was written to an order several times): the browser checks whether a payment has landed on a repeating poll, and every check that arrived after the payment completed added the note again. Measured on one ordinary card purchase: three identical lines for one payment, and the number varied with the shopper's connection. A merchant reading the history of a disputed order could not tell that from three payment attempts. The note is now written once per payment.

= 4.0.677 =
* Fix (the block checkout lost the payment system's messages the same way the classic one did): the fix in 4.0.674 was applied to the classic checkout only. The block checkout refreshes the payment system before a payment in exactly the same way, and that refresh clears the two handlers it had registered - so from that moment nothing was listening. Measured: a Klarna order placed through the block checkout came back without the confirmation the classic checkout records for the same purchase. Both handlers are now restored after every refresh there as well.

= 4.0.676 =
* Fix (two sets of text on top of each other while Klarna opened): Klarna draws its own full-screen message over the shop's checkout while its payment window opens, and the plugin's "Betalingen lastes" panel stayed underneath and read through it. The customer got both messages at once and neither was legible. The plugin's panel now steps aside while Klarna is on screen, and comes back the moment Klarna's window is gone, so the option to cancel or confirm manually is never lost.
* Fix (the payment system's own error and status messages were going nowhere): the plugin registers two handlers with the payment system - one for errors, one for payment status. The payment system accepts each of these once and rejects any later attempt, and since 4.0.552 the plugin had been claiming the error slot early with a small logging-only handler in the belief that the real one would replace it later. It did not; the attempt was rejected, and the rejection stopped the real registration before it reached the status handler. The result: no status handler at all, and an error handler that only wrote to the log - so a cancelled or declined payment produced no message to the customer and no offer to try again. The shop was carried by the plugin's own polling of the payment status, which is why this was not visible sooner. Both handlers are now installed for real, and both keep working after the payment system is refreshed mid-checkout.

= 4.0.673 =
* Fix (Klarna could not be paid with at all): choosing Klarna opened an empty white panel over the checkout with nothing in it, and the page then waited for a payment that could never happen. The order stayed at "Awaiting payment" and the customer had no way forward. Before a payment the plugin refreshes the payment system so it holds the current amount, and that refresh resets the payment system's own record of where each payment method should be drawn on the page. Klarna was therefore told to draw itself nowhere. The refresh now puts those records back, so Klarna appears and can be completed. The same reset affects Vipps, Swish, MobilePay, Apple Pay and Google Pay, which are refreshed the same way.
* The plugin used to recognise the resulting error and hide it. Hiding it left the customer on a checkout that would never finish; the cause is fixed instead.

= 4.0.672 =
* Fix (payment links did not work at all - the customer was sent to a page saying the link was invalid or expired): the plugin asked the hosted payment page to lock the order to Apple Pay, and the provider refuses that outright - "P_0026: Lock toPayment method APPLE_PAY is not supported". The order was therefore never created, so there was no payment page to send anyone to. The code already knew this about Google Pay and mapped it to card; it believed the opposite about Apple Pay. Both are card payments on the hosted page and both now map to card, applied where the request is assembled so no caller can get it wrong. Verified on the test store: the provider now returns a real payment page and the customer sees the amount to pay.
* Fix (opening the same payment link twice failed): the plugin has a guard that stops a second payment being created for an order that already has one, and it answers on the provider's behalf with an order reference and no link. For an ordinary checkout that is the whole answer; for a hosted payment page the link IS the answer, so the second attempt had nothing to open and the customer was told the payment could not be started - on an order that had a working payment page minutes earlier. Requests for a hosted page now go to the provider, which is the only thing that can issue the link. The link is also remembered when issued and offered again while it is still valid.

= 4.0.666 =
* New (a partial refund on a Vipps order is stopped before it is sent): Vipps supports one refund of the whole amount and nothing else. A partial refund was being sent anyway, and the merchant found out afterwards - with a refund already written into their books for money that never left, so the shop's records and Saga's no longer agreed on an order with a customer waiting. Such a refund is now refused before anything is sent, and the message says what can be done instead: refund the whole order and take a new payment for what the customer keeps, or pay the difference by bank transfer and leave the order alone. A notice on the order screen says so above the refund controls as well, so the rule is known before an amount is typed rather than after. Card orders can still be refunded in parts, as many times as needed.

= 4.0.665 =
* An attempted fix for payment links was withdrawn the same hour it was written, and the reason is recorded in the code so nobody repeats it. 4.0.664 tried to free the order reference by cancelling the abandoned payment upstream first. The cancellation worked, the provider sent back an order-cancelled notification, the plugin believed it - as it should - and it cancelled the customer's own order while they were trying to pay it. A payment link that does not work is bad; an order that quietly cancels itself under the customer is worse. 4.0.663's behaviour stands: nothing is fabricated, the shopper is told plainly, and the order is left exactly as it was.

= 4.0.663 =
* Fix (a customer paying an unpaid order through its payment link was sent to a page saying "Invalid or Expired Link"): when the payment provider returns no hosted payment page for an order, the plugin used to build a URL itself from the order id. Measured three times on the test store: that URL does not work. The reason is that an unpaid order already carries the payment created when the shopper first opened the checkout, both requests carry the same WooCommerce order number, and the provider therefore returns that first payment again instead of a new hosted one - and the first kind has no hosted page. A URL built from it can never resolve. The plugin now refuses to send the customer anywhere it was not given a real link: the shopper is asked to try again, the order gets a note, and the failure is logged. Links the provider does return are used exactly as before. Being able to create a hosted payment for an order that already has one is a question outstanding with Saga.

= 4.0.662 =
* Fix (express buttons offered payment methods the shop had switched off): the express checkout rendered a button for every method selected in its own list without checking whether the gateway could actually take that payment. Measured on the test store, which has Vipps and Swish disabled: Vipps and Swish buttons were visible on the product page, the cart page and the checkout, on the most prominent button a shopper sees. Version 4.0.593 fixed the same class of fault one setting along and stated the reason - a method offered by a shop with no agreement for it is a failed payment and a confused customer. The gateway's switch now decides: a disabled method gets no express button, and turning it back on restores the button without the merchant having to reselect anything. A method whose setting is missing altogether is not offered either.

= 4.0.661 =
* Fix (on a phone, the Blocks checkout showed "Laster betalingsmetoder..." and never stopped): on mobile the payment system is started only once the shopper shows they intend to pay, which is deliberate - it stops orders being created for people who are only browsing. The payment section showed a loading spinner for the whole of that wait, and the wait ends only when the shopper touches something. Measured on a phone, doing what a shopper does and reading the payment options before filling anything in: the spinner was still there after five seconds, after a minute, and after a minute and a half. The payment methods are now shown while the checkout waits, so the shopper can see what they can pay with, and the card fields appear as soon as they start filling in their details.

= 4.0.660 =
* Fix ("Lagre kort for fremtidige kjop" did nothing on a shop using the inline card form): saving a card depends on a flag set against the order, and the only two places that set it were a checkout path the inline flow never reaches and a redirect-only update. The browser knew what the shopper had ticked and never told the server, so the card was never stored and the next visit offered nothing to pay with. Measured signed in as a customer: the box was ticked, the payment went through, and not a single token line appeared in the log. The choice is now sent with the order sync that runs when the shopper pays, and recorded once - an unticked box on a later sync cannot retract a choice already made.

= 4.0.659 =
* Fix (pressing "try again" after a declined card could hand the shopper back the card the provider had just refused): the check for whether a payment attempt was dead rested in part on two order fields nothing in the plugin has ever written, so those conditions were permanently false. Since 4.0.589 a refused payment is deliberately not marked failed straight away - the provider is asked first, because a browser must never overwrite a payment that actually went through - and in that window the attempt was dead while the order still looked open. The check now asks whether the provider refused THIS attempt, which the plugin already records, so a retry gets a fresh payment as it should.
* Fix (the subscription panel could not show when renewals were last examined): the "last checked" line read a field nothing wrote, so it never appeared and a merchant had no way to tell the renewal job was running at all. It is now recorded once per run. A renewal throttle that was never called by anything, and a table created on every installation that nothing has ever written a row to, have both been removed.

= 4.0.658 =
* Safety (a shop trading in a currency without ore could have sent every amount a hundred times too high): all amounts are converted for the payment provider by multiplying by 100, which is correct for a currency with two decimal places and wrong for any other. Icelandic kronur, which the plugin's own currency table lists, have no smaller unit than one krona - so a 2 500 ISK payment would have been sent as 250 000. The plugin now refuses to offer itself as a payment method in any such currency, including Japanese yen and the three-decimal currencies, instead of risking the wrong amount. Norwegian, Swedish, Danish, euro, dollar, pound, zloty and franc shops are unaffected. The conversion itself is unchanged: which convention the provider applies is being confirmed with Saga before any of it is touched.

= 4.0.657 =
* Fix (the first renewal of every new subscription was scheduled in the wrong timezone): 4.0.654 corrected the subscription code, but the date stamped when a subscription is first taken out is written elsewhere, and that one still stored the shop's local time into a field the scheduler reads as UTC. It is the date the first renewal is measured from, so on any shop not running UTC every new subscription started out mis-scheduled by the shop's offset. A capture failure also recorded its timestamp in two different formats depending on which branch wrote it; both now use the same one.

= 4.0.656 =
* Fix (uninstalling the plugin left one of its own tables in the database): the duplicate-order ledger is created on every start-up and was never listed for removal, so it stayed behind for good on a site that had removed the plugin. It holds no accounting record - the order and refund history that does is preserved on purpose, as before.

= 4.0.655 =
* Fix (a capture that could never succeed was sent anyway, and the order note could not say what the merchant needed to know): nothing recorded how much was actually reserved on the customer's card. The message shown when Saga refuses a capture reads that amount in order to tell the merchant what they may take instead - from a field no code had ever written, so it was always empty. An authorisation now records the reserved amount, once, and a later edit to the order does not move it: the customer's bank is holding the original sum. A capture above it is refused before anything is sent, naming both figures, so a merchant who adds a forgotten item to an order before capturing is told exactly what to do rather than meeting a refusal from the provider. The amount actually collected is recorded at capture as well.

= 4.0.654 =
* Fix (subscription renewals could run on the wrong day, and the guard against charging a card twice was weakened, on any shop not set to UTC): every subscription date is read as UTC, but five places wrote the shop's local time into those fields, because a WooCommerce date object prints itself in the shop's timezone. Three of those lines used both conventions in the same expression, depending on whether the order had a completion date. The whole error is the offset, so it is invisible on a UTC shop: west of Greenwich a renewal falls due early and the too-soon-to-charge-again check is weakened by exactly the offset, and east of Greenwich renewals run late. All five now store UTC, taken from the true instant rather than from how the date happens to print.

= 4.0.653 =
* Fix (a product sync could empty the barcodes on every variation): version 4.0.471 established that a barcode, EAN or GTIN belongs to the merchant, so a pull that does not carry one leaves the existing value alone. Variations never got that rule - each pull deleted the barcode, the EAN, the GTIN, WooCommerce's own product identifier field and three third-party equivalents from every variation whenever the catalogue returned no barcode for it. The catalogue has no barcode field for new variants at all, so for a shop whose variations were created in WooCommerce this happened on every variation on every sync. Nothing in the shop looks wrong afterwards; the products simply stop matching in Google Shopping, Prisjakt and Amazon feeds. Variations now follow the same rule as products.
* Fix (variations pulled from the catalogue all came back with the same option, so a shopper could not choose between them): each variant carries both the attribute's label and the variant's value - "Farge" and "Bla" - and the sync read the label where the value belongs. Every variation of a pulled product ended up as "Farge: Farge", collapsing onto a single option. The two halves of the same pull disagreed about this: the code that identifies a variant already read the value, while the code that sets the shopper-facing option read the label.
* Fix (a variation created in the point of sale arrived incomplete in WooCommerce): the sync sends each variation's SKU, weight, cost price and image upstream, but read none of them back. A variation that originated in the catalogue therefore reached the shop with no SKU - leaving it out of picking lists, exports and product feeds - no weight, so shipping was calculated on nothing, no cost price for margin reporting, and no picture of its own. All four are now read back, and like every other field of this kind they are only filled in when empty: anything the merchant has typed on the variation screen wins.

= 4.0.652 =
* Fix (products pushed to the catalogue came back without their SKU, and the duplicate guard depends on it): the plugin writes a product's SKU into the catalogue's productProperties, which is where the v2 API accepts it, but the pull still looked for it in the metadata block that was removed in 4.0.539. So from that release the SKU was written to one place and read from another. A pull could not recognise a product it had itself created, which is the same failure 4.0.478 fixed from the other side: with the SKU index empty the duplicate guard cannot fire, and a shop re-linking to an existing catalogue duplicates every product upstream. Found by pushing a fully populated product through the mapper and feeding the result straight back: every field survived the round trip except the SKU. Records written by older builds are still read from the old location.
* Fix (a product with no properties could stop a pull): the catalogue sends an empty JSON object for a product that has none, and reading a field from it raised a fatal error part-way through the pull. It now reads as empty, and a populated properties object keeps its SKU and short description instead of being discarded.

= 4.0.651 =
* Fix (an English or Swedish shop showed a Norwegian loading message): while the Blocks checkout started the payment SDK it displayed "Laster betalingsmetoder..." to every shopper, because the text was a Norwegian string literal rather than a translated one. It now follows the shop's language like every other message.
* No behaviour change on the Blocks checkout. 4.0.649 and 4.0.650 were withdrawn before release: they delayed the payment SDK until the shopper had entered an email, on the theory that Surfboard refuses a secure channel to an order created for the placeholder customer the API falls back to. That theory was wrong. The refusals were produced by the test harness - an automated browser identifies itself as headless, and Surfboard will not open a secure channel for one. A normal browser is given a channel on the very first attempt, with the form still empty, and the inline card fields appear as they always have.

= 4.0.648 =
* Fix (Blocks created the order and then left the shopper on the checkout): when the payment has to complete on the hosted page, the checkout hands off to WooCommerce, which creates the order and navigates to the payment URL. That works - with the shop set to redirect mode the shopper lands on the payment page every time. It did not work when the same hand-off happened from inline mode, and the difference was that an SDK rebuild was still running: still creating Saga orders and still throwing "Invalid NONCE" a second after the checkout had already succeeded, and evidently able to stop the navigation. Nothing the SDK does after the hand-off can help the shopper, so the hand-off now ends it.

= 4.0.647 =
* Fix (the Blocks checkout created the order and then left the shopper standing on it): when the payment has to complete on the hosted page, WooCommerce creates the order and returns the payment URL - and Blocks refused to navigate to it. A guard there defers any WooCommerce redirect until polling confirms the order is PAID, which is right for a "thank you" page and wrong for a payment page: the shopper is being sent there in order to pay, so the order is not paid yet and never becomes paid while the browser waits. Measured: the order was created, the checkout logged the hand-off, and the browser sat on the checkout for two minutes. A hosted payment URL now goes straight through; completion URLs are still held back until the payment is confirmed.

= 4.0.646 =
* Reverted 4.0.638 and 4.0.640, which broke the Blocks checkout. Those two stopped the payment SDK initialising until the shopper showed intent to pay, on every device rather than only on mobile, and gated the passive mount on that. The mount gate deliberately does not count deferred ticks toward its timeout, so on desktop nothing ever initialised and the payment section sat on "Loading payment methods..." indefinitely. The Blocks checkout rendered correctly before that change; it does again. The reasoning behind those two - that initialising before there is a customer is what the provider refuses - was never demonstrated to fix anything, and it is not worth a checkout that never renders. Everything else from this session's Blocks work is kept: the SDK reference is no longer lost mid-initialisation, a rebuild discards the already-initialised SDK and takes a fresh Saga order rather than a spent nonce, the rebuild budget is two rather than six, and a shopper who presses "Place order" before the inline form is ready is taken to the hosted payment page instead of being told to wait.

= 4.0.645 =
* Fix (the Blocks checkout needed a second press of "Place order"): the first press was answered with "The payment system is loading, please wait" and the submission was cancelled. Because the inline card widget does not come up on that checkout, every first press landed there, and nothing re-submitted afterwards - the shopper had to press again, by which time the checkout had fallen back to the hosted payment page and the payment went through. The waits before that point already give the inline widget its chance; if it is still not ready at the moment the shopper asks to pay, they are now taken to the hosted payment page instead of being told to wait. That is the same route their second press was taking anyway. Required billing fields are still checked first, so an incomplete form is still reported rather than carried to a payment page.

= 4.0.644 =
* Fix (an order could be marked PAID when nothing at all was collected): capturing more than was authorised is refused by Saga, and the refusal reads "Capture amount must be less than or equal to payment amount [Already Captured: 0]". The plugin matched the words "already captured" in that sentence and treated the refusal as a successful capture - it marked the payment collected, moved the order to Processing and told the merchant the money was in. Measured on the sandbox: an order authorised for kr 251 was edited up to kr 751 and captured; Saga answered "Already Captured: 0", meaning it took nothing, and the order still went to Processing at kr 751. A merchant reading that ships the goods. The count in Saga's message is now what decides: more than zero means the money really is in, which is the duplicate-capture race this branch was written for, and zero means the capture was refused. A refusal is recorded as a failure and the order note says plainly that no money was collected, that a capture can never exceed the amount reserved on the card, and what to do about it.

= 4.0.643 =
* Fix (the Blocks checkout took eighty-six seconds and two presses to become payable): with the initialisation faults of 4.0.638 through 4.0.642 fixed, a Blocks shopper could pay again - verified live, reaching the hosted payment page with exactly one WooCommerce order - but only after six SDK rebuilds had each spent about fifteen seconds failing. Their first press of "Place order" was rejected while that was still going on, so they had to press a second time. The inline card widget has not appeared once on the Blocks checkout in any run of this audit, on any build tested, so six attempts buy nothing. Two are enough to give a transient failure its chance, and get the shopper to a working checkout in about fifteen seconds on the first press.

= 4.0.642 =
* Fix (a Blocks rebuild reused a nonce that had already been spent): with the SDK made virgin in 4.0.641 the refusal moved from "you are attempting to modify the current domain" to "ON_001: Invalid NONCE" - because the rebuild asked the server for an order and was given the same one back, twice in a row on the measured run, whose nonce the previous initialisation had already consumed. A nonce is good for exactly one initialisation. The rebuild now asks with the context that makes the server retire the dead Saga order, keep it as an alias and issue a fresh one, which is the same fix the classic checkout received in 4.0.626. No second WooCommerce order is created.

= 4.0.641 =
* Fix (every Blocks rebuild was refused by the SDK): the payment SDK refuses a second initialisation on the same page - "ON_005: You are attempting to modify the current domain" - and that is exactly what each rebuild attempted. The reset that runs before a rebuild only forgot the plugin's reference to the SDK; the browser global still held the instance that had already been initialised, so the loader handed the same instance straight back and the re-initialisation was refused. Measured on the Blocks checkout: six rebuilds, six refusals, and the card widget never appeared. The classic checkout does not hit this because it initialises once. A rebuild now genuinely discards the SDK - the global, the script tag and the memoised load promise - so the next attempt initialises something that has never been initialised before.

= 4.0.640 =
* Fix (Blocks initialised the payment SDK before there was a customer): the Blocks checkout created a Saga order and initialised the SDK as soon as its payment section appeared - seconds after page load, before the shopper had typed anything or shown any intention to pay. Surfboard answers that contextless initialisation with "Secure channel not available for this client" and "ON_004: initialisation failed - Secure Channel", the SDK's mount function never arrives, the card widget never renders, and pressing "Place order" then has nothing to run. The classic checkout never behaved this way, which is why the same SDK and the same credentials worked there: it initialises when the shopper goes to pay. The deferral rule for this already existed in the Blocks code but was only consulted on mobile, and the call that mattered did not consult it at all. Blocks now waits for payment intent on every device and initialises where the classic checkout does.

= 4.0.639 =
* Fix (the Blocks initialisation kept losing its own SDK reference): the SDK is held in a module-level variable, and the failure path that gives up on the card widget resets that variable to null. The Blocks initialisation reads it again after two waits - order creation alone took ten seconds on the measured run - so whenever the reset landed inside one of those waits, the next line dereferenced null and the whole initialisation died, leaving the shopper with a button that did nothing. Seen as both "Cannot read properties of null (reading initialiseOnlineSDK)" and "(reading mount)". The initialisation now holds its own local reference, which cannot be taken away mid-flight, and the remaining checks elsewhere in the file no longer assume the reference is still there.

= 4.0.638 =
* Fix (the Blocks checkout could not be paid on - the real cause): the deferral that stops the payment SDK being initialised before the shopper has shown any intention to pay was written to apply on mobile only. On desktop the Blocks checkout therefore created a Saga order and initialised the SDK a few seconds after page load, with no customer behind it. Surfboard answers that with "Secure channel not available for this client" and "ON_004: initialisation failed - Secure Channel"; the SDK's mount function then never appears, the card widget never renders, and pressing "Place order" has nothing to run - measured as a checkout where the button did nothing at all, for two minutes, with no order created. The classic checkout never behaved this way because it initialises around a customer that already exists, which is why the same SDK and the same credentials worked there. The deferral now applies on every device, so Blocks initialises where classic does: when the shopper actually goes to pay.

= 4.0.637 =
* Fix (the Blocks checkout could not be paid on at all): a shopper filled in the Blocks checkout, pressed "Place order", and nothing happened - no navigation, no card form, and no WooCommerce order, for two minutes. The cause is a race on the SDK reference. It is a module-level variable, loaded at the start of initialisation and then used again after the order-creation call, which took ten seconds on the measured run. The failure path that gives up on mounting the card widget calls a reset that sets that reference to null, and when it landed during those ten seconds the next line dereferenced null: "Cannot read properties of null (reading initialiseOnlineSDK)". The initialisation died there, so the click produced nothing whatsoever. The reference is now re-acquired if it was cleared while the initialisation was in flight, which costs nothing when it survived.
* This was not introduced recently - it reproduces on 4.0.616, 4.0.626 and 4.0.634 - and it is separate from the Secure Channel refusal that stops the inline card widget appearing on that page in the first place.

= 4.0.636 =
* Fix (4.0.635 did not actually take effect): the shorter budget for a Secure Channel refusal was selected by matching the initialisation error text, and that is not the text that reaches the failure handler. The refusal is raised inside initialisation; the code then waits for the SDK's mount function to appear and reports "SDK mount timeout" fifteen seconds later. Measured after 4.0.635 shipped, the Blocks checkout still took over a minute to become usable. Both strings are the same failure seen from two places, and neither has recovered in any run, so both now select the shorter budget.

= 4.0.635 =
* Fix (the Blocks checkout took a minute to become usable): on the Blocks checkout the payment SDK is refused by Surfboard with "ON_004: initialisation failed - Secure Channel" and the card widget never appears. That is not new and it is not something this plugin does - it reproduces identically on builds 4.0.616, 4.0.626 and 4.0.634, with the address filled and with it empty, and again with the customer's details already stored before the SDK initialises. Each rebuild already asks for a fresh Saga order, so there is nothing for a later attempt to change, and the shopper simply watched "Laster betalingsmetoder..." for a minute before the checkout became usable. A Secure Channel refusal now gets two rebuilds rather than six, so Blocks falls to redirect mode within seconds and the customer can pay. Every other failure keeps the full allowance, because those do recover - measured on the classic checkout, a payment that failed to mount on the first attempt mounted on the second and completed.
* Note for merchants using the Blocks checkout: card payments there currently complete on the hosted payment page rather than inline. The inline form is not reachable while Surfboard refuses the Secure Channel on that page.

= 4.0.634 =
* Fix (a declined shopper came back to a checkout that looked untouched): when a card is refused the SDK tears its fields down, so the checkout shows the message and reloads the page two seconds later to guarantee the next attempt starts clean. The message was meant to survive that reload, and for a specific reason it did. But the reason the SDK gives on an ordinary decline is literally "Payment failed", and a rule added in 4.0.521 discarded anything that generic rather than replay it - so nothing was carried across and the shopper arrived back at an empty form with no explanation at all. Measured: 100 seconds after a declined card, polling every 400ms with the processing overlay included in the scan, nothing was shown, while the console recorded the error being raised two seconds before the reload wiped it. A specific reason is still carried through verbatim; a generic one now replays a clear statement instead of silence, together with the line the shopper actually needs - that they have not been charged and can check the card or choose another method.

= 4.0.633 =
* Fix (the refusal was reported but nobody was listening): 4.0.632 made the server say that Saga had refused an attempt, and it did - measured on the shipping build, the response carried the flag and the right Norwegian message - but the shopper was still shown nothing. That endpoint is called from eight places in the checkout script, and only the one that had been taught to look was checking. A single delegated handler now catches the answer whichever call received it. It is shown once, it never fires after the payment has completed or a redirect has begun, and it stops nothing: the poll continues, so a payment that does complete still redirects and overrides the message.

= 4.0.632 =
* Fix (a declined card told the shopper nothing): measured on the test store with a sandbox decline, polling the page every 400ms for 100 seconds with the processing overlay included in the scan - the shopper was shown NOTHING. The card form reset itself and that was all: no message, no guidance, no way to know whether they had been charged, while the order held their stock. The browser was asking the right question every two seconds and getting an answer that could only ever say "not yet", because that poll had no branch for an attempt the provider had already refused. Saga's own failure event for the attempt is now reported and the shopper is told, once, with the overlay cleared and the form given back so they can try again.
* Two things make that safe, and both were missing from the attempt reverted in 4.0.630. Only Saga's failure event for THIS attempt is reported - never the WooCommerce order status, so an order that the abandoned-order sweep cancels while a payment is in flight can no longer be reported as a refusal. And the poll deliberately keeps running, so a payment that does complete still redirects the shopper and overrides the message. The order is not touched and its grace period runs exactly as before; only the silence is gone.
* The browser now also sends the WooCommerce order id with that poll. The server resolved the order from the Saga id alone, that lookup can come back empty, and it is why the earlier attempt never fired at all. The fallback is ownership-checked.

= 4.0.631 =
* Fix (a rebuild could abandon a payment the shopper had already made): 4.0.626 and 4.0.627 made the SDK rebuild patient - six attempts over about twenty-four seconds instead of three over five - and gave each rebuild a fresh Saga order, so that a refusal the SDK holds against one order could be cleared. That is right while the card form has not come up. It is destructive once the shopper has entered their card and the SDK has submitted it: the rebuild takes the Saga order that attempt belongs to and replaces it, leaving the payment they made with nothing pointing at it. Measured across three live payments in a row - the one that reached the thank-you page made a single SDK initialisation, and both that did not made two - plus an earlier attempt for which Saga received no event at all. A rebuild is now refused outright while a payment is in flight; the verify calls, the pollers and the server-side checks decide that payment's outcome, and a rebuild could only take their evidence away.

= 4.0.630 =
* Reverted 4.0.628 and 4.0.629. Those two attempted to break the silence a shopper meets when their card is declined, by reporting a failure the server already knew about to the browser that was still polling. They did not achieve it - the branch was not reached in the measured case - and worse, the second of them introduced a real risk: an order that the abandoned-order sweep cancels while a payment is still in flight would have been reported to the shopper as failed, stopping the poll on a payment that then succeeded. Telling a paying customer they did not pay is a far worse fault than the one being fixed, and the success rate across three live test payments was visibly worse than on 4.0.627. Both changes are backed out in full, including the meta key they introduced. The decline silence is documented as open in GO-LIVE-STATUS.md, with the exact point where the chain breaks, rather than papered over with a change that was never proven.

= 4.0.629 =
* Fix (a decision already taken was still reported as "not yet"): the endpoint the checkout polls every two seconds kept answering "the payment is not confirmed yet" for an order that had already been marked failed. Measured: order #2840 read "Mislyktes" in the admin while the shopper in front of it was still being told to wait. A terminal order status is now reported to the browser, which stops the poll and tells them, in addition to the provider's own failure event added in 4.0.628. A recorded failure that belongs to a different attempt is logged rather than silently ignored, so the two can never drift apart unnoticed.

= 4.0.628 =
* Fix (a declined card told the shopper nothing at all): measured on the test store with a sandbox decline, polling the page every 400ms for 100 seconds and watching the processing overlay as well as every notice container - the shopper was shown NOTHING. The card form reset itself and that was the whole of it: no message, no guidance, no way to tell whether they had been charged, while the order sat in "Awaiting payment" holding stock. The browser was asking the right question every two seconds and getting an answer that could only ever say "not yet" - that poll had no failure branch at all, so nothing could end it. Saga's own failure event for the attempt is now reported to the browser, which stops polling and tells the shopper. The order is deliberately NOT failed by this: when the webhook says failed and the API still says pending the plugin waits out its grace period before deciding, because an early webhook can precede a payment that succeeds. That is the right rule for the order and the wrong one for the person waiting, and only the second has changed. The report is scoped to the attempt it belongs to, so a retry - which is a new Saga order against the same WooCommerce order - is never declared refused by a previous attempt's failure.

= 4.0.627 =
* Fix (the redirect card is gone - no customer can see it, on either checkout): the card reading "Click the button to complete the payment / Go to secure payment" appeared when the inline card form could not be built. It appeared at INITIALISATION time - before the shopper had decided to pay - so it interrupted a checkout they were half way through with a payment card they had not asked for. Auto-redirecting instead would have been worse: it would send someone still typing their address to a payment page. So the checkout now quietly becomes what it would have been if the merchant had chosen redirect mode - the ordinary checkout, the ordinary "Place order" button, and the hosted payment page after they press it. One line tells them where the payment happens. The renderer has been deleted rather than left unused, so nothing can draw it again. The Blocks checkout had its own version of this - an amber "The payment module could not be loaded" panel that also replaced the entire payment area, taking every other payment method with it - and it is gone the same way.
* Fix (the plugin gave up on the inline form far too quickly): the rebuild budget was three attempts spanning about five seconds. The refusal that reaches it is usually Surfboard declining to open the card fields' Secure Channel, which clears on its own moments later, so five seconds of patience threw away the merchant's inline setting on a hiccup that had not finished happening. It is now six attempts backing off to about twenty-four seconds, on both checkouts. The shopper sees an ordinary checkout while that happens.

= 4.0.626 =
* Fix (the redirect card could still reach a customer): the card that must never be shown - "Click the button to complete the payment / Go to secure payment" - appears only after every SDK rebuild has failed. Read out of the store's own diagnostics, 264 entries across four days, every route to it has the same shape: the SDK refuses to initialise with "ON_005: You are attempting to modify the current domain" or "ON_004: initialisation failed - Secure Channel", the rebuild runs, and the log then says "using existing checkout-success Saga order" with THE SAME order id - followed by the same refusal, three times, and then the card. Both of those refusals are states the SDK holds against that particular order and session, so re-initialising against the same order cannot clear either: the three rebuilds were three identical failures and the allowance was spent without anything having changed. A rebuild now starts from a fresh Saga order, created with the context the server treats as a new page session so the refused order is retired and kept as an alias. This does not create a second WooCommerce order - the WooCommerce order is the one that must never multiply, and it does not.

= 4.0.625 =
* Fix (a declined card told the shopper nothing at all): measured on the test store against a sandbox decline, polling the page every 400ms for 150 seconds across four consecutive attempts - not one error notice appeared. The card form simply reset itself. The shopper was not told the card had been refused, not told to try another, and had no way to know whether they had been charged; meanwhile the order sat in "Awaiting payment" holding stock for an hour. The cause was a single early return. When the payment SDK reports a failure the checkout asks Saga before believing it, which is right - the verify endpoint answers "ask again" while another request is completing the same payment, and reading that as a refusal would tell paying customers they had not paid. On that answer it returned and left the verdict to "the watchdog and the polling fallback", but there is no polling fallback on the card path: that loop only runs for Swish. So nothing ever reached a verdict. The question is now re-asked on a bounded schedule - at most four times over about twenty-four seconds - and every route out of it ends in an answer: a payment that landed redirects the customer, a payment that did not tells them it did not, and an answer that stays ambiguous to the end tells them too, because by then no evidence of payment has appeared. A payment that completes at any point still wins, so a paying customer is never told otherwise.

= 4.0.624 =
* Fix (the out-of-order payment-event gate could never refuse anything): payment events do not arrive in order - Saga retries anything that is not a 2xx, the browser's return races the webhook, and a redelivery of an earlier attempt's failure can land after a later attempt has succeeded. The gate that checks whether an incoming event is even a legal next step asked the order for a state stored under a name nothing in the plugin has ever written. It therefore resolved to "unknown" for every order that was not already paid, and the gate only acts when the state IS known - so it has been inert since it was written, on exactly the orders it was written for. An order holding an authorisation is not "paid", so a stale event from an earlier attempt walked straight past it onto a live authorisation. The gate now reads the status the plugin actually records. Stale declines on a held authorisation, completion claims on a voided order, and failure claims after the money is in are refused and noted on the order; every legitimate event - the capture, a genuine void, refunds, duplicate deliveries, and a genuine failure on a held authorisation - still passes untouched.
* Fix (a pre-order could not find the customer's saved card): when a pre-order came due and the Saga token had not been copied onto the order, the fallback that looks up the saved card read a meta key the gateway does not write, so it never ran: the order failed with "No saved payment method found" while the card sat right there. It reads the right key now - and because reviving that lookup exposes two things that were only safe while it was dead, it will not charge a card that belongs to a different customer than the order, and it will not rebuild a token on an order whose cancellation kill-switch has been engaged.

= 4.0.623 =
* Fix (a sibling order could be cancelled without checking whether it was the same payment): when one checkout produces several WooCommerce orders, the paid one cancels its abandoned siblings. That is only safe if a sibling that is a DIFFERENT payment can be told from one that is the SAME payment under another order number, and the code says so itself - "if we cannot positively prove the ids differ, we err on the safe side and do NOT cancel". The per-sibling check honoured that. The step that built the set of ids to compare against did not: if it failed it emptied the set and said nothing, and the whole check then ran behind an "is the set non-empty" condition, so the one failure that mattered skipped the guard and cancelled every sibling unchecked. A cancelled same-identity sibling can be reactivated later by the cancelled-order safety scan, and one payment then sits behind two paid orders - two confirmations to the customer, two orders in the books, two conversions reported to the ad platforms. Nothing is cancelled now unless the paid order's identity was read successfully and is non-empty, and both the failure and the decision it caused are logged.
* Fix (uncaptured money was invisible to the merchant): with Authorize-only capture an order can hold the customer's money until the merchant collects it. The hourglass in the orders list and the "Payment is authorized but not captured" notice on the order screen both asked for a meta key that nothing in the plugin has ever written, so neither has appeared on any store since they were written. There is now one definition of "this order is holding uncaptured funds", used by both indicators and by the capture/void actions - it treats a missing capture flag as NOT captured, because the other direction is the one that loses the merchant money.
* Fix (the timezone bug in 4.0.622's own guard): the fifteen-minute wait before a product can be retired compared a local-time stamp against a UTC clock. The condition reduced to "true age < grace + the site's offset", so in Norway the wait was silently two and a quarter hours, and on any store west of Greenwich it could never be satisfied at all - the guard added in 4.0.622 did not exist for those merchants. Both sides are now read in the same clock, and the conversion uses WordPress's own timezone rather than the fixed offset number, which is an hour out for half the year on a site that uses a named timezone.
* Removed: a second, unhooked copy of the capture/void order actions that offered action names no handler listens for. Dead code contradicting the live implementation is worse than none - it sends the next reader to the wrong place.

= 4.0.622 =
* Fix (a product was trashed eleven seconds after it was created): the sweep that retires products deleted from the Saga catalogue judged them against a product list that could easily be older than the products themselves. Measured in the log on the test store: a product created at 23:14:19 and linked at 23:14:20 was trashed at 23:14:30 as "no longer in catalogue", by a pull whose list had been read before it existed - the catalogue held 323 by then and the list said 322. Push and pull are separate scheduled runs and overlap freely, the client reuses a product list for up to a minute, and a catalogue is not obliged to show a new product in the very next read; any one of those is enough. That product returned two minutes later only because the next pull found it and republished it, so on a store with auto-create off, or with no pull after it, it would have stayed in the trash with nothing to say why. A list read at a given moment is now treated as evidence about that moment only: a product linked to the catalogue after the list was read is never retired on the strength of it, and neither is one linked within the last fifteen minutes, since a catalogue that has not yet caught up looks identical from here. A product genuinely deleted upstream is still retired, on the next run. The wait is adjustable with the `saga_orphan_grace_seconds` filter.
* This is the same family as the migration that trashed 291 products - an absence read as a deletion - and it is guarded the same way: say what the evidence actually covers, and refuse to conclude more than that.

= 4.0.621 =
* Fix (a duplicated product wrote to the original's catalogue entry): copying a product in WooCommerce did not give the copy its own entry in the Saga catalogue - it gave the copy the ORIGINAL's. WooCommerce's Duplicate copies every meta key on a product, and clones each variation of a variable product wholesale; the key that ties a product to its catalogue entry was not excluded from either. From that moment both products pushed their name, their price and their stock to the same place and the last write won, so selling the copy moved the original's stock at the till. Nothing reported it: every sync call succeeded, every product showed green, and the stock comparison agreed with itself because per catalogue entry the numbers really did match. Measured on the test store, where three variations of a copied product were writing to three variants of the original. Copies now keep none of the link - note that WooCommerce applies its exclusion filter to the parent only, so the variations are cleared separately or the fault survives in exactly the shape it was found in.
* Fix (the links that are already wrong): stores that duplicated a product on an earlier build, or that received products by import, CSV or a staging clone, already carry the damage and no hook can reach it. Each sync run now repairs it: the earliest product keeps the catalogue entry, always, and any later product sharing it has its link cleared so the ordinary push gives it an entry of its own. Nothing is deleted or rewritten upstream, and both sides are named in the log. A report that the CATALOGUE has lost something is deliberately never repaired this way - a fetch that was cut short by a rate limit looks identical, and recreating products on that basis would be a far worse failure than the one being fixed.
* Diagnostics: the sync health panel now also reports products that share a catalogue entry, or point at one the catalogue no longer holds, with the product, the entry and what to do about each.

= 4.0.620 =
* Diagnostics (two variations recorded against one catalogue variant): the row-by-row comparison added in 4.0.619 reported nothing at all on the test store - every published variation carried a catalogue id, and every one of those ids was present in the catalogue - while the catalogue still held 157 variants against the shop's 160 variations. Both of those can only be true together if some ids are written down twice, and that is a worse fault than a missing variant: two WooCommerce variations pointing at one catalogue variant both WRITE to it, so selling the green one moves the blue one's stock at the till, and the per-variant stock comparison agrees with itself the whole time because per variant the numbers do match. The reconciliation now detects it, names both variations, and says which one holds the variant and which one needs its own.

= 4.0.619 =
* Diagnostics (a missing variant is now named, not just counted): the sync health line could say "160 variations here, 157 in the catalogue" and nothing more. On the test store that gap stood at three for days with no failure anywhere in the log, no product out of publish and nothing orphaned - a permanent alarm with no next step, which is how a merchant learns to stop reading the panel. The snapshot the panel reads now compares the two sides row by row instead of totalling them: every published variation of a published product is checked against the variant ids the catalogue actually returned for its parent, and each one that is absent is reported with its product, its variation number and the reason it is absent - the product never reached the catalogue, the variant was never pushed, the catalogue no longer holds the parent, the parent is there but the variant is not, or the recorded id was generated locally and matches nothing upstream. Each of those is a different repair, and the panel now says which one. Every missing variant is written to the sync log; the panel lists the first twenty. This costs one database query and no extra network call - the product fetch it compares against was already being made.

= 4.0.618 =
* Diagnostics (the sync health line now counts what the sync can reach): it compared every published variation row in the database against the catalogue, including variations whose parent product has been deleted or unpublished. Those orphans belong to no product and the catalogue cannot hold them, so counting them would report a gap that is not one. On the test store the corrected count came out identical, which established something worth knowing: the three variants missing from that store's catalogue are NOT orphans — they belong to published products and genuinely have not synced. Any orphans found are now written to the sync log rather than silently folded into the comparison.

= 4.0.617 =
* Fix (a customer could not pay from a payment link): the customer payment page — how a shopper retries after a declined card, and how a merchant collects on an order taken over the phone — could not start the inline card form. Measured on three orders, as a guest and signed in, in a fresh browser and in the session that created the order: the payment SDK refused every attempt with "you are attempting to modify the current domain", three retries in a row, and the shopper was then shown the redirect card that is meant never to appear. From there neither that card's button nor the ordinary pay button did anything at all; the customer simply could not pay. The same page in redirect mode has always worked, taking the customer to the hosted payment page where the payment completes. The payment page therefore now always uses the hosted flow, whatever the shop's checkout mode is set to. Nothing else changes: the ordinary checkout, where virtually every payment happens, still follows the setting and still runs inline. Shops that need the old behaviour can restore it with the `saga_force_redirect_on_order_pay` filter.

= 4.0.616 =
* Diagnostics: the payment-creation endpoint now logs which Saga order it hands back, so a browser using a different one than the server just created is a grep rather than a reconstruction.

= 4.0.615 =
* Fix (a guest could not pay their own payment link): opening the customer payment page for an existing order — how a shopper retries after a decline, and how a merchant collects on an order taken over the phone — ended with "Kunne ikke oppdatere ordrebeløpet. Vennligst last siden på nytt", and no amount of reloading helped. The order update refused with "Order ownership validation failed", three times in a row. A guest has no session tying them to an order placed earlier, so the only proof they can offer is the order key from the link they were sent — the same key WooCommerce itself uses to let them see the page at all, and one the plugin's ownership check already accepts. That call was simply not sending it, although every other Saga request on the same page does. It now does.

= 4.0.614 =
* Fix (on an Authorize-only shop the customer paid and heard nothing): the plugin suppresses WooCommerce's customer "order on-hold" email for Saga orders, because the holds it was written for are backend reconciliation states — amount mismatch, a missing payment id, a verification race — where the customer has already paid in full and "we are waiting for your payment" would be false. But with delayed capture, on-hold is the normal state of every order, and that email is the only one that fires there. Measured on four consecutive held orders: the merchant received the admin "new order" email each time and the shopper received nothing at all. The next thing the customer hears is the Processing email when the merchant ships, which may be days after an authorisation has appeared on their card statement — and people who have paid with no confirmation contact support, or their bank. An order that is authorised and awaiting capture now gets the on-hold email, whose wording — thanks for your order, it is on-hold until payment is confirmed — is exactly true of an authorisation that has not been captured. The Processing email still follows at capture. Reconciliation holds stay silent, as they must, and an email the merchant or another plugin has switched off is never switched back on.

= 4.0.613 =
* Fix (changing capture mode mid-checkout could complete an order that was only authorised): 4.0.612 stopped the automatic capture asking the shop's current setting about orders that already existed, but the completion gate — the code that decides whether to hold an order rather than mark it paid — still did. An order created while the shop was on "Authorize only" has had delayCapture sent to Saga, so Saga has only authorised it; if the merchant saved the switch to immediate capture while that shopper was still paying, the gate consulted the new setting, declined to hold, and completed the order as though the money had been collected. Because the hold is exactly what did not happen, the order never got the authorised marker either, so the automatic capture correctly found nothing to capture: nothing was collected, and nothing said so. The plugin now records what Saga was actually told for each order, at the single place that decides it, and every later decision reads that back instead of re-deriving it from a setting that may since have changed. It is re-recorded on every order update, so a shopper switching between Klarna and card cannot leave a stale value behind. Orders placed before this version have nothing recorded and behave exactly as they did.

= 4.0.612 =
* Fix (switching capture mode stranded every order already authorised): the automatic capture that runs when an order is marked Processing or Completed first asked whether the SHOP is set to "Authorize only". That is a setting, not a property of an order that already exists. A merchant who tried Authorize only, took some orders, and then switched back to immediate capture had every one of those held orders answer "this shop captures immediately" — so the capture returned immediately, without collecting anything, without an order note, and without an API call that could fail and be reported. The order still moved to Processing looking paid; the merchant ships, and the authorisation expires uncollected. The decision now reads the order itself — Saga reported AUTHORIZED and nothing has been captured — which is true exactly when there is money waiting to be collected, regardless of what the setting says today. Orders captured immediately never reach that state and are still skipped, and a Klarna order on an immediate-capture shop is still captured for a reason that cannot drift with a setting.

= 4.0.611 =
* Fix (the "has this already been captured?" check was answered from a stale copy): capture and void both re-read the order from the database before acting, precisely so a page that has been open for hours cannot act on what it remembers. But `wc_get_order()` returns the instance the request already loaded, so the re-read handed back the state the request started with. Measured on an order captured twice at once: the losing submission passed the check as though nothing had happened and only learned the truth from Saga's API. The re-read now forces the meta to be fetched, so a duplicate capture or a capture racing a void stops one HTTP call earlier, before the payment provider is contacted at all.

= 4.0.610 =
* Fix (capturing twice told the merchant the money never arrived): measured by submitting the Capture order-action twice at once, which is what a double-click on "Oppdater" produces on a slow admin page. The capture re-reads whether the payment is already captured before acting, but records it only after the round trip to Saga, so both requests saw "not captured" and both captured. One succeeded; the other came back "Capture amount must be less than or equal to payment amount [Already Captured: 1]" and was written onto the order as "⚠️ Capture failed — money was NOT collected", directly beneath the note saying it had been captured successfully. With the opposite timing the same branch pulls the paid order back to On hold telling the merchant not to ship. Either way the shop withholds goods it has already been paid for. Saga answering "already captured" is now read as what it is — confirmation that the money is collected — so the order is marked captured, the failure flag is cleared, and the note says plainly that the capture was not repeated. Genuine failures, including "payment is not completed", are unaffected and still refuse loudly.
* Fix (capture and void were only serialised for the AJAX buttons): a per-order lock has protected the Capture and Void buttons since 4.0.471, but the equivalent entries in the order-actions dropdown took no lock at all — and that is the path a merchant uses when changing an order's status. Both dropdown paths now take the same shared lock, so a capture cannot race another capture, and Capture in one tab can no longer race Void in another with the outcome decided by whichever call Saga happened to process last. The lock deliberately fails open: a shop whose database cannot support advisory locks (Saga Diagnostics reports this separately) can still collect money and release authorisations, and a duplicate is now handled rather than mis-reported.

= 4.0.609 =
* Fix (a failed card payment could be held as though it were authorised): measured on the test shop, on an Authorize-only checkout. Saga's event stream for the order was order.paymentinitiated, then order.paymentprocessed, then order.paymentfailed — the payment never succeeded. The plugin treated PAYMENT_PROCESSED as an authorisation, so it acted on the middle event: the shopper was shown a thank-you page, stock was reduced, and the merchant was handed an order on-hold reading "Payment authorized. Funds will be captured when the order is marked as Processing" with a Capture button. Saga refuses that capture — "Payment is not completed. Payment needs to be completed to perform capture. The authorization is still open." — so the order could never be fulfilled or collected, and a merchant who shipped against it would have shipped for free. The premise behind treating PAYMENT_PROCESSED as final was that no PAYMENT_COMPLETED webhook arrives until the merchant captures. That is not what Saga sends: four genuine held card orders on the same shop each received order.paymentcompleted seconds after order.paymentprocessed, while still on-hold and uncaptured. PAYMENT_PROCESSED is now treated as intermediate for every payment method and every capture mode; the API is cross-checked and the order waits for a real verdict rather than being given one. Genuine authorisations are unaffected — they arrive as PAYMENT_COMPLETED or AUTHORIZED and are held exactly as before.
* Fix (a wrongly held order could never be un-held): the same order then received two order.paymentfailed webhooks and ignored both, because the failure branch refuses to fail any order marked `_saga_payment_verified = yes` — a flag the delayed-capture hold sets itself. "Verified" is not "settled": an uncaptured authorisation is not money in the till, and this is the same conflated flag that caused the 4.0.607 fault. A failure report about a held, uncaptured payment is now cross-checked against the API instead of being dropped. Captured and completed payments keep the shield, and the API cross-check still refuses to fail anything Saga confirms, so a genuine authorisation survives a stray failure webhook untouched.

= 4.0.608 =
* Fix (an authorised order could be "refunded", returning money that was never taken): measured on the test shop. An Authorize-only order sat on-hold holding an authorisation — nothing captured, no paid amount on the order — and WooCommerce still offered the gateway refund button. Clicking it sent a refund to Saga, Saga accepted it and created a return order, and the WooCommerce order became Refunded. The shop had returned money it never received, and its books now showed a refund against an order that was never paid. This is not an exotic state: on a delayed-capture shop every unshipped order is an uncaptured authorisation, and Refund is the button a merchant already knows — cancelling an order is exactly when they would reach for it. A refund is now refused while a payment is authorised and uncaptured, with an order note and an error naming the action that does work: "Void Saga authorization", which releases the customer's funds and takes nothing. Captured payments, completed orders and ordinary immediate-capture orders are unaffected — a refund guard that is too eager strands a real customer with no way to get their money back.

= 4.0.607 =
* Fix (a held authorisation was repeatedly mistaken for a stalled payment): the delayed-capture hold marks the order `_saga_payment_verified = yes`, which is true — the payment is verified, it is simply not captured yet. That is also the exact flag the escalating payment check uses to decide "this order should have completed and something blocked it", so every deliberately held order looked like a completion that had lost a lock race, and the check tried to complete it again every ten minutes for as long as the merchant left it waiting to be shipped. Measured: on one order the check ran twice and the delayed-capture gate had to hold eleven times; on another the recovery won the race and the order silently went to Processing on a shop that had asked to capture at shipment. Winning most of the time is not a guarantee — the race simply should not be run. An order that is AUTHORIZED and not yet captured is now recognised as being exactly where it belongs, and is left alone. A genuine lock race, where a verified payment really did fail to complete, is still recovered.

= 4.0.606 =
* Diagnostics (a delayed-capture hold that does not apply now says why): three separate faults have been "the hold silently did not happen", and each one took a log reconstruction to pin down, because the gate only logged the two outcomes that did something — it held, or it announced a bypass. Falling through for any other reason left no trace, and no trace is indistinguishable from never having run. The completion path now logs one line whenever the gate declines to hold, naming the capture mode, the recorded payment method, and which condition decided it.

= 4.0.605 =
* Fix (every declined card collected a "money may still be held at Saga" warning): a declined payment leaves a transaction id behind, so the cancelled-order warning added in 4.0.596 fired on it — telling the merchant to go and check Saga for an authorisation that never existed. Measured on a plain declined card: Saga sent order.paymentfailed, then order.cancelled, and the warning appeared anyway. Roughly one payment in eight declines, so this would attach a warning to the most common failure path a shop has, and a warning on everything is a warning on nothing — which defeats the reason that note exists. An order Saga has reported a payment failure for is now left alone. Genuine authorisations, captured payments and the stuck-at-PAYMENT_PROCESSED case are untouched: none of them has a failure webhook.

= 4.0.604 =
* Fix (a product named with symbols could not be sold at all): order line names are stripped of everything outside letters, digits, space, hyphen, underscore and full stop — and there was no fallback, so a name made only of symbols became an empty string. Saga rejects the order outright. Measured on the test shop with a product renamed to "★★★": no Saga order was created, no payment form appeared, and the shopper was told "Kunne ikke laste betalingsmetoder: Name is not valid". A total, permanent failure of that sale for every customer, with an error blaming payment methods so no merchant would ever trace it to a product name. Letters and digits in any script are unaffected — Norwegian, Japanese and Arabic names were always safe — but "★★★ VIP", "♥ Gavekort" and emoji-decorated tier names are ordinary things to sell. The line name now falls back to the product SKU, and to a generic label if that is unusable too. Stripping also no longer leaves double spaces behind, so "Salt & Pepper" reads "Salt Pepper" rather than "Salt  Pepper".

= 4.0.603 =
* Fix (Authorize only silently became immediate capture): measured on the first Authorize-only order tested after the feature was built. A kr0.01 card payment was correctly held three times in a row — once from the finalize AJAX and twice from webhooks — and then a fourth concurrent completion path read the order's total as zero, took the free-order escape hatch, and marked the order paid. That promoted it to Processing, which fired the auto-capture hook, which captured the money within seconds. The merchant had asked to capture at shipment. Nothing looked wrong afterwards: the order sat in Processing like any other, and the only trace was a single log line. Several completion routes run concurrently by design, so one of them observing an order mid-rebuild is not exotic. A zero total is no longer sufficient on its own — the order must also have no payment id to capture. Genuinely free orders are unaffected: they have no payment, and they were already covered by the `free_order` source check sitting immediately above.

= 4.0.602 =
* Diagnostics (an unverified store was invisible unless you happened to scroll to it): SagaPay pins each store to the outgoing IP its server uses, so stolen merchant credentials cannot be replayed from another machine. That binding is installed at the proxy when the merchant verifies with an emailed one-time code — and payments work perfectly well without it, so nothing ever pushes back. The only place the state appeared was a panel at the bottom of the gateway settings page. A shop could run live for months with no binding installed and never know. The diagnostics screen now reports it, asking the proxy live rather than the local cached flag, and tells the three states apart: verified, not verified, and verified-but-this-server-is-outside-the-bound-range — the last being what a host move looks like, and a live payment problem rather than a housekeeping note. The plugin still enforces nothing locally, which is correct: the lock belongs at the proxy.

= 4.0.601 =
* Fix ("Authorize only" parked Vipps, Swish and MobilePay orders for a capture that never comes): those methods are captured by the provider at authorisation — there is no second step. With Authorize only enabled they were still held on-hold with the note "Funds will be captured when the order is marked as Processing or Completed", which is untrue for them. The shopper got an "order on hold" email and then a "processing" one for nothing, every status-driven integration saw a manual-review state that was never real, and the Capture button correctly refused to appear because there was nothing to capture. Delayed capture now applies only to methods that can actually be captured later — card, wallets and Klarna. An order whose method is not yet resolved is still held, because assuming the wrong way there would mark an uncaptured order as paid.
* Refactor with teeth: the list of capturable methods lived in the admin class, and the delayed-capture decision could not see it. One list now, owned by the gateway, asked by both — with opposite defaults for the unknown case, which is deliberate: when deciding whether to HOLD an order an unknown method means "hold it", and when deciding whether to SHOW a Capture button it means "do not offer one". A second copy of a lookup is how the dead amount gates in 4.0.592 happened.

= 4.0.600 =
* Fix (a status word could be stored as the payment method, and then nobody could reach the money): found on a real live Klarna order. The browser posts the payment method to the wallet-completion endpoint, and for Klarna it posted `ABORTED` — the Klarna popup closing, reported as an abort, on a payment that had in fact succeeded. That value seeds `_saga_payment_method_used` and also feeds the two-step safety check. On that order the API answered KLARNA and overwrote it, so nothing went wrong. When the API lookup fails there is no override and the order records "ABORTED" as its payment method: `method_supports_void_capture()` does not recognise it, so an AUTHORISED order is left with neither a Capture nor a Void button, and Klarna loses its delayed capture. An authorisation with no way to release or take it. The browser's value is now a hint that must be a recognised method code; anything else is treated as unknown and the API answer fills it in, which every caller already handles.

= 4.0.599 =
* Feature (Klarna is authorised now and captured at shipment): Klarna is buy-now-pay-later, and capturing it at checkout before anything ships is what Klarna's merchant terms exist to prevent. Klarna orders now always carry `controlFunctions.delayCapture = true`, on every path that creates or updates a Saga order — inline checkout, PaymentPage/redirect, cart updates and WooCommerce-order updates — regardless of the shop's global capture mode. Because `controlFunctions` is full-replace at Saga, switching a checkout TO Klarna sets the flag and switching away clears it, with no extra bookkeeping. A `saga_klarna_delay_capture` filter exists for a shop that must opt out.
* Fix (the capture decision was asked per SHOP, never per order): `should_delay_capture()` only knew the global setting, and all ten branch sites called it with no argument — the completion gate, the redirect-ready check, the PAYMENT_PROCESSED safety net, the webhook status promotion, the API success helper and the admin auto-capture. A Klarna order on an immediate-capture shop would have been captured at checkout, and the admin capture path would never have run. Every one of them now asks per order.
* Fix (a Klarna order could have been authorised with no way to capture it): `method_supports_void_capture()` allowlisted card and wallets only. Delayed capture without that entry parks the customer's authorisation on an order with no Capture and no Void button anywhere in WooCommerce — money held until it expires. KLARNA/SKLARNA added; the two changes are only safe together.
* Security fix (opening an app counted as authorising a payment): in delayed-capture mode the plugin promoted `PAYMENT_PROCESSED` to a success status for every method. For a card that is right — it means authorised. For Klarna, Vipps, Swish and MobilePay it means the app or widget merely opened; the customer has confirmed nothing, and Klarna does not authorise until the Klarna SDK's `authorizationToken` is exchanged via `POST /payments/{paymentId}/klarna/place-order`. Any shop running `capture_mode = manual` with Vipps or Swish enabled could therefore mark an order AUTHORIZED for a customer who opened an app and walked away — this was reachable before any Klarna work. The safety net now stays up for those methods. It costs nothing: measured on a real delayed-capture payment, Saga still sends `order.paymentcompleted` at authorisation, contradicting two comments in the source that claimed it does not.

= 4.0.598 =
* Refinement to 4.0.596/4.0.597: a deliberately voided authorisation no longer collects a warning that contradicts it. Voiding an authorisation writes "Authorization voided successfully. Funds released to customer." and then cancels the order — which fired the cancelled-order warning underneath it, telling the merchant the money "may still be sitting as an authorisation" directly below the line saying it had been released. Measured on a live void. The void sets its own flag before it changes the status, so the two are now told apart and a void stays quiet.

= 4.0.597 =
* Refinement to 4.0.596: the cancelled-order warning now tells a CAPTURED payment apart from one that was only ever authorised. A completed payment should be refunded, and the note says so. A payment that has a Saga payment id but never completed was never actually taken — telling the merchant to "issue a refund" would send them after money that does not exist, while the customer may still be looking at an authorisation on their card. That order now gets its own wording: the payment was never completed, there is most likely nothing to refund, and it should be checked at Saga before assuming either way. This is not a hypothetical case: the plugin's own two-hour backstop cancels unconfirmed pending orders, and an order sitting at Saga in PAYMENT_PROCESSED is exactly the kind it cancels.

= 4.0.596 =
* Fix (a cancelled order could still be holding the customer's money, silently): measured on the test shop — an order paid by card was cancelled from the status dropdown, the way any merchant would cancel one. WooCommerce restored the stock and wrote "Ordrestatus endret fra Under behandling til Kansellert", and that was the whole of it. No refund at Saga, and nothing anywhere saying the money was still held. The merchant's books and the payment provider's books diverge in silence, and the customer is out of pocket on an order that says "cancelled". This is core WooCommerce behaviour for every gateway, Stripe included — a status change never refunds — and that is deliberately left alone here: moving real money because someone picked the wrong entry from a dropdown is a worse failure than the silence, and it cannot be undone. What changes is the silence. A cancelled order that still holds captured money now says so on the order, names the amount and the payment id, and tells the merchant what to do about it. Nothing is refunded automatically. Orders that were never paid — the overwhelming majority of cancellations — stay quiet, because a warning on every abandoned checkout would teach merchants to ignore the one that matters.

= 4.0.595 =
* Diagnostics (the one database feature the plugin cannot work without is now checked): every duplicate-protection guard in the plugin — the payment-completion lock, the checkout-session lock, the express-checkout lock and the product-sync locks, fourteen call sites — is a MySQL `GET_LOCK`. It is what stops one payment producing two `payment_complete()` calls, and therefore two purchase events for Meta and Google to count. `GET_LOCK` returns 1 on success, 0 on timeout and NULL on error, and the plugin casts that to an int, so a database that cannot run `GET_LOCK` at all is indistinguishable from one that is merely busy: the completion is skipped and the order stays pending. Failing closed is the right direction — a pending order is better than a double charge — but on such a host it happens to every order, and the only symptom a merchant sees is that payments stopped working. `GET_LOCK` is not universal: Vitess/PlanetScale reject it outright, and on a Galera cluster it is node-local rather than cluster-wide. The diagnostics screen now probes it directly, on its own key, and says what to ask the host about if it is missing. No payment path changed.

= 4.0.593 =
* Fix (turning every express method off turned them all on): a merchant who deselects every express payment method in the settings still got express buttons on their product and cart pages. WooCommerce stores an emptied multiselect as an empty array, and the plugin read that as "never configured" and substituted the full default set. Measured on the test shop: deselect all, save, and three buttons — Apple Pay, Swish, Vipps — were still rendered. A shop with no Vipps agreement was offering Vipps, which is a failed payment and a support ticket about a method the merchant thought was off. A shop that has genuinely never touched the setting still gets the defaults; the two cases are now told apart by whether the setting exists at all rather than by whether it is empty.

= 4.0.592 =
* Fix (a five-minute bound was enforced at eleven minutes): 4.0.588 gave an unconfirmed payment-failed webhook a bounded wait, after which the order is failed and its stock released. But the only checks were at +60s and then every 10 minutes, so nothing looked between minute one and minute eleven. Measured live on a declined card: "a payment-failed webhook was reported 670s ago". The shopper hit the ten-minute payment timeout first and was told the payment took too long rather than that their card was declined, and the stock stayed reserved for twice as long as intended. A running grace clock now pulls the next check forward to the moment it expires — never later, and never sooner than 30 seconds.
* Security fix (every amount check in the plugin was dead code): v4.0.497 found ONE amount gate reading `$result['data']['amount']` â€” a field the API does not return â€” and recorded that the comparison was therefore always skipped, "a security gate that looked present in the code and enforced nothing". Eight more sites were doing exactly the same thing and were never found: the return handler, three webhook paths, the escalating payment check, the wallet completion, the safety check and the poll endpoint. Not one of their "SECURITY: amount mismatch" guards could ever fire. A ninth site had a hand-rolled copy of the extractor that had already drifted, missing two field names. Every one of them now reads through the single shared extractor, and a test fails the build if anyone reads an amount by hand again. The gates are unchanged; they can simply see the amount now, which means they work for the first time.
* Fix (a declined shopper watched a spinner for ten minutes): when a card is declined, the plugin correctly refuses to fail the order until the API confirms it â€” but the browser was never told when that confirmation finally arrived. Measured live: no message after three minutes, on course for the full ten-minute timeout, while the order had already been marked failed server-side. The status endpoint now reports a terminal failure to the browser, with the specific decline reason from the webhook where one exists, and the checkout shows it and hands the shopper their form back. No money rule changed: this reports a decision already taken, it does not take one.

= 4.0.591 =
* Fix (every paid order was held for manual review first): the amount gate refuses to complete an order whose paid amount it cannot confirm, and holds it instead â€” which is right. The fault was that it could never confirm one. /orders/{id}/status reports the amount inside `transactionDetails[]`, and the extractor knew every other field name the provider uses except that one, so it always came back empty. Confirmed on the test shop across orders from three separate days: every single successful payment went Pending â†’ On-hold with "âš ï¸ the paid amount could not be verified", then was rescued to Processing by another path. The extra hop is not cosmetic â€” WooCommerce sends its "order on-hold" email as well as the processing one, so the shopper is told their order is held before being told it is not, and every status-driven integration a merchant runs sees a manual-review state that never existed. The gate's refusal is unchanged; it can simply see the amount now.
* Fix (a refused poll kept asking forever): when an order's ownership check fails, the endpoint answers {success:false, retry:false} â€” and both the classic and Blocks pollers discarded the response and scheduled the next request anyway. Measured live: 25 rejected calls in 90 seconds, on course for roughly 130 before the ten-minute cap, each one a full WordPress boot, with the shopper under a spinner throughout. Both pollers now stop on an explicit refusal â€” and only on an explicit refusal. A network error or a timeout still keeps polling, because never ending a payment on a quiet backend is the more important rule. Every ownership rejection now sends that flag; some did and some did not, which is how the inconsistency stayed hidden.

= 4.0.590 =
Both of these were found by running 4.0.589 against a real store, not by reading it.

* Fix (the plugin was asking Saga to cancel orders that had been paid): measured on the test shop, 353 cancel attempts across 23 WooCommerce orders â€” every one of them already `processing`, every one refused by Saga with "Unable to cancel order in the current order status - PAYMENT_COMPLETED". Three faults stacked up. The superseded-order path had no WooCommerce paid-check, though the abandoned-order path beside it has always had one. The status guard that should have caught it was skipped whenever the status READ failed â€” and that read fails routinely on this store with "Permission denied", so an order the plugin could not see was treated as one it could cancel. And nothing left the queue unless the cancel succeeded, so a refusal that can never change was retried on every run forever, at roughly three API calls a time. The money was safe only because Saga said no; an authorised-but-uncaptured order is cancellable, and would not have been. All three are fixed: the paid-check is there, an unreadable status now refuses to cancel, and a terminal refusal stops being retried.
* Fix (the shopper-path guard added in 4.0.589 could be bypassed): the Blocks integration carried its own copy of the AMEX capability lookup â€” same cache key, same TTL, its own API call â€” so it walked straight past the guard added to the gateway's copy. blocks.js is enqueued on the classic checkout too, so the unguarded copy was running on exactly the page the guard was written for. It now delegates to the gateway. The duplication was the real defect: two implementations sharing one cache key drift the moment either is touched, and this one also skipped the durable fallback, so whenever it won the race it left the gateway's fallback with nothing to fall back to.

= 4.0.589 =
* Fix (the bound added in 4.0.588 depends on WP-Cron, and WP-Cron is not a scheduler): failing a genuinely declined order and releasing its stock is now the scheduled check's job, which means it does not happen at all on a store where DISABLE_WP_CRON is set and the promised system crontab was never created, or on a store quiet enough that hours pass between requests. The order sits pending forever and nothing reports it. Overdue payment checks now also run when a shop manager loads a wp-admin page: the same handler, called from a second place, at most ten checks a minute, one per order, and never inside a customer's request.
* Fix (money taken against a deleted order vanished into a log file): a PAYMENT_COMPLETED webhook for an order in the trash was correctly refused â€” restoring something a human deleted is its own kind of wrong â€” but refused in silence, with one debug line and nothing else. A merchant restoring that order a week later found an ordinary unpaid order. The refusal stands; it now writes the transaction id onto the order, adds a note explaining what happened, logs at error level and raises a notice in wp-admin listing every affected order.
* Fix (a shopper waited on Saga's API to see the checkout): the merchant capability lookups behind the payment logos and method list are cached for an hour, so the API call looks rare â€” but the cost of a cache miss falls on whichever shopper arrives first after it expires, and their checkout does not paint until Saga answers. Worse, an object-cache restart or a deploy expires every store's cache at once, so they all call out together, which is the shape that gets rate-limited. A cache miss on a shopper-facing request now answers from the last value actually received; the scheduled scan refreshes it, where nobody is waiting.
* Fix (one failed lookup made a merchant offer methods they had switched off): when the payment-method lookup failed, an empty list was cached for five minutes, and every caller reads an empty list as "assume all methods available". The last known-good list is now held instead. Failing open is right when we have never known the answer; when we do know it, the real answer beats a guess.
* Fix (an SDK error during 3DS tore down a live authentication): the recovery for a known Surfboard internal error creates a fresh Saga order and discards the mounted one. Its own comment records that the error is seen "after a 3DS retry", so a challenge from the previous attempt can still be on screen â€” and the discarded order can still settle, which is money against an order the shopper was navigated away from. The three other automatic teardown paths already stood down while a challenge is visible; this one now does too. Shopper-initiated cancels are deliberately unchanged: someone who asks to stop must never be held.

= 4.0.588 =
* Fix (the bound added in 4.0.587 never actually fired): the five-minute limit on waiting for the API to corroborate a payment-failed webhook was only checked when another webhook arrived. Saga sends its payment-failed webhooks within a few seconds of each other, so none of them is ever more than five minutes after the first, and the order went on sitting unresolved exactly as before â€” measured, not assumed. The scheduled payment check is the only thing still running minutes later, so it now enforces the limit: if a failure was reported, the grace has elapsed and the order record still will not confirm a payment, the order is failed and its stock released, instead of waiting for the two-hour backstop.

= 4.0.587 =
* Fix (a declined payment could sit unresolved for two hours): 4.0.582 stopped the plugin failing an order on a payment-failed webhook the API had not corroborated, which was right in principle and unbounded in practice. Measured with three published decline cards: Saga sends the failure and its order record answers PENDING and never moves, so the order stayed on "Avventer betaling" until the two-hour backstop cancelled it â€” holding stock and showing the merchant a pending order that was already dead. The wait is now bounded. The first disagreement is still honoured, because an early webhook really can arrive before the record catches up; but once the API has had five minutes and still will not corroborate, the webhook is the better evidence and the order fails. The window is filterable via saga_failed_webhook_grace_seconds.

= 4.0.586 =
* Fix (healthy products listed as broken because the catalogue asked us to slow down): when the catalogue answers "429 Rate limit exceeded" on a variant, the product is fine â€” it simply has to be tried again. The parent product already had this exemption; variants never did, so a throttled variant put its product on the merchant's sync-error list as though something were wrong with it. Throttling is now counted separately from rejection: the parent is still left unpushed so the next run retries it, but nothing is reported to the merchant as a product error. This only became visible once 4.0.585 started carrying the catalogue's reason into the log, where it read "Rate limit exceeded".

= 4.0.585 =
* Fix (a variant that would not synchronise gave no reason): when the catalogue refused a variant write the log said only "Variant sync FAILED", naming the variation and the product and nothing else. A missing parameter, a permission problem and a rejected field all produced that same sentence, so there was nothing to act on. This is the same shape as the fault that kept product deletion broken on every store: the catalogue was answering "400 STORE_ID is not present" and the reason never left the API layer. Both variant calls now carry the catalogue's own message out, and the log line quotes it â€” or says plainly that none was given.

= 4.0.584 =
* Fix (upgrading could point a product at the wrong catalogue entry): the one-time rename of the old _surfboard_ meta keys to _saga_ renamed every row unconditionally. On a shop that carries both sets â€” one that ran an intermediate build, which is exactly the shops this update reaches â€” that leaves two rows with the same key on the same product, and WordPress then returns whichever the database hands back first. For the catalogue link that means stock changes can land on a different product, and it would look like a synchronisation fault rather than an upgrade one. The stale row is now removed before the rename, so the current link always wins, and a shop that only has the old keys still migrates normally.

= 4.0.583 =
* Fix (the protection added in 4.0.582 never ran on a correctly configured store): a webhook carrying a valid signature was trusted outright and failed or cancelled the order with no further check, while only unsigned webhooks were cross-checked against the API. Those answer different questions â€” a signature proves the message really came from Saga, and says nothing about whether Saga has finished deciding. Since every production merchant configures a signing secret, the trusted branch is the live one, so the check was being skipped exactly where it was needed. Both paths now cross-check. Forged webhooks are still refused outright, by the signature, before any of this is reached.
* Fix (a background scan asked the same unanswerable question for three days): the pending-payment scan runs every five minutes over every unpaid Saga order from the last 72 hours. When the API replied "Fetching order failed" it logged the error and asked again on the next pass â€” 918 identical failed requests in a single session on the test store, all for one order. It now gives up on reading that order's status after six consecutive failures, leaves one note on the order explaining why, and clears the count the moment a readable answer comes back so a brief outage cannot strand a healthy order. The payment itself is untouched: the gateway's own escalating check still owns it.

= 4.0.582 =
* Fix (a customer was told the payment failed while it was still being decided): when Saga sends a payment-failed webhook the plugin cross-checks it against the API before failing the order. It waited when the API said the payment was still processing, but treated every other answer as agreement â€” including PENDING, which is simply the API saying it has not decided yet. Measured on the test store, the same PENDING comes back on payments that go on to SUCCEED. A failure is now only recorded when the API names a terminal outcome (failed, cancelled, declined, expired, voided); anything else schedules a follow-up check instead. Nothing marks an order paid that was not paid â€” this only stops orders being failed before there is an answer.
* Fix (express checkout destroyed the evidence before judging): when the payment widget reported a failure, express checkout showed the customer the error â€” and showing it stops the background poll, which is the one thing that could have contradicted it. The poll asks the backend, which is authoritative, and already shows the same error when it confirms the failure itself. The widget's report now waits a few seconds for that answer instead of being the last word.
* Fix (the block checkout skipped the same check deliberately): the block-based checkout cleared the payment and told the customer it had failed without asking Saga at all, on the stated reasoning that a reported failure means "the payment is 100%% dead". The measurement above shows that is not so â€” the widget and the webhook can report a failure while Saga's own order record has not decided. Two cases are now told apart: a card-entry or SDK error never reached Saga and still resets instantly, while a failure Saga actually reported is verified first, in the guard's short mode (three checks over five seconds). The screen clears immediately either way.
* Fix (card payments got none of the protection wallet payments had): if the payment widget reported a failure, the plugin asked Saga whether the payment had in fact completed before telling the customer â€” but only for Apple Pay and Google Pay. Card went straight to the error message with no check, although the endpoint it would have called has always handled card. Card now gets the same verification. A customer whose payment really did fail sees the same message a moment later; a customer whose payment went through is no longer told it did not.

= 4.0.581 =
* Fix (a product withdrawn from the shop was never withdrawn from the till): asking the catalogue to remove a product sent the request without the store id, and the catalogue answered "400 STORE_ID is not present". Every other call carries it â€” the reads in the query string, the writes in the body â€” and this one carried it nowhere, so no product has ever been removed from a catalogue by this plugin. It is now sent in both shapes, query string first. Note for testers: this is a diagnosis with a correction, verified against the log that produced the 400, but not yet against a successful delete.
* Fix ("FAILED" was the entire explanation): withdrawing a product from the shop asks the catalogue to remove it, and on the test store that request was refused. The log said only "Saga delete <id>: FAILED" â€” the same line whether the cause is a permission, a product referenced by an order, or the wrong catalogue. The catalogue's own reason is now carried through, the line says plainly that the product is still in the catalogue and can still be sold at the till, and it is recorded as an error rather than as information.
* Fix (withdrawing a product from the shop said nothing about the till): trashing a product wrote no line to the sync log on any path. A merchant who withdrew a product and then found it still on sale at the till could not tell whether the shop had tried to withdraw it and the catalogue refused, or whether the shop never tried â€” and those need different fixes. The attempt, and each reason for not making one, are now stated. A variant sale that finds no catalogue credentials is reported too; it sends nothing and queues nothing, so it is a permanent loss rather than a late one.
* Fix (a dropped sale looked exactly like a reported one): when two stock changes for the same product arrived together, one of them was discarded to avoid sending the adjustment twice â€” but the discarded one was never retried, and nothing was written to the log. The same silence covered a sale of a variation that had lost its link to the catalogue. In a log, "pushed successfully" and "quietly dropped" were the same thing: no line at all. Every reason a stock movement is not sent is now stated, with whether the drift sweep will recover it. Nothing about when stock is pushed has changed; this is what the log says when it is not.

= 4.0.580 =
* Fix (a lost background job could strand a sale forever): the sweep that finds products needing a re-push compares modification times, and WooCommerce does not reliably update one when only the stock changes. A sale whose background push was lost was therefore invisible to every other part of the sync. A drift sweep now compares the stock against the last value actually pushed, which needs no timestamp to be right.
* Fix (a sale could vanish from BOTH systems, and the unit be sold twice): reducing stock for an order is reported to the catalogue by a background job a moment later. If a catalogue read landed in that gap it wrote the pre-sale quantity back over WooCommerce, the difference became zero, and the sale was never sent at all â€” the shop then sold the same unit again. The guard that prevents this was only armed once a push had already succeeded, so it did not exist during the one window it was needed. A stock reduction now claims local authority the instant it happens, and releases it only when the catalogue confirms the write.
* Fix (variant stock never reached the POS): a variant sold in the web shop did not reduce the stock the till sees. The catalogue refused every adjustment with "PC_0031: Product inventory : no product found" on the documented route, using ids the API itself had issued. The cause was that a variant has no inventory record until one is seeded, and the plugin was always sending a difference â€” a difference against nothing. A variant whose record is missing now seeds it once, and subsequent sales adjust normally. Measured on a live variant: 47 -> 46 -> 45.
* Fix (part of the catalogue stopped syncing after a store change, silently): the repair pass that re-links products to a new catalogue examined a fixed-size window of products rather than a fixed amount of work. Once the first batch was repaired it carried ids the catalogue did hold, so every later run spent its whole batch skipping healthy products and never reached the rest. 118 products and all 157 variants stopped syncing while the sync reported "0 created, 0 errors".
* Fix (every variant missing from the POS after a store change): unpinning a product left its variations pointing at variant ids in the previous catalogue, so each update tried to modify something that no longer existed. The catalogue held 0 variants against 157 in WooCommerce while the parent products looked perfectly synced. Variations are now repaired with their parent, and variations orphaned by an earlier repair are found on their own.
* Fix (a duplicate upstream became a duplicate in the shop): when the catalogue held the same product twice, the pull treated the second copy as a product WooCommerce was missing and created it. The shop got two identical products with no message. A catalogue entry whose name already belongs to a linked product is now reported as an upstream duplicate and not created.
* Fix (barcode and images never updated on a variant): the variant update sent `barCode` and `variantImages`; the schema takes `barcode` and `productImages`, so both fields were dropped on every update.
* Fix (a detail fetch could empty a variant list): the per-product detail response carries no variants array, so merging it was safe only while that stayed true. An empty one would have replaced a real list with nothing and the product would have looked like it had lost every size and colour it sells. Enrichment can now only add.
* Improvement (a variant push that does nothing now says why): a paid variant sale that produced no inventory call read identically to "nothing needed" and to "it never fired". Both cases are now stated in the log.
* Fix (a variant could only ever repair itself once): the guard that stops a re-seed looping was never cleared, so a variant working today could not recover from a later problem. A confirmed adjustment now restores it.
* Fix (a merchant's unit of measure was overwritten): the unit, price type and HSN code a merchant sets are now theirs. The pull previously wrote the catalogue's values straight over them, so a product set to sell by the kilo read "per piece" again within minutes.
* Fix (a product with a long description could never sync): any free-form property value over 2,500 characters had the WHOLE product rejected, not the field truncated. Long values are now trimmed, and trimming can no longer cut a multibyte character in half.
* Fix (a stock write that was accepted but never stored looked successful): the inventory endpoint can answer 200 SUCCESS without storing the value. A push is now treated as unverified until a later read confirms it, and a write the catalogue accepted and discarded is reported instead of disappearing. A rate-limit response no longer counts as one of those checks â€” it means "not attempted", not "did not arrive".
* Fix (a partially downloaded gallery was recorded as complete): images that failed to download were never retried, and the product kept whichever photos happened to succeed.
* Fix (a trashed product could be orphaned in the POS): a 404 was treated as "already deleted", which is right for a removed product and wrong for a route that does not exist. The two are now distinguished, so a missing route is reported as a failure rather than quietly clearing the link.
* Fix (campaign price did not cross between systems): a campaign price set at the till was sold at full price on the web, and a WooCommerce sale never reached the till, because each system wrote it to its own field. Both now read and write the same one. Adopting the till's price is off by default until the till charges it.
* Fix (unit types the catalogue rejects): the plugin offered five price types where the catalogue documents three, which had the whole product refused with "Product unit type is not valid". The create and fetch schemas also spell the enum differently; a refusal on those grounds is now retried once with the other spelling.
* Improvement (the merchant can set what the POS needs): unit of measure, price type and HSN code are now editable on the product and variation screens. Products created in WooCommerce previously always went to the POS as "per piece", with no field anywhere to correct it.
* Improvement: `discount` from the catalogue is now read as a sale price. A product discounted in the POS was previously sold at full price on the web.
* Improvement (sync errors say when): the admin panel listed products as broken long after they had synced, with no date. Failures older than the product's last successful push are no longer shown, and the rest carry a timestamp.
* Improvement (fewer catalogue reads): a single push run read the entire catalogue six times. It now reads it once per window, which matters most for merchants with large catalogues and for the rate limit shared across integrations.


= 4.0.474 =
* Fix (products missing from the POS â€” pagination): Surfboard pages its product listings via the x-page-number request header, and the plugin never sent it, so every part of the sync only ever saw PAGE ONE of the catalogue. The pull mirrored a subset, the duplicate-detection index was built from a subset, and the orphan cleanup compared WooCommerce against a partial catalogue. All product fetches now walk every page.
* Fix (variations created in WooCommerce never reached the POS): the change-detection that drives the sync cron only looked at the parent product's modification time â€” which editing or adding a variation does not reliably update. A merchant could add variations and the cron would never consider the product changed. Variation changes (and variations that have never been pushed) now mark the parent as needing sync.
* Fix (variants rejected by the API): a variation using "Any â€¦" for an attribute sent an empty value in a field the variant schema requires, so the whole variant was rejected and silently never appeared in the POS. Unpinned attributes are now omitted, and a variation with no concrete attributes gets a synthesized identifying value so it is always representable.
* Fix (wrong currency for non-Norwegian stores): the catalogue currency code was hardcoded to NOK. Products and variants now carry the store's configured WooCommerce currency.
* Fix (invisible sync failures): a product the Saga API rejected failed silently â€” the merchant's only signal was a count mismatch in the POS. The API's rejection message is now stored on the product and shown, together with a sync-health readout (WooCommerce counts vs the Saga catalogue's own product/variant statistics), on the Saga Product Sync admin page. "17 in the POS, 24 in WooCommerce" is now a screen that names the 7 and says why.
* Improvement: products and variants created in the catalogue now carry the WooCommerce product ID and SKU in the catalogue's metadata field, giving support and future reconciliation a mapping that survives SKU edits and renames.
* Improvement: cost price now reads WooCommerce's native Cost of Goods field (WC 9.5+) first, with the legacy meta key as fallback.

= 4.0.472 =
* Fix (paid order knocked back to unpaid): the draft-order promotion added in 4.0.471 ran on the order copy the caller had loaded, before the completion lock. Two completion routes racing each other â€” webhook and browser verification, which run concurrently by design â€” could interleave so that the loser wrote its stale "pending" status over the winner's finished "processing", leaving a paid, emailed, stock-reduced order permanently marked unpaid and invisible to every rescue path. All status decisions now happen under the lock, on a fresh read.
* Fix (paying customer told "declined"): the return from a payment app carries a status hint in the URL, and that hint won over the payment provider's own answer. A customer who approved in the Vipps app but backed out of the waiting page travelled the failure URL while the money settled: the order was marked failed, its verification jobs cancelled, and the customer was shown "payment declined" with their cart intact â€” inviting a second payment. The provider's PAYMENT_COMPLETED now beats the URL hint, and such returns route to the order-received page.
* Fix (paid express order auto-cancelled): the express checkout scheduled its payment-verification safety net with the wrong argument list, so the verification job asked the API about order "1" instead of the real order. An express payment whose webhook was lost was never confirmed and got auto-cancelled two hours later despite being paid. A new automated check now verifies every scheduled job's argument list against its handler, so this class of bug cannot ship again.
* Fix (subscription silently dead after three declines): after the third failed renewal the subscription was marked failed and nothing could bring it back â€” the customer was never told, and even paying the failed renewal order did not revive it. The customer now receives an email with a payment link at the moment renewals give up, and completing that payment reactivates the subscription and resumes its schedule. "Cancel at end of period" also no longer revokes the stored card, so reactivating a pending cancellation works â€” matching what the gateway has always declared it supports.
* Fix (renewal charge timeout retried as a decline): when confirmation of a renewal charge timed out, it was treated as a decline and retried â€” a second charge on top of one that may have gone through seconds later. A timeout is now held as unresolved, checked against the provider before any new charge, and converted to the recorded renewal if it turns out to have succeeded.
* Fix (stale admin screen moves money): the order screen's Capture and Void buttons acted on whatever the page believed when it was rendered. A capture and a void could race from two tabs, and voiding an authorisation that had since been captured flipped a genuinely paid order to cancelled with a false "funds released" note. Both actions now take a shared per-order lock and re-check the payment's actual state server-side before calling the provider.
* Fix (telemetry sent for every shopper): the block checkout uploaded diagnostics to the server for every customer session â€” the debug switch added in 4.0.469 was never wired up there, and the classic checkout's "urgent" channel bypassed the same switch. With Debug off, nothing is sent by anyone now. Production console noise from the wallet-readiness gate is gone, and that gate also gained a 20-second ceiling so a blocked wallet script can no longer leave the Place Order button permanently dead.
* Fix (express "buy now" refused where free shipping applies): WooCommerce evaluates free-shipping minimums against the session cart, which a product-page "buy now" purchase is not in â€” so stores whose shipping zone offers only threshold free shipping got every express purchase refused. The threshold is now checked against the actual purchase amount.
* Fix (declined payment shown as "waiting"): a customer returning from a declined redirect payment was held for five seconds and then shown "Waiting for order confirmationâ€¦" â€” a reassuring message meaning declined. Declined orders now skip the wait and say plainly that the payment failed.
* Fix (merchant's failed-order alert suppressed): scaffolding orders suppress their emails so abandoned checkouts do not spam anyone â€” but the suppression tag was only removed on successful payment, so a real customer whose payment declined kept the tag and the merchant's "failed order" alert never fired. The tag is now cleared the moment the customer presses Place Order.
* Fix (order-note spam): every unpaid order accrued a "not yet confirmed" note every ten minutes â€” a dozen identical notes per abandoned order, burying the notes that matter. The first check leaves one note; the rest go to the log.
* Fix (housekeeping): a daily cron event with no handler is no longer scheduled; product-sync cron jobs are now removed on deactivation even when WooCommerce is inactive; a fetch-guard no longer breaks other plugins' requests when a URL contains a malformed percent-escape; the admin "complete the configuration" notice accepts a redirect-only setup as complete.
* New automated coverage: every scheduled job's argument list is verified against its handler (25 calls); every customer-facing text key is verified to exist in all three languages (79 keys); every AJAX endpoint is verified to carry a nonce, capability or signature check (43 endpoints).

= 4.0.471 =
* Fix (payment silently lost): a customer who authorised in the payment window before WooCommerce had finished setting up their order had the completed-payment notification discarded â€” money taken, order never marked paid, no confirmation email, no stock movement. This was a consequence of the 4.0.470 change and is the most serious defect found in this round. Every place in the plugin that reasons about order status has now been enumerated mechanically rather than by reading: 33 such lists exist, 20 handle this case, and the remaining 13 are individually documented as deliberately excluding it. An automated check enforces that list, so a new one cannot be added silently.
* Fix (customer's other cards deleted): cancelling a subscription that had no stored card on record deleted and revoked EVERY saved card belonging to that customer â€” including cards funding other active subscriptions and the card they use for ordinary purchases. The code's own comment said it removed only the cancelled subscription's card; a missing card reference silently turned that into "remove all of them". With no card to target it now removes nothing.
* Fix (customer charged repeatedly): a subscription renewal recorded the charge only after building the renewal order, which involves enough work to be interrupted by a server timeout. If that happened, the card had been charged but the subscription still looked due, so the renewal job charged it again 30 minutes later â€” and again, indefinitely. The charge is now recorded immediately, before anything that can fail.
* Fix (customer charged, order never marked paid): in "Authorize only" capture mode, payments that are already settled when they arrive â€” subscription renewals and admin charges against a saved card â€” were parked on hold waiting for a capture that can never happen, because those payments capture in a single step. Renewals on manual-capture stores were charged and then left unpaid, which also stopped WooCommerce Subscriptions extending the subscription. Zero-total orders (100% coupon or fully gift-carded) were held the same way despite having no payment at all. Both now complete normally.
* Fix (products deleted from your Saga/POS catalogue): when a catalogue sync trashed WooCommerce products for being absent from a pull, it promised the merchant could restore them. Minutes later a second sweep found those same trashed-but-still-linked products, concluded the merchant had deleted them, and sent deletions upstream â€” permanently removing them from the real Saga/POS catalogue and destroying the copy the restore promise depended on. Products retired by the sync are now excluded from that sweep; only deletions a human actually made are sent upstream.
* Fix (subscriptions killed by one declined payment): 4.0.469 stopped a failed payment from cancelling a subscription and revoking the customer's card, because a decline is a payment outcome rather than a decision to cancel. A background job running every 30 minutes still applied the full irreversible cancellation to failed orders, so that fix was never actually in effect. It now follows the same rule, and stores that do want a failed payment to end the subscription can opt in.
* Fix (subscriptions that can never renew): a subscription bought on a store with "saved cards" switched off never requested a stored card, so no renewal could ever be charged â€” the customer was billed once and the subscription quietly became a one-off, surfacing only at the first missed renewal. A subscription now always stores the card it needs, independently of the saved-cards preference, because that token is what the customer agreed to when subscribing.
* Fix (free shipping given away): the wallet "buy now" buttons on product pages created the order with no shipping line at all, so the merchant paid the freight on every such order with nothing indicating it. Shipping is now calculated for the customer's address exactly as the normal checkout does, and if no rate can be determined the express flow steps aside and sends the shopper to the normal checkout rather than completing at the wrong price.
* Fix (billing address replaced by shipping address): on the block checkout, the fallback that reads address fields from the page took whichever field appeared first in the page, not the one it was told to prefer â€” and the block checkout renders the shipping address above the billing one. For every customer whose invoice address differs from their delivery address, the delivery address was written over the billing address on both the payment and the WooCommerce order.
* Fix (order total higher than the amount charged): the admin "charge saved card" action recorded the charge as a taxable line, so WooCommerce added store tax on top of a figure that was already the gross amount taken. On a 25% VAT store a 100,- charge was recorded as a 125,- order â€” the books disagreed with the settlement, and a later full refund would have tried to return more than was ever taken.
* Fix (product data erased by sync): the catalogue pull treated a field the Saga payload simply did not mention as an instruction to delete it, so merchant-entered GTIN/EAN, barcode, cost price and HSN code were wiped on the next 15-minute sync â€” and wiped again each time they were re-entered. An absent field is now treated as "no opinion"; genuine removals made in Saga are still mirrored.
* Fix (order total changed after payment verification): when recovering a payment, the plugin checks the amount Saga charged against the WooCommerce order total and refuses to complete on a mismatch â€” and then rebuilt a missing shipping line from the live cart, which on a recovery run can belong to a completely different basket the shopper has since built. That moved the total after the check meant to protect it. The verified amount is now kept and the discrepancy noted on the order.
* Fix (Place Order button unresponsive): after an aborted payment attempt â€” an incomplete card field, a failed amount update, a wallet click the browser did not trust â€” the button silently swallowed clicks for up to 15 seconds, right after telling the customer to try again. Twelve separate places reset the payment state without releasing this guard. Rather than patch each, the guard now follows the same flag all of them already clear, so any abort frees the button instantly; an automated check keeps it that way.
* Fix (duplicate orders â€” the guard that never fired): the protection added in 4.0.469 to stop a background checkout request creating a second order while Place Order was still running could not work, because of how WooCommerce stores sessions. Place Order records "a submit is in flight" and writes it to the database immediately; the background request then checks for it â€” but WooCommerce loads the whole session into memory once at the start of a request, so the background request was reading a copy taken *before* that note existed. The note was always invisible in exactly the situation it was written for. Both this guard and the equivalent one for redirect payments now read the current stored value instead of the stale in-memory copy, and the redirect one also writes immediately instead of waiting until its request finishes.
* Fix (double charge): the "Charge saved card" and "Test renewal" buttons in the admin had no protection of any kind against being pressed twice. Each press is a real charge against the customer's stored card, so a double-click â€” or a browser retrying a slow request â€” charged the customer twice, with no automatic reversal. Both now take a genuine lock so two charges cannot run at once, and refuse an identical repeat within a short window, telling the merchant to wait rather than silently charging again.
* Fix (empty basket after a cancelled wallet payment): tapping Vipps, Apple Pay or Google Pay from the cart emptied the basket immediately, before the payment was confirmed. A customer who cancelled in the wallet app, or whose payment was declined, came back to an empty cart and no order â€” everything gone, nothing bought, nothing to retry. The basket is now only emptied once the payment is actually confirmed.
* Fix (false confirmation): "Add payment method" on the My Account page told the customer "Payment method successfully added" without saving anything at all â€” the card request was assembled and then discarded without ever being sent. The gateway no longer advertises that capability, so the button is gone, and cards are saved the way they always actually worked: by ticking "save card" during a real payment.
* Fix (fatal error): removed a registration pointing at a handler that does not exist, which produced a white screen instead of a payment form on the pay-for-order page. A new automated check now resolves every one of the plugin's 206 internal callbacks on each build, so this class of error cannot ship again.
* Fix (slow customer pages): resolving an order's payment method name could make a call to the Saga API from inside the routine that renders it, including on the thank-you page and My Account. A slow or unreachable API therefore delayed pages the customer was waiting on. That lookup now happens only in the admin, where a merchant browsing orders is the right place to absorb it; customer-facing pages use the already-stored value and make no network call.
* Fix (stuck orders never rescued): the daily scan that recovers orders stuck without a confirmed payment processed at most 25 per day and then stopped. On a busy store â€” the one that needs it â€” anything past the 25th was never looked at again, including orders the customer had genuinely paid for. The per-run limit stays (it is what keeps the job fast), but a full batch now queues another pass a few minutes later so the backlog actually drains.

= 4.0.470 =
* Fix (duplicate orders / double-counted ad conversions â€” root cause): a checkout on the WooCommerce Checkout **block** produced TWO WooCommerce orders for one purchase. This is the underlying reason Meta and Google Ads counted a single sale two or three times, and it is why previous releases needed so much sibling-order cleanup to compensate. The plugin creates an order up front so the payment SDK has something to attach to, and published it to the session key the **classic** checkout reads (`order_awaiting_payment`). The block checkout does not read that key â€” it resumes the order recorded in `store_api_draft_order`, which the plugin never wrote anywhere. The block therefore never saw the pre-warm order and created its own at Place Order. The pre-warm order is now registered under the reuse contract of whichever checkout is actually running, so WooCommerce promotes that exact order at Place Order instead of minting a second one: one order, one order-received page, one conversion. Because it is the same order, the payment reference already sent to Saga stays correct and needs no rewrite.
* Fix: on the block checkout the pre-warm order is now parked in WooCommerce's own `checkout-draft` status â€” the status core uses for exactly this purpose. Unpaid scaffolding orders therefore no longer clutter the merchant's order list, are excluded from Analytics, and are tidied up by WooCommerce itself. They are promoted to a normal pending order the moment the shopper places the order or a payment arrives.
* Fix (express checkout): wallet buttons on product and cart pages created their order and published nothing to the session at all, so a shopper who tapped Vipps or Apple Pay and then opened the normal checkout got a second WooCommerce order for the same purchase â€” and every reuse, return and rescue path in the plugin was blind to the express order. Express orders now join the same session handoff as every other order.
* Fix: the "prepare order" path created an order without recording it anywhere either; it now registers the order like every other creation path.
* Fix (order safety): three lookups that resolve an order from a Saga payment used WooCommerce's default order query, which silently excludes draft orders. With the pre-warm order now able to be a draft, this closes three ways a payment could have failed to find its order â€” including the duplicate guard that stamps a Saga payment onto an order, which would otherwise have concluded the payment was unused and attached it to a second order as well.
* Fix (order safety): WooCommerce core permanently deletes draft orders older than 24 hours, and its payment-completion routine does not accept a draft order at all. Both are now handled: a payment arriving for an order still in draft promotes it first, and any draft that outlives a plausible checkout session is returned to pending well before core's purge, so an order carrying a real payment can never be sitting in the status core is about to delete.
* Fix (fatal error): the plugin registered a handler for WooCommerce's receipt page that did not exist. Whenever WooCommerce rendered that page for this gateway, PHP raised a fatal error and the customer got a white screen instead of a payment form â€” reachable by paying an existing order from My Account. The dead registration has been removed; the pay-for-order page renders through the standard gateway surface as it already did everywhere the fatal did not fire.
* Fix (compatibility with caching and optimisation plugins): before sending a response, the plugin discarded every open PHP output buffer â€” including buffers it had never opened. Page-caching and optimisation plugins, compression handlers and themes all keep buffers open with real page content in them, and this threw that content away and collapsed the buffer stack, leaving the owning plugin to close a buffer that no longer existed. It ran on fourteen paths including Place Order, so on a store with almost any caching plugin the checkout response could come back blank or truncated. Only genuinely empty buffers (and the stray byte-order marks this was written to strip) are discarded now; anything holding real content is left untouched.
* Fix (refunds / subscriptions): a full refund set the order status to "refunded" from inside the gateway, while WooCommerce was still creating the refund record. That fired the refunded transition early, which in turn triggered this plugin's subscription cascade â€” cancelling the subscription and revoking the customer's stored card at Saga partway through the refund instead of after WooCommerce had completed it. It also bypassed the standard filter merchants use to choose a different status after a full refund. The status is now left to WooCommerce, which applies it immediately after the refund is recorded, exactly as it does for every other gateway.

= 4.0.469 =
* Fix (duplicate orders / double-counted ad conversions): a checkout session could produce TWO WooCommerce orders, which made Meta and Google Ads count one purchase as two or three and distorted the merchant's whole conversion picture. Three independent causes are closed. (1) The checkout page pre-creates an order so the payment SDK has something to attach to; that pre-warm request and the Place Order submit could run at the same time, and only the pre-warm took the session lock. A pre-warm that started while the submit was waiting for the payment provider saw no sign of the submit and created a second order. The submit now claims the session immediately â€” before any provider call â€” and holds the same lock, so the pre-warm reuses the real order instead of minting one. (2) The plugin cleared WooCommerce's own double-submit guard on every Place Order click, so a double-click (or Enter plus click) fired two checkout requests and created two orders. The guard is now left in place while a checkout request is genuinely open, and is still cleared when it is actually stale, so the mobile "Bekreft ordre does nothing" behaviour does not return. (3) When a payment completed, every other tab that had started a checkout was sent to the paying tab's order-received page, loading the confirmation â€” and every purchase pixel on it â€” a second time. A tab now only follows that signal for its own order.
* Fix: the payment provider delivers each webhook event twice, in two different envelope formats whose top-level ids differ. Both copies passed the duplicate check, so one payment was processed twice â€” duplicate status transitions, duplicate order notes, and a second payment-complete event for analytics and server-side conversion tracking to record. Events are now identified by the underlying event rather than the envelope, so the second copy is recognised and acknowledged without reprocessing.
* Fix: the remaining cause of two shipping lines on one order (freight listed twice, with the extra amount cancelled out by a negative "Cart total alignment" fee). When an order was rebuilt after its cart had expired, the fallback builder appended a shipping line priced at 0.00 next to the real line. The duplicate check grouped lines by method AND amount, so 0.00 and 76.00 were treated as unrelated and the phantom line survived every cleanup pass. Lines are now grouped by shipping method, and a duplicate is removed when it is empty or repeats an amount already present. Genuinely different prices for the same method are still kept, and manually added shipping lines are never touched.
* Fix (data loss): a single empty product-catalogue response could trash the merchant's ENTIRE WooCommerce catalogue, including products that had never been synced to Saga. The check that was supposed to establish "this store mirrors Saga" was satisfied by any store that had at least one WooCommerce product, so enabling product sync before the Saga catalogue was populated was enough to trigger it. An empty catalogue is now only treated as authoritative when the store demonstrably mirrors Saga AND the response has been empty across three consecutive pulls, and products that were never linked to Saga are permanently out of scope for catalogue mirroring.
* Fix (double charges / double refunds): failed API requests were retried for every HTTP method on the grounds that "no response means nothing was processed". That is not true of a timeout, where the request may already have been accepted while the store stopped waiting for the answer. A timed-out refund, subscription charge or order creation could therefore be sent two or three times. Retries of non-idempotent calls are now limited to failures that provably happened before the request was sent (DNS and connection errors); a timeout is reported as indeterminate instead of being repeated.
* Fix (subscriptions): one declined renewal permanently cancelled the whole subscription and revoked the customer's card token at Saga, defeating WooCommerce Subscriptions' retry system â€” and an initial payment that declined before a successful retry killed the subscription at checkout. A failed order is a payment outcome, not a merchant's decision to cancel, so it no longer triggers the cancellation cascade. Cancelling, refunding or trashing an order still does.
* Security: the saved-card lookup used at checkout no longer returns the payment provider's card token identifier to the browser. The endpoint is reachable without logging in (a guest types their email), so this exposed the stored-card reference to anyone who could name an email address. No functionality changes â€” the checkout already identifies saved cards by their WooCommerce id, and the provider token is resolved server-side behind the existing e-mail verification step.
* Fix (money not returned): a refund could be recorded in WooCommerce when no money actually moved. The provider's "exceeds the available limit" response was treated as "already refunded" for every payment method, but for a card it means the opposite â€” the refund was rejected because the amount had already been refunded elsewhere. That response now only counts as an auto-refund for the methods that genuinely refund on their own, and is otherwise reported as a failure. Separately, a return order still sitting at "pending"/"initiated" was accepted as proof of refund; that is exactly the state a refund whose initiation just FAILED reports, so those statuses now only count for the asynchronous methods that are tracked to settlement by the existing background poll.
* Fix (money not collected): in manual-capture mode, moving an authorised order to Processing or Completed triggers the capture. If that call failed the plugin only added an order note and left the order looking paid, so a merchant bulk-updating orders during a provider outage could ship goods for money that was never captured. A failed capture now returns the order to on-hold with an explicit warning note. The Capture/Void buttons also no longer appear on orders that were merely parked on-hold for review rather than actually authorised, where pressing Void could cancel a real, already-captured order.
* Fix: orders deliberately placed on hold for manual review (amount mismatch, missing payment reference) were auto-cancelled by the two-hour timeout. Those orders are on hold precisely because the customer paid something that did not line up, so cancelling one hid a real payment behind a cancelled order. On-hold orders are now left for the merchant.
* Fix (privacy): when a shopper returned from the payment page and the order could not be identified, the last-ditch lookup ignored the customer filter for logged-out shoppers and picked the store's most recent pending order from ANY customer â€” sending the visitor to a stranger's order confirmation, or marking that stranger's order failed. The fallback is now restricted to logged-in customers.
* Fix: the browser could mark a paid order as failed. The payment SDK's error callback can fire while the payment is being confirmed (3DS timeouts, a wallet sheet closed after the charge went through), which sent the customer a failure e-mail for an order they had actually paid. A payment already confirmed by webhook or API is no longer overridden by a browser-reported failure.
* Fix: webhooks arriving while another webhook for the same order was still being processed were answered with "handled", so the provider never redelivered them and the event was lost. They now return a retry response.
* Fix: two simultaneous requests to the payment-verification URL could each create and complete a WooCommerce order for the same payment, after which the webhook rejected the ambiguous pair and required manual intervention. Order creation on that path is now serialised, and a request that arrives second reuses the order the first one created.
* Fix: two refunds submitted at the same moment (double-click, or two administrators) could both reach the provider, because the duplicate check was not atomic. It now uses the same claim-and-verify locking as payment completion, and no longer blocks the server for two seconds on every contended refund.
* Fix: the daily rescue scan read the wrong field from the order status response â€” the envelope state ("open"/"closed") instead of the payment state â€” so a paid-but-stuck order could look unpaid and be quietly cancelled, then reactivated by the next scan. That cancel/reactivate churn produced stray customer e-mails.
* Fix: a signed webhook that carried no amount was still marked as fully verified, which made the completion step skip its own amount check. A valid signature proves the message came from the provider, not that it is for the right amount, so completion now falls back to verifying against the provider's API.
* Fix (subscriptions): a provider outage of about 90 minutes permanently killed every subscription that fell due during it â€” three retries were attempted 30 minutes apart and the subscription was then marked failed forever. Retries are now spread over roughly three days. A failed subscription is also shown as failed in admin instead of appearing green and active, and the pending-payment scan now verifies the paid amount against the order total like every other path. Monthly renewal dates no longer drift forward on short months (31 January renewed on 3 March and crept later every cycle).
* Fix: "charge upon release" pre-orders could never succeed â€” the card reference was never saved, so every such order hard-failed at release. The gateway no longer advertises support for them rather than letting merchants sell an order guaranteed to fail. Pre-orders charged up front are unaffected.
* Performance: the product catalogue sync no longer runs inside customer requests. Adding to cart or loading checkout could block on a full catalogue fetch from the provider (up to 30 seconds), and placing an order triggered several catalogue and inventory calls per line item because WooCommerce saves products during stock reduction. Cart checks now use a short cache and fail open, and catalogue pushes triggered outside wp-admin are handed to a background event. The catalogue pull also honours its intended 15-minute schedule instead of running every 5 minutes, skips per-product detail fetches when the list response already contains the data, and only writes to the database when something actually changed.
* Fix: a sale price scheduled for a future date (or an expired one still stored on the product) created an immediately active campaign at the point of sale, so the till charged a discounted price while the webshop charged full price. WooCommerce's sale schedule is now respected.
* Fix: deactivating the plugin discarded queued stock corrections and the price review queue. Those are now preserved; they are only cleared on uninstall. Uninstall itself was a permanent no-op â€” its safety check aborted whenever the sibling plugin file existed on disk, which is always â€” so no options, scheduled tasks or tokens were ever cleaned up. The scheduled-task and option lists it uses were also wrong and have been corrected. Order and refund payment references are still deliberately preserved for bookkeeping retention.
* Fix: the diagnostics inventory probe defaulted to a hardcoded product id and a mode that performs four real stock reductions against live inventory. It now requires an explicit product and defaults to a read-only check.
* Compatibility: removed the client-side machinery that most often interfered with themes and other plugins. On Blocks checkout the plugin no longer replaces the browser's fetch, XMLHttpRequest and history functions for ordinary shoppers (that instrumentation is now opt-in, matching classic checkout). On classic checkout it no longer defines an Apple Pay global on browsers that have none, which broke other payment plugins' feature detection. The bundled QR library no longer claims a generic global name that collides with other plugins, and no longer registers itself with module loaders in a way that breaks unrelated scripts on some themes. The express-checkout buttons are styled by one scoped stylesheet instead of a script that re-applied around thirty forced inline styles per button on a timer and on every cart update, so themes can style them again. Checkout assets are no longer loaded on pages that merely contain the word "checkout" â€” most importantly the order-received page â€” and the Store API helper is only loaded where WooCommerce blocks actually render.
* Performance: shopper-side diagnostics telemetry is now gated behind the gateway's debug setting. It previously posted event batches to the site's AJAX endpoint throughout every shopper's checkout, which is uncached and competes with other plugins.
* Fix: the payment step no longer launches through four parallel paths. A single latch is taken the moment a payment starts, so the backup handlers can no longer run the setup a second time (the cause of order references changing mid-checkout), and the confirmation page is navigated to exactly once even if a late status response arrives. The payment method is also no longer offered at Blocks checkout when the gateway is not fully configured.
* Admin: the orders list no longer makes a blocking API call per row while rendering, and the "repair payment titles" button now works in capped batches instead of timing out.
* Fix (duplicate orders, express checkout): the express buttons (Apple Pay, Google Pay, Vipps, Swish) created a WooCommerce order and a payment order on every call, with none of the protection the normal checkout has. On mobile a second tap before the wallet sheet opens is easy to make, and it produced a second order â€” the same duplicate that double-counts a purchase in ad conversion tracking. Express initialisation is now serialised per shopper, so the second tap joins the first attempt instead of starting another one.
* Fix (wallets on the "pay for order" page): Apple Pay and Google Pay were configured with the hardcoded fallbacks "Store" / NO / NOK instead of the shop's real name, country and currency. The checkout parameters are assembled in two places and the last one printed replaces the other wholesale rather than merging; the order-pay version was missing the store identity. On a store not using NOK this presented the wallet sheet with the wrong currency.
* Fix (the main remaining cause of double-counted conversions): the block-based checkout â€” the default in current WooCommerce â€” chased payment confirmation with several timers that were never cancelled once the confirmation page had been reached. A timer firing afterwards navigated a second time, often to a different URL, which aborted the confirmation page mid-render and loaded it again. Each arrival fires WooCommerce's thank-you hook, so every purchase pixel and server-side conversion counted 2-3 times. The block checkout and the express buttons now navigate to the confirmation page exactly once per payment, and the trailing verification timers stand down instead of redirecting.
* Fix: one payment can no longer be recorded as paid on two WooCommerce orders. The background rescue scans judged each order only on its own status and total â€” and a leftover order from the same checkout session has the same total â€” so a payment already collected by the real order could be applied to the leftover one as well, producing duplicate stock reduction, duplicate customer e-mails and a duplicate conversion. Every rescue path now refuses to complete an order whose payment another order has already consumed.
* Fix (correctness of the duplicate protection itself): several of the plugin's locks did not actually exclude anything. The pattern used â€” write a unique value, then read it back to confirm ownership â€” always confirms, because the read returns the value the same process just wrote. Two simultaneous requests therefore both believed they held the lock. The locks protecting payment completion, refunds, express initialisation and payment verification now use a database-level lock that is genuinely atomic across web workers and is released automatically if a request dies.
* Fix: the empty-catalogue safeguard added earlier in this release did not actually gate anything â€” the plugin logged that it was refusing to retire the catalogue and then retired it anyway. The confirmation threshold now controls the deletion, as intended.
* Fix: a Swish or MobilePay refund that the provider had already processed was reported as a failure, which made WooCommerce discard the refund record and show the order as unrefunded â€” inviting a second refund. Those methods are now recognised alongside Klarna and Vipps. Refunds still settling asynchronously also keep their record so the existing background poll can track them to completion, rather than being discarded.
* Fix: a repeated delivery of the same webhook event, which the provider sends routinely about a second after the first, is acknowledged instead of being asked for again. Only a genuinely different event arriving during processing is deferred for retry.
* Fix: the payment method is no longer hidden from checkout for merchants who only use the hosted payment page. The availability check demanded the inline terminal even when a payment-page terminal was configured.
* Fix: cancelling an Apple Pay sheet on block-based checkout left the Place Order button spinning indefinitely. The handlers that detect the cancellation were standing down for a component that had been removed.
* Fix: the debug setting could not switch on diagnostics, because the flag was compared against a boolean that the value never is. Also: the saved-card lookup no longer includes the payment provider's card reference in its markup for callers who have not proven they own the cards.

= 4.0.468 =
* Fix: the duplicate-shipping cleanup introduced in 4.0.467 never matched real orders. The plugin's manual order builders store the full shipping rate id (e.g. "flat_rate:3") in the line's method id with no instance id, while WooCommerce core stores the bare method ("flat_rate") plus a separate instance id â€” so the two lines that make up a real duplicate pair were grouped under different keys and never collapsed. The grouping key is now normalized so both shapes match. In addition, when a duplicate shipping line is removed, any stale negative "Cart total alignment" fee that previously masked it is removed too (a fresh reconciliation re-adds one only if the total is still genuinely off), and the same healing now also runs when a payment is confirmed (webhook/API), before the amount verification gate â€” so orders are cleaned even on paths that skip the rebuild reconciliation. Verified with runtime tests executing the shipped code against the exact real-world duplicate line shapes.

= 4.0.467 =
* Fix: some orders showed two identical shipping lines (e.g. freight listed twice) together with a negative "Cart total alignment" fee that cancelled the extra amount out. The customer was ALWAYS charged the correct total by Saga â€” only the WooCommerce order looked wrong: when a completion or return request rebuilt an order whose live cart had already been cleared, a second, empty shipping line was appended alongside the original, and the total reconciler then masked the difference with a compensating negative fee. A single order-build path never produced this, but two requests acting on the same order could. Duplicate shipping lines are now collapsed at the universal reconciliation chokepoint (keeping the real line that carries the package contents, while genuine multi-package shipments are preserved), and the manual cart fallback no longer re-adds shipping, fees or coupons to an order that already has them. This removes both the phantom second shipping line and the negative alignment fee, so the WooCommerce order reads cleanly and matches what the customer paid.

= 4.0.466 =
* Fix: overlapping classic-checkout updates are now serialized per Saga order across PHP workers, closing the final race between a debounced coupon/shipping update and the payment-time update triggered by Place Order. A later request that overtakes an older one becomes the sole financial authority, and the older request is rejected before it can replace Saga, the canonical WooCommerce product lines or the current recovery snapshot. An update can no longer report success unless the same current cart has been written to Saga, rebuilt through WooCommerce core (including product-level discount metadata), and persisted as the exact recovery snapshot; incomplete synchronization returns a retryable error and blocks payment instead of risking an order with old prices.

= 4.0.465 =
* Fix: checkout recovery snapshots are now refreshed after every successful cart update, but only after both Saga and the canonical WooCommerce order have been synchronized from the same current cart. Previously, the complete snapshot introduced in 4.0.464 was saved only when the Saga order was first created; if a product-level discount, coupon, quantity, shipping method or fee changed later, Saga received the correct new amount while an expired cart/session could restore the older product lines and total into WooCommerce. The reusable snapshot now retains the latest exact product totals, VAT arrays, shipping, fees, coupons and per-product discount-rule metadata for inline and PaymentPage orders. Concurrent older requests cannot overwrite a newer snapshot, and a failed refresh removes the known-stale snapshot instead of using old prices during payment recovery.

= 4.0.464 =
* Fix: product-level dynamic discounts could disappear from the final WooCommerce order even though Saga charged the correct discounted cart amount. A pre-warmed canonical order could be overwritten by a technical duplicate order during classic checkout, and manual cart-to-order copying bypassed WooCommerce's native line-item hooks, so discount plugins such as Woo Discount Rules could not persist which products were discounted. The live cart is now the sole financial authority whenever technical duplicates are merged, all cart-backed order rebuilds finish through WC_Checkout core (including native per-line discount metadata hooks), and taxes/shipping/fees/coupons are copied together. Checkout-time recovery snapshots now also preserve exact product totals, VAT arrays, variations, shipping, fees, coupons, tax lines and per-product discount metadata if payment completes after the cart/session disappears. This keeps each discounted product, VAT and the WooCommerce order total aligned with the amount sent to Saga.

= 4.0.463 =
* Fix: percent discount coupons could be recorded with the wrong amount on the WooCommerce order (prices-include-tax stores) when the order was rebuilt from the cart around payment time. The customer was ALWAYS charged the correct discounted amount by Saga (taken directly from the cart) â€” only the WooCommerce order documentation was wrong: WC_Order::apply_coupon() re-derived the discount from order line items that were missing their tax data, misread the ex-tax subtotal as tax-inclusive and divided the tax factor out twice (example: 99% coupon on a 299.00 kr product incl. 25% MVA â†’ WC order said 62.19 kr while the customer correctly paid 2.99 kr). All cart-to-order rebuild paths now copy full line tax data (like core checkout does), document coupons with the cart's exact discount amounts instead of recalculating them, carry shipping/fee taxes, and reconcile the final order total against the cart total that Saga actually charged. Orders affected before this fix show the correct charge in Surfboard/Saga and should be corrected manually in WooCommerce (edit the order's discount/total).

= 4.0.462 =
* Store safety: the plugin no longer triggers ANY global cache purge or object-cache flush, ever (now locked in place by an automated release test). 4.0.461 fired a one-time LiteSpeed "purge all" after upgrading (to evict accounting API responses cached before the no-cache-header fix), and the plugin activation routine ran a full wp_cache_flush(). A global purge/flush also clears the object cache, forcing every WordPress option to be re-read from the database â€” and on a store with a pre-existing broken timezone option (gmt_offset stored as an empty string with no timezone_string set), LiteSpeed Cache 7.8.1 performs unguarded arithmetic on that value at load time (litespeed-cache.php line 69) and fatals the entire site on PHP 8. That fatal is in LiteSpeed's code and the broken option predates this plugin (which never writes WordPress core options) â€” but this plugin must never be the trigger either, so both global purges are removed. The 4.0.461 no-cache security headers are unchanged: new accounting API responses are never cacheable; responses cached before 4.0.461 expire with the page-cache TTL or can be evicted with a deliberate, manual "Purge All" by the store admin. If your site shows a critical error in litespeed-cache.php line 69 after any cache flush: set Settings â†’ General â†’ Timezone to a named city (e.g. Oslo) and save â€” that repairs the option state LiteSpeed's bug trips over.

= 4.0.461 =
* Security: responses from the Saga Regnskap read API (/wp-json/saga-accounting/v1/) now always carry explicit no-cache/no-store headers, an X-LiteSpeed-Cache-Control: no-cache header and a per-request LiteSpeed no-cache signal. Previously, page caches that key on the URL alone (observed with LiteSpeed Cache) could store an authenticated 200 response and replay order/refund data to unauthenticated clients â€” bypassing both the Bearer authentication and the 120/min rate limiter â€” and could serve stale data to the accounting reader. Error responses (400/401/429) were never cacheable; only success responses on stores running such a page cache were affected, and the API contains no customer PII by design. On upgrade the plugin also triggers a one-time LiteSpeed purge-all so any API responses cached before the fix are evicted immediately.

= 4.0.460 =
* Fix: the Saga Regnskap panel's status line now updates immediately when the "Koble til regnskap" button succeeds, instead of showing a stale "Ikke tilkoblet" (from page load) next to a fresh "Tilkoblet!" confirmation. Cosmetic only â€” the pairing itself was completed correctly.

= 4.0.459 =
* Improvement: the Saga Regnskap registration ping is now fully deferred to a background event so merchant OTP verification always responds instantly, even if the accounting backend is slow or not yet reachable. A failed ping is handled silently (state stored in options only, no admin notices) and retried automatically: on the next OTP activation, once per plugin version after upgrades â€” now also for stores that were OTP-activated before the connector shipped or whose last attempt errored â€” and via the manual "Koble til regnskap" button. Stores already connected are never re-registered automatically.

= 4.0.458 =
* New: Saga Regnskap (accounting) integration. The plugin now exposes a read-only, PII-free REST API (/wp-json/saga-accounting/v1/) that the Saga Regnskap accounting service uses for two-sided bookkeeping control against the Surfboard money flow: order and refund totals in integer Ã¸re, per-line tax rates, gift card events and Saga payment references. Access requires a pairing token minted during merchant OTP verification; only a SHA-256 hash of the token is stored on the store, all endpoints are Bearer-authenticated with constant-time comparison, strictly rate-limited, and never expose customer names, emails, phones, addresses or IPs. Merchants without a Saga Regnskap agreement are unaffected (nothing is stored, no data is read).
* Hardening: refunds can no longer silently fail. If the Saga refund API does not explicitly confirm that the refund was accepted (a refund payment ID or an accepted status), the refund is now ABORTED with an error in WooCommerce admin instead of being recorded as if money moved. Previously an unclear API response was optimistically treated as success, which could leave an order marked refunded while no money was returned. Asynchronous refunds (e.g. Klarna) are now tracked to settlement with a background status poll, the final status is stored on the refund itself, and a refund that fails to settle adds a loud warning note on the order.
* Uninstall change: order and refund meta (_saga_* payment references) is now preserved when the plugin is deleted â€” it is accounting documentation subject to the Norwegian bookkeeping act's 5-year retention and is required for bookkeeping reconciliation. Options, tokens, transients and crons are still fully cleaned up.

= 4.0.457 =
* Customer experience: a shopper will now only ever receive emails about their one real, paid order. To start a payment the checkout has to create a WooCommerce order up front (before the shopper clicks pay) so the payment SDK has something to attach to. Previously, if that pre-created "reference" order was left behind (the shopper switched payment method, reloaded, or abandoned) and was later put on hold, cancelled or failed, the shopper could receive a confusing extra "order received" or "order failed/cancelled" email for an order they never knowingly placed â€” on top of the correct emails for their real order. That conflicting-email confusion drove unnecessary support tickets and chargebacks. These scaffolding reference orders are now tagged at creation and every customer- and admin-facing email is suppressed for them while they are unpaid, so they stay completely silent and are cleaned up in the background. The tag is removed the instant an order is actually paid â€” and as a hard safety net the suppression never applies to a paid order â€” so a genuine order (including a reference order the shopper does pay on) always emails the customer exactly as before. No change to payment behaviour.

= 4.0.456 =
* Hardening for the 4.0.455 duplicate-order cleanup. Added a double-payment safety guard so the cleanup can never act on two orders that share the same underlying Saga payment (which in a rare race could otherwise have led to one payment being counted on two orders), and made the automatic cancellation of never-paid leftover orders silent so it no longer sends the store admin a "Cancelled order" email for each cleaned-up reference order. The cleanup still only ever cancels unpaid, never-charged reference orders, never touches paid orders, never marks anything as paid, and remains fully reversible. No change to payment behaviour.

= 4.0.455 =
* Improvement: the plugin now automatically cleans up duplicate "pending" orders. To start a payment, the checkout has to create a WooCommerce order up front (before you click pay) so the payment SDK has something to attach to. When a shopper switches payment method, reloads the checkout, or abandons the page, that pre-created order was left behind as a leftover "pending" order, so testing or browsing produced several duplicate pending orders for the same shopper. Now, the moment a real payment in the same session succeeds, the leftover sibling orders from that session are cancelled automatically, and a background scan cancels any pending pre-created order that the Saga API confirms never received a payment. This is fully reversible â€” if a late payment confirmation arrives, the order is reactivated automatically â€” and it never touches paid orders or the live payment flow. No change to payment behaviour.

= 4.0.454 =
* Hardening: the new Order Attribution capture (4.0.453) is now wrapped in a fail-safe so it can never interfere with order creation or payment. It is best-effort analytics only â€” if anything unexpected happens while reading the traffic source, it is skipped silently and the order/payment continues exactly as before. No change to payment behaviour.

= 4.0.453 =
* Improvement: WooCommerce Order Attribution (the customer's traffic source â€” Google, Instagram, Facebook, direct, etc.) is now recorded on orders. Because Saga creates the order through its own checkout flow, WooCommerce's built-in attribution tracking was bypassed and every Saga order showed its origin as "Unknown". The plugin now reads the visitor's source from WooCommerce's own first-party tracking cookies (set by WooCommerce core, the same data WooCommerce itself uses) and saves it to the order, so the Origin column in WooCommerce > Orders shows the real source again. It never overwrites attribution WooCommerce already recorded, ignores the payment provider's own domain so the gateway is never credited as the source, and applies to all checkout flows (redirect, inline, dynamic, classic and Blocks).

= 4.0.452 =
* Improvement: the customer's phone number is now included when creating Vipps orders and is saved to the WooCommerce order on both the billing and shipping address. This applies across all checkout flows (redirect, inline, dynamic, classic and Blocks checkout) so merchants can create shipping labels (e.g. Posten/Bring) that require a phone number. Previously the phone number was withheld for Vipps to avoid a payment-provider validation error; that limitation has been resolved on the Saga side, so the number now flows through.

= 4.0.451 =
* Fix: Apple Pay could open the payment sheet and then close after about a second on some stores. The Apple Pay domain association file served at /.well-known/apple-developer-merchantid-domain-association is now sent exactly as provided by Surfboard (verbatim), instead of being transformed before output. Apple compares this file byte-for-byte against the value registered during onboarding, so serving it verbatim is required for merchant validation to succeed.

= 4.0.450 =
* Fix: the saved-card identity button ("Bekreft identitet for lagret kort") could appear when it should not â€” after a failed wallet/Apple Pay payment, for guests with no saved cards, when the Card method was not selected, or while a non-Saga gateway was the active WooCommerce payment method. All reveals now run through a single gated check (Saga gateway selected + inline Card + a real saved-card token).
* Fix: the inline payment widget (Vipps and other wallet methods) could keep showing a stale amount after the cart total changed (e.g. after a coupon/price change). It now remounts to reflect the current cart total even when the previously synced amount was unknown. The charged amount was always taken from the live cart; this corrects the displayed amount.

= 4.0.449 =
* Release metadata refresh for WordPress.org update-check propagation. Includes the redirect checkout retry fix from 4.0.448 that prevents expired Saga/Surfboard PaymentPage links from being reused.

= 4.0.448 =
* Fix redirect checkout retries so expired Saga/Surfboard PaymentPage links are never reused. A retry now discards stale PaymentPage attempt state and always creates a fresh Saga order linked to the WooCommerce order.

= 4.0.447 =
* Release metadata rebuild to force WordPress.org's public plugin page and update-check index past the stale 4.0.445 cache. No functional payment or product-sync changes.

= 4.0.446 =
* Release metadata refresh to force a fresh WordPress.org package/update check after some installs continued receiving the older 4.0.420 package from cache. No functional payment or product-sync changes.

= 4.0.428 =
* Fix (wave10fg): inventory PUT root cause. Catalog API was sending an undocumented `storeId` field in the JSON body of `PUT .../product/{p}/inventory`. The endpoint already encodes the store in the URL (`/merchants/{m}/stores/{s}/...`), so the extra body field caused Saga to respond `200 {status:SUCCESS, message:"Inventory updated successfully"}` while silently no-op'ing the update â€” verified by subsequent GET still showing the pre-PUT quantity for hours. Body is now exactly `{operation, quantity, unit}` per docs, and the unnecessary PATCH fallbacks (which 404'd every call) were removed. This is the true fix that previous waves (10fd/10fe/10ff) were defending around without addressing.

= 4.0.427 =
* Fix (wave10ff): guarantee_catalog_stock_meta() ran unconditionally after the wave10fd bounce guard and overwrote the held WC stock via update_post_meta('_stock', ...), effectively cancelling the guard. The retry-queue reconcile then saw WC == bounced catalog and purged the corrective STOCK_DOWN. The helper is now skipped when bounce_handled is true so the local post-sale WC stock survives until the corrective delta is pushed.

= 4.0.426 =
* Product sync (wave10fe): post-push verification. After every successful real-time stock change and successful per-order-item stock push, the plugin now fetches the actual Saga catalogue quantity and pushes a corrective delta if it does not match WooCommerce. Defends against the Surfboard "bounce" race where the catalog was reverted to the pre-sale quantity in the seconds between the cached baseline and the new sale's push, leaving Saga catalog 1â€“N higher than WooCommerce despite a SUCCESS response on the blind delta. The verification reuses the existing `reconcile_catalog_to_wc_stock` helper (wave10fc), so it is a no-op when catalog already matches WC.

= 4.0.425 =
* Product sync (wave10fd): defend WooCommerce stock against a Surfboard-side "bounce". After a successful `STOCK_DOWN` PATCH against the Saga catalogue (real-time hook, order-item push, or full sync), the plugin now stamps `_saga_local_stock_at` on the WC product. If the next cron PULL sees the Saga `inventory.quantity` higher than the WC value within a 30-minute local-authority window, the pull skips the WC stock overwrite (which previously reverted a paid order's stock decrement) and queues a corrective `STOCK_DOWN` for the next cron tick instead. Fixes the regression where a 1-unit sale dropped WC from 10â†’9, Saga's POS aggregate restored it to 10 within minutes, and the following PULL silently pushed WC back to 10 (logged as `Pull complete: ... 1 updated`).

= 4.0.424 =
* Product sync (wave10fc full): eliminate the "double-spend" cascade that left Saga catalog drifted out of sync with WooCommerce after a sale. The cron retry queue used to replay stale `STOCK_DOWN` deltas blindly, which drained the catalog ahead of fresh sales and caused the next real-time push to be rejected with `PC_0030: Invalid stock quantity`. Three changes work together: (a) the retry queue is now deduplicated by product binding before each cron run, (b) each retry replays via an absolute reconcile against the actual catalog quantity â€” looking up the WC product by `_saga_product_id` and applying the exact delta that brings catalog to current WC stock â€” instead of replaying the recorded delta, and (c) every successful real-time push, immediate order-item push, and reconcile now purges all queued retries for the same binding so a single network blip cannot cascade into multiple drains.
* Product sync: real-time `on_product_stock_changed` failure path now reconciles to actual catalog quantity before queuing a retry, so a transient mismatch self-heals in the same request instead of waiting up to 5 minutes for cron.

= 4.0.423 =
* Product sync (wave10fc): when the immediate per-item stock push from `woocommerce_reduce/restore_order_stock` fails (typically the Saga `PC_0030: Invalid stock quantity` error caused by catalog drift from a stale `_saga_synced_stock` baseline), the new handler now fetches the current Saga catalog quantity and applies the correct absolute delta to bring the catalog into sync with WooCommerce in the same request â€” instead of queuing a blind delta retry that will keep failing. Catalog-at-0 floor is accepted as in-sync when WooCommerce is at or below 0, preventing the permanent drift seen when both WC and catalog reach 0 by different paths.

= 4.0.422 =
* Product sync: push per-item stock change to Saga/POS in the same request that reduces or restores stock for an order (hooks `woocommerce_reduce_order_stock` and `woocommerce_restore_order_stock`). Uses the line-item quantity directly as the delta, so a sold-out product reaches the POS within seconds â€” no waiting for the 5-minute cron when the previous per-product hook bailed silently (missing `_saga_synced_stock` baseline, missing catalogue link, or lock contention). Per-order-item meta guards against double-push when the existing real-time hook also succeeded.

= 4.0.421 =
* Redirect-mode checkout: suppress the duplicate "Ukjent" pending WooCommerce order that appeared when a late JS prewarm (`saga_create_checkout_order`) raced the WC submit redirect. `process_payment` now stamps a session breadcrumb when it hands a redirect order to PaymentPage, and `ajax_create_checkout_order` short-circuits any follow-up create within 120 seconds, returning the in-flight Saga order ID + payment page link instead of opening a stray second WC + Saga order.

= 4.0.420 =
* Product sync: stop calling the `/product-catalogue/{catId}/products` Fetch Products variant without a productId query, which was raising `PC_0061: Missing minimum paramter to fetch product` on SagaPay's PC_GET_PRODUCTS alerting. Only the documented `/product-catalogue/{catId}?storeId=...` endpoint is used to list products now.

= 4.0.419 =
* Product pull now enables WooCommerce stock tracking for tangible Saga catalogue products even when Saga's fetch response omits inventory values, preserving a readable Woo stock baseline or using an in-stock baseline so Woo does not leave managed stock disabled.

= 4.0.418 =
* Product pull now activates WooCommerce stock tracking for existing Saga-managed products that already have a Woo stock value when the Saga API omits an absolute inventory quantity, preserving that value instead of leaving tracking disabled.

= 4.0.417 =
* Product pull now treats Surfboard inventory payloads as a stock-management signal even when the API omits a readable quantity, enabling WooCommerce stock tracking without overwriting existing stock with zero.

= 4.0.416 =
* Product statistics fallback now includes the connected `storeId`, so Surfboard stock can be imported when the product payload omits inventory fields.

= 4.0.415 =
* Product pull now guarantees WooCommerce `_manage_stock`, `_stock`, `_stock_status` and `_saga_synced_stock` meta are persisted after Surfboard stock quantities are imported.

= 4.0.414 =
* Surfboard stock quantities from `inventoryStatus.currentStock` now activate WooCommerce stock management and write the remote stock quantity during product pull.

= 4.0.413 =
* STRICT CONNECTED STORE PRODUCT SYNC (wave10es) - Product sync is scoped back to the connected Store ID only. Merchant-wide store discovery is not used for Woo catalogue import, SKU dedupe, cart authority checks or inventory retry pruning.

= 4.0.412 =
* STORE-AWARE PRODUCT WRITE PATHS (wave10er) - Products imported from another active Surfboard store now carry their owning store through related-products, variable/variant sync, stock updates, order/refund/cancel inventory safety nets and inventory retry queue entries. This prevents later Woo actions from writing back to the gateway store when the product belongs to another Surfboard store.

= 4.0.411 =
* ALL-STORE CATALOG PULL (wave10eq) - Product pull now discovers active stores under the merchant via the live `/stores?merchantId=...` API and reads each store product catalogue, not only the gateway store. Imported products remember `_saga_store_id` so later delete/update operations can target the store that actually owns the Surfboard product.

= 4.0.410 =
* MAIN DESCRIPTION AUTHORITY (wave10ep) - WooCommerce full product description is now the authoritative source for the Saga top-level `description` field on push. Short description is still preserved separately in `productProperties.shortDescription`, so editing the main Woo description no longer leaves Surfboard stuck with an old short description.

= 4.0.409 =
* NATIVE GTIN PUSH FIX (wave10eo) - WooCommerce's native GTIN/EAN field is now read via `get_global_unique_id()` before fallback meta keys, and product admin saves run one final push after Woo has persisted product meta. This closes the live-tested gap where name, price and stock reached Surfboard immediately but a changed Woo GTIN could remain stale remotely until overwritten by the next pull.

= 4.0.408 =
* LIVE BARCODE PAYLOAD FIX (wave10en) - Live Surfboard persists EAN/barcode updates from the lowercase `barcode` field, while the docs show `barCode`. Product and variant push payloads now send both field names so Woo-created or Woo-updated products keep EAN/GTIN in Surfboard and pull back into Woo's native GTIN field.

= 4.0.407 =
* PRODUCT FIELD AUTHORITY (wave10em) - Pull now fetches each full Surfboard product by ID before mapping, so compact list responses cannot drop description, category, barcode/EAN/GTIN, HSN, tax, product properties, images or other documented fields. Saga categories are now applied as real WooCommerce product categories, barcode/EAN is written through Woo's native GTIN setter when available and common EAN plugin meta keys are covered.
* EMPTY SURFBOARD CATALOGUE AUTHORITY - If Surfboard/Saga returns zero products while Woo has active products, the pull treats the empty catalogue as authoritative and moves active Woo products out of the live catalogue instead of pushing them back up and resurrecting POS-deleted products.
* VARIANT AND TAX HARDENING - Saga variants pulled from Surfboard are materialised as Woo variable product variations with attributes, variant IDs, stock, EAN/GTIN, HSN and raw variant payload metadata. Woo product updates now send the tax array as well as the existing VAT field, with a fallback retry that strips tax if an endpoint rejects tax on PATCH.

= 4.0.406 =
* STALE INVENTORY RETRY PRUNE (wave10el) - Inventory retry queue entries are now removed when a linked Saga product is deleted or orphan-cleaned from WooCommerce, and retry cron drops stale adjustments when the current Saga catalogue no longer contains the product ID. This prevents old STOCK_UP/STOCK_DOWN retries for deleted products from retrying for 24 hours and sending manual correction emails after the product is already gone upstream.

= 4.0.405 =
* CART/CATALOG AUTHORITY PRODUCT SYNC (wave10ek) - Existing WooCommerce cart sessions are now swept on cart load and checkout validation so Saga-managed products that were deleted upstream or removed from the publishable Woo catalogue cannot remain purchasable from stale sessions. Add-to-cart now verifies linked Saga products against the current catalogue before accepting the item.
* MULTI-CATALOG PRODUCT ROUTING - Product pulls now parse live plural catalogue fields such as `productCatalogueIds`, store `_saga_catalogue_id` on Woo products/variations, report all catalogue counts in Test Connection, and use the owning catalogue ID for product updates, deletes, related-products, variants, product statistics, inventory deltas and retry-queue replays.

= 4.0.404 =
* WOO DELETE/STATUS PRODUCT SYNC (wave10ej) - WooCommerce product trash/delete and publish-status changes now propagate to Surfboard/Saga by deleting the linked catalogue product when the Woo product leaves the publishable catalogue. Draft/private/pending products are no longer pushed into Surfboard from save hooks, and successful remote deletes clear the active `_saga_product_id` while keeping audit meta so a later restore/publish creates a clean new catalogue product instead of leaving POS with stale inventory.

= 4.0.403 =
* DELETE-SAFE MANUAL PRODUCT SYNC (wave10ei) - Manual Sync no longer pushes Woo products back into Surfboard/Saga in the same run after pull-side cleanup removed POS-deleted products from WooCommerce. This prevents remote deletions from being immediately resurrected by the push phase when the Surfboard catalogue contains fewer products than Woo.

= 4.0.402 =
* AUTHORITATIVE PRODUCT SYNC MIRROR (wave10eh) - Treats an empty Surfboard/Saga catalogue as an authoritative delete state after a previous sync or when Saga-managed products exist in WooCommerce. Manual Sync now refuses to push Woo products back into Saga immediately after an authoritative empty pull, so POS-side product deletion cannot be undone by the plugin. The pull cleanup can remove all active Woo products from the catalogue in this state, not only products still carrying a current `_saga_product_id`.
* DOCUMENTED SURFBOARD PRODUCT ENDPOINTS - Product catalogue API calls now try the documented `/catalog`, `/catalog/:catalogId/products`, product, variant, related-products, statistics and inventory routes first, while keeping the older `/product-catalogue` routes as fallback. The catalogue ID cache namespace was refreshed so stores do not stay pinned to an old/parallel catalogue.
* INVENTORY SYNC HARDENING - Product stock parsing now accepts `inventory.currentStock`, `stock`, `availableQuantity`, `inventoryQuantity` and related Surfboard field shapes, and falls back to product statistics when the product list omits stock. Order completion, refund and cancellation safety nets now resolve variation inventory bindings and queue variant retry adjustments instead of only handling simple product stock.

= 4.0.401 =
* WALLET GHOST-TRIGGER GUARD (wave10eg) - Prevents Apple Pay / Google Pay from being launched again after the customer closes the wallet sheet. Classic checkout now treats wallet cancel/reset as a durable stale-attempt signal, blocks checkout-success and payment fallback paths from calling `initiatePayments()` without a fresh trusted Place Order click, and skips automatic SDK remount immediately after a clean wallet cancellation.

= 4.0.400 =
* CLEAN CHECKOUT UTF-8 FALLBACKS (wave10ef) - Fixes mojibake in customer-facing classic checkout fallback messages, including the Apple Pay / Google Pay cancelled notice that could render as `PrÃƒÂ¸v igjen` instead of `PrÃ¸v igjen`. Classic checkout now normalizes known double-encoded fallback text before display and routes wallet cancel notices through the central i18n keys.

= 4.0.399 =
* ORDER ATTRIBUTION PAYMENT-DOMAIN GUARD (wave10ee) - Prevents WooCommerce Order Attribution from recording Saga/Surfboard payment domains such as `pay.withsurfboard.com` as the merchant's traffic source after redirect/cancel/retry flows. Classic checkout now snapshots clean Woo attribution before Saga submission and restores it before order creation if the browser came back from the payment page; the server also removes or restores polluted `_wc_order_attribution_*` meta for classic, Blocks and programmatic Saga orders.

= 4.0.398 =
* PRODUCT PULL REACTIVATION FIX (wave10ed) - Fixes the case where a Surfboard/Saga product is found during pull and the sync reports it as updated, but WooCommerce still does not show it because the matched Woo product stayed as draft/private/pending. Pull updates now publish and make visible any product returned by the Saga catalogue, because Saga is the pull-side source of truth.
* RESTORE LINKED PRODUCTS FROM TRASH - If Saga returns a product whose `_saga_product_id` already exists in Woo trash, the pull lookup now finds it and restores/publishes it instead of creating another hidden/duplicate copy.

= 4.0.397 =
* PRODUCT VARIANT ENDPOINT COVERAGE (wave10ec) - WooCommerce variable products are no longer skipped by product sync. Variable parent products are created/updated as Saga catalogue products, Woo variations are created/updated through the Saga variant endpoints, variation IDs are stored on each WC variation, and variation stock changes now use the Saga variant inventory endpoint instead of being ignored as unlinked stock changes. Variation save hooks are registered so admin edits to size/color variants push immediately.
* DOCUMENTED PRODUCT ROUTE/VERB FALLBACKS - Product API calls now try the documented plural `/products/:productId` route shape and documented `PATCH` inventory methods first, while keeping the older singular `/product/:productId` and legacy `PUT` inventory calls as quiet fallbacks for existing deployments. This covers create/fetch/update/delete, related products, add/update variants, product inventory, variant inventory, and product statistics without breaking stores still served by the previous route aliases.
* VARIABLE PRODUCT FIELD COVERAGE - Product mapping now derives parent prices from the minimum variation price when a variable parent has no direct regular price, and sends variant category metadata from Woo variation attributes so Surfboard/POS receives the variant dimensions needed to group child variants correctly.

= 4.0.396 =
* PRODUCT SYNC EMPTY-CATALOG ORPHAN FIX (wave10eb) - Fixes the exact case where Surfboard/Saga has zero products but WooCommerce still shows old products after manual sync. The pull orphan reconciliation now runs even when the upstream product list is empty; every active Woo product linked with `_saga_product_id` that is missing upstream is moved to Woo trash (not permanently deleted, so historical orders stay safe) and tagged with `_saga_orphaned_at`, `_saga_orphaned_saga_id`, and `_saga_orphan_action` for auditability. Manual sync now reports how many products were removed from the active Woo catalogue.
* PRODUCT FIELD/API COVERAGE - Extends product mapping to preserve `hsnCode`, unit type, product type, related products, variant categories, variants, billing/monthly plans, and campaign metadata, while continuing to sync barcode/EAN/GTIN, price, cost price, VAT, unit, images, dimensions, weight, stock and product properties. Adds catalogue API wrappers for fetch-by-id, related products, add/update variants, variant inventory, catalogue statistics and product statistics, and includes `storeId` in product catalogue request bodies/queries where required by the Surfboard product endpoint documentation.
* RELATED PRODUCTS SYNC - Saga `relatedProducts` now pull into Woo upsells after all products are linked, and Woo upsell/cross-sell relationships are pushed back to Saga related products when related products already have Saga IDs.
* PRODUCT SYNC INVARIANTS - Adds a dedicated product-sync invariant test that locks the empty-catalog cleanup, related-products mapping, required product API wrappers and field round-trip coverage so this regression cannot silently come back.

= 4.0.395 =
* CAPTURE/VOID ALLOWLIST (wave10ea) - Manual Capture and Void buttons (and the matching WooCommerce order-actions, auto-capture-on-Processing/Completed and auto-void-on-Cancelled hooks, plus their AJAX handlers) are now restricted to payment methods Saga actually exposes an authorise/capture/void lifecycle for: Card, Apple Pay and Google Pay. All other methods (Klarna, Vipps, Swish, MobilePay, etc.) are auto-captured by the upstream provider on authorisation â€” there is no separate authorisation that can be voided, so the previous Void button silently failed and never returned funds to the customer. For those orders the Saga Payment Actions meta box now shows a clear notice telling the merchant to use the WooCommerce Refund button instead, and the order-actions dropdown no longer offers Capture or Void. Method is resolved from `_saga_payment_method_used` (API-confirmed) with fallback to `_saga_payment_method` (checkout-time selection); REDIRECT placeholders are excluded.

= 4.0.394 =
* WALLET METHOD-SWITCH GUARD (wave10dz) - Adds a final synchronous DOM re-check immediately before calling `SDK.order.initiatePayments` in the wallet click handler. Reads the live `#saga_payment_method` hidden input value and aborts cleanly (no popup, no state mutation) if the customer has switched to a non-wallet method (CARD, Klarna, Vipps, Swish, MobilePay) in the same event tick. Prevents a rare scenario where users who switch from Google Pay or Apple Pay to Card payment could see a stale Google Pay popup appear from a click that had already been queued in the wallet handler. Wallet-only scope preserved â€” non-wallet flows are unchanged because the wallet click handler is attached only for GP/AP and this re-check only runs inside that handler.

= 4.0.393 =
* MANUAL CANCEL ON "STARTER BETALINGâ€¦" OVERLAY (wave10dy) - If the customer closes the Google Pay or Apple Pay popup (or the popup otherwise fails to complete), the "Starter betalingâ€¦ Vennligst vent mens betalingsvinduet Ã¥pnes" overlay no longer leaves them permanently stuck. An "Avbryt betaling" button now appears on that overlay after a 2.5-second delay (delay prevents the button from flashing during a normal fast popup open). Clicking it calls `resetPaymentForRetry` which stops all wallet/payment monitoring, clears in-flight flags, and lets the user pick another method or retry. Mechanism: added `cancelDelayMs` option to `showProcessingOverlay` so a cancel button can be shown after a configurable delay; the "Starter betalingâ€¦" call now passes `{ cancel: true, cancelDelayMs: 2500 }` instead of the previous `{ cancel: false }`. The "Behandler bestillingenâ€¦" overlay is unchanged (still no cancel) because that runs before the SDK is even involved.

= 4.0.392 =
* GOOGLE PAY / APPLE PAY â€” CLEAN ONE-CLICK CHECKOUT (wave10dx, wallet path only) - Removes the yellow/green "klikk FullfÃ¸r bestilling pÃ¥ nytt" notice introduced in 4.0.391. Replaces it with a silent button-readiness gate: when Google Pay or Apple Pay is the selected method but `SurfboardOnlineSDK.order.initiatePayments` is not yet callable, the Place Order button is briefly faded (pointer-events:none + opacity 0.6) so the user physically cannot click before the SDK is ready. The gate polls every 120 ms and releases the instant the SDK is callable, so the user's very first real click opens the wallet popup synchronously from a trusted gesture (no Firefox popup block, no "click again" prompt). Scope is strictly wallet-only â€” CARD, Klarna, Vipps, Swish, MobilePay are untouched because the gate is engaged only inside `attachWalletClickHandler` for GP/AP. If a click somehow lands during the unready window it is now blocked silently (no inline notice, just `preventDefault` + `stopImmediatePropagation` + console.warn diagnostic) and the gate engages so the next click works.

= 4.0.391 =
* GOOGLE PAY / APPLE PAY "POPUP NEVER OPENS" FIX (wave10dw, wallet path only) - On classic checkout in Firefox (and any browser with strict popup-blocking under non-user-gesture context), the Place Order button could be clicked during the brief window where `updated_checkout` had torn down and was re-mounting the Saga SDK. The wallet click handler saw `SurfboardOnlineSDK.order.initiatePayments` was not yet callable, silently bailed, and the click fell through to WooCommerce's asynchronous form submit. By the time the WC AJAX returned and tried to open the wallet popup, the user gesture was gone and Firefox blocked the window. The bug presented as the "Starter betaling\u2026" overlay showing forever with no Google Pay popup appearing. This release replaces the silent bail with a synchronous block (`preventDefault` + `stopImmediatePropagation`), polls SDK readiness for up to 5 seconds, and surfaces an inline message asking the user to click Fullf\u00f8r bestilling again. The next click is a fresh trusted gesture so the popup opens immediately. Scope is strictly wallet-only (Google Pay + Apple Pay); CARD, Klarna, Vipps, Swish, and MobilePay flows are unchanged because the handler is only attached for wallet methods. Always-on console.warn diagnostics added so the bail path is visible without enabling `?saga_debug=1`.

= 4.0.390 =
* Verification release - bumps the version so that merchants on 4.0.389 see an update available on the Updates screen. With the 4.0.389 root-cause fix in place (plugin header stripped from `saga-payments-gateway.php`, active_plugins migration moved to canonical), the Updates screen should now show exactly one Saga Payments row and never the duplicate that crashed the bulk upgrader in earlier releases. No functional code changes in this release.

= 4.0.389 =
* DUPLICATE PLUGIN ROW ROOT-CAUSE FIX - Removes the WordPress plugin header (Plugin Name, Version, Description, etc.) from `saga-payments-gateway.php` so that `get_plugins()` no longer indexes it as a separate plugin entry. The duplicate row appeared because WordPress's plugin discovery scans every .php file with a `Plugin Name:` header and treats each match as a distinct plugin; we historically shipped two files with plugin headers in the same folder (the canonical `saga-payments.php` and the legacy `saga-payments-gateway.php` kept for backward compatibility with installations whose `active_plugins` option still recorded the legacy basename). Now the legacy file is a header-less compatibility loader that only does `require_once __DIR__ . '/saga-payments.php';` so backward compatibility for un-migrated stores is preserved (WordPress validates active_plugins entries via `file_exists`, not via the plugin header, so the legacy basename still loads correctly). The `active_plugins` migration code that used to live in the legacy file has moved to canonical so it runs on every boot regardless of which entry point WordPress picked. The 4.0.387 / 4.0.388 runtime filters (all_plugins, site_transient_update_plugins, CSS, JS) remain in place as belt-and-suspenders defense but are no longer the primary mechanism.

= 4.0.388 =
* UPDATES SCREEN DUPLICATE ROW + BULK-UPDATE CRASH FIX - The 4.0.387 dedupe handled wp-admin Plugins list but did NOT touch the Updates screen (`update-core.php`), which reads from a separate WordPress transient (`update_plugins`) containing both basenames. When merchants ticked both rows and clicked Update, WordPress tried to upgrade the same `saga-payments/` folder twice and the second pass hit a half-written ZIP, crashing the upgrader. This release filters the `site_transient_update_plugins` and `transient_update_plugins` read paths at PHP_INT_MAX priority to scrub the legacy `saga-payments/saga-payments-gateway.php` entry from `response`, `no_update`, and `checked` buckets. A matching CSS+JS row-removal runs on `admin_head-update-core.php` so any stale-cached transient still renders as a single row. A one-shot `delete_site_transient('update_plugins')` on first 4.0.388 boot forces a fresh wp.org check to seed the now-filtered cache cleanly.

= 4.0.387 =
* PLUGINS LIST DUPLICATE ROW BULLETPROOF FIX - The 4.0.386 PHP `all_plugins` filter did not take effect on all sites (suspected causes: opcache serving stale bootstrap code after in-place update, third-party plugin re-adding the entry at a later filter priority, or WP_List_Table population paths that bypass the filter). This release adds two reinforcements: (a) the `all_plugins` filter now runs at PHP_INT_MAX priority so no other filter can revive the legacy row, and (b) a server-rendered CSS rule injected on `admin_head-plugins.php` hides the `<tr data-plugin="saga-payments/saga-payments-gateway.php">` row directly in the rendered table with `display:none !important`. The CSS path works even if opcache is serving stale PHP, because the new code is what writes the style tag in the first place. After updating, deactivate and reactivate Saga Payments once to force PHP-FPM to pick up the new bootstrap if opcache.revalidate_freq is high.

= 4.0.386 =
* PLUGINS LIST DUPLICATE ROW FIX - The legacy `saga-payments-gateway.php` compatibility entry-point no longer shows as a second "Saga Payments for WooCommerce" row on the wp-admin Plugins list. The 4.0.319 hide-filter that was supposed to suppress it was bailing out whenever the plugins.php URL carried any `action` / `action2` query parameter (added in 4.0.329 to protect upload-conflict detection), but WP's upload-conflict flow runs on update.php, not plugins.php, so the action-query guard was unnecessary and was causing the duplicate row to render whenever WordPress redirected back to plugins.php with an action parameter (e.g. after an activation, or when a host or security plugin added tracking params). The filter now hides the legacy basename unconditionally on the plugins list render, while still allowing it through on update.php, plugin-install.php, AJAX, REST and WP-CLI so slug-collision / Replace-current upload flows continue to work.

= 4.0.385 =
* EXPRESS SWISH OPEN-APP BUTTON RESTORED - The express-checkout quickbuy popups (product, cart, checkout) now consistently render the "Ã…pne Swish-appen" button inside the Swish QR state on both mobile and desktop, matching the classic-checkout and Blocks behaviour added in 4.0.381 and 4.0.383. Two fixes were needed: (1) the express mobile branch no longer auto-redirects to swish:// before the user sees the modal - the QR modal with the Open-app button is now always shown when the SDK returns qrData, and the user's tap on the button is the fresh user gesture iOS Safari requires for custom-scheme navigation; (2) a string-only deep scan of the SDK result was added as a third fallback for the swish:// URL (after the well-known getters/property keys and the qrData fallback), mirroring the helper added to checkout.js / blocks.js so the button still renders when the SDK tucks the deep link into a nested or non-standard field. Auto-redirect only kicks in now as fallback when the SDK did not return a QR payload at all.

= 4.0.384 =
* SWISH CLASSIC MOBILE FALL-THROUGH FIX - The classic-checkout mobile Swish branch no longer early-returns after mounting the QR modal and no longer raises a hard "Kunne ikke Ã¥pne Swish-appen. PrÃ¸v igjen" error when neither qrData nor a usable swish:// URL was returned by the SDK. Control now falls through to the natural post-initiatePayments polling setup just like the desktop branch did all along, so the modal is rendered AND the backend payment-status polling is armed in lockstep. If the SDK returns nothing at all the normal fall-through handles the verify_url redirect path without a duplicate error overlay.

= 4.0.383 =
* SWISH QR MODAL ALWAYS SHOWN - Classic checkout and WooCommerce Blocks now always render the Swish QR code modal with the "Ã…pne Swish-appen" button on BOTH mobile and desktop instead of auto-redirecting to the swish:// app on mobile before the modal mounts. Customers now consistently see the QR code (so a second device can scan it) and an explicit button to launch the Swish app on the current device. The user's tap on that button is its own fresh user gesture, so iOS Safari still accepts the subsequent swish:// navigation - the v4.0.380 user-gesture concern is preserved without hiding the visual QR confirmation. Auto-redirect only kicks in as fallback when the SDK did not return a QR payload at all.

= 4.0.382 =
* SWISH DEEP-SCAN SIDE-EFFECT GUARD - Removed the SDK getter invocations from the 4.0.381 deep-scan helper. Generic getter names such as getRedirectUrl could trigger SDK side effects (auto-navigation toward the Saga payment page) on certain merchant configurations, breaking the Swish flow entirely - the checkout would briefly show "Betalingssystemet laster, vent litt" and then auto-navigate to the Saga verify_url page instead of opening the Swish app or rendering the QR modal. The deep scan now only walks string properties of the SDK result for a usable swish:// or intent:// URL and never invokes additional getters. The regular code path still calls the safe Swish-specific getters before deep scan kicks in.

= 4.0.381 =
* SWISH OPEN-APP BUTTON DEEP SCAN - Classic checkout and WooCommerce Blocks Swish QR modals now find the swish:// deep link even when the SDK tucks it into a nested or non-standard field. The mobile redirect path, the desktop QR modal, and the blocks modal all fall back to a recursive deep scan of the entire initiatePayments result for a usable swish:// / intent:// URL after the well-known getters/property keys and the qrData fallback have been exhausted. This fixes the case where 4.0.380 surfaced the QR code on classic checkout and blocks but no Open Swish app button rendered because the SDK on the merchant's terminal returned the QR payload as a data:image/png blob and exposed the deep link only via a nested field.
* DIAGNOSTIC HARDENING - Added a saga_diag entry on the classic desktop Swish modal path capturing whether the SDK exposed a usable URL, the qrData shape (data:image vs raw URL) and the top-level result keys, so future merchant-specific SDK shapes can be diagnosed without code changes.

= 4.0.380 =
* SWISH MOBILE HOTFIX - Restores the iOS Safari user-gesture window for the swish:// deep link on classic checkout and WooCommerce Blocks. In 4.0.379 the QR modal was mounted BEFORE the redirectToSagaAppUrl(swish://...) call on mobile, and the modal's DOM activity invalidated the user-gesture allowance iOS Safari requires for custom-scheme navigation. The Swish app then silently failed to open, polling kicked in, and JS eventually navigated to the verify_url which made PHP show "Betalingen ble ikke bekreftet. Kontakt support hvis du ble belastet" almost instantly after Place Order. The mobile branch now redirects FIRST and only mounts the QR modal as a true fallback (when the redirect dispatch fails or no usable swish URL exists).
* SWISH URL NORMALISATION - Classic checkout (checkout.js) now mirrors the express-checkout normaliser: a bare Surfboard payment token returned by the SDK is converted into a real swish://paymentrequest?token=... URL before validation, matching what blocks.js and express-checkout.js already did. This ensures the Open Swish app button and the mobile auto-redirect work on the merchants whose SDK responses only contain the token.
* SWISH QR-AS-DEEPLINK FALLBACK - In all three surfaces (classic, blocks, express) the QR data string is now also tried as a deep-link source for the Open Swish app button and the mobile redirect. Swish QR payloads commonly ARE the swish:// URL itself, so when the SDK only populates the QR field (and not getSwishAppRedirectUrl/nswishAppRedirectUrl) the same string now drives the button â€” fixing the case where the express quickbuy Swish QR popup showed no Open Swish app button.

= 4.0.379 =
* SWISH CHECKOUT UI RESTORED - The Swish QR modal in both classic checkout and WooCommerce Blocks now shows the real bundled Swish logo (assets/images/swish.svg) instead of a green text fallback, matching the design of the Swish express-checkout button. Added a dedicated "Open the Swish app" button to ALL Swish QR surfaces â€” classic checkout modal, blocks modal AND every express-checkout quickbuy popup (product, cart, checkout) â€” using the swish:// deep link from the Saga SDK, so customers on mobile (and any desktop visitor with the Swish app installed) can manually launch the Swish app if the automatic redirect is blocked by the browser. The button uses the Swish brand colour (#50BF6F) with the same look across all three surfaces. On classic mobile checkout the swish:// URL is now also passed through to the desktop QR modal path, so a single fallback path covers every device. Title text is unified across all three surfaces (i18n key scan_qr â†’ "Skann QR-koden med Swish-appen"). Customer-visible Norwegian/Swedish strings are loaded via i18n with clean UTF-8 fallbacks (was previously double-encoded mojibake such as "Ãƒâ€¦pner Swish-appen..." / "Kunne ikke ÃƒÂ¥pne Swish-appen. PrÃƒÂ¸v igjen.").

= 4.0.378 =
* ADMIN VOID NO LONGER LOOPS - The 5-minute cancelled-order safety scan and the central paid-status alias rescue (try_complete_order_from_paid_api_status) now skip orders that an admin has explicitly voided (meta _saga_authorization_voided=yes). Previously a Klarna authorisation that the admin voided via the Void Authorization button would be reactivated five minutes later because Saga's /orders/{id}/status endpoint still reports orderStatus=PAYMENT_COMPLETED for voided Klarna authorisations, putting the cancelled order back to on-hold and looping the merchant through repeat void clicks. Card voids worked because Saga reflects them on the order status immediately; Klarna does not. With this fix the explicit admin void is authoritative and no scan will undo it.

= 4.0.377 =
* DELAYED CAPTURE EVENT MAPPING FIX - Identified the correct terminal event Saga emits in Authorize-only mode. Saga sends order.paymentprocessed (paymentStatus = PAYMENT_PROCESSED) as the customer-facing success event in delayCapture mode â€” no PAYMENT_COMPLETED webhook arrives until the merchant captures later. The webhook handler, central API success check (is_api_payment_success_status), polling and the PAYMENT_PROCESSED safety net now all treat PAYMENT_PROCESSED (and AUTHORIZED) as a successful terminal state ONLY when delayCapture is enabled. The complete_payment_with_emails() short-circuit still parks such orders on-hold with _saga_payment_status=AUTHORIZED instead of marking them paid, so this never marks an unauthorised order paid in normal mode (where PAYMENT_PROCESSED remains intentionally ambiguous for Vipps/MobilePay). Also extends the cancel/fail webhook API safety check to honour these statuses, so a stray CANCELLED webhook will not cancel a successfully authorised order in delayCapture mode.

= 4.0.376 =
* DELAYED CAPTURE REDIRECT FIX - With Capture Mode = "Authorize only (capture later)" the customer is now correctly redirected to the order-received (thank-you) page after a successful authorisation. The central API success check (is_api_payment_success_status) now accepts AUTHORIZED as a verified terminal state when delayCapture is enabled, so finalize AJAX, the webhook handler, polling, cron self-heal and the wallet completion path all treat an authorised payment as a successful customer flow. The complete_payment_with_emails() short-circuit still parks such orders on-hold with _saga_payment_status=AUTHORIZED instead of marking them paid, and on-hold AUTHORIZED orders are now considered ready for the customer redirect. Previously the spinner could remain on the checkout indefinitely waiting for PAYMENT_COMPLETED that never arrives in authorise-only mode, and (worse) the 2-hour pending auto-resolve could eventually cancel the held order.
* I18N FIX - Repaired mojibake in the inline "Click here to try again" recovery button (was rendering literal "ÃƒÂ¥/ÃƒÂ¸" instead of "Ã¥/Ã¸") so the Norwegian fallback string now matches the i18n lookup key and shows correctly.

= 4.0.375 =
* DELAYED CAPTURE END-TO-END - When "Capture Mode" is set to "Authorize only (capture later)" the plugin now sends controlFunctions.delayCapture=true on every order create and update, holds successfully authorised orders in on-hold with status AUTHORIZED instead of completing them, and uses the documented Saga capture endpoint POST /payments/{paymentId}/capture (replaces the previous /captures call that was not the canonical endpoint). Auto-capture continues to fire when a held order is moved to Processing or Completed, and Auto-Void continues to fire on Cancelled. Subscription / MIT renewals are unaffected and still capture immediately.
* VOID ENDPOINT FIXED - The void/cancel call now uses the documented Saga endpoint PUT /payments/{paymentId}/void (replaces the previous POST /transactions/{id}/cancel). The plugin surfaces Saga's voidStatus (VOID_INITIATED / VOIDED / CANNOT_VOID); CANNOT_VOID is correctly treated as a failure with a clear error note. Voids on captured payments must happen before 23:00 UTC same day; voids on delayCapture authorisations can happen any time before capture.

= 4.0.374 =
* README ENCODING CLEANUP - Fixed double-encoded UTF-8 characters in readme.txt that displayed as garbled symbols on the WordPress.org listing (checkmarks, em-dashes, arrows, the Norwegian letter o-slash). Shortened short description to satisfy the WordPress.org 150-character limit. No functional code changes - this is a documentation-only release.

= 4.0.373 =
* MERCHANT VERIFICATION (OTP onboarding) - New verification panel under WooCommerce > SagaPay settings. On first install (or after credentials change) the store owner clicks "Send verification code", receives a 6-digit OTP at the email registered with their SagaPay account, and confirms it to bind the store's outbound IP range to its merchant credentials. Prevents credential abuse from other servers. Three layers of rate-limiting (5 starts/hour, 5 wrong-guess lockout, 10 min TTL). Backend verification is enforced by the SagaPay edge worker; unverified stores receive a clear "Merchant not verified: complete OTP onboarding" error rather than a generic forbidden response, making the required action obvious to the merchant.

= 4.0.372 =
* WP.ORG LISTING FIXES - Synchronised the legacy `saga-payments-gateway.php` Version header to 4.0.372 (was 4.0.356, which caused wp.org to flag a version mismatch between the plugin header and the SVN tag), bumped tested-up-to to WP 7.0 / WC 10.7 in both entry files, removed third-party gateway brand references from the public description, added a setup note clarifying that Terminal ID and Public Key are auto-provisioned on save once Merchant ID and Store ID are entered, and trimmed the long changelog to satisfy the 5,000-word limit (full per-wave history retained in the plugin source).

= 4.0.371 =
* WALLET FAILED BACKEND-VERIFY RESCUE (wave10dv) - For wallet payment methods (Apple Pay, Google Pay, Vipps, Swish, MobilePay) the failed-status branch now performs an authoritative backend verification via `saga_complete_wallet_payment` before surfacing a payment error. SDK-reported FAILED can be overruled by a Saga API `PAYMENT_COMPLETED` status, eliminating a race where a transient wallet SDK failure would mark a successful payment as failed. Also includes wave10du wallet visibility/polling immediate backend checks at multiple submit gates, fail-open semantics so verified-paid orders are never blocked by client-side state. Hardens against charged-but-declared-failed scenarios across all wallet flows.
* WP 7.0 "Armstrong" compatibility verified - no breaking changes for the plugin. jQuery retained, PHP 7.4 minimum unchanged, HPOS unchanged, classic checkout unchanged, WC Blocks API unchanged. Plugin tested up to WP 7.0 and WC 10.7.
* Many internal stability and reconciliation improvements (waves 10df through 10du) covering Klarna fresh-order rebind, webhook HMAC volume mapping, surfboard poll throttling, real-iframe cancel detection, Klarna repeat-cancel latch reset, and post-success suppression. See plugin source for full wave-by-wave technical notes.

= Older releases =
* Full per-version changelog (4.0.0 through 4.0.355) is preserved in the plugin's source repository. WordPress.org limits the readme changelog to 5,000 words, so detailed older entries have been archived there to keep this listing concise.
== Upgrade Notice ==

= 1.0.0 =
Initial release of Saga Payments for WooCommerce.

