<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: WebKoding</title>
    <description>The latest articles on DEV Community by WebKoding (@webkoding).</description>
    <link>https://dev.to/webkoding</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3842681%2F7aeb248a-2a98-426d-a4de-13d68057b2bc.png</url>
      <title>DEV Community: WebKoding</title>
      <link>https://dev.to/webkoding</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webkoding"/>
    <language>en</language>
    <item>
      <title>WooCommerce cache serving the wrong country’s price</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Fri, 06 Nov 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/webkoding/woocommerce-cache-serving-the-wrong-countrys-price-3g4l</link>
      <guid>https://dev.to/webkoding/woocommerce-cache-serving-the-wrong-countrys-price-3g4l</guid>
      <description>&lt;p&gt;The dangerous failure of a cached WooCommerce shop is not a slow page. It is a fast one that carries the previous visitor’s country: a Portuguese customer sees the German price on the product page, then a different total in the cart. Here is a test that catches it before launch — two browser profiles, a fixed order of requests, and the cache header at every layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are actually looking for
&lt;/h2&gt;

&lt;p&gt;A load test tells you whether the shop survives traffic. It tells you nothing about whether the traffic saw the right numbers. When a page cache sits in front of a shop whose prices depend on the customer’s location, the first visitor to a URL decides what everyone after them sees until the entry expires. If that visitor was in Germany, the page is now the German page — 19% VAT baked into every price — and it is served, quickly and with a cache HIT, to the next visitor from Portugal, who owes 23%.&lt;/p&gt;

&lt;p&gt;The customer only finds out in the cart. The cart is never cached, so WooCommerce computes it fresh, with the real location, and the total no longer matches the price they clicked. That is the support ticket you get: “the price changed when I added it to the basket.” The cache did exactly what caches do. It just was not told that the page varied.&lt;/p&gt;

&lt;p&gt;If you have not yet decided how to structure the cache, read &lt;a href="https://webkodingtheme.com/blog/woocommerce-cache-prices-by-country" rel="noopener noreferrer"&gt;caching a WooCommerce shop when the price depends on the customer’s country&lt;/a&gt; first. This article assumes you have built one of those and want to know whether it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: two profiles, not two tabs
&lt;/h2&gt;

&lt;p&gt;Use two separate browser profiles, one standing in for each country. Not two tabs, and not one normal window plus one incognito window — the point is that neither profile has the other’s cookies, and a WooCommerce session cookie in particular changes how most cache plugins behave. Many of them bypass the cache the moment they see a wp_woocommerce_session_ cookie, which means a test run from a browser that has ever added something to a cart is testing the uncached path and will pass no matter what.&lt;/p&gt;

&lt;p&gt;The country signal has to arrive the way it arrives in production. If your cache is keyed on the CDN’s country header, or WooCommerce geolocates from the IP address, then a cookie or a query string that tells staging “pretend I am in Portugal” tests a different system from the one you are shipping. Use a VPN exit in each country, or two small servers, one in each. If that is not available, inject the override at the same point production reads the country — the CDN header, not a WordPress filter — so every layer below it sees exactly what it would see for a real visitor.&lt;/p&gt;

&lt;p&gt;Do it on staging with the same cache configuration as production. A test against a staging site with the cache plugin disabled tells you your tax rules are right, which you already knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence, and why the order matters
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Purge the cache at every layer — plugin, server, CDN.&lt;/li&gt;
&lt;li&gt;In the Germany profile, open a product URL. This warms the cache. Note the displayed price and the cache status of the response (it should be a MISS).&lt;/li&gt;
&lt;li&gt;In the Portugal profile, open the same URL. Note the price and the cache status. If it is a HIT and the price is the German one, you have found the failure.&lt;/li&gt;
&lt;li&gt;Purge again. Repeat with the order reversed: Portugal first, then Germany.&lt;/li&gt;
&lt;li&gt;Compare each displayed price with what your configured tax rules say it should be. Not with the other profile — with the rule. Both profiles showing the same number is a failure if the rates differ; both showing different numbers is still a failure if one of them is wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reversing the order is not thoroughness for its own sake. A cache that is not keyed on country but whose default location happens to be Germany will pass the Germany-first run — the German page is the right page, and the Portuguese visitor’s leak is what you catch on step 3. But some setups have a second cache, keyed on something like the base country, that only misbehaves when the warming visitor is not in the default location. Running both orders costs two minutes and covers both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the cache status at every layer, not just the last one
&lt;/h2&gt;

&lt;p&gt;A typical stack has three caches in a row, and each reports separately. Open the browser’s network panel, select the document request, and read the response headers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare: cf-cache-status — HIT, MISS, DYNAMIC (not eligible for caching), BYPASS or EXPIRED.&lt;/li&gt;
&lt;li&gt;LiteSpeed: x-litespeed-cache — hit or miss. With ESI in use, the page can be a hit while the price fragment is served separately.&lt;/li&gt;
&lt;li&gt;Varnish: usually X-Cache and X-Cache-Hits, though the exact header names depend on the VCL the host wrote.&lt;/li&gt;
&lt;li&gt;WP Rocket and similar plugins: no header — look at the very end of the page source for the plugin’s HTML comment, which includes the time the cached copy was generated.&lt;/li&gt;
&lt;li&gt;Any of them: an Age header greater than zero means somebody upstream served a stored copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layers can disagree, and the disagreement is the diagnosis. Cloudflare says DYNAMIC and the price is still wrong: the leak is in the plugin or the server cache. Plugin comment says the copy is fresh, Cloudflare says HIT: the plugin is doing the right thing and the CDN is serving the previous country over the top of it. And if you have configured a Vary or a cache key on country, the second profile’s first request must be a MISS. A HIT there means the key is not being applied to this URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not only the product page
&lt;/h2&gt;

&lt;p&gt;The product page is where people look, so it is where the exclusions and the workarounds tend to have been applied. The other pages that print prices are separate cache entries with their own rules, and it is common for the product page to be handled correctly while the pages that lead to it are not. Repeat the same two-profile sequence for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The home page, if it shows featured or recent products with prices.&lt;/li&gt;
&lt;li&gt;The shop page and at least one category archive.&lt;/li&gt;
&lt;li&gt;Search results.&lt;/li&gt;
&lt;li&gt;A product page’s related-products and upsell blocks — these are part of the same cached document, but their prices come from a different code path than the main product’s.&lt;/li&gt;
&lt;li&gt;Any page with a mini-cart in the header that shows a subtotal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are on the JavaScript-fetch approach, where the page is cached once and the prices are filled in by a request per page, check that the endpoint itself is not being cached at the CDN. It is a GET request to a URL that does not change, which is exactly what a CDN caches by default. The symptom is identical to a leaked page, and the page will look innocent because the placeholders are there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change country mid-session, then walk to the cart
&lt;/h2&gt;

&lt;p&gt;The tests so far cover a visitor who arrives from one country and stays there. Two transitions break differently.&lt;/p&gt;

&lt;p&gt;First, a country change within one session. In one profile, browse a product, then change the country — at the checkout address fields, or through whatever switcher the shop provides — and go back to the product page. WooCommerce now has an address on the session and will tax the cart accordingly. Whether the product page follows depends on whether the cache was told that this visitor is now someone else. If the page still shows the old country’s price, the visitor has one price on the page and another in the cart, and it is the cache, not the tax engine, that is lying.&lt;/p&gt;

&lt;p&gt;Second, the walk from browsing to the cart. Add the product from the cached page, open the cart, proceed to checkout. Every step after the first is uncached and computed against the real location. Compare the line-item price at each step with the price the product page showed. Any change between the product page and the cart is a leak that the earlier tests missed — most often because the product page was served before the session existed and the cart was computed after it did.&lt;/p&gt;

&lt;p&gt;WooCommerce ships its own partial answer to this, the “Geolocate (with page caching support)” option under default customer location, which redirects the visitor to the same URL with a hash of their location as a query string so that each country gets its own cache entry. It works if every layer honours query strings in the cache key and nobody has configured the CDN to strip them — which is another thing this test reveals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the load test separate
&lt;/h2&gt;

&lt;p&gt;Once the prices are right, load-test — but as a separate exercise, with the host’s permission, and against a representative mix. Fetching the home page ten thousand times measures the CDN, not the shop: after the first request every one of them is a HIT and never reaches PHP. A real day on an EU shop is a mix of countries, which under a per-country cache means a lower hit rate than a single-country test will ever show, plus carts and checkouts, which are never cached at all. That is the traffic that has to fit on the server.&lt;/p&gt;

&lt;p&gt;The two tests answer different questions and are easy to conflate because both come back with a number. One is “is the number right,” the other is “how fast does it arrive.” A fast wrong price fails the first and passes the second, and it is the first that costs you a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two browser profiles, country signal delivered the way production delivers it, cache configured as in production.&lt;/li&gt;
&lt;li&gt;Product URL: warm in A, open in B, compare each to the tax rule. Purge, reverse, repeat.&lt;/li&gt;
&lt;li&gt;Read the cache status at every layer — CDN, server, plugin — on every request.&lt;/li&gt;
&lt;li&gt;Same sequence for the home page, the shop page, one category, search, and the related-products block.&lt;/li&gt;
&lt;li&gt;If prices are fetched by JavaScript, confirm the endpoint is not cached at the CDN.&lt;/li&gt;
&lt;li&gt;Change the country in one session and revisit the product page.&lt;/li&gt;
&lt;li&gt;Add to cart from a cached page and compare the price at every step to checkout.&lt;/li&gt;
&lt;li&gt;Load test afterwards, separately, with a browsing-and-cart mix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building a WooCommerce store for the EU? WebKoding makes self-hosted WooCommerce plugins — including SplitVAT for products that carry more than one VAT rate — bought once, no subscriptions. &lt;a href="https://webkodingtheme.com/plugins" rel="noopener noreferrer"&gt;Browse the plugins →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://webkodingtheme.com/blog/woocommerce-cache-serving-wrong-country-price" rel="noopener noreferrer"&gt;webkodingtheme.com&lt;/a&gt;. I build self-hosted WordPress and WooCommerce plugins at &lt;a href="https://webkodingtheme.com" rel="noopener noreferrer"&gt;WebKoding&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>caching</category>
      <category>performance</category>
    </item>
    <item>
      <title>“Past-due actions found; something may be wrong” — the fix</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Tue, 03 Nov 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/webkoding/past-due-actions-found-something-may-be-wrong-the-fix-8hm</link>
      <guid>https://dev.to/webkoding/past-due-actions-found-something-may-be-wrong-the-fix-8hm</guid>
      <description>&lt;p&gt;The notice means jobs WooCommerce queued more than a day ago still have not run. Nothing is corrupt; the runner that should process them is not being started. Here is what the queue is, why it stalls, how to drain it, and how to keep it from filling again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exactly triggers the notice
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Action Scheduler: 1,240 past-due actions found; something may be wrong. Read documentation »&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Action Scheduler is the job queue inside WooCommerce. Anything that should happen later or in the background — sending a webhook, running an import in chunks, generating analytics, a subscription renewal, an abandoned-cart email from a plugin — is written to the queue as a “pending action” with a date. A runner is supposed to pick up due actions every minute and execute them.&lt;/p&gt;

&lt;p&gt;The notice is printed on admin pages when at least one pending action has a scheduled date more than 24 hours in the past. That is the whole test: pending, and overdue by a day. It is checked at most every six hours, which is why the number on the notice can lag behind what the list shows. It says nothing about why the actions did not run; it only says the runner has not got to them.&lt;/p&gt;

&lt;p&gt;Click the past-due actions link, or go to WooCommerce → Status → Scheduled Actions and filter by Past-due. The hook names in that list tell you what is not happening on your shop right now: woocommerce_deliver_webhook_async means webhooks are not being delivered, wc-admin_import_orders means Analytics is behind, and so on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the runner is supposed to start
&lt;/h2&gt;

&lt;p&gt;Action Scheduler registers a WP-Cron event, action_scheduler_run_queue, on a one-minute schedule. Each run processes a batch of up to 25 actions and stops after 30 seconds, then the next minute’s run continues. Only one batch runs at a time. There is also an async runner that fires a background request when an admin page loads and actions are due, which is why a stalled queue often lurches forward when somebody logs in.&lt;/p&gt;

&lt;p&gt;WP-Cron is not a real cron. It runs when a visitor requests a page, on that visitor’s request. So the entire mechanism depends on two things: page views arriving regularly, and each of those page views being allowed to spawn the cron request. When either fails, the queue fills and the notice appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five causes, in the order to check them
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;DISABLE_WP_CRON is set in wp-config.php and no system cron replaced it. Hosts and caching guides recommend the constant; the second half of the advice — “and add a real cron job” — is the part that gets skipped. Nothing runs, ever, except on admin page loads.&lt;/li&gt;
&lt;li&gt;The site gets too little traffic to trigger WP-Cron. A B2B shop with thirty visits a day gets thirty chances a day to run a 30-second batch. A queue that receives more work than that never catches up.&lt;/li&gt;
&lt;li&gt;Page cache serves every page without touching PHP. If Cloudflare, Varnish or a full-page cache plugin serves the whole site from cache, WP-Cron is never spawned by visitors at all; only uncached requests count.&lt;/li&gt;
&lt;li&gt;The cron request cannot reach the site. WP-Cron works by the site making an HTTP request to its own wp-cron.php. A firewall, HTTP authentication on a staging site, a wrong siteurl, or DNS that resolves the domain to a different server from inside the box all break that loopback silently. WooCommerce → Status → System status → WordPress environment shows a WP-Cron loopback test.&lt;/li&gt;
&lt;li&gt;The batch keeps dying before it finishes. A single action that fatals or hits the PHP memory limit takes the batch down with it, and the same action is picked up first next time. The queue looks stalled while the runner is actually being killed once a minute. Check the error log, and look for one hook name at the top of the past-due list with a very old date.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Draining the queue
&lt;/h2&gt;

&lt;p&gt;Once the cause is fixed, the backlog still has to run. A few hundred actions will clear on their own within an hour of a working cron. Thousands will not, because the runner only gets 30 seconds a minute and other work keeps arriving. Run it from the command line, where it has no time limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Process everything that is due, 100 at a time, until the queue is empty&lt;/span&gt;
wp action-scheduler run &lt;span class="nt"&gt;--batch-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100

&lt;span class="c"&gt;# Only the webhook deliveries, if that is what matters right now&lt;/span&gt;
wp action-scheduler run &lt;span class="nt"&gt;--hooks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;woocommerce_deliver_webhook_async
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you drain, decide whether every one of those actions should still happen. Twelve hundred abandoned-cart emails from three weeks ago should not go out today. Filter the list by hook, and for hooks whose actions are no longer wanted, use Cancel on the list rather than running them. Cancelled actions are kept for the log and then removed by the cleaner.&lt;/p&gt;

&lt;p&gt;If the actionscheduler_actions table itself has grown to millions of rows, the queue will be slow even when it works, because every query scans it. The cleaner removes completed actions after a month and failed ones after three; a long-broken queue can accumulate far more than that. wp action-scheduler clean, or deleting rows with status complete or canceled older than a month, brings it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it from filling again: a real cron
&lt;/h2&gt;

&lt;p&gt;The durable fix is the same for every cause: stop depending on visitors. Disable WP-Cron’s visitor trigger and have the server call it on a schedule instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# wp-config.php&lt;/span&gt;
define&lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISABLE_WP_CRON'&lt;/span&gt;, &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c"&gt;# crontab -e  (every minute, as the web user)&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/shop &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1

&lt;span class="c"&gt;# Or, without WP-CLI, request the file directly&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://shop.example/wp-cron.php?doing_wp_cron &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every minute is right, not every five: Action Scheduler’s own schedule is per minute, and a runner that is called less often simply does less work. On a busy shop, add a second entry that runs wp action-scheduler run directly so the queue is not limited to WP-Cron’s 30-second batches at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WooCommerce → Status → Scheduled Actions → Past-due: which hooks, how old is the oldest.&lt;/li&gt;
&lt;li&gt;wp-config.php: is DISABLE_WP_CRON set, and if so, is there a system cron actually calling the site?&lt;/li&gt;
&lt;li&gt;WooCommerce → Status: does the WP-Cron loopback check pass?&lt;/li&gt;
&lt;li&gt;Full-page cache: does any request reach PHP without a logged-in user?&lt;/li&gt;
&lt;li&gt;Error log: is one action fataling and killing every batch?&lt;/li&gt;
&lt;li&gt;Drain with wp action-scheduler run; cancel what should no longer happen.&lt;/li&gt;
&lt;li&gt;Install a per-minute system cron so it does not happen again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was lost while the queue was stalled
&lt;/h2&gt;

&lt;p&gt;Most queued work is harmless to run late. Webhooks are the exception. An order.created webhook delivered three days after the order is a fulfilment system, a CRM or an accounting tool that learned about the sale three days late — or, if the webhook had already been switched off after failed deliveries, never learned at all. Action Scheduler keeps one line per attempt; it does not tell you which orders the other system is still missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HookPilot keeps the delivery log&lt;/strong&gt; — Every webhook attempt with its response, retries with backoff, and a resend button for the orders that went out late or not at all — so a stalled queue is a delay, not a gap. &lt;a href="https://webkodingtheme.com/plugins/hookpilot" rel="noopener noreferrer"&gt;HookPilot →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://webkodingtheme.com/blog/action-scheduler-past-due-actions-found" rel="noopener noreferrer"&gt;webkodingtheme.com&lt;/a&gt;. I build self-hosted WordPress and WooCommerce plugins at &lt;a href="https://webkodingtheme.com" rel="noopener noreferrer"&gt;WebKoding&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your WooCommerce webhook is not firing. Here is the order to check things in.</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/webkoding/your-woocommerce-webhook-is-not-firing-here-is-the-order-to-check-things-in-45dj</link>
      <guid>https://dev.to/webkoding/your-woocommerce-webhook-is-not-firing-here-is-the-order-to-check-things-in-45dj</guid>
      <description>&lt;p&gt;A webhook that stops is rarely broken in the way it looks broken. WooCommerce fires it, something at the other end times out, WooCommerce marks the webhook disabled after five failures, and from then on nothing happens at all — silently, with no email and nothing on any screen you normally look at. Most of the time the integration was fine and the receiver was asleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: WooCommerce disabled it and did not tell you
&lt;/h2&gt;

&lt;p&gt;WooCommerce counts consecutive delivery failures. After five it sets the webhook’s status to disabled and stops trying. This is sensible — it stops a dead endpoint generating traffic forever — but nothing announces it. Go to WooCommerce → Settings → Advanced → Webhooks and look at the status column before you debug anything else. If it says Disabled, that is your answer, and re-enabling it does not resend what was missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second: was it ever actually sent?
&lt;/h2&gt;

&lt;p&gt;WooCommerce delivers webhooks through the scheduled-action queue, not during the request that triggered them. So the order completes, the customer sees their thank-you page, and the delivery is a separate job that runs afterwards. If Action Scheduler is stuck — a common state on shops with a large backlog, or where WP-Cron never runs because the site gets no traffic — the webhook is not failing. It is queued, and queued looks exactly like nothing happening.&lt;/p&gt;

&lt;p&gt;Check WooCommerce → Status → Scheduled Actions, filter to Pending, and look for actions named woocommerce_deliver_webhook_async. A pile of them with dates in the past is a cron problem, not a webhook problem, and the fix is a real system cron hitting wp-cron.php rather than anything to do with the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third: what the other end actually said
&lt;/h2&gt;

&lt;p&gt;WooCommerce treats any response outside 2xx as a failure, and it keeps only the last delivery per webhook. That is enough to tell you the endpoint returned 500 once; it is not enough to tell you it has been returning 500 all week, or that it returned 200 for nine orders and timed out on the tenth. The pattern is the diagnosis, and the pattern is what WooCommerce throws away.&lt;/p&gt;

&lt;p&gt;A timeout is worth separating from a rejection. Five seconds is the default, and a receiver that does real work before replying — writing to a CRM, calling another API — will exceed it under load while being perfectly healthy. Those deliveries are lost, the order is fine, and nothing in the shop indicates a gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrying, rather than hoping
&lt;/h2&gt;

&lt;p&gt;The behaviour you want is unglamorous: when a delivery fails, try again after a minute, then after a few minutes, then after an hour, and keep the whole history so you can see whether it eventually landed. That is what every payment provider does to you, and it is what a shop sending its own orders somewhere should do too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HookPilot does the retrying and keeps the log&lt;/strong&gt; — Every attempt with its response code and body, backoff between retries, and a webhook that stays on rather than being switched off behind your back. &lt;a href="https://webkodingtheme.com/plugins/hookpilot" rel="noopener noreferrer"&gt;HookPilot →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you blame the plugin at the other end
&lt;/h2&gt;

&lt;p&gt;Two things account for most reports. A staging site cloned from production keeps the same webhooks and starts delivering real orders to somebody’s live endpoint — check that first if deliveries doubled. And a security plugin or a host’s firewall will block outbound POSTs to an address it does not recognise, which looks identical to the receiver being down, except the receiver never saw a request at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://webkodingtheme.com/blog/woocommerce-webhook-not-firing" rel="noopener noreferrer"&gt;webkodingtheme.com&lt;/a&gt;. I build self-hosted WordPress and WooCommerce plugins at &lt;a href="https://webkodingtheme.com" rel="noopener noreferrer"&gt;WebKoding&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>webhooks</category>
      <category>webdev</category>
    </item>
    <item>
      <title>EU Digital Product Passport in WooCommerce: Implementing ESPR Compliance with Structured Data and QR Codes</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 06 May 2026 19:06:59 +0000</pubDate>
      <link>https://dev.to/webkoding/eu-digital-product-passport-in-woocommerce-implementing-espr-compliance-with-structured-data-and-bka</link>
      <guid>https://dev.to/webkoding/eu-digital-product-passport-in-woocommerce-implementing-espr-compliance-with-structured-data-and-bka</guid>
      <description>&lt;p&gt;The EU's &lt;strong&gt;Ecodesign for Sustainable Products Regulation (ESPR)&lt;/strong&gt; mandates Digital Product Passports (DPP) for most physical goods sold in Europe starting 2026–2030 (phased by category). If you run a WooCommerce store shipping to EU customers, your products will need machine-readable passports containing lifecycle data, materials, repairability scores, and recycling instructions — all accessible via a physical carrier (QR code, RFID, or datamatrix).&lt;/p&gt;

&lt;p&gt;This article walks through how we built a self-hosted WooCommerce plugin to generate DPP-compliant structured data and printable QR codes — no third-party SaaS, no ongoing fees.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a Digital Product Passport?
&lt;/h2&gt;

&lt;p&gt;A DPP is a structured data record attached to a physical product containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product identity&lt;/strong&gt;: GTIN/EAN, model, manufacturer, batch/serial&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materials &amp;amp; substances&lt;/strong&gt;: Bill of materials, hazardous substances (SVHC list)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repairability&lt;/strong&gt;: Spare parts availability, repair manual links, disassembly score&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environmental footprint&lt;/strong&gt;: Carbon footprint (kg CO₂e), energy efficiency class&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-of-life&lt;/strong&gt;: Recycling instructions, collection points, recyclate content %&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance documents&lt;/strong&gt;: CE declaration, REACH compliance, test reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data must be accessible via a &lt;strong&gt;carrier&lt;/strong&gt; (QR code printed on product/packaging) pointing to a URL that returns structured data in a machine-readable format (JSON-LD recommended by the EU).&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Data Model — WooCommerce Product Meta
&lt;/h2&gt;

&lt;p&gt;We store DPP data as WooCommerce product meta, mapped to the EU DPP data model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SP_DPP_Data_Model&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="c1"&gt;// Identity&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_gtin'&lt;/span&gt;                   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_manufacturer_name'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_manufacturer_country'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_batch_number'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// Materials&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_main_material'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_recycled_content_pct'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_hazardous_substances'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_textarea_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// Repairability&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_repairability_score'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_spare_parts_url'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'esc_url_raw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_repair_manual_url'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'esc_url_raw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_warranty_years'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// Environmental&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_carbon_footprint_kg'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_energy_class'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_energy_kwh_year'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// End of life&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_recycling_instructions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_textarea_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_disassembly_time_min'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_collection_point_url'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'esc_url_raw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// Compliance&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_ce_declaration_url'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'esc_url_raw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'dpp_reach_compliant'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save_product_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'edit_product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sp_dpp_nonce'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;wp_verify_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sp_dpp_nonce'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'sp_dpp_save_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELDS&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sanitizer&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;call_user_func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$sanitizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_passport_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

        &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELDS&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. JSON-LD Passport Endpoint (WP REST API)
&lt;/h2&gt;

&lt;p&gt;The QR code points to a public REST endpoint returning JSON-LD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SP_DPP_REST_Controller&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp/v1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$rest_base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'passport'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register_routes&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;rest_base&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/(?P&amp;lt;product_id&amp;gt;[\d]+)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;WP_REST_Server&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;READABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'callback'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'get_passport'&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Public&lt;/span&gt;
                &lt;span class="s1"&gt;'args'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'product_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                        &lt;span class="s1"&gt;'validate_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;is_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'absint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_passport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;WP_REST_Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WP_REST_Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'product_id'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$product&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;is_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Product not found'&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SP_DPP_Data_Model&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$data&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_passport_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$passport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'@context'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'schema'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://schema.org/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://digital-product-passport.eu/ns#'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'@type'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'schema:Product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dpp:DigitalProductPassport'&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'@id'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'schema:name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'schema:gtin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_gtin'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:materials'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:primaryMaterial'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_main_material'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:recycledContentPct'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_recycled_content_pct'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:hazardousSubstances'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_hazardous_substances'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:repairability'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:score'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_repairability_score'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:sparePartsUrl'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_spare_parts_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:repairManual'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_repair_manual_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:warrantyYears'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_warranty_years'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:environmental'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:carbonFootprintKg'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_carbon_footprint_kg'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:energyClass'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_energy_class'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:energyKwhYear'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_energy_kwh_year'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:endOfLife'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:recyclingInstructions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_recycling_instructions'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:disassemblyTimeMin'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_disassembly_time_min'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:collectionPointUrl'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_collection_point_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:compliance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:ceDeclarationUrl'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_ce_declaration_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'dpp:reachCompliant'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'dpp_reach_compliant'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'dpp:generatedAt'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'c'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$passport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/ld+json'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Cache-Control'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public, max-age=3600'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Endpoint URL: &lt;code&gt;https://yourstore.com/wp-json/sp-dpp/v1/passport/123&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. QR Code Generation (No External API)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SP_DPP_QR_Generator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generate_qr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$passport_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rest_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp/v1/passport/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$cache_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sp_dpp_qr_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$cached&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache_key&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cached&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="no"&gt;SP_DPP_PATH&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'vendor/phpqrcode/qrlib.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nb"&gt;ob_start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;QRcode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$passport_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;QR_ECLEVEL_M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$raw_png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;ob_get_clean&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$data_uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'data:image/png;base64,'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$raw_png&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;WEEK_IN_SECONDS&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;save_qr_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$upload_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_upload_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$dpp_dir&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$upload_dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'basedir'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp-qr/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dpp_dir&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;wp_mkdir_p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dpp_dir&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dpp_dir&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'index.php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;?php // Silence is golden.'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'dpp-qr-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.png'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="no"&gt;SP_DPP_PATH&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'vendor/phpqrcode/qrlib.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;QRcode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rest_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp/v1/passport/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product_id&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$dpp_dir&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;QR_ECLEVEL_M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$upload_dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'baseurl'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp-qr/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$filename&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. Admin Product Tab
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_product_data_tabs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tabs&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$tabs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sp_dpp'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Digital Passport'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'target'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sp_dpp_product_data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$tabs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_product_data_panels'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;wp_nonce_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sp_dpp_save_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp_dpp_nonce'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div id="sp_dpp_product_data" class="panel woocommerce_options_panel"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div class="options_group"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;woocommerce_wp_text_input&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_gtin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'GTIN / EAN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Global Trade Item Number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'desc_tip'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_dpp_gtin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nf"&gt;woocommerce_wp_text_input&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_repairability_score'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Repairability Score (e.g. 7.5/10)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_dpp_repairability_score'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nf"&gt;woocommerce_wp_text_input&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_carbon_footprint_kg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Carbon Footprint (kg CO₂e)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_dpp_carbon_footprint_kg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// QR preview&lt;/span&gt;
    &lt;span class="nv"&gt;$qr&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SP_DPP_QR_Generator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$qr&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_qr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div class="options_group"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DPP QR Code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;img src="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$src&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'" width="150" /&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;p&amp;gt;&amp;lt;a href="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rest_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp/v1/passport/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'" target="_blank"&amp;gt;'&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_html__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'View Passport JSON-LD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sp-dpp'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Bulk Import via WooCommerce CSV
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_csv_product_import_mapping_default_columns'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$columns&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'GTIN'&lt;/span&gt;                &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_gtin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Manufacturer'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_manufacturer_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Carbon Footprint kg'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_carbon_footprint_kg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Recycled Content %'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_recycled_content_pct'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Repairability Score'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_repairability_score'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Energy Class'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'dpp_energy_class'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_product_import_pre_insert_product_object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;WC_Product&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WC_Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;SP_DPP_Data_Model&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIELDS&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$field&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  ESPR Rollout Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;Categories&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;Batteries, textiles, electronics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2027&lt;/td&gt;
&lt;td&gt;Furniture, steel, cement, chemicals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2028–2030&lt;/td&gt;
&lt;td&gt;All remaining categories&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The DPP endpoint must stay accessible for the &lt;strong&gt;full product lifecycle&lt;/strong&gt; — typically 10+ years after sale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Self-Hosted?
&lt;/h2&gt;

&lt;p&gt;DPP SaaS platforms (Circularise, Renoon, Fairly Made) charge €200–500/mo per brand. For most WooCommerce stores, a one-time plugin generating compliant JSON-LD and QR codes covers the full technical requirement without ongoing costs.&lt;/p&gt;

&lt;p&gt;The full plugin is on CodeCanyon: &lt;a href="https://codecanyon.net/item/eu-digital-product-passport-espr-woocommerce/63129568" rel="noopener noreferrer"&gt;EU Digital Product Passport for WooCommerce (ESPR)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Questions about the JSON-LD structure or ESPR data requirements? Drop them in the comments.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>php</category>
      <category>sustainability</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Implementing ZATCA Phase 2 E-Invoicing in WooCommerce: UBL XML, XAdES Signing, and Hash Chains</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 06 May 2026 18:51:21 +0000</pubDate>
      <link>https://dev.to/webkoding/implementing-zatca-phase-2-e-invoicing-in-woocommerce-ubl-xml-xades-signing-and-hash-chains-1682</link>
      <guid>https://dev.to/webkoding/implementing-zatca-phase-2-e-invoicing-in-woocommerce-ubl-xml-xades-signing-and-hash-chains-1682</guid>
      <description>&lt;p&gt;Saudi Arabia's ZATCA Phase 2 e-invoicing mandate is one of the most technically demanding compliance requirements I've encountered in WooCommerce development. It's not just generating a PDF — it's UBL 2.1 XML, XAdES-BES digital signatures, a cryptographic hash chain, TLV-encoded QR codes, and real-time API submission to government servers.&lt;/p&gt;

&lt;p&gt;Here's how I built FatooraPro, a WooCommerce plugin that handles the full ZATCA Phase 2 flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ZATCA Phase 2 Actually Requires
&lt;/h2&gt;

&lt;p&gt;Every invoice must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Be generated as UBL 2.1 compliant XML&lt;/li&gt;
&lt;li&gt;Be digitally signed with XAdES-BES using a ZATCA-issued certificate&lt;/li&gt;
&lt;li&gt;Include a TLV-encoded QR code with 8 specific fields&lt;/li&gt;
&lt;li&gt;Maintain a hash chain (each invoice's hash references the previous one)&lt;/li&gt;
&lt;li&gt;Be submitted to ZATCA's API — either for clearance (B2B) before delivery, or reporting (B2C) within 24 hours&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss any of these and the invoice is legally invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Invoice Types
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Standard Tax Invoice (B2B — Clearance)&lt;/strong&gt;&lt;br&gt;
Submitted to ZATCA &lt;em&gt;before&lt;/em&gt; the invoice is delivered to the buyer. ZATCA must approve it. This means your checkout flow has to wait for an API response, or queue it and hold the invoice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simplified Tax Invoice (B2C — Reporting)&lt;/strong&gt;&lt;br&gt;
Submitted to ZATCA within 24 hours. More lenient — you can generate and deliver the invoice immediately, then report asynchronously.&lt;/p&gt;

&lt;p&gt;In WooCommerce, I detect the invoice type based on whether the buyer has a VAT number (B2B) or not (B2C).&lt;/p&gt;
&lt;h2&gt;
  
  
  UBL 2.1 XML Structure
&lt;/h2&gt;

&lt;p&gt;ZATCA requires a specific XML namespace and field order. Here's a simplified version of what the XML looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Invoice&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"&lt;/span&gt;
         &lt;span class="na"&gt;xmlns:cac=&lt;/span&gt;&lt;span class="s"&gt;"urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"&lt;/span&gt;
         &lt;span class="na"&gt;xmlns:cbc=&lt;/span&gt;&lt;span class="s"&gt;"urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:ProfileID&amp;gt;&lt;/span&gt;reporting:1.0&lt;span class="nt"&gt;&amp;lt;/cbc:ProfileID&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:ID&amp;gt;&lt;/span&gt;INV-2026-001&lt;span class="nt"&gt;&amp;lt;/cbc:ID&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:UUID&amp;gt;&lt;/span&gt;6f4d3885-0e99-4f13-9c8b-...&lt;span class="nt"&gt;&amp;lt;/cbc:UUID&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:IssueDate&amp;gt;&lt;/span&gt;2026-05-06&lt;span class="nt"&gt;&amp;lt;/cbc:IssueDate&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:IssueTime&amp;gt;&lt;/span&gt;14:30:00&lt;span class="nt"&gt;&amp;lt;/cbc:IssueTime&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;cbc:InvoiceTypeCode&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"0200000"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;388&lt;span class="nt"&gt;&amp;lt;/cbc:InvoiceTypeCode&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Seller info, buyer info, line items, tax totals... --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Invoice&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;InvoiceTypeCode&lt;/code&gt; name attribute is a 7-digit bitmask that encodes invoice properties (standard/simplified, credit/debit, etc.).&lt;/p&gt;

&lt;h2&gt;
  
  
  XAdES-BES Digital Signing
&lt;/h2&gt;

&lt;p&gt;This is where it gets complex. ZATCA requires XAdES-BES (XML Advanced Electronic Signatures, Basic Electronic Signature profile). In PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sign_invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$private_key_pem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$certificate_pem&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Canonicalize the XML (C14N)&lt;/span&gt;
    &lt;span class="nv"&gt;$dom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loadXML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$xml&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$dom&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;C14N&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Hash the canonicalized XML&lt;/span&gt;
    &lt;span class="nv"&gt;$digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Build SignedInfo element with digest&lt;/span&gt;
    &lt;span class="nv"&gt;$signed_info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build_signed_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$digest&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Sign the SignedInfo with the private key&lt;/span&gt;
    &lt;span class="nb"&gt;openssl_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$signed_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature_raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$private_key_pem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_ALGO_SHA256&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$signature_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$signature_raw&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Embed certificate hash and signature into XML&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;embed_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$signature_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$certificate_pem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$digest&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The certificate itself is issued by ZATCA through a 4-step onboarding process: CSR generation → Compliance CSID → Simulation testing → Production CSID.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hash Chain (PIH/ICV)
&lt;/h2&gt;

&lt;p&gt;ZATCA requires sequential invoice integrity. Each invoice must contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ICV (Invoice Counter Value)&lt;/strong&gt;: sequential integer starting at 1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PIH (Previous Invoice Hash)&lt;/strong&gt;: SHA-256 hash of the previous invoice's XML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means invoices are cryptographically linked — you can't insert, delete, or modify an old invoice without breaking the chain. In WooCommerce, I store the last hash in a WordPress option and update it atomically after each successful submission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_and_increment_icv&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Use DB transaction to prevent race conditions&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'START TRANSACTION'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$current_icv&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'fatoorapro_icv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$previous_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'fatoorapro_pih'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2YzNDkyMQ=='&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$new_icv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$current_icv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'fatoorapro_icv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$new_icv&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'COMMIT'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'icv'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$new_icv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pih'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$previous_hash&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default PIH for the first invoice is a SHA-256 hash of a specific base64 string defined in ZATCA's spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLV-Encoded QR Code
&lt;/h2&gt;

&lt;p&gt;ZATCA's QR code uses Tag-Length-Value encoding, not a simple URL. It must contain 8 fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generate_tlv_qr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$tlv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'seller_name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;           &lt;span class="c1"&gt;// Seller name&lt;/span&gt;
        &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'vat_number'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;            &lt;span class="c1"&gt;// VAT registration number  &lt;/span&gt;
        &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timestamp'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;             &lt;span class="c1"&gt;// Invoice timestamp&lt;/span&gt;
        &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'total_with_vat'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;        &lt;span class="c1"&gt;// Invoice total with VAT&lt;/span&gt;
        &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'vat_amount'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;            &lt;span class="c1"&gt;// VAT amount&lt;/span&gt;
        &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'xml_hash'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;              &lt;span class="c1"&gt;// Invoice XML hash (B2B only)&lt;/span&gt;
        &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ecdsa_signature'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;       &lt;span class="c1"&gt;// ECDSA signature (B2B only)&lt;/span&gt;
        &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'public_key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;            &lt;span class="c1"&gt;// Public key (B2B only)&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$fields&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_convert_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$length&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$encoded&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$tlv&lt;/span&gt;    &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;chr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;chr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$length&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$tlv&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Async Submission with WP-Cron
&lt;/h2&gt;

&lt;p&gt;B2B clearance requires synchronous submission (blocking). But B2C reporting can be async. I use WP-Cron for retry logic on failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schedule_submission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;wp_schedule_single_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 30 second delay&lt;/span&gt;
        &lt;span class="s1"&gt;'fatoorapro_submit_invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'fatoorapro_submit_invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;submit_to_zatca&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_zatca_attempts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;update_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_zatca_attempts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;wp_schedule_single_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fatoorapro_submit_invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$invoice_id&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// retry in 5 min&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  HPOS Compatibility
&lt;/h2&gt;

&lt;p&gt;ZATCA data is stored per-order, so it needs to work with both classic order posts and HPOS. I use WooCommerce's order meta abstraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Works with both post meta and HPOS order tables&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_zatca_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cleared'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_zatca_uuid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$uuid&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_zatca_qr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$qr_code&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Onboarding Flow
&lt;/h2&gt;

&lt;p&gt;Getting a production certificate from ZATCA requires 4 steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CSR generation&lt;/strong&gt; — create a private key + certificate signing request with your VAT number embedded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance CSID&lt;/strong&gt; — submit CSR to ZATCA, get a compliance certificate back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance tests&lt;/strong&gt; — run 6 predefined test invoices against ZATCA's sandbox&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production CSID&lt;/strong&gt; — exchange compliance certificate for production certificate&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built a 4-step wizard in WP Admin that walks store owners through this. Most of them aren't developers, so the UI has to handle the complexity invisibly.&lt;/p&gt;




&lt;p&gt;I packaged all of this into FatooraPro — a complete ZATCA Phase 2 solution for WooCommerce stores in Saudi Arabia: &lt;a href="https://webkodingtheme.com/plugins/fatoorapro" rel="noopener noreferrer"&gt;FatooraPro, a ZATCA Phase 2 WooCommerce plugin&lt;/a&gt; · &lt;a href="https://codecanyon.net/item/fatoorapro-zatca-phase-2-einvoicing-for-woocommerce/62634635" rel="noopener noreferrer"&gt;FatooraPro on CodeCanyon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any part — the XAdES signing and hash chain were the most challenging pieces.&lt;/p&gt;

</description>
      <category>php</category>
      <category>woocommerce</category>
      <category>wordpress</category>
      <category>security</category>
    </item>
    <item>
      <title>WooCommerce Only Allows One Tax Rate Per Line Item — Here's How I Solved It</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 06 May 2026 18:30:54 +0000</pubDate>
      <link>https://dev.to/webkoding/woocommerce-only-allows-one-tax-rate-per-line-item-heres-how-i-solved-it-21hj</link>
      <guid>https://dev.to/webkoding/woocommerce-only-allows-one-tax-rate-per-line-item-heres-how-i-solved-it-21hj</guid>
      <description>&lt;p&gt;WooCommerce has a limitation that quietly causes legal compliance issues for thousands of stores across Germany, Austria, France, and other EU countries: &lt;strong&gt;you can only assign one tax class per line item&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For most stores this is fine. But if you sell brunch tickets, hotel packages, event passes, or any product that legally requires multiple VAT rates on a single invoice — you have a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real-World Problem
&lt;/h2&gt;

&lt;p&gt;Take a brunch ticket priced at €39. In Germany, food is taxed at 7% and beverages at 19%. Since January 1, 2026, the German BMF (Bundesministerium der Finanzen) requires that invoices for such products show the split explicitly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;th&gt;VAT&lt;/th&gt;
&lt;th&gt;Gross&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Food portion (80%)&lt;/td&gt;
&lt;td&gt;€29.16&lt;/td&gt;
&lt;td&gt;7% → €2.04&lt;/td&gt;
&lt;td&gt;€31.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drinks portion (20%)&lt;/td&gt;
&lt;td&gt;€6.55&lt;/td&gt;
&lt;td&gt;19% → €1.24&lt;/td&gt;
&lt;td&gt;€7.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€35.71&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€3.28&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€39.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without the split, your invoice is legally non-compliant — the customer overpays VAT by €2.94, and your tax report is wrong. Similar requirements exist in Austria, Switzerland, and France.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why WooCommerce Can't Do This Natively
&lt;/h2&gt;

&lt;p&gt;WooCommerce's order data model stores tax per line item as a single tax class. There is no native concept of sub-items within a line item — you can't attach two different tax rates to one product without fundamentally changing how the order is stored.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Approach: Virtual Split Parts in Order Meta
&lt;/h2&gt;

&lt;p&gt;The solution is to store split rules on the product, then at order creation inject virtual line items into order metadata — one per split part — while keeping the customer-facing display as a single product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Product-Level Split Rules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stored as product meta: _splitvat_rules&lt;/span&gt;
&lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Food portion'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'percentage'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'tax_class'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'reduced-rate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 7% in Germany&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'label'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Drinks portion'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'percentage'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'tax_class'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'standard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// 19% in Germany&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Hooking Into Order Creation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'woocommerce_checkout_create_order_line_item'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cart_item_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_product&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$rules&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'_splitvat_rules'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$split_parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splitvat_calculate_parts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_total&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_splitvat_parts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$split_parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'_splitvat_enabled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Penny-Perfect Rounding
&lt;/h3&gt;

&lt;p&gt;Splitting €39 into 80/20 is clean, but many totals produce floating point rounding errors. The "remainder on last" algorithm fixes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;splitvat_calculate_parts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$parts&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$allocated&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$last_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rules&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rule&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$last_index&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$allocated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// remainder — no drift&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$amount&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'percentage'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$allocated&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guarantees split parts always sum to exactly the original total.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: HPOS Compatibility
&lt;/h3&gt;

&lt;p&gt;If you're on WooCommerce High-Performance Order Storage, always use the order abstraction layer — not direct SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Always use this&lt;/span&gt;
&lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_orders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'status'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'meta_query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'_splitvat_enabled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Accounting Export Formats
&lt;/h2&gt;

&lt;p&gt;Different countries need different export formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DATEV Buchungsstapel&lt;/strong&gt; — Germany (direct import into tax advisor software)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BMD/RZL&lt;/strong&gt; — Austria (Steuercode + Kontenrahmen mapping)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FEC&lt;/strong&gt; — France (18 mandatory fields, required by tax authorities)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt; — Universal (Excel, Google Sheets, any tool)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Key Insight
&lt;/h2&gt;

&lt;p&gt;Orders are immutable records. Do the heavy lifting at checkout, store the computed split in order item meta, and read it back whenever you need it (invoices, exports, reports). Trying to recalculate after the fact is unreliable — prices and tax rates can change.&lt;/p&gt;




&lt;p&gt;I packaged all of this into SplitVAT — 13 country presets, 4 export formats, and integrations for the major WooCommerce invoice plugins. If you're dealing with this compliance problem: &lt;a href="https://codecanyon.net/item/splitvat-woocommerce-split-vat-mixed-tax-rate-invoice-splitter/62604026" rel="noopener noreferrer"&gt;SplitVAT on CodeCanyon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any part of the implementation.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>php</category>
      <category>wordpress</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dead Stock is Silently Killing Your WooCommerce Store (And How to Find It)</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 25 Mar 2026 07:20:33 +0000</pubDate>
      <link>https://dev.to/webkoding/dead-stock-is-silently-killing-your-woocommerce-store-and-how-to-find-it-2l4</link>
      <guid>https://dev.to/webkoding/dead-stock-is-silently-killing-your-woocommerce-store-and-how-to-find-it-2l4</guid>
      <description>&lt;p&gt;Most WooCommerce store owners focus on what's selling. But the real profit killer is what &lt;em&gt;isn't&lt;/em&gt; selling — dead stock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is dead stock?
&lt;/h2&gt;

&lt;p&gt;Dead stock is inventory that hasn't sold in 60+ days and has more than a few weeks of stock remaining. It's capital sitting in your warehouse doing nothing — and often getting worse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Storage costs keep accumulating&lt;/li&gt;
&lt;li&gt;Products may expire, go out of fashion, or become obsolete&lt;/li&gt;
&lt;li&gt;Cash that could fund better-selling products is locked up&lt;/li&gt;
&lt;li&gt;Eventually it becomes a write-off&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The detection formula
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013" rel="noopener noreferrer"&gt;StockPulse&lt;/a&gt;, I flag a product as dead stock when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;current_stock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;minimum_threshold&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;
&lt;span class="n"&gt;units_sold_last_60_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt;
&lt;span class="n"&gt;days_of_stock_remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple, but surprisingly revealing when you run it across your full catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with dead stock
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Bundle it&lt;/strong&gt; — pair slow movers with bestsellers. A customer buying a $50 product might add a $10 dead-stock item for $5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Run a clearance sale&lt;/strong&gt; — even 20-30% margin recovery is better than a full write-off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Return to supplier&lt;/strong&gt; — if your agreement allows it, especially for newer inventory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Liquidate wholesale&lt;/strong&gt; — sell in bulk at cost to clear warehouse space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Donate for tax benefit&lt;/strong&gt; — in some countries, donated inventory generates a tax deduction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention with reorder discipline
&lt;/h2&gt;

&lt;p&gt;The best cure is prevention. StockPulse's reorder point system means you only reorder what you're actually selling, and in quantities aligned with your real velocity — not gut feeling or "round numbers."&lt;/p&gt;

&lt;p&gt;If your daily velocity on a product is 0.3 units/day and your lead time is 14 days, you need a reorder point of about 6 units — not the 50 you ordered "just in case."&lt;/p&gt;

&lt;h2&gt;
  
  
  See your dead stock in real time
&lt;/h2&gt;

&lt;p&gt;The StockPulse Reports page shows all dead stock products at a glance, sorted by estimated capital tied up. It's often the first time store owners see the full picture — and the numbers are usually surprising.&lt;/p&gt;

&lt;p&gt;Try the live demo at &lt;a href="https://stockpulse.webkodingtheme.com" rel="noopener noreferrer"&gt;https://stockpulse.webkodingtheme.com&lt;/a&gt; or grab it on CodeCanyon: &lt;a href="https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013" rel="noopener noreferrer"&gt;https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013&lt;/a&gt;&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>ecommerce</category>
      <category>inventory</category>
    </item>
    <item>
      <title>How I Implemented Inventory Forecasting Algorithms in a WordPress Plugin</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 25 Mar 2026 07:17:52 +0000</pubDate>
      <link>https://dev.to/webkoding/how-i-implemented-inventory-forecasting-algorithms-in-a-wordpress-plugin-55j2</link>
      <guid>https://dev.to/webkoding/how-i-implemented-inventory-forecasting-algorithms-in-a-wordpress-plugin-55j2</guid>
      <description>&lt;p&gt;When building &lt;a href="https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013" rel="noopener noreferrer"&gt;StockPulse&lt;/a&gt;, the core challenge was: how do you calculate reliable inventory forecasts when different products have wildly different sales patterns?&lt;/p&gt;

&lt;p&gt;Here's a breakdown of the forecasting engine I built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three velocity algorithms, automatic selection
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;SP_Velocity_Calculator&lt;/code&gt; class implements three approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simple Moving Average (SMA)&lt;/strong&gt; — baseline, used when less than 90 days of data exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$daily_sales&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$daily_sales&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Weighted Moving Average (WMA)&lt;/strong&gt; — recent days get higher weight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$total_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$weighted_sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$daily_sales&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sales&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$weighted_sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$sales&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$weighted_sum&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$total_weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Exponential Smoothing&lt;/strong&gt; — alpha=0.3 by default (configurable per product):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$smoothed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$daily_sales&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$daily_sales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$smoothed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$smoothed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$smoothed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine auto-selects: less than 90 days of history → SMA, 90+ days → WMA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reorder point formula
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;calculate_reorder_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$daily_velocity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$lead_time_days&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$safety_stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$daily_velocity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lead_time_days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$daily_velocity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$lead_time_days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$safety_stock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 25% safety buffer accounts for supplier delays and demand spikes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seasonal adjustment (prototyped, not shipped)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$seasonal_coefficient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$same_period_last_year&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$last_month_average&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$adjusted_velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$base_velocity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$seasonal_coefficient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prototyped this, but it is not in the released plugin. StockPulse's velocity is a moving average of recent sales and nothing reads last year's orders, so a product that peaks every December will look slow in October. For seasonal lines, set the low-stock threshold with the season in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  HPOS compatibility
&lt;/h2&gt;

&lt;p&gt;WooCommerce's High-Performance Order Storage moved orders to custom tables. Instead of querying wp_posts directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wc_get_orders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'status'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'wc-completed'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'date_after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strtotime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'-90 days'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s1"&gt;'return'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ids'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'limit'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Caching architecture
&lt;/h2&gt;

&lt;p&gt;All calculations are stored in wp_stockpulse_product_cache with a 24-hour TTL, refreshed daily via WP-Cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;v1.1 will add purchase order PDF generator and ABC analysis.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Product page: &lt;a href="https://webkodingtheme.com/plugins/stockpulse" rel="noopener noreferrer"&gt;StockPulse, a WooCommerce inventory forecasting plugin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Plugin: &lt;a href="https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013" rel="noopener noreferrer"&gt;https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://stockpulse.webkodingtheme.com" rel="noopener noreferrer"&gt;https://stockpulse.webkodingtheme.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>I Built a WooCommerce Inventory Forecasting Plugin — Self-Hosted, No Monthly Fees</title>
      <dc:creator>WebKoding</dc:creator>
      <pubDate>Wed, 25 Mar 2026 07:13:29 +0000</pubDate>
      <link>https://dev.to/webkoding/i-built-a-woocommerce-inventory-forecasting-plugin-self-hosted-no-monthly-fees-446d</link>
      <guid>https://dev.to/webkoding/i-built-a-woocommerce-inventory-forecasting-plugin-self-hosted-no-monthly-fees-446d</guid>
      <description>&lt;p&gt;After months of development, I just launched &lt;strong&gt;StockPulse&lt;/strong&gt; — a WooCommerce plugin that turns your order history into an inventory intelligence system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I was solving
&lt;/h2&gt;

&lt;p&gt;Running a WooCommerce store means constantly answering questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How many days of stock do I have left for each product?"&lt;/li&gt;
&lt;li&gt;"When should I reorder before I run out?"&lt;/li&gt;
&lt;li&gt;"Which products have been sitting in my warehouse for 60+ days with zero sales?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The existing solutions are either expensive SaaS tools ($49–199/month) or ancient CodeCanyon plugins that haven't been updated since 2017. I wanted something &lt;strong&gt;self-hosted, one-time payment, no external API calls&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What StockPulse does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Days of Stock Remaining&lt;/strong&gt; — calculates how many days your current stock will last based on your actual sales velocity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart Reorder Points&lt;/strong&gt; — tells you exactly when to reorder, factoring in your supplier lead time plus a 25% safety buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reorder_point = (daily_velocity × lead_time_days) + safety_stock
safety_stock = daily_velocity × (lead_time_days × 0.25)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dead Stock Detection&lt;/strong&gt; — flags products with 60+ days of stock and zero recent sales so you can run promotions before they become a write-off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;30/60/90-day demand forecasts&lt;/strong&gt; — projects future demand using three algorithms (SMA, WMA, Exponential Smoothing) with automatic selection based on data availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email alerts&lt;/strong&gt; — notifies you when products hit critical stock levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSV export&lt;/strong&gt; — full data export for deeper analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;p&gt;Built with PHP 7.4+ (OOP, PSR-4 structure), vanilla JS + Chart.js for the admin dashboard, WordPress REST API for data endpoints, and WP-Cron for daily cache refresh.&lt;/p&gt;

&lt;p&gt;Fully HPOS-compatible (uses &lt;code&gt;wc_get_orders()&lt;/code&gt; — no raw SQL on order tables).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-hosted matters
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;All calculations happen on your server&lt;/li&gt;
&lt;li&gt;No data leaves your store&lt;/li&gt;
&lt;li&gt;GDPR compliant by design&lt;/li&gt;
&lt;li&gt;No API key to manage, no service to go down&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Live demo + CodeCanyon
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product page:&lt;/strong&gt; &lt;a href="https://webkodingtheme.com/plugins/stockpulse" rel="noopener noreferrer"&gt;StockPulse, a WooCommerce inventory forecasting plugin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://stockpulse.webkodingtheme.com" rel="noopener noreferrer"&gt;https://stockpulse.webkodingtheme.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CodeCanyon:&lt;/strong&gt; &lt;a href="https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013" rel="noopener noreferrer"&gt;https://codecanyon.net/item/stockpulse-inventory-forecast-sales-intelligence-for-woocommerce/62458013&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Would love feedback from the WooCommerce/WordPress dev community. What features would you want to see in v1.1?&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>wordpress</category>
      <category>php</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
