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
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.
Installation
- Upload the
saga-paymentsfolder to the/wp-content/plugins/directory - Activate the plugin through the ‘Plugins’ menu in WordPress
- Go to WooCommerce > Settings > Payments > Saga Payments
- Enter your Merchant ID and Store ID (from your Saga Payments dashboard at https://dashboard.sagapay.no) and click Save changes
- The plugin will automatically provision your Terminal ID and Public Key on save – no manual key handling required
- Enable the payment method and save
FAQ
-
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.
-
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.
Reviews
There are no reviews for this plugin.
Contributors & Developers
“Saga Payments for WooCommerce” is open source software. The following people have contributed to this plugin.
ContributorsTranslate “Saga Payments for WooCommerce” into your language.
Interested in development?
Browse the code, check out the SVN repository, or subscribe to the development log by RSS.
Changelog
4.0.747
- Product sync: a product without a SKU is recognised by its name when pushed, the way the pull has always recognised it. The push’s duplicate check was SKU-only, so a SagaPOS-born product with no SKU that also existed in WooCommerce was pushed as a NEW catalogue product — the till showed it twice, and the shop was linked to the copy. Measured: thirteen such copies on the test store, now healed. Exactly one catalogue product with the same name that nobody else owns is linked; an ambiguous name is not guessed.
4.0.746
- Product sync: a product the shop put in the trash does not come back on its own. Every WooCommerce lookup the pull used to recognise a product is blind to the trash, so a catalogue row whose shop product had been trashed (the withdrawal failed, or the row was a second copy) matched nothing and was created again as a new product on the next pull — measured on 2026-09-10, two products trashed at 11:35 were back, published, at 11:36. A trashed product with the same SKU, or the same exact name, now stops the create; the log says which product and what to do (restore it in WooCommerce, or delete the row in SagaPOS).
4.0.745
- Product sync: a variation’s price now goes up through the tax lens like every other price. The variant payload was built outside the mapper and skipped the lens, so for a shop storing prices without tax the pull read a variant through the lens while the push sent it bare — the two disagreed by the rate on every run (measured on the live tax drive).
4.0.744
- Product sync: the catalogue’s VAT is the shop’s own echo when it is what the push sent. A product pushed in a tax class the nominal map does not know (a Norwegian shop’s
redusert-sats, say) while that class had no rate went up with “VAT 25”; the first pull read 25, had never seen a rate on the product, called that a change made at the till and set the class to standard — the merchant’s class gone, silently, and every later pull priced the product at the wrong rate. A rate that equals what the push would send for the class the product has is not a change and is not a mismatch; only a rate that differs from that, and from the last one seen, reaches the shop.
4.0.743
- Product sync: a variable product that manages one stock count for all its variations sent that count up once PER VARIANT (measured: a parent with 7 units and two variations put 14 on the till), and the pull then switched each variation to its own stock management with the parent’s number (the shop would have sold 21). The parent’s count now goes up once, on the product; a parent-managed variation is left as the merchant set it.
4.0.742
- Product sync: the tax lens on the pull is the class the product HAS in the shop, not the class the payload’s nominal VAT maps to. Measured on the live tax drive: a product in the reduced class was pushed at 80 × 1,15 = 92,00 and pulled back as 92,00, because the payload said VAT 25, the map said standard, and standard had no rate.
- Product sync: the till is told the VAT the shop really charges for the product’s class (the sum of its base rates), and a VAT from the catalogue resolves to the shop’s own class with that rate before the nominal map (a Norwegian shop’s
redusert-satsat 15 %, not the English slug that does not exist there).
4.0.741
- Product sync: no single-product push while a sync runs in another process. Three times on 2026-09-07 a product got two catalogue entries: the deferred push created it while “Synk nå” was pulling, and that request’s create loop, half a minute later, still did not see the link — through a purged cache and through a direct database read alike. A deferred or admin-save push that finds the pull or push lock held by another process now waits 90 seconds instead; by then the sync has either created the product (the push finds the link and updates) or finished without it (the push creates, alone).
4.0.740
- Product sync: the SKU index is rebuilt for every push run, so a product another process created seconds earlier is found by its SKU instead of being created again.
- Product sync: right before any create, the log now records what every source says about the product’s link (the loaded object, the database, a fresh meta read, the row count, the connection’s isolation level and the SKU index age) — a second catalogue product was still measured on 4.0.739, and the next occurrence must be a measurement, not a theory.
4.0.739
- Product sync: the database is the only re-read. 4.0.736 purged WordPress’s post-meta cache before re-checking a product’s link; measured on 4.0.738, a second catalogue product was still created for a product the deferred push had linked 25 seconds earlier — WooCommerce keeps its own per-object meta cache, and WordPress caches query results per request, so the “re-read” was this request’s stale copy on two more levels. Both create paths now ask the database directly right before creating, and the unlinked-products query is never answered from the request’s query cache.
4.0.738
- Product sync: an empty barcode is no barcode. The catalogue answers
"barCode":""for a product it holds no barcode for; the pull took that as a barcode and emptied the merchant’s GTIN/EAN (global_unique_id,_barcode,_ean,_gtin) on every pull. Only a non-empty value is a barcode now. - Product sync: the till cannot count below zero. A product sold on backorder (negative stock in WooCommerce) was set to 0 and marked OUT OF STOCK by the pull, and the web stopped selling it. The shop’s negative is kept when the till reports 0, and every stock status now follows WooCommerce’s own rule (‘onbackorder’ when backorders are allowed).
- Product sync: the push-side SKU dedupe never read
productProperties.wcSku— the field this plugin itself writes — so it was blind to every product it had created, and a product that lost its link (a repaired shared link, for example) was given a second catalogue product. The index now reads it, and a product whose stored payload names a catalogue product that still exists and has no other owner is relinked to it instead.
4.0.737
- Product sync: the catalogue may still hold the figure an earlier build pushed without the tax lens (4.0.736). For a shop that stores prices without tax, the first pull after upgrading would have read that figure through the lens and cut every shop price by a fifth. A catalogue price that equals the shop’s stored price to the øre, while the lens applies, is now recognised as the shop’s own echo: the price is kept and sent up again through the lens.
- Product sync: variation prices go through the tax lens like the parent’s, on both the push and the pull.
- Product sync: a variation’s
salePricein the catalogue is what the shop itself wrote when a sale was pushed, and cannot be cleared by a later push. Reading it back re-applied a sale the merchant had ended (the defect 4.0.732 fixed for simple products). Only the till’s own discount is now a sale on a variation; a sale the merchant set is left alone; a sale that came from the till is cleared when the till drops it.
4.0.736
- Product sync: one catalogue product per push. When a product saved outside wp-admin was carried up by the deferred push while “Synk nå” was running, the running push created the same product AGAIN — it re-read the link from its own memory, which another process cannot update. Measured: three products doubled in one run. Both push paths now re-read the link from the database after taking the lock.
- Product sync: a catalogue row whose SKU already belongs to a product linked to another catalogue id is a duplicate upstream, not a new product. The pull used to refuse the relink and then create a copy in the shop anyway; it now reports the duplicate and creates nothing.
- Product sync: a name longer than 255 characters is fitted on the way out, so the catalogue’s copy never matched the shop’s title and the duplicate guard could not see it. Fitted names are now matched by prefix and compared as the push would send them.
- Product sync: the deferred push and the five-minute cron could fall due in the same cron request and send the same body twice. Whichever runs second now sees the product was pushed after its last edit and sends nothing.
- Product sync: a linked external or grouped product is taken off the till by the pull, whether or not it was edited since the last push (4.0.734 only did so for edited products).
- Product sync: the till’s price is what the customer pays. A shop that stores prices WITHOUT tax and adds it at the checkout (WooCommerce’s default) was sending the bare figure to the till — 20 % under the web at 25 % VAT — and the pull wrote the till’s consumer price into the ex-VAT field, 25 % over. Prices now pass through the shop’s real base tax rates both ways; shops storing prices including tax, and shops with no rates, are untouched.
4.0.735
- Product sync: “Sync now” says when it did not run. A second click while a sync was already running was answered “Synk fullført — 0 opprettet, 0 oppdatert …”, which reads as “it ran and found nothing”; it had been refused by the lock. It now says that a sync is already running and to try again shortly.
4.0.734
- Product sync: a product that does not manage stock in WooCommerce – a service, a virtual or downloadable item, anything the merchant left untracked – is no longer forced out of stock by the pull. The catalogue’s count for such a product is 0, and the pull turned stock management on and wrote that 0, so the product could not be bought (measured on the test shop: a virtual product was out of stock two minutes after it was created). Stock now follows the till only where the shop tracks it.
- Product sync: external and grouped products are no longer sent to the till. An external product is sold on another site and a grouped product has no price of its own; both were pushed as ordinary catalogue products (a partner’s item and a 0 kr phantom on the till). They are skipped, and any that an earlier build pushed are withdrawn.
- Product sync: a product name longer than the catalogue’s 255-character limit is fitted on a word boundary instead of being refused every five minutes (“Length validation failed”). The shop keeps its full name; the pull recognises the fitted copy as its own.
- Product sync: data the catalogue refuses outright – a price above 999 999,99 kr, for instance (“Expecting values between 0-99999999”) – is no longer re-sent every five minutes. It is left alone for an hour, or until the merchant edits the product, and the reason stays on the sync page.
- Product sync: a product the sync had retired (its catalogue entry gone) and the merchant then restored from the trash is no longer retired again by the next pull. The dead catalogue link and the retirement stamps are dropped on restore, and the next push creates it in the catalogue afresh.
- Product sync: a SKU that already belongs to another shop product’s catalogue entry is no longer linked to it. Two shop products on one catalogue product would overwrite each other upstream and pull each other’s data; the newcomer now gets its own entry. A product in the trash yields its entry to the newcomer.
4.0.733
- Product sync: a variation deleted in WooCommerce no longer comes back. Deleting a variation told the catalogue nothing, and the next pull – finding a catalogue variant with no variation behind it – created it again: measured on the test shop, a variation deleted at 10:07 was back at 10:09 with the same catalogue id. The deletion is now remembered on the product, so the pull never re-creates that variant whatever the catalogue lists, and it is withdrawn from the catalogue too, with the catalogue’s answer in the log if it refuses.
- Product sync: a sale the merchant ended in WooCommerce no longer comes back from the catalogue. The plugin writes the sale price into the catalogue’s product properties when a product is created, and those properties never change afterwards – so the pull kept reading the sale the product was born with and put it back every fifteen minutes after the merchant had ended it (measured: ended at 12:26, re-applied at 12:41). That echo is no longer read as a sale. The till’s own signals – its documented discount and, where adopted, its campaign price – still reach the shop, and the pull now clears only a sale it set from the till, never one the merchant set in the shop.
- Product sync: the sync page keeps showing why a sale was refused by the catalogue until the refusal is actually resolved; a later successful product update no longer wipes the reason.
- Product sync: a sale the catalogue refuses is now retried once an hour, not on every run. 4.0.728 marked such a product as unsynced so it would be retried, which meant the product update and the campaign call were both sent every five minutes for as long as the refusal lasted. Measured on the test shop: Surfboard’s campaign endpoint currently answers 404 (“The requested endpoint does not exist”) for every product, so a shop with fifty products on sale would have sent 1,200 calls an hour into a dead route and been rate-limited for everything else. The shop keeps its sale throughout, the sync page names the catalogue’s reason, and a sale the merchant changes or ends in the meantime is sent at once.
- Product sync: NOTE for merchants – sales set in WooCommerce do not currently reach the till, because the catalogue’s campaign endpoint answers 404. The plugin keeps the shop’s sale and reports the refusal; the endpoint is Surfboard’s to restore.
4.0.732
- Product sync: a sale set in WooCommerce is no longer cleared by the pull before it has reached the till. A sale is sent to the catalogue as a campaign, on a separate call a minute after the save, and the 15-minute pull could land in that minute: measured on the test shop, a 70 kr sale was read by the pull as present and cleared to nothing, because an unrelated price push a second earlier had marked the product as synced. The pull now keeps a sale that WooCommerce holds but the catalogue has no matching campaign for, and lets the push carry it up.
4.0.731
- Product sync: when the catalogue refuses a sale (campaign), the reason is now recorded instead of “(no message)”. The campaign call reported nothing on failure, so a refused sale could not be told apart from a duplicate or a missing route on the sync page or in the log. The catalogue’s own words and the HTTP status are now carried through.
4.0.730
- Product sync: a SKU changed in WooCommerce after the product was created is no longer reverted by the pull. The SKU travels in the catalogue’s product properties, which the update call does not carry, so the catalogue keeps the day-one SKU and the pull wrote it back over the merchant’s change. The catalogue’s copy now only fills an empty SKU.
- Product sync: a tax class changed in WooCommerce is no longer silently reverted by the pull. The catalogue takes no VAT change after creation, so the shop’s change could never reach the till, and the next pull put the till’s rate back without a word. The tax class now follows the catalogue only when the catalogue’s rate differs from the rate the shop last saw there – that is, when the till changed it – and a shop/till mismatch is written to the log once a day, naming both rates, so the side that is wrong can be corrected.
4.0.729
- Diagnostics: the inventory probe now reads the whole catalogue, page by page, instead of the first page only. On any shop with more than one page of products (about seventy) it answered “product not found in catalogue” for every product past the first page – a false answer, measured on the test shop for product after product. An empty page, or a server that repeats page one, ends the read, and a not-found answer now says how many products were actually read.
4.0.728
- Product sync: the pull no longer downloads a second copy of the shop’s own product image. The push sends the featured image as a URL on the shop’s own site, the catalogue hands it back, and the pull imported it again – measured on the test shop: one product, one image, two media-library items after a single round trip, and the product’s featured image switched to the copy. A URL that resolves to one of the shop’s own attachments is now used as it is, for the featured image and the gallery alike, and an image the merchant already chose is never replaced by it.
- Product sync: a sale set in WooCommerce is no longer wiped by the pull when the catalogue refused the campaign. The product had already been stamped as pushed by the time the campaign call failed, so the pull saw no pending edit and cleared the sale because the catalogue had none – measured: a 7 kr sale was gone from the shop eight minutes after it was set. A push that falls short now leaves the product unpushed, with the catalogue’s reason on the sync page, so the next run retries and the shop keeps its prices meanwhile.
- Product sync: saving variations no longer pushes the whole product once per variation. The hook fires for every variation WooCommerce saves, and each firing pushed the parent and all its variants immediately – four full pushes for a four-variation product created over the REST API, the first three sent before the later variations existed. Outside wp-admin the parent now gets one deferred push a minute later; in wp-admin the push still happens at once, but once, after the last variation of the request has been saved.
4.0.727
- Product sync: a “<” that does not start a tag – “under <10 stk”, “a < b” – no longer takes the rest of the sentence with it on the way to the till. PHP’s tag stripping treats such a “<” as a tag on some PHP versions and not others (measured: the test shop’s PHP kept the text, PHP 8.3 returned “under “), so what reached the till depended on which PHP the merchant’s host runs. The character is now shielded before stripping. Measured on the catalogue: a bare & < > is accepted and stored HTML-encoded, and the pull’s comparison sees through that encoding.
4.0.726
- Product sync: a description with a straight double quote or a backtick now reaches the till. Measured on the test shop across 24 products, one character class each: the catalogue refuses exactly those two characters in the description and nothing else – emoji, HTML, long text, newlines, guillemets and every other punctuation mark went through. A merchant who wrote “beste kvalitet” had a product that was refused every five minutes and never reached the iPad. The push now sends the Norwegian typographic forms instead.
- Product sync: the pull no longer rewrites a merchant’s product text with the catalogue’s copy of it. The catalogue only ever holds the tag-free copy the push sent, and writing it back replaced every bold, list and line break in the shop’s own description within fifteen minutes of saving. Text that matches the shop’s own outbound copy is left alone; only text changed on the till is applied.
- Product sync: an edit made in WooCommerce that has not been pushed yet is no longer overwritten by the pull. The push runs a minute after a save and the pull every fifteen, and whenever the push was late, throttled or refused, the pull wrote the catalogue’s older name, price and text back over the edit and then stamped the product as pushed – so the edit was lost from both systems. The merchant’s name, prices, text and dimensions are now kept while an edit is waiting, and the stamp is withheld so the push still carries it up. Stock keeps following the till throughout. The same rule applies to each variation.
- Product sync: the pull no longer republishes – or untrashes – a product the merchant set to draft, private or trash. That revival was meant for products the sync itself had retired, which carry a retirement stamp; without the stamp the status was the merchant’s decision and now stands. Catalogue visibility follows the same rule.
- Product sync: a short description, weight or dimension changed after the product was created is no longer reverted by the pull. Those fields travel in the catalogue’s product properties, which the update call does not carry (by Surfboard’s own documentation), so what the catalogue holds is the copy frozen at creation. The pull now uses that copy only to fill an empty field, never to overwrite a later edit.
- Product sync: a product created in the catalogue is no longer sent a second time in the same run. The create path never set the marker the dirty query looks for, so every new product was created and then immediately updated with identical data.
4.0.725
- Product sync: the pull and the push no longer keep each other busy. Measured on the test shop over one hour: 298 variant updates sent to the catalogue, 297 of them for variants where nothing had changed, and 57 rate-limit errors. The pull saved every variable product on every run whether or not anything differed, which stamped it as modified, which made the push re-send every one of its variants. The pull now compares attributes before saving, and the push remembers what the catalogue last accepted for each variant and does not send it again unchanged.
- Product sync: a catalogue page that is not empty but repeats page one is now recognised for what it is – a server ignoring the page header, with everything past page one unreadable – and logged as a short read. Measured: Surfboard sends no total in its response headers, so this is the only signal there is; the end of a list is an empty page, which is still treated as the end. Discarding a short read stays behind the saga_catalog_refuse_short_reads filter, off by default, so a server that repeats page one on a one-page catalogue cannot stop a shop’s pull.
4.0.724
- Product sync: the short-read check from 4.0.723 now measures before it enforces. What the server’s declared total actually counts – products, variants, or every store – has not yet been read from a live catalogue, and if it counts more than the listing, discarding every “short” read would have stopped the pull for every shop. So by default a short read is now logged at error level, with both numbers, and nothing is discarded; the guard that stops a sync-retired product being deleted from the catalogue keeps a wrong retirement non-destructive in the meantime. Discarding can be switched on with the saga_catalog_refuse_short_reads filter once the header is understood. A shop whose products keep returning to the trash will now have the reason in its log after one pull: “declared N products but only M were read”.
4.0.723
- Product sync: a catalogue read that comes up short is now detected instead of being treated as the whole catalogue. Surfboard declares its total in a response header that the plugin never read, so a server that ignored the page header and answered every page with page one was indistinguishable from a complete read – and the orphan check then judged every product not on that page as deleted upstream and moved it to the trash. A merchant restoring those products found them back in the trash after the next pull, because the read was short every time. A short read is now treated exactly like a failed page: nothing is retired on the strength of it, and the log says how many the server declared against how many were read. When the server declares no total, nothing changes.
- Product sync: a product the sync itself retired is no longer deleted from the catalogue when WordPress purges the trash. WordPress empties the trash after 30 days on a cron; the plugin hooked that to the same handler as a merchant’s own deletion, whose only guard did not apply on a cron, so every product the sync had parked in the trash was a month later deleted from the real POS catalogue by the plugin. If the retirement was right the delete was noise; if it was wrong – a short read, a catalogue migration – it destroyed a product the till still sold. The push delete-sweep had already been fixed for exactly this after an earlier incident; this hook was the other door to the same room. Both now key on the same stamp.
- The retirement stamp is cleared when the catalogue lists the product again, so a product that came back does not carry it for life and have the merchant’s own later deletion of it refused.
4.0.722
- A VAT rate nobody set is no longer printed as “0 %”. A line with no tax information at all – tax switched off in the shop, or a line from before it was configured – now shows a dash and the summary reads “Merverdiavgift” without a rate. Printing 0 % put a rate on a legal document that the shop never stated, and made it indistinguishable from a genuine zero rate. A configured 0 % rate is a statement and still prints as one.
- When the seller is not registered in the VAT register, the document says so. A document with no VAT and no stated reason reads as an omission; that one line is what makes the figures make sense.
4.0.721
- Sales documents can now be switched on. Everything the series needs is in place: the seller resolved from the Surfboard merchant and refreshed daily, a number series keyed per legal entity and year, annulment and replacement for a document issued with an error, the Foretaksregisteret marking and head-office address for a limited company, and the reverse-charge wording. The setting is still off by default and a shop must also have an accounting agreement, so nothing starts issuing on its own.
- Two things are still unproven and are stated here rather than left to be discovered: the reverse-charge trigger has never fired on a real cross-border B2B sale, and the seller comes from whichever Surfboard merchant a shop is paired to – a shop paired to the wrong merchant will get documents naming the wrong company, correctly resolved and wrong.
4.0.720
- The seller identity is now refreshed daily rather than captured once. A company can be renamed, move, change its VAT registration or enter liquidation – and “Under avvikling” is exactly what the regulation requires the document to state. An identity captured only at pairing would have been frozen for the life of the shop and printed on every document it ever issued, with nothing to say it had gone stale. There is also a “Hent selger på nytt” button for when a change should take effect immediately.
- No credential is sent to fetch it: the accounting backend proves the shop by calling back to this site’s own handshake endpoint, the same challenge-response the pairing uses, so nothing is stored in the clear and the pairing token is not rotated to read an identity.
- Issuing never waits on that lookup. Documents are rendered from what is stored, so a sales document cannot fail because the accounting backend is unreachable, and a failed lookup never clears an identity already held.
4.0.719
- The seller on a sales document is no longer typed into a settings field. It is resolved from the Surfboard merchant and handed over by Saga Regnskap when the shop pairs – the same chain the accounting side uses, so the two can never name different sellers for one sale. A typed field was one typo away from producing two valid documents with two different sellers for the same sale, each looking correct.
- Documents from a limited company now carry the word “Foretaksregisteret” and the company’s registered head-office address, as bokf.forskr. 5-1-2 annet ledd requires, and say “Under avvikling” when that applies. The Foretaksregisteret marking keys on the company form (AS, ASA, NUF), not on mere presence in the register – a sole proprietorship can be registered without the wording covering it. When the company form is not known, nothing is printed: a register claim that cannot be told from a checked one is worse than a missing one.
- Still switched off and unable to be switched on. …