SlipGuard for WooCommerce
WooCommerce PromptPay gateway for Thai shops: QR with the exact order total, slip upload stored outside the public uploads path, and duplicate-slip refusal enforced by a UNIQUE index. 31 tests, no WordPress needed.
by Phisit Tantiranon · github.com/vl4dimirz/slipguard · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/vl4dimirz/slipguard/archive/refs/heads/main.zipReadme
SlipGuard for WooCommerce

Thai shops take payment by bank transfer and a photo of the slip. The failure that costs them money is not a forged image. It is the same real slip uploaded against several orders — one transfer of 500 baht, four orders shipped. A shop that only eyeballs the picture never notices.
SlipGuard does three things:
- A PromptPay QR that already carries the exact order total. Nothing for the customer to type, so nothing for them to mistype.
- A slip upload on the order confirmation page, with the file stored outside the public uploads path.
- Duplicate refusal. Every slip is fingerprinted, and a fingerprint can only ever belong to one order.
The duplicate check is enforced by the database, not by a query
This is the part worth reading the code for.
The first version looked fingerprints up with wc_get_orders() and a meta_query. It passed a casual end-to-end test — first slip accepted, second one refused as a duplicate — and it was completely broken. wc_get_orders() silently ignores meta_query, so the lookup matched every order in the shop, including for a fingerprint that had never been stored. Every second slip a shop received would have been refused, genuine or not, and nothing anywhere would have raised an error.
It was caught by querying with a fingerprint that could not possibly exist and getting every order back.
So the check no longer trusts a query to behave. Fingerprints live in their own table with a UNIQUE index, and claiming one is an INSERT that the database refuses if it is already taken. That also closes a race a SELECT-then-INSERT cannot: two customers uploading the same slip in the same second can never both be told it is free.
Verdicts
Three outcomes, not two. A payment check that pretends to be certain when it is not is worse than one that says it does not know.
| Verdict | When | The slip's fingerprint |
|---|---|---|
| Accepted | The amount was read and matches the order total | stays claimed |
| Review | The amount could not be read, or is higher than the total | stays claimed |
| Rejected | The slip belongs to another order, or is short of the total | released if short, kept by the other order if duplicate |
A slip sitting in review still holds its fingerprint. An earlier version released it, which left the obvious fraud wide open: upload one slip to five orders, watch all five land in review, and let the shop approve them one at a time.
Out of the box the amount is never read, so honest slips land in review and a human decides. Filter slipguard_detected_amount to plug in SlipOK, Slip2Go or a bank API and the accepted path switches on.
Slips are personal data
A slip carries the payer's name, their bank, part of an account number and a timestamp. Under Thailand's PDPA that is personal data, and the WordPress uploads directory is world-readable by design.
Uploaded slips go into a protected subdirectory guarded three ways, because any one layer can be missing on a given host: .htaccess for Apache, an index.php, and a 32-character random filename — the last being the only one that survives an nginx host, which ignores .htaccess entirely.
Security of the upload endpoint
The upload is the whole attack surface, so every check is load-bearing: a nonce tied to the specific order, an order-key match (guests have no login, so the key is the only proof they own the order), a 5 MB cap, and two independent content checks — wp_check_filetype_and_ext reads the magic bytes, getimagesize refuses anything that is not really an image. A polyglot that passes one usually fails the other. The stored extension comes from the verified type, never from the filename the browser sent.
The plugin also declares HPOS compatibility and touches orders only through the CRUD API, so shops can turn on high-performance order storage without a warning.
Running the tests
composer install
composer test
31 tests, no WordPress and no WooCommerce required. The PromptPay payload builder and the slip rules are plain PHP with no WordPress calls in them; the registry is an interface so the tests use an in-memory implementation whose claim() mirrors what the UNIQUE index does.
The payload tests check against the published CRC-16/CCITT-FALSE value, that an amount always serialises with two decimals, that a mobile number becomes the country-code form, and that a payload altered after signing fails validation — the attack being intercepting the QR string and changing the amount.
Verified end to end
Against WordPress with WooCommerce 10.9.4 on MariaDB, three cases run through a real browser:
| Case | Result |
|---|---|
| Slip A, first use | review, fingerprint claimed |
| Slip A again on a second order | rejected as a duplicate |
| A different slip B on a third order | review — not rejected |
The third case is the one the broken lookup used to fail.
Install
Requires PHP 8.4+, WordPress 6.0+ and WooCommerce 8.0+.
git clone https://github.com/Vl4dimirz/slipguard.git wp-content/plugins/slipguard
cd wp-content/plugins/slipguard && composer install --no-dev
Activate it, then set the PromptPay account under WooCommerce → Settings → Payments → PromptPay (SlipGuard).
What this is not
It does not decide that a slip is genuine. Fingerprinting catches the lazy reuse case — the same file submitted twice — not a determined forger, who can re-save a screenshot and change every byte. That is precisely why the review verdict exists and why nothing marks an order paid on an unverified slip.
License
MIT. Written by Phisit Tantiranon.