This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -18,6 +18,46 @@ catcrafts.net {
|
|||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
X-Content-Type-Options "nosniff"
|
||||
-Server
|
||||
|
||||
# HSTS. Caddy redirects http->https but does NOT send this header on
|
||||
# its own, so without it a first visit over http is still interceptable
|
||||
# and every later one is only as safe as the redirect. A shop taking
|
||||
# card payments should not be relying on a redirect.
|
||||
#
|
||||
# includeSubDomains commits EVERY catcrafts.net subdomain to HTTPS —
|
||||
# www and forgejo are both on TLS here, so it holds. Drop that token if
|
||||
# a subdomain ever has to serve plaintext. `preload` is deliberately
|
||||
# NOT set: submission to the browser preload list is months to undo,
|
||||
# and it should be a decision, not a side effect of this file.
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
|
||||
# CSP. Defence in depth rather than the primary control — markup is
|
||||
# built through Catcrafts.Shared:Html, where escaping is enforced by
|
||||
# the type system and forgetting is a compile error. What this adds is
|
||||
# the damage limitation that escaping cannot provide:
|
||||
#
|
||||
# form-action 'self' the checkout form cannot be retargeted at
|
||||
# another origin — the directive that matters
|
||||
# most on a page that collects an address
|
||||
# frame-ancestors no clickjacking the buy button
|
||||
# base-uri 'none' an injected <base> cannot re-point every
|
||||
# relative script src on the page
|
||||
# object-src 'none' no plugin content, ever
|
||||
#
|
||||
# 'unsafe-inline' in script-src is a known and bounded compromise: the
|
||||
# geo price hint (kGeoPriceHintScript) must run before first paint to
|
||||
# set a class on <html> without a flash, so it is inline by design.
|
||||
# Removing it means hashing that constant here and re-hashing on every
|
||||
# edit — silently breaking the hint when someone forgets. To tighten
|
||||
# this properly, move the script to a real file and give it a nonce.
|
||||
# 'wasm-unsafe-eval' is what the WASM runtime needs to compile the
|
||||
# module; it does not enable eval() for JavaScript.
|
||||
#
|
||||
# img-src and media-src allow https: because a post whose media mirror
|
||||
# failed still points at the source instance's URL (see Media::Describe)
|
||||
# — locking those to 'self' would blank exactly the pictures a post is
|
||||
# talking about.
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
|
||||
}
|
||||
|
||||
# ── cross-origin isolation, scoped ────────────────────────────────────
|
||||
|
|
@ -110,6 +150,18 @@ catcrafts.net {
|
|||
# Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own
|
||||
# Cache-Control and returns real status codes — a 404 for an unknown path and
|
||||
# a 301 for the retired /blog URLs, which a client-side router cannot do.
|
||||
# Checkout rate limiting is per-peer, and this block is what makes that
|
||||
# possible: reverse_proxy APPENDS the real client address to
|
||||
# X-Forwarded-For, and the backend reads the rightmost entry (see
|
||||
# ClientAddressFromForwarded — the leftmost is whatever the client claimed).
|
||||
# That is only sound while nothing but Caddy can reach 8081, which is why
|
||||
# the backend binds loopback and why the warning at the top of this file
|
||||
# says not to expose the port.
|
||||
#
|
||||
# Caddy's own rate_limit directive is a third-party module and is NOT in a
|
||||
# standard build — adding it here without rebuilding Caddy stops the server
|
||||
# from starting. If volume ever justifies limiting at the edge, build Caddy
|
||||
# with github.com/mholt/caddy-ratelimit first.
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
health_uri /api/healthz
|
||||
|
|
|
|||
165
deploy/README.md
165
deploy/README.md
|
|
@ -15,8 +15,9 @@ and bank credentials.
|
|||
|
||||
Runtime state goes in a third place, `/var/lib/catcrafts`, created by the
|
||||
service's `StateDirectory=`. Today that is `orders.jsonl` (the order event log)
|
||||
and `bunq-state.json` (the bunq session context, including the client RSA key
|
||||
the server generates on first contact).
|
||||
and `orders.jsonl.shipping.json` (the cached carrier rate table). Neither
|
||||
payment provider needs stored state — both authenticate with a bearer token per
|
||||
request.
|
||||
|
||||
**Two things on this box cannot be regenerated.** Everything else — the wasm
|
||||
bundle, the content, the binary — comes back from a rebuild.
|
||||
|
|
@ -33,8 +34,8 @@ cp /var/lib/catcrafts/orders.jsonl \
|
|||
|
||||
It contains names, addresses and email addresses, so it is personal data: keep
|
||||
it 0600, keep it off the web root, and encrypt it before it leaves the machine.
|
||||
(`bunq-state.json` is deliberately NOT worth backing up: delete it and the
|
||||
server re-onboards from the API key on the next start.)
|
||||
(The cached shipping table is deliberately NOT worth backing up: delete it and
|
||||
the next Sendcloud refresh rebuilds it.)
|
||||
|
||||
The second is `/srv/catcrafts-app/media` — the mirrored post images and screen
|
||||
recordings. Usually reproducible from `content/posts.json`, but **not if a source
|
||||
|
|
@ -265,57 +266,97 @@ hotlinking would send every visitor's IP to the source instance; and these posts
|
|||
are their media, so a deleted upstream file would gut the page. Filenames are the
|
||||
content hash, which is why the cache lifetime can be a year.
|
||||
|
||||
## Payments: Mollie setup
|
||||
## Payments: Mollie and CoinGate
|
||||
|
||||
The rail is Mollie (bunq.me was measured and disqualified: €500/transaction on
|
||||
cards and no method at all for a non-EU buyer at phone prices — it is a P2P
|
||||
tool; the bunq client remains in the tree, unused, in case an account sweep is
|
||||
ever wanted). The server needs exactly one secret: the Mollie API key.
|
||||
Checkout offers the buyer **two choices**, each served by its own rail:
|
||||
|
||||
| Choice | Rail | Env var | What the buyer gets |
|
||||
|----------|----------|---------------------|-----------------------------------------|
|
||||
| `bank` | Mollie | `MOLLIE_API_KEY` | iDEAL, cards, bank transfer |
|
||||
| `crypto` | CoinGate | `COINGATE_API_KEY` | Bitcoin + Lightning, stablecoins, more |
|
||||
|
||||
The slots are independent. Set one key and the form offers only that method;
|
||||
set both and the buyer picks; set neither and checkout answers 503 with an
|
||||
honest message — the whole site still works, degraded rather than down. A
|
||||
mode whose key is missing is a **startup refusal**, not a silent downgrade:
|
||||
a checkout that 502s at the last step is worse than one that never offered.
|
||||
|
||||
```sh
|
||||
# Keys live in the Mollie dashboard: Developers -> API keys. A test_… key
|
||||
# works against the real API from the moment the account exists — verify the
|
||||
# whole flow with it BEFORE swapping in the live_… key.
|
||||
# Mollie keys: dashboard -> Developers -> API keys. CoinGate: dashboard ->
|
||||
# API -> new app token. BOTH have real test modes that work against the real
|
||||
# endpoints — verify the whole flow before swapping in production keys.
|
||||
install -d -m 0755 /etc/catcrafts
|
||||
cat > /etc/catcrafts/payments.env <<'ENV'
|
||||
MOLLIE_API_KEY=test_your-key-here
|
||||
COINGATE_API_KEY=your-coingate-token
|
||||
COINGATE_SANDBOX=1
|
||||
ENV
|
||||
chmod 0600 /etc/catcrafts/payments.env
|
||||
systemctl restart catcrafts-server
|
||||
journalctl -u catcrafts-server | tail # should say "payments: mollie"
|
||||
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=coingate"
|
||||
```
|
||||
|
||||
Without the env file the server starts with payments off: the whole site works,
|
||||
the product page renders, and checkout answers 503 with an honest message —
|
||||
degraded, not down.
|
||||
Drop `COINGATE_SANDBOX=1` for live crypto payments; it selects
|
||||
`api-sandbox.coingate.com` and its tokens are not interchangeable with live
|
||||
ones.
|
||||
|
||||
Mechanics worth knowing:
|
||||
|
||||
* The reconciler polls each open order (`GET /v2/payments/{id}`) every 10 s
|
||||
while fresh, backing off with age. `?redirect` back from Mollie is ignored
|
||||
by design — only the authenticated poll moves an order to paid, and the
|
||||
paid event records the method (`ideal`, `creditcard`, …) in the ledger.
|
||||
* Mollie payments EXPIRE. A payment that reaches canceled/expired/failed
|
||||
lapses the order automatically — the buyer just orders again.
|
||||
* The reconciler polls each open order against **the rail that issued its
|
||||
link** — the ledger records `pay_choice` per order for exactly this reason.
|
||||
Mollie every 10 s, CoinGate every 20 s (a blockchain confirmation will not
|
||||
arrive faster), both backing off with age. The `?redirect` back from either
|
||||
provider is ignored by design: only the authenticated poll moves an order
|
||||
to paid, and the paid event records the method (`ideal`, `creditcard`,
|
||||
`btc`, …) in the ledger.
|
||||
* **Both providers EXPIRE unpaid orders**, which lapses them automatically and
|
||||
the buyer just orders again. CoinGate is far more aggressive about it: two
|
||||
hours before a coin is picked, twenty minutes after. Expect crypto orders to
|
||||
lapse routinely; that is the normal case, not a fault.
|
||||
* CoinGate settles in **EUR** (`receive_currency=EUR` in the rail), so the
|
||||
money that lands is the money the invoice says, the rate is locked when the
|
||||
buyer opens the invoice, and no crypto touches the balance sheet. That one
|
||||
parameter is the whole difference between "a second Mollie" and "the shop
|
||||
now holds crypto" — changing it is a tax decision, not a code cleanup.
|
||||
* Card money stays disputable for months even after "paid": before shipping a
|
||||
large or exported order, glance at the `via` column in `--orders`. iDEAL
|
||||
and bank transfers are final; `creditcard` is the one with a tail.
|
||||
large or exported order, glance at the `via` column in `--orders`. iDEAL,
|
||||
bank transfers and crypto are final; `creditcard` is the one with a tail.
|
||||
This is the one real advantage of the crypto rail — no chargebacks — and it
|
||||
matters most on exactly the non-EU orders where cards get declined.
|
||||
* A `refunded` CoinGate order that the reconciler sees while still awaiting
|
||||
gets **lapsed and logged loudly**: it means a long outage spanned the entire
|
||||
paid window and the money has since gone back. That is the case where
|
||||
`--mark-paid` may be the right answer and only a human can tell.
|
||||
* Mollie onboarding reviews the shop: the imprint (KVK, contact address),
|
||||
terms and privacy pages must be real before they approve live payments.
|
||||
They are — and e2e now fails the build if a PLACEHOLDER marker ever
|
||||
reaches a rendered page again.
|
||||
reaches a rendered page again. CoinGate onboarding is a KYB review of the
|
||||
registered business (KVK, UBO, bank account) and wants the same pages.
|
||||
* Accepting crypto for goods does **not** make this shop a CASP under MiCA —
|
||||
no custody, no transfer for third parties, so no licence is required. What
|
||||
it does require is that the processor holds one: since 1 July 2026 only
|
||||
MiCA-authorised CASPs may serve EU clients, and CoinGate holds both a MiCA
|
||||
licence and a Payment Institution licence. Verify any replacement provider
|
||||
in the ESMA register before switching a key.
|
||||
* VAT is unchanged by payment method: the sale is priced and invoiced in euro
|
||||
and taxed on the euro value, whichever rail settled it.
|
||||
|
||||
## Shipping rates: Sendcloud (optional)
|
||||
## Shipping rates: Sendcloud (REQUIRED to sell)
|
||||
|
||||
Without configuration, shipping is priced by the three-zone table in
|
||||
the compiled-in product data (NL / EU / world, Catcrafts.Shared-Content.cppm) — honest flat rates you set. With a
|
||||
Sendcloud account, the server fetches the real per-country prices of one
|
||||
shipping method daily and uses those instead, falling back to the zones for
|
||||
any country the method does not cover:
|
||||
Sendcloud is the only source of shipping prices. There is no compiled-in
|
||||
fallback table: a country the carrier has no rate for is a country the shop
|
||||
cannot post a parcel to, so checkout **refuses** it rather than quoting a price
|
||||
that would then have to be refunded or absorbed. The consequence is blunt and
|
||||
intended — **with no rate table, every checkout refuses**, and the server says
|
||||
so at startup:
|
||||
|
||||
```
|
||||
shipping: NO RATE TABLE — checkout will refuse every order until Sendcloud answers
|
||||
```
|
||||
|
||||
```sh
|
||||
# credentials from Sendcloud: Settings -> Integrations -> API
|
||||
cat >> /etc/catcrafts/bunq.env <<'ENV'
|
||||
cat >> /etc/catcrafts/payments.env <<'ENV'
|
||||
SENDCLOUD_PUBLIC_KEY=...
|
||||
SENDCLOUD_SECRET_KEY=...
|
||||
SENDCLOUD_METHOD='PostNL Parcels non-EU,DPD Home'
|
||||
|
|
@ -325,18 +366,48 @@ journalctl -u catcrafts-server | grep shipping: # "table refreshed (N countrie
|
|||
```
|
||||
|
||||
`SENDCLOUD_METHOD` is a comma-separated list of name substrings, merged in
|
||||
order with the FIRST match per country winning — put the postal method
|
||||
first so non-EU destinations get post rates (a courier method that also
|
||||
covers Norway or Switzerland would otherwise price them at courier rates,
|
||||
€54 instead of €19), and the courier second to fill the EU. The fetched table is
|
||||
cached next to the orders file so a restart during a Sendcloud outage keeps
|
||||
the last known prices. Like the bunq client, this integration is UNTESTED
|
||||
against the live API until credentials exist — the response parser is covered
|
||||
by --selftest, the fetch around it is thin.
|
||||
order with the FIRST FILTER to cover a country winning it — put the postal
|
||||
method first so non-EU destinations get post rates (a courier method that also
|
||||
covers Norway or Switzerland would otherwise price them at courier rates, €54
|
||||
instead of €19), and the courier second to fill the EU.
|
||||
|
||||
Every method matching a filter is kept, not just the first: Sendcloud lists the
|
||||
same service once per **weight band**, so the matches for `DPD Home` are that
|
||||
service's ladder. A parcel is priced at the cheapest band that can carry it,
|
||||
where the weight is the product's boxed unit weight (`shipWeightGrams` in
|
||||
Catcrafts.Shared-Content.cppm) times the quantity ordered. That also sets the
|
||||
quantity ceiling: one order is one parcel, so an order heavier than every band
|
||||
is refused with the number that *would* fit, and the buy form's `max` shows the
|
||||
best case across destinations.
|
||||
|
||||
A method that publishes no `max_weight` is skipped rather than treated as
|
||||
unlimited — same principle, no invented numbers.
|
||||
|
||||
### The cache is the resilience layer
|
||||
|
||||
The fetched table is cached next to the orders file (`<orders>.shipping.json`)
|
||||
and read at startup **whether or not credentials are configured**, so a
|
||||
Sendcloud outage keeps selling at the last known prices. Format is
|
||||
`country -> [[maxWeightGrams, consumerCents], …]`, prices already VAT-inclusive
|
||||
(EU rates are grossed up once, at fetch, so the shop nets the carrier's cost).
|
||||
|
||||
That also means a hand-written cache file is a complete rate table, which is how
|
||||
dev and `tools/e2e.sh` run with no Sendcloud account at all. A cache written by
|
||||
an older build (flat `country -> cents`, no weight bands) is ignored on load and
|
||||
replaced by the next refresh — those numbers were an unknown weight band and
|
||||
re-serving them would price parcels by guess.
|
||||
|
||||
Like the CoinGate rail, this integration is UNTESTED against the live API until
|
||||
credentials exist — the response parser is covered by `--selftest`, the fetch
|
||||
around it is thin. **Verify one real fetch before opening the shop**: check that
|
||||
the logged country count and the weight bands match what the Sendcloud panel
|
||||
shows, because that table is now the difference between a shop that sells and
|
||||
one that refuses everything.
|
||||
|
||||
The buyer sees whatever the server will charge: the checkout page embeds the
|
||||
active table into its live total, and the amount is computed server-side at
|
||||
order time from the same data.
|
||||
active table into its live total (picking the same band, refusing in the same
|
||||
places), and the amount is computed server-side at order time from the same
|
||||
data.
|
||||
|
||||
## Invoice signing (GPG)
|
||||
|
||||
|
|
@ -451,7 +522,7 @@ CC-3F9A2C paid 595.00 NL 2026-08-04T14:02:11Z 3f9a2c…
|
|||
CC-91B04D awaiting_payment 534.34 CA 2026-08-04T15:40:03Z 91b04d…
|
||||
```
|
||||
|
||||
Manual transitions exist for the cases automation cannot see — a payment bunq
|
||||
Manual transitions exist for the cases automation cannot see — a payment
|
||||
confirmed out-of-band, the parcel handed to the carrier, a refund:
|
||||
|
||||
```sh
|
||||
|
|
@ -490,9 +561,11 @@ reports, each with its own persistent DB and ingest ledger:
|
|||
anonymized at ingest (last octet zeroed before anything reaches its DB),
|
||||
no HOSTS or full-URL REFERRERS panels, and log lines matching `CENSOR_RE`
|
||||
in the script never enter its DB at all — the public tier cannot leak
|
||||
what it never ingested. Extend `CENSOR_RE` when the shop launches so
|
||||
order/payment URLs can never surface; keep secrets out of URL *paths*
|
||||
regardless (query strings are already stripped).
|
||||
what it never ingested. `CENSOR_RE` covers `/api` **and `/order`**: an
|
||||
order token is the entire capability to read that buyer's status page and
|
||||
their invoice (name, street, postal code, city), so publishing the path
|
||||
publishes the buyer. Keep secrets out of URL *paths* regardless — query
|
||||
strings are already stripped, paths are not.
|
||||
- `https://catcrafts.net/analytics/private/` — **uncensored** (basic auth,
|
||||
hash in the Caddyfile): full IPs, all panels.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,21 @@ STATE_DIR=/var/lib/goaccess
|
|||
OUT_PUBLIC=/var/www/analytics/index.html
|
||||
OUT_PRIVATE=/var/www/analytics-private/index.html
|
||||
|
||||
# Log lines whose URI matches this never enter the public tier. Extend it
|
||||
# when the shop launches so order/payment URLs can never surface publicly.
|
||||
CENSOR_RE='"uri":"/api'
|
||||
# Log lines whose URI matches this never enter the public tier.
|
||||
#
|
||||
# /order MUST be here. An order token is not an identifier, it is the whole
|
||||
# capability: /order/<token> is the buyer's status page and
|
||||
# /order/<token>/invoice.md is their name, street, postal code and city. The
|
||||
# public report lists requested paths (only the HOSTS and REFERRERS panels are
|
||||
# suppressed, and --no-query-string does nothing for a token that lives in the
|
||||
# PATH), so an uncensored public tier would have published a harvestable index
|
||||
# of every buyer's address within an hour of the first sale.
|
||||
#
|
||||
# Filters apply at INGEST, so this must be correct BEFORE the shop opens.
|
||||
# Should an order URL ever reach the public DB, changing this line is not
|
||||
# enough — follow the re-filter procedure at the top of this file to rebuild
|
||||
# from the retained raw logs.
|
||||
CENSOR_RE='"uri":"/(api|order)'
|
||||
|
||||
# Serialize runs: a manual run racing the hourly timer once ingested the same
|
||||
# rotated log twice (both processes passed the ledger check before either
|
||||
|
|
|
|||
|
|
@ -35,8 +35,7 @@ WorkingDirectory=/srv/catcrafts-app
|
|||
ExecStart=/srv/catcrafts-app/catcrafts-server --serve 8081 \
|
||||
--content=/srv/catcrafts-app/content \
|
||||
--webroot=/srv/catcrafts.net \
|
||||
--orders=/var/lib/catcrafts/orders.jsonl \
|
||||
--bunq-state=/var/lib/catcrafts/bunq-state.json
|
||||
--orders=/var/lib/catcrafts/orders.jsonl
|
||||
|
||||
Restart=always
|
||||
RestartSec=2s
|
||||
|
|
@ -76,17 +75,17 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
|
|||
# Secrets arrive from OUTSIDE the deployed tree — the web root is public and
|
||||
# rsync-wiped, and /srv/catcrafts-app is CI-writable; neither may ever hold a
|
||||
# credential. /etc/catcrafts/payments.env (root:root 0600) carries:
|
||||
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail
|
||||
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional,
|
||||
# live shipping rates; zone table without them
|
||||
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the BANK rail
|
||||
# COINGATE_API_KEY=... the CRYPTO rail; omit and checkout offers only
|
||||
# COINGATE_SANDBOX=1 bank. Sandbox tokens are not live tokens.
|
||||
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — REQUIRED
|
||||
# to sell: no rate table means checkout refuses
|
||||
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)
|
||||
# MAIL_COMMAND=msmtp -t order confirmation email (see deploy/README.md,
|
||||
# MAIL_FROM=... "Order email"); unset = no email is sent
|
||||
# BUNQ_API_KEY=... legacy: only used when no Mollie key is set
|
||||
# The '-' prefix makes the file optional: without it the server starts with
|
||||
# payments off and the shop renders but refuses checkout — degraded, not down.
|
||||
EnvironmentFile=-/etc/catcrafts/payments.env
|
||||
EnvironmentFile=-/etc/catcrafts/bunq.env
|
||||
# Invoice signing keyring (see deploy/README.md, "Invoice signing").
|
||||
Environment=GNUPGHOME=/var/lib/catcrafts/gnupg
|
||||
|
||||
|
|
|
|||
10
project.cpp
10
project.cpp
|
|
@ -125,17 +125,17 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
"server/implementations/Catcrafts.Server-Orders",
|
||||
"server/implementations/Catcrafts.Server-Mollie",
|
||||
"server/implementations/Catcrafts.Server-Invoice",
|
||||
"server/implementations/Catcrafts.Server-Bunq",
|
||||
"server/implementations/Catcrafts.Server-Coingate",
|
||||
"server/implementations/Catcrafts.Server-Shipping",
|
||||
"server/implementations/Catcrafts.Server-Mail",
|
||||
};
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
|
||||
// The bunq client signs requests with an RSA key (OpenSSL EVP). The
|
||||
// TLS transport already links libssl through Crafter.Network; libcrypto
|
||||
// is named explicitly because the signing code calls it directly.
|
||||
// Both rails reach their provider over TLS, which is what libssl is
|
||||
// for here. No code in this product calls libcrypto directly any more
|
||||
// — the RSA request signing that did went with the bunq rail — so it
|
||||
// is left to libssl's own dependency rather than named again.
|
||||
cfg.linkFlags.push_back("-lssl");
|
||||
cfg.linkFlags.push_back("-lcrypto");
|
||||
|
||||
// libmsquic.so.2 is built in crafter-build's external cache, and the
|
||||
// RUNPATH pointing there only exists on the build machine — the first
|
||||
|
|
|
|||
|
|
@ -1,562 +0,0 @@
|
|||
/*
|
||||
catcrafts.net
|
||||
Copyright (C) 2026 Catcrafts
|
||||
|
||||
The source code of this website is made available for viewing purposes only.
|
||||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// The payment rails: bunq (real money) and fake (tests).
|
||||
//
|
||||
// The bunq client speaks the v1 REST API over Crafter.Network's ClientHTTP1
|
||||
// with TLS — no SDK, because the four calls this needs (installation,
|
||||
// device-server, session-server, bunqme-tab) do not justify a dependency, and
|
||||
// every byte in and out goes through the same strict JSON reader as the rest
|
||||
// of the site.
|
||||
//
|
||||
// Context (RSA key, installation token, session token, ids) persists in ONE
|
||||
// JSON file under the service's StateDirectory — never in the repo, never in
|
||||
// the web root. Delete the file and the client re-onboards from the API key.
|
||||
//
|
||||
// A word on trust direction: this code never treats an inbound signal as
|
||||
// authoritative. The ?status= on bunq's redirect back to the order page is
|
||||
// ignored entirely; an order becomes paid ONLY when an authenticated GET to
|
||||
// bunq's API says the tab's payments cover the amount. That is the poll — the
|
||||
// reconciler in Catcrafts.Server-Http.cpp drives it.
|
||||
//
|
||||
// Request signing: bunq stopped REQUIRING body signatures in 2019, but the
|
||||
// keypair exists anyway (installation demands a public key), signing is ~40
|
||||
// lines, and a signed request is valid whether or not the server checks. So
|
||||
// every body is signed — X-Bunq-Client-Signature, RSA-SHA256 over the raw
|
||||
// body, base64.
|
||||
|
||||
module;
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rsa.h>
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// ── small pure helpers ────────────────────────────────────────────────
|
||||
|
||||
std::string Base64(std::span<const unsigned char> in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((in.size() + 2) / 3) * 4);
|
||||
std::size_t i = 0;
|
||||
for (; i + 2 < in.size(); i += 3) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||
}
|
||||
if (i + 1 == in.size()) {
|
||||
const std::uint32_t n = in[i] << 16;
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += "==";
|
||||
} else if (i + 2 == in.size()) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JsonEscapeB(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string RandomHex(std::size_t words) {
|
||||
std::random_device rd;
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the first object under any key in bunq's Response array:
|
||||
// {"Response":[{"Id":{...}},{"Token":{...}}]}
|
||||
const Json::Value* FindInResponse(const Json::Value& doc, std::string_view key) {
|
||||
const Json::Value* resp = doc.Find("Response");
|
||||
if (!resp || !resp->IsArray()) return nullptr;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
if (!item.IsObject()) continue;
|
||||
if (const Json::Value* v = item.Find(key)) return v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||
// spaces, thousands separators — is rejected. Money parsing has no
|
||||
// "probably fine" mode.
|
||||
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||
std::int64_t units = 0;
|
||||
std::size_t i = 0;
|
||||
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
units = units * 10 + (s[i] - '0');
|
||||
}
|
||||
std::int64_t cents = 0;
|
||||
if (i < s.size()) {
|
||||
if (s[i] != '.') return std::nullopt;
|
||||
++i;
|
||||
const std::size_t fracStart = i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
cents = cents * 10 + (s[i] - '0');
|
||||
}
|
||||
const std::size_t digits = i - fracStart;
|
||||
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||
if (digits == 1) cents *= 10;
|
||||
}
|
||||
return units * 100 + cents;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// ── the fake rail ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||
// which the test creates when it wants "the customer has paid" to happen.
|
||||
|
||||
class FakeRail final : public PaymentRail {
|
||||
public:
|
||||
explicit FakeRail(std::filesystem::path marker) : marker_(std::move(marker)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||
const std::string& redirectUrl) override {
|
||||
static std::atomic<std::int64_t> counter{1};
|
||||
PaymentLink link;
|
||||
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||
// send anyone to, so it points at the order page itself — which keeps
|
||||
// the browser flow usable in dev and the e2e redirect parseable.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||
std::error_code ec;
|
||||
return PaidStatus{
|
||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||
"fake" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "fake"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||
|
||||
private:
|
||||
std::filesystem::path marker_;
|
||||
};
|
||||
|
||||
// ── the bunq rail ─────────────────────────────────────────────────────
|
||||
|
||||
class BunqRail final : public PaymentRail {
|
||||
public:
|
||||
explicit BunqRail(RailConfig cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
host_(cfg_.sandbox ? "public-api.sandbox.bunq.com" : "api.bunq.com") {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
const std::string body = std::format(
|
||||
R"({{"bunqme_tab_entry":{{"amount_inquired":{{"value":"{}","currency":"EUR"}},)"
|
||||
R"("description":"{}","redirect_url":"{}"}}}})",
|
||||
Money::FormatMinor(amountMinor), JsonEscapeB(description),
|
||||
JsonEscapeB(redirectUrl));
|
||||
|
||||
auto doc = Call("POST", TabsPath(), body);
|
||||
if (!doc) return std::nullopt;
|
||||
const Json::Value* id = FindInResponse(*doc, "Id");
|
||||
if (!id) return std::nullopt;
|
||||
const std::int64_t tabId = id->Int("id");
|
||||
if (tabId <= 0) return std::nullopt;
|
||||
|
||||
// The POST answers with the id only; the share URL comes from a GET.
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
const std::string url(bmt->Str("bunqme_tab_share_url"));
|
||||
if (url.empty()) return std::nullopt;
|
||||
|
||||
PaymentLink link;
|
||||
link.payId = std::to_string(tabId);
|
||||
link.payUrl = url;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// The id is a bunq tab number that travelled through our ledger.
|
||||
std::int64_t tabId = 0;
|
||||
auto [ptr, ec] = std::from_chars(payId.data(), payId.data() + payId.size(), tabId);
|
||||
if (ec != std::errc{} || ptr != payId.data() + payId.size() || tabId <= 0) {
|
||||
return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
|
||||
// Sum every settled inquiry on the tab. A tab accepts unlimited
|
||||
// payments until cancelled, so the question is "do the payments cover
|
||||
// the amount", not "is there a payment".
|
||||
std::int64_t paid = 0;
|
||||
if (const Json::Value* inquiries = bmt->Find("result_inquiries");
|
||||
inquiries && inquiries->IsArray()) {
|
||||
for (const Json::Value& entry : inquiries->array) {
|
||||
if (!entry.IsObject()) continue;
|
||||
const Json::Value* payment = entry.Find("payment");
|
||||
if (payment && payment->IsObject()) {
|
||||
if (const Json::Value* inner = payment->Find("Payment");
|
||||
inner && inner->IsObject()) payment = inner;
|
||||
}
|
||||
if (!payment) continue;
|
||||
const Json::Value* amount = payment->Find("amount");
|
||||
if (!amount || !amount->IsObject()) continue;
|
||||
if (amount->Str("currency") != "EUR") continue;
|
||||
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||
paid += *minor;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A bunq tab never dies on its own — it accepts payments until
|
||||
// cancelled — so the only states here are Paid and Pending. The
|
||||
// method is not identified per payment; "bunq" is honest enough.
|
||||
return PaidStatus{ paid >= expectedMinor ? PayState::Paid : PayState::Pending,
|
||||
"bunq" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "bunq"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(15); }
|
||||
|
||||
private:
|
||||
// ── context persistence ───────────────────────────────────────────
|
||||
|
||||
void LoadState() {
|
||||
std::ifstream in(cfg_.statePath, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
auto doc = Json::Parse(buf.str());
|
||||
if (!doc || !doc->IsObject()) return;
|
||||
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
|
||||
installationToken_ = std::string(doc->Str("installation_token"));
|
||||
deviceRegistered_ = doc->Bool("device_registered");
|
||||
sessionToken_ = std::string(doc->Str("session_token"));
|
||||
userId_ = doc->Int("user_id");
|
||||
accountId_ = doc->Int("account_id");
|
||||
}
|
||||
|
||||
bool SaveState() {
|
||||
// 0600 before content: the file holds the private key.
|
||||
std::ofstream out(cfg_.statePath, std::ios::trunc | std::ios::binary);
|
||||
if (!out) return false;
|
||||
out << std::format(
|
||||
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
||||
R"("device_registered":{},"session_token":"{}","user_id":{},"account_id":{}}})",
|
||||
JsonEscapeB(privateKeyPem_), JsonEscapeB(installationToken_),
|
||||
deviceRegistered_, JsonEscapeB(sessionToken_), userId_, accountId_);
|
||||
out.flush();
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(cfg_.statePath,
|
||||
std::filesystem::perms::owner_read
|
||||
| std::filesystem::perms::owner_write,
|
||||
ec);
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
// ── crypto ────────────────────────────────────────────────────────
|
||||
|
||||
bool EnsureKeypair() {
|
||||
if (!privateKeyPem_.empty()) return LoadKey();
|
||||
EVP_PKEY* raw = EVP_RSA_gen(2048);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return false;
|
||||
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) {
|
||||
BIO_free(bio);
|
||||
return false;
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return SaveState();
|
||||
}
|
||||
|
||||
bool LoadKey() {
|
||||
if (key_) return true;
|
||||
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
|
||||
static_cast<int>(privateKeyPem_.size()));
|
||||
if (!bio) return false;
|
||||
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
|
||||
BIO_free(bio);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PublicKeyPem() {
|
||||
if (!LoadKey()) return {};
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return {};
|
||||
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
|
||||
BIO_free(bio);
|
||||
return {};
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
std::string pem(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return pem;
|
||||
}
|
||||
|
||||
std::string SignBody(std::string_view body) {
|
||||
if (!LoadKey()) return {};
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) return {};
|
||||
std::string out;
|
||||
do {
|
||||
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, key_.get()) != 1) break;
|
||||
std::size_t len = 0;
|
||||
if (EVP_DigestSign(ctx, nullptr, &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
std::vector<unsigned char> sig(len);
|
||||
if (EVP_DigestSign(ctx, sig.data(), &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
sig.resize(len);
|
||||
out = Base64(sig);
|
||||
} while (false);
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── transport ─────────────────────────────────────────────────────
|
||||
|
||||
// One HTTPS call, returning the parsed JSON on 2xx. On 401 with a live
|
||||
// session the caller decides whether to re-session; this layer only
|
||||
// reports. Network and TLS failures land as nullopt — the reconciler
|
||||
// treats that as "unknown, retry later", never as "unpaid".
|
||||
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
|
||||
const std::string& body, const std::string& authToken,
|
||||
std::string* statusOut = nullptr) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
host_, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = host_;
|
||||
req.body = body;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["cache-control"] = "no-cache";
|
||||
req.headers["x-bunq-client-request-id"] = RandomHex(4);
|
||||
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
|
||||
req.headers["x-bunq-language"] = "en_US";
|
||||
req.headers["x-bunq-region"] = "nl_NL";
|
||||
if (!body.empty()) {
|
||||
req.headers["content-type"] = "application/json";
|
||||
const std::string sig = SignBody(body);
|
||||
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
|
||||
}
|
||||
if (!authToken.empty()) {
|
||||
req.headers["x-bunq-client-authentication"] = authToken;
|
||||
}
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (statusOut) *statusOut = res.status;
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path, res.status,
|
||||
res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
auto doc = Json::Parse(res.body);
|
||||
if (!doc) return std::nullopt;
|
||||
return std::move(*doc);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// A session-authenticated call, with one automatic re-session on 401 —
|
||||
// sessions expire server-side and that must not surface as a failure.
|
||||
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
std::string status;
|
||||
auto doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
if (!doc && status == "401") {
|
||||
sessionToken_.clear();
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── onboarding ────────────────────────────────────────────────────
|
||||
//
|
||||
// installation (once, ever) -> installation token
|
||||
// device-server (once, ever) -> binds the API key to this "device"
|
||||
// session-server (per session) -> session token + user id
|
||||
// monetary-account (once) -> account to attach tabs to
|
||||
//
|
||||
// All idempotent to re-run individually; state records how far we got.
|
||||
|
||||
bool EnsureSession() {
|
||||
if (!loaded_) { LoadState(); loaded_ = true; }
|
||||
if (cfg_.apiKey.empty()) {
|
||||
std::println(std::cerr, "bunq: no API key configured");
|
||||
return false;
|
||||
}
|
||||
if (!EnsureKeypair()) return false;
|
||||
|
||||
if (installationToken_.empty()) {
|
||||
const std::string body =
|
||||
std::format(R"({{"client_public_key":"{}"}})", JsonEscapeB(PublicKeyPem()));
|
||||
auto doc = DoCall("POST", "/v1/installation", body, {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
installationToken_ = std::string(token->Str("token"));
|
||||
if (installationToken_.empty()) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (!deviceRegistered_) {
|
||||
// permitted_ips "*": this box sits on a residential connection
|
||||
// whose address changes; pinning the current IP would brick the
|
||||
// integration on the next DHCP lease. The API key secret still
|
||||
// gates everything.
|
||||
const std::string body = std::format(
|
||||
R"({{"description":"catcrafts.net server","secret":"{}","permitted_ips":["*"]}})",
|
||||
JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/device-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
deviceRegistered_ = true;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (sessionToken_.empty() || userId_ == 0) {
|
||||
const std::string body =
|
||||
std::format(R"({{"secret":"{}"}})", JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/session-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
sessionToken_ = std::string(token->Str("token"));
|
||||
// The user object's key varies by account type; take whichever came.
|
||||
for (std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
||||
if (const Json::Value* u = FindInResponse(*doc, k)) {
|
||||
userId_ = u->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionToken_.empty() || userId_ == 0) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (accountId_ == 0) {
|
||||
auto doc = Call("GET",
|
||||
std::format("/v1/user/{}/monetary-account?count=25", userId_), {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* resp = doc->Find("Response");
|
||||
if (!resp || !resp->IsArray()) return false;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
||||
if (acc && acc->Str("status") == "ACTIVE") {
|
||||
accountId_ = acc->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (accountId_ == 0) {
|
||||
std::println(std::cerr, "bunq: no active MonetaryAccountBank found");
|
||||
return false;
|
||||
}
|
||||
SaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string TabsPath() const {
|
||||
return std::format("/v1/user/{}/monetary-account/{}/bunqme-tab", userId_, accountId_);
|
||||
}
|
||||
|
||||
struct PkeyDeleter {
|
||||
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||
};
|
||||
|
||||
RailConfig cfg_;
|
||||
std::string host_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
||||
bool loaded_ = false;
|
||||
std::string privateKeyPem_;
|
||||
std::string installationToken_;
|
||||
bool deviceRegistered_ = false;
|
||||
std::string sessionToken_;
|
||||
std::int64_t userId_ = 0;
|
||||
std::int64_t accountId_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The roster itself (MakeRail) lives in the Mollie unit; these two factories
|
||||
// keep FakeRail/BunqRail construction next to their definitions.
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config) {
|
||||
return std::make_unique<FakeRail>(config.statePath);
|
||||
}
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config) {
|
||||
return std::make_unique<BunqRail>(config);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
286
server/implementations/Catcrafts.Server-Coingate.cpp
Normal file
286
server/implementations/Catcrafts.Server-Coingate.cpp
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/*
|
||||
catcrafts.net
|
||||
Copyright (C) 2026 Catcrafts
|
||||
|
||||
The source code of this website is made available for viewing purposes only.
|
||||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// The CoinGate payment rail — the crypto half of the checkout.
|
||||
//
|
||||
// Why a processor rather than a self-hosted node: accepting crypto for goods
|
||||
// makes this shop a MERCHANT, not a crypto-asset service provider, under either
|
||||
// arrangement. What differs is everything around it. CoinGate is MiCA-licensed
|
||||
// (mandatory to serve EU clients since 1 July 2026) and settles EUR to the
|
||||
// business account by SEPA at a locked rate, so the money that lands is the
|
||||
// money the invoice says, the bookkeeping line is identical to Mollie's, and no
|
||||
// coin ever sits on this balance sheet waiting to move in price. A self-hosted
|
||||
// BTCPay would cost 1% less and a Bitcoin node's worth of operations, custody
|
||||
// and per-payment revaluation — a trade worth making for sovereignty, not for a
|
||||
// tail of international orders.
|
||||
//
|
||||
// Which is why receive_currency is EUR below and not DO_NOT_CONVERT: that one
|
||||
// parameter is the whole difference between "a second Mollie" and "the shop now
|
||||
// holds crypto". Changing it is a tax decision, not a code cleanup.
|
||||
//
|
||||
// The API is the same shape as Mollie's, so this client is the same shape as
|
||||
// that one:
|
||||
//
|
||||
// POST /api/v2/orders {price_amount, price_currency, …} -> id + payment_url
|
||||
// GET /api/v2/orders/{id} -> status, pay_currency
|
||||
//
|
||||
// Two differences from Mollie worth knowing. Requests are form-encoded, which
|
||||
// is what every CoinGate example uses and what their v2 API is documented
|
||||
// against — the responses are JSON either way, and JSON is the only direction
|
||||
// that matters here, since it is the one carrying money. And their ids are JSON
|
||||
// NUMBERS, not strings, so the parser renders them to decimal (see
|
||||
// ParseCoingateOrder) and the ledger stores text like it does for every rail.
|
||||
//
|
||||
// Trust direction is the design rule and is unchanged: CoinGate can be told a
|
||||
// callback_url and it is deliberately NOT given one. An order becomes paid only
|
||||
// when an authenticated GET says status=paid over a covering EUR amount.
|
||||
// Crypto invoices die fast — two hours before a coin is picked, twenty minutes
|
||||
// after — so Dead is a state this rail reaches far more often than Mollie does,
|
||||
// and the reconciler lapsing those orders is the normal case rather than an
|
||||
// exception.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode one form value. Unreserved characters pass; everything else
|
||||
// becomes %XX, including the space (rather than '+', which is only correct in
|
||||
// a query string and is one of those differences that works until it doesn't).
|
||||
std::string FormEncode(std::string_view s) {
|
||||
static constexpr std::string_view kHex = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '-' || c == '_' || c == '.' || c == '~';
|
||||
if (unreserved) {
|
||||
out += c;
|
||||
} else {
|
||||
const auto byte = static_cast<unsigned char>(c);
|
||||
out += '%';
|
||||
out += kHex[byte >> 4];
|
||||
out += kHex[byte & 0x0f];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ticker symbols arrive uppercase ("BTC"); the ledger's via column is lowercase
|
||||
// everywhere else ("ideal", "creditcard"), and a column that shouts in one row
|
||||
// and whispers in the next is just noise to read past.
|
||||
std::string LowerAscii(std::string_view s) {
|
||||
std::string out(s);
|
||||
for (char& c : out) {
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// CoinGate caps title at 150 characters and description at 500. Both are built
|
||||
// from the order reference here and come nowhere near either, but a truncating
|
||||
// helper means a future longer description degrades to a shorter one rather
|
||||
// than to a 422 at checkout — with the buyer already committed.
|
||||
std::string Clamp(std::string_view s, std::size_t max) {
|
||||
return std::string(s.substr(0, std::min(s.size(), max)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
||||
CoingateOrder o;
|
||||
// The id arrives as a number. Accept a string too: costing a live checkout
|
||||
// over a provider changing a field's JSON type would be an absurd way to
|
||||
// lose a sale, and either spelling names the same order.
|
||||
if (const Json::Value* id = doc->Find("id")) {
|
||||
if (id->type == Json::Type::String) {
|
||||
o.id = id->string;
|
||||
} else if (id->type == Json::Type::Number) {
|
||||
o.id = std::format("{}", static_cast<std::int64_t>(id->number));
|
||||
}
|
||||
}
|
||||
o.status = std::string(doc->Str("status"));
|
||||
o.payCurrency = std::string(doc->Str("pay_currency"));
|
||||
o.payUrl = std::string(doc->Str("payment_url"));
|
||||
if (o.id.empty() || o.status.empty()) return std::nullopt;
|
||||
|
||||
// Only euro-priced orders are ever created, so anything else failing to
|
||||
// parse to zero is the safe outcome — a zero amount never satisfies an
|
||||
// order total. Note this is price_amount (what the buyer owed) and not
|
||||
// receive_amount (what lands after conversion and fee): the question being
|
||||
// asked is whether the buyer paid their invoice, not what the shop nets.
|
||||
if (doc->Str("price_currency") == "EUR") {
|
||||
if (auto minor = ParseAmountToMinor(doc->Str("price_amount"))) {
|
||||
o.priceMinor = *minor;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class CoingateRail final : public PaymentRail {
|
||||
public:
|
||||
explicit CoingateRail(RailConfig cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
host_(cfg_.sandbox ? "api-sandbox.coingate.com" : "api.coingate.com") {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// receive_currency=EUR is the settlement decision; see the header.
|
||||
// No callback_url on purpose: state comes from the poll, never from
|
||||
// something that arrives unbidden claiming an order was paid.
|
||||
const std::string body = std::format(
|
||||
"price_amount={}&price_currency=EUR&receive_currency=EUR"
|
||||
"&title={}&description={}&order_id={}&success_url={}&cancel_url={}",
|
||||
FormEncode(Money::FormatMinor(amountMinor)),
|
||||
FormEncode(Clamp(description, 150)),
|
||||
FormEncode(Clamp(description, 500)),
|
||||
FormEncode(Clamp(description, 255)),
|
||||
FormEncode(redirectUrl), FormEncode(redirectUrl));
|
||||
|
||||
const std::optional<std::string> res = Call("POST", "/api/v2/orders", body);
|
||||
if (!res) return std::nullopt;
|
||||
const auto order = ParseCoingateOrder(*res);
|
||||
if (!order || order->payUrl.empty()) {
|
||||
std::println(std::cerr, "coingate: create returned no payment url");
|
||||
return std::nullopt;
|
||||
}
|
||||
PaymentLink link;
|
||||
link.payId = order->id;
|
||||
link.payUrl = order->payUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// CoinGate ids are decimal integers. The id came from them, but it
|
||||
// travels through our ledger — keep the path composition strict anyway.
|
||||
if (payId.empty()) return PaidStatus{ PayState::Dead, {} };
|
||||
for (const char c : payId) {
|
||||
if (c < '0' || c > '9') return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
|
||||
const std::optional<std::string> res =
|
||||
Call("GET", "/api/v2/orders/" + payId, {});
|
||||
if (!res) return std::nullopt;
|
||||
const auto order = ParseCoingateOrder(*res);
|
||||
if (!order) return std::nullopt;
|
||||
|
||||
PaidStatus out;
|
||||
// What settled it, for the ledger's "via" column: the coin the shopper
|
||||
// actually paid in ("BTC" -> "bitcoin" is their business, not ours —
|
||||
// the ticker is the honest record). Empty until a coin is picked.
|
||||
out.method = order->payCurrency.empty()
|
||||
? std::string("crypto")
|
||||
: LowerAscii(order->payCurrency);
|
||||
|
||||
const std::string_view status = order->status;
|
||||
if (status == "paid" && order->priceMinor >= expectedMinor) {
|
||||
out.state = PayState::Paid;
|
||||
} else if (status == "new" || status == "pending" || status == "confirming") {
|
||||
// Still in flight. "confirming" is the blockchain-confirmation
|
||||
// wait: the money is visible but not final, and this rail does not
|
||||
// treat visible as received.
|
||||
out.state = PayState::Pending;
|
||||
} else if (status == "refunded" || status == "partially_refunded") {
|
||||
// Paid and then given back — which, seen from an order still
|
||||
// awaiting payment, means the reconciler missed the entire paid
|
||||
// window (a long outage) and the money has since left again. Lapse
|
||||
// it rather than confirm an order whose payment was undone, and say
|
||||
// so loudly: this is the one case where --mark-paid may be the
|
||||
// right answer and only a human can tell.
|
||||
std::println(std::cerr,
|
||||
"coingate: order {} is {} — lapsing; confirm by hand if "
|
||||
"the refund was partial and the goods still ship",
|
||||
payId, status);
|
||||
out.state = PayState::Dead;
|
||||
} else if (status == "invalid" || status == "expired" || status == "canceled") {
|
||||
out.state = PayState::Dead;
|
||||
} else {
|
||||
// An unknown status is not a licence to guess. Pending means "ask
|
||||
// again", which is the only safe reading of a word we do not know.
|
||||
std::println(std::cerr, "coingate: order {} has unknown status '{}'",
|
||||
payId, status);
|
||||
out.state = PayState::Pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "coingate"; }
|
||||
// Slower than Mollie's ten seconds: a crypto payment waits on block
|
||||
// confirmations, so there is nothing a faster sweep could learn. The
|
||||
// buyer's own arrival at the order page still polls once immediately.
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(20); }
|
||||
|
||||
private:
|
||||
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
|
||||
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
|
||||
std::optional<std::string> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
host_, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = host_;
|
||||
req.body = body;
|
||||
// Not "Bearer": CoinGate's scheme word is literally "Token".
|
||||
req.headers["authorization"] = "Token " + cfg_.apiKey;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["accept"] = "application/json";
|
||||
if (!body.empty()) {
|
||||
req.headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "coingate: {} {} -> {} {}", method, path,
|
||||
res.status, res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "coingate: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::string host_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config) {
|
||||
return std::make_unique<CoingateRail>(config);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
@ -34,6 +34,33 @@ using namespace Crafter;
|
|||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
// Request provenance. Both are pure and declared in the module interface,
|
||||
// where the reasoning for each lives; the self-test drives them directly.
|
||||
|
||||
std::string_view ClientAddressFromForwarded(std::string_view forwarded) {
|
||||
// The RIGHTMOST entry, because that is the one Caddy appended. Anything to
|
||||
// its left is whatever the client felt like claiming.
|
||||
const std::size_t comma = forwarded.rfind(',');
|
||||
const std::string_view last = comma == std::string_view::npos
|
||||
? forwarded
|
||||
: forwarded.substr(comma + 1);
|
||||
return Form::Trim(last);
|
||||
}
|
||||
|
||||
bool OriginAllowed(std::string_view origin, std::string_view redirectBase) {
|
||||
if (origin.empty()) return true; // not a browser form post; see the header
|
||||
// A trailing slash is legal in a configured base and never present in an
|
||||
// Origin header, so normalise both ends rather than depend on the operator.
|
||||
auto trim = [](std::string_view s) {
|
||||
while (!s.empty() && s.back() == '/') s.remove_suffix(1);
|
||||
return s;
|
||||
};
|
||||
const std::string_view want = trim(redirectBase);
|
||||
// An unconfigured base must not silently accept every origin.
|
||||
if (want.empty()) return false;
|
||||
return trim(origin) == want;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Loaded once at startup. The content files are generated at build time (CI
|
||||
|
|
@ -43,11 +70,28 @@ Views::SiteContent gContent;
|
|||
std::string gBootScripts;
|
||||
std::string gCssHref = "/styles.css";
|
||||
|
||||
// The payment rail, installed by ConfigurePayments before Serve; a null rail
|
||||
// means checkout answers 503 rather than creating orders nothing can pay.
|
||||
std::unique_ptr<PaymentRail> gRail;
|
||||
// The payment rails, installed by ConfigurePayments before Serve; two null
|
||||
// rails mean checkout answers 503 rather than creating orders nothing can pay.
|
||||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// The reconciler's sweep cadence: the shortest interval any configured rail
|
||||
// asks for. Each order is still paced by ITS OWN rail's interval inside the
|
||||
// loop — a shared sweep that ran at the slower rail's pace would make the
|
||||
// faster one late for every order, and one that ran at the faster pace would
|
||||
// poll the slower provider harder than it asked to be polled.
|
||||
std::chrono::seconds SweepInterval() {
|
||||
std::chrono::seconds out = std::chrono::seconds(10);
|
||||
bool first = true;
|
||||
for (const PaymentRail* rail : { gRails.bank.get(), gRails.crypto.get() }) {
|
||||
if (!rail) continue;
|
||||
const std::chrono::seconds want = rail->PollInterval();
|
||||
out = first ? want : std::min(out, want);
|
||||
first = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
|
|
@ -64,6 +108,7 @@ struct AdvanceResult {
|
|||
std::string paidVia;
|
||||
};
|
||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
||||
std::string NowIso8601();
|
||||
|
||||
// Common headers on every HTML response.
|
||||
|
|
@ -104,12 +149,15 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
// The product page embeds the live carrier rate table into its checkout
|
||||
// preview, and that table is runtime state — so, like orders below, it is
|
||||
// rendered here rather than through the shared dispatch (which the wasm
|
||||
// backend-down fallback uses with the zone table only).
|
||||
// backend-down fallback uses with no rate table at all, and therefore
|
||||
// quotes no totals: with the zone fallback gone there is nothing for it to
|
||||
// price from, which is correct — that path cannot reach checkout either).
|
||||
if (route.kind == RouteKind::Product) {
|
||||
if (const Product* product = gContent.FindProduct(route.slug)) {
|
||||
const ShippingTable ship = CurrentShippingTable();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry);
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
|
||||
{}, {}, CryptoPaymentAvailable());
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||
|
|
@ -197,22 +245,32 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
}
|
||||
|
||||
// The buyer usually arrives here seconds after paying, redirected by
|
||||
// Mollie — but the reconciler may not have polled yet. Ask the rail
|
||||
// right now so the page they land on already says paid, instead of an
|
||||
// alarming "awaiting payment" that flips ten seconds later. Still the
|
||||
// poll-is-truth rule: this trusts Mollie's authenticated answer, never
|
||||
// the fact of being redirected.
|
||||
// the provider — but the reconciler may not have polled yet. Ask the
|
||||
// rail right now so the page they land on already says paid, instead
|
||||
// of an alarming "awaiting payment" that flips ten seconds later.
|
||||
// Still the poll-is-truth rule: this trusts the provider's
|
||||
// authenticated answer, never the fact of being redirected.
|
||||
//
|
||||
// Gated to one call per token per rail interval — see
|
||||
// ArrivalPollAllowed. A reload past that renders from the ledger and
|
||||
// lets the reconciler do its job, which is the whole point of having
|
||||
// one. The interval is the ORDER'S rail's, so a crypto order is not
|
||||
// paced by Mollie's cadence or the other way round.
|
||||
if (order->status == "awaiting_payment") {
|
||||
if (const PaymentRail* rail = gRails.For(order->payChoice);
|
||||
rail && ArrivalPollAllowed(order->token, rail->PollInterval())) {
|
||||
if (const auto advanced = PollAndAdvance(*order)) {
|
||||
order->status = advanced->status;
|
||||
order->paidVia = advanced->paidVia;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OrderView view;
|
||||
view.token = order->token;
|
||||
view.reference = order->reference;
|
||||
view.status = order->status;
|
||||
view.payChoice = order->payChoice;
|
||||
view.payUrl = order->payUrl;
|
||||
view.createdAt = order->createdAt;
|
||||
view.country = order->buyer.country;
|
||||
|
|
@ -331,29 +389,66 @@ HTTPResponse ServeFeed() {
|
|||
return res;
|
||||
}
|
||||
|
||||
// A very coarse rate limit on checkout submissions.
|
||||
// Rate limiting on checkout submissions: per-peer first, global as a backstop.
|
||||
//
|
||||
// Not a general-purpose limiter, and deliberately not per-IP: the server sits
|
||||
// behind Caddy, so every request arrives from 127.0.0.1 unless forwarding
|
||||
// headers are trusted — and trusting a client-settable header for rate limiting
|
||||
// is worse than not limiting at all. So this is a global cap, which is the
|
||||
// honest thing a reverse-proxied process can enforce by itself. Per-IP limiting
|
||||
// belongs in Caddy, where the real peer address lives.
|
||||
// This used to be a single global cap, on the reasoning that a reverse-proxied
|
||||
// process cannot know its real peer and that trusting a client-settable header
|
||||
// is worse than not limiting at all. The first half was wrong and the second
|
||||
// half made the conclusion dangerous. A SHARED budget is exhaustible by
|
||||
// whoever is rudest: thirty submissions from one script closed checkout for
|
||||
// every real buyer for ten minutes, and one submission every twenty seconds
|
||||
// kept the shop shut indefinitely — a working denial of sales for the price of
|
||||
// a shell loop. A limit that turns one attacker into an outage is not a limit.
|
||||
//
|
||||
// The intent is only to stop a script filling the file overnight; the honeypot
|
||||
// handles ordinary bots and Caddy handles volume.
|
||||
// The real peer IS knowable here, carefully: Caddy appends it to
|
||||
// X-Forwarded-For, so the rightmost entry is Caddy's own word rather than the
|
||||
// client's (see ClientAddressFromForwarded, which is where that reasoning
|
||||
// lives). It is trustworthy only because nothing else can reach this listener.
|
||||
//
|
||||
// So the per-peer cap is the actual control, and the global cap stays purely
|
||||
// as a runaway backstop — set high enough that it is not a lever one peer can
|
||||
// pull, since tripping it still denies everyone. A flood broad enough to reach
|
||||
// it is an infrastructure problem, and belongs to Caddy and the host.
|
||||
//
|
||||
// Unproxied requests (dev, e2e, a direct curl at the loopback port) carry no
|
||||
// X-Forwarded-For. They are charged to the global budget only — there is no
|
||||
// peer to key on, and inventing one would be a lie.
|
||||
using RatePoint = std::chrono::steady_clock::time_point;
|
||||
|
||||
std::mutex gRateMutex;
|
||||
std::deque<std::chrono::steady_clock::time_point> gRecentSubmissions;
|
||||
constexpr std::size_t kMaxSubmissionsPerWindow = 30;
|
||||
std::deque<RatePoint> gRecentSubmissions;
|
||||
std::unordered_map<std::string, std::deque<RatePoint>> gRecentPerPeer;
|
||||
// Per peer: enough for a buyer who mistypes, retries, changes their mind about
|
||||
// a colour and orders twice. Not enough to be a source of volume.
|
||||
constexpr std::size_t kMaxSubmissionsPerPeer = 6;
|
||||
// Global: a backstop, an order of magnitude above any real ten minutes here.
|
||||
constexpr std::size_t kMaxSubmissionsPerWindow = 240;
|
||||
constexpr auto kRateWindow = std::chrono::minutes(10);
|
||||
|
||||
bool RateLimitAllows() {
|
||||
bool RateLimitAllows(std::string_view peer) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gRateMutex);
|
||||
while (!gRecentSubmissions.empty() && now - gRecentSubmissions.front() > kRateWindow) {
|
||||
gRecentSubmissions.pop_front();
|
||||
}
|
||||
|
||||
auto expire = [&](std::deque<RatePoint>& seen) {
|
||||
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
||||
};
|
||||
|
||||
expire(gRecentSubmissions);
|
||||
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
|
||||
|
||||
if (!peer.empty()) {
|
||||
// Expire every peer, not just this one, and drop those whose window has
|
||||
// emptied: otherwise the map keeps one entry per address that ever
|
||||
// submitted, which is a slow leak an attacker chooses the rate of.
|
||||
std::erase_if(gRecentPerPeer, [&](auto& entry) {
|
||||
expire(entry.second);
|
||||
return entry.second.empty();
|
||||
});
|
||||
std::deque<RatePoint>& seen = gRecentPerPeer[std::string(peer)];
|
||||
if (seen.size() >= kMaxSubmissionsPerPeer) return false;
|
||||
seen.push_back(now);
|
||||
}
|
||||
|
||||
gRecentSubmissions.push_back(now);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -409,12 +504,25 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
res.status = std::string(status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||
const Views::RenderedPage page = Views::RenderProduct(
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev);
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev,
|
||||
CryptoPaymentAvailable());
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
};
|
||||
|
||||
// Cross-site request forgery. This POST creates an order and calls the
|
||||
// payment provider, so it must have come from our own form — a page on
|
||||
// another origin must not be able to drive it with a visitor's browser.
|
||||
// OriginAllowed carries the reasoning, including why a MISSING Origin is
|
||||
// accepted (a non-browser client cannot forge cross-site).
|
||||
if (const auto origin = req.headers.find("origin"); origin != req.headers.end()) {
|
||||
if (!OriginAllowed(origin->second, gRedirectBase)) {
|
||||
return reject({{ "", "That submission didn't come from this site. "
|
||||
"Nothing was charged." }}, {}, "403");
|
||||
}
|
||||
}
|
||||
|
||||
// Only urlencoded — the form sends nothing else, and accepting more content
|
||||
// types means parsing more attacker-chosen formats for no benefit.
|
||||
const auto ct = req.headers.find("content-type");
|
||||
|
|
@ -443,12 +551,35 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
parsed.value, "409");
|
||||
}
|
||||
|
||||
if (!gRail) {
|
||||
if (!gRails.Any()) {
|
||||
return reject({{ "", "Checkout is offline right now — nothing was charged. "
|
||||
"Please try again later." }}, parsed.value, "503");
|
||||
}
|
||||
|
||||
if (!RateLimitAllows()) {
|
||||
// The rail the buyer picked. ValidateCheckout has already refused anything
|
||||
// that is not one of the two words, so what remains is the case where the
|
||||
// word is valid but its slot is not configured — a form cached from before
|
||||
// the rail was switched off, or a hand-made post. Say which one is missing
|
||||
// rather than "checkout is offline": the other method is right there and
|
||||
// still works.
|
||||
const bool wantsCrypto = parsed.value.payChoice == Form::kPayCrypto;
|
||||
PaymentRail* rail = gRails.For(parsed.value.payChoice);
|
||||
if (!rail) {
|
||||
return reject({{ "pay", wantsCrypto
|
||||
? "Crypto payment isn't available right now — nothing "
|
||||
"was charged. Please pick bank or card."
|
||||
: "Bank and card payment isn't available right now — "
|
||||
"nothing was charged. Please pick crypto." }},
|
||||
parsed.value, "503");
|
||||
}
|
||||
|
||||
// Charged to this peer's own budget, so a flood costs the flooder their
|
||||
// checkout and nobody else theirs.
|
||||
std::string_view peer;
|
||||
if (const auto fwd = req.headers.find("x-forwarded-for"); fwd != req.headers.end()) {
|
||||
peer = ClientAddressFromForwarded(fwd->second);
|
||||
}
|
||||
if (!RateLimitAllows(peer)) {
|
||||
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
||||
parsed.value, "429");
|
||||
}
|
||||
|
|
@ -472,12 +603,38 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
// THE amount. Computed here from the catalogue, the validated country and
|
||||
// the live shipping table; nothing about money ever arrives from the
|
||||
// client. Shipping is per order, not per unit — one parcel.
|
||||
const std::int64_t shippingMinor = ShipCostFor(
|
||||
parsed.value.country, product->shipNlMinor, product->shipEuMinor,
|
||||
product->shipWorldMinor);
|
||||
// client. Shipping is per order, not per unit — one parcel — so the weight
|
||||
// that picks the carrier bracket is the whole order's.
|
||||
if (product->shipWeightGrams <= 0) {
|
||||
// A catalogue bug, not a buyer problem: without a weight no bracket can
|
||||
// be selected. Refuse rather than fall through to the cheapest rate,
|
||||
// and say so in the log where it can be fixed.
|
||||
std::println(std::cerr, "checkout: product '{}' has no shipping weight",
|
||||
product->slug);
|
||||
return reject({{ "", "Shipping for this product can't be priced right now — "
|
||||
"nothing was charged." }}, parsed.value, "503");
|
||||
}
|
||||
const std::int64_t parcelGrams = product->shipWeightGrams * parsed.value.quantity;
|
||||
const std::optional<std::int64_t> shippingMinor =
|
||||
ShipCostFor(parsed.value.country, parcelGrams);
|
||||
if (!shippingMinor) {
|
||||
// No rate covers this parcel, so there is no price to charge. Which of
|
||||
// the two refusals it is decides what the buyer can do about it: an
|
||||
// uncovered country is ours to fix, a too-heavy parcel has a quantity
|
||||
// that would work. The error hangs off the field the buyer would
|
||||
// change in each case.
|
||||
const std::int64_t fits =
|
||||
shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams);
|
||||
if (fits <= 0 && parsed.value.quantity == 1) {
|
||||
return reject({{ "country", Form::NoShippingMessage(parsed.value.country) }},
|
||||
parsed.value, "422");
|
||||
}
|
||||
return reject({{ "quantity",
|
||||
Form::TooHeavyMessage(parsed.value.country, fits) }},
|
||||
parsed.value, "422");
|
||||
}
|
||||
const Money::Totals totals = Money::ComputeTotals(
|
||||
unitMinor, parsed.value.quantity, shippingMinor, parsed.value.country);
|
||||
unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
|
||||
|
||||
OrderRecord order;
|
||||
order.token = NewOrderToken();
|
||||
|
|
@ -492,8 +649,11 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
order.shippingMinor = totals.shipping;
|
||||
order.totalMinor = totals.total;
|
||||
order.vatIncluded = totals.vatIncluded;
|
||||
// Normalised, not echoed: the record must name the rail that issued the
|
||||
// link, and an empty submitted choice took the bank rail above.
|
||||
order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank);
|
||||
|
||||
auto link = gRail->CreateLink(
|
||||
auto link = rail->CreateLink(
|
||||
order.totalMinor,
|
||||
std::format("{} catcrafts.net", order.reference),
|
||||
std::format("{}/order/{}", gRedirectBase, order.token));
|
||||
|
|
@ -517,7 +677,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
std::println(std::cerr, "order {} created: {} {} -> {}", order.reference,
|
||||
Money::FormatMinor(order.totalMinor), order.buyer.country,
|
||||
gRail->Name());
|
||||
rail->Name());
|
||||
|
||||
// Straight to the payment page — the buyer clicked "buy", not "read an
|
||||
// interim status page". The order page stays the receipt/status URL that
|
||||
|
|
@ -531,12 +691,60 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
return res;
|
||||
}
|
||||
|
||||
// The gate on the order page's arrival poll.
|
||||
//
|
||||
// Rendering /order/<token> asks the provider whether the payment landed, so a
|
||||
// buyer redirected back from Mollie sees "paid" immediately instead of an
|
||||
// alarming "awaiting payment" that flips ten seconds later. That is a good
|
||||
// thing to do once. The problem was that it happened on EVERY render: an
|
||||
// outbound HTTPS round trip, on the request thread, holding the rail's mutex,
|
||||
// reachable as often as anyone cared to reload.
|
||||
//
|
||||
// The hole that closes: an attacker places one order — their own, so no token
|
||||
// guessing is involved — and then reloads it in a loop. Every reload spent a
|
||||
// live Mollie API call against the shop's key, and because CreateLink shares
|
||||
// that same mutex, real buyers' checkouts queued behind the flood. The
|
||||
// listener is thread-per-connection with no cap, so the blocked threads piled
|
||||
// up as well.
|
||||
//
|
||||
// One poll per token per rail interval is all the arrival check ever needed.
|
||||
// Its job is to beat the reconciler to the FIRST render, not to become a
|
||||
// second reconciler — everything after that is the reconciler's work, and it
|
||||
// already paces itself by order age. Tying the gate to the rail's own cadence
|
||||
// keeps the two honest about each other: the fake rail's one-second interval
|
||||
// leaves dev and the e2e suite behaving exactly as before.
|
||||
std::mutex gArrivalPollMutex;
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
||||
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gArrivalPollMutex);
|
||||
// Orders settle or lapse; their entries should not outlive them. An hour
|
||||
// idle is far past both, and pruning here keeps the map bounded by live
|
||||
// traffic rather than by every token ever viewed.
|
||||
std::erase_if(gLastArrivalPoll, [&](const auto& entry) {
|
||||
return now - entry.second > std::chrono::hours(1);
|
||||
});
|
||||
const auto [it, inserted] = gLastArrivalPoll.try_emplace(std::string(token), now);
|
||||
if (inserted) return true;
|
||||
if (now - it->second < interval) return false;
|
||||
it->second = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// One reconciliation step for one order: ask the rail, append the transition
|
||||
// if there is one, and report the order's (possibly new) status fields.
|
||||
// Shared by the reconciler thread and the order page's on-arrival check.
|
||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
|
||||
if (!gRail || order.status != "awaiting_payment") return std::nullopt;
|
||||
const std::optional<PaidStatus> paid = gRail->CheckPaid(order.payId, order.totalMinor);
|
||||
if (order.status != "awaiting_payment") return std::nullopt;
|
||||
// The rail that ISSUED this order's link, never simply "the rail": asking
|
||||
// the wrong provider about an id it never handed out is at best a 404 and
|
||||
// at worst a question about somebody else's order. A slot that is no
|
||||
// longer configured means this order cannot be polled at all — leave it
|
||||
// awaiting for the manual CLI rather than guess with the other one.
|
||||
PaymentRail* rail = gRails.For(order.payChoice);
|
||||
if (!rail) return std::nullopt;
|
||||
const std::optional<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
|
||||
if (!paid.has_value()) return std::nullopt;
|
||||
if (paid->state == PayState::Paid) {
|
||||
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
||||
|
|
@ -627,11 +835,13 @@ std::size_t ContentPostCount() { return gContent.posts.size(); }
|
|||
std::size_t ContentProjectCount() { return gContent.projects.size(); }
|
||||
std::size_t ContentProductCount() { return gContent.products.size(); }
|
||||
|
||||
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase) {
|
||||
gRail = std::move(rail);
|
||||
void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
||||
gRails = std::move(rails);
|
||||
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
||||
}
|
||||
|
||||
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
||||
|
||||
namespace {
|
||||
|
||||
// The reconciler: the ONLY thing that moves an order to paid.
|
||||
|
|
@ -641,45 +851,58 @@ namespace {
|
|||
// the client (or a redirect parameter) says. This thread sweeps awaiting
|
||||
// orders and asks the rail; a positive answer appends a status event.
|
||||
//
|
||||
// Poll pacing backs off with order age — a buyer mid-flow gets answers in
|
||||
// seconds, a day-old order gets checked hourly, and after seven days the
|
||||
// order stops being polled (a very late payment is then found by the manual
|
||||
// CLI path, which exists for exactly that).
|
||||
// Poll pacing backs off with order age — a buyer mid-flow gets answers at
|
||||
// their provider's own cadence, a two-hour-old order drops to every 10
|
||||
// minutes, and after seven days it stops being polled (a very late payment is
|
||||
// then found by the manual CLI path, which exists for exactly that).
|
||||
//
|
||||
// Two timestamps per order rather than one. The sweep runs at the FASTEST
|
||||
// configured rail's cadence, because that rail's orders deserve it, so
|
||||
// "poll on every pass" would silently poll the slower provider at the faster
|
||||
// one's rate — with two rails that is no longer a rounding error but double
|
||||
// the request volume CoinGate was promised. `first` drives the age backoff,
|
||||
// `last` enforces the interval; keeping them apart also retires the modulo
|
||||
// pacing that used to approximate this with one.
|
||||
void ReconcilerLoop(const std::stop_token& stop) {
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastPoll;
|
||||
struct Seen {
|
||||
std::chrono::steady_clock::time_point first; // for the age backoff
|
||||
std::chrono::steady_clock::time_point last; // for the interval
|
||||
};
|
||||
std::unordered_map<std::string, Seen> seen;
|
||||
|
||||
while (!stop.stop_requested()) {
|
||||
std::this_thread::sleep_for(gRail->PollInterval());
|
||||
std::this_thread::sleep_for(SweepInterval());
|
||||
if (stop.stop_requested()) break;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
for (const OrderRecord& order : ListOrders()) {
|
||||
if (order.status != "awaiting_payment") {
|
||||
lastPoll.erase(order.token);
|
||||
seen.erase(order.token);
|
||||
continue;
|
||||
}
|
||||
// The order's OWN provider: the two rails ask to be polled at
|
||||
// different rates, and a sweep running at the faster one's cadence
|
||||
// must not push the slower one.
|
||||
const PaymentRail* rail = gRails.For(order.payChoice);
|
||||
if (!rail) continue;
|
||||
|
||||
// Age from the record's own timestamp is string math we don't
|
||||
// need: steady-clock first-seen is good enough for backoff.
|
||||
auto [it, inserted] = lastPoll.try_emplace(order.token, now);
|
||||
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now });
|
||||
if (!inserted) {
|
||||
const auto sinceFirst = now - it->second;
|
||||
// it->second tracks FIRST time seen; store poll pacing in a
|
||||
// parallel structure? One map is enough: after the first
|
||||
// pass, re-poll every interval for 2 h, then only every
|
||||
// 10 min, dropping to nothing after 7 days.
|
||||
using namespace std::chrono;
|
||||
if (sinceFirst > hours(24 * 7)) continue;
|
||||
if (sinceFirst > hours(2)) {
|
||||
// Coarse modulo pacing: only act on passes that land in
|
||||
// the first interval of every 10-minute window.
|
||||
const auto inWindow = duration_cast<seconds>(sinceFirst) % minutes(10);
|
||||
if (inWindow > gRail->PollInterval() * 2) continue;
|
||||
}
|
||||
const auto age = now - it->second.first;
|
||||
if (age > hours(24 * 7)) continue;
|
||||
const auto due = age > hours(2)
|
||||
? seconds(minutes(10))
|
||||
: rail->PollInterval();
|
||||
if (now - it->second.last < due) continue;
|
||||
it->second.last = now;
|
||||
}
|
||||
|
||||
// Paid, lapsed (the provider says the payment can never arrive),
|
||||
// or nothing to report — the shared step handles the transition.
|
||||
if (PollAndAdvance(order)) lastPoll.erase(order.token);
|
||||
if (PollAndAdvance(order)) seen.erase(order.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -832,7 +1055,7 @@ int Serve(std::uint16_t port) {
|
|||
// The reconciler only exists when there is a rail to ask. jthread: the
|
||||
// stop token fires on destruction, so shutdown does not hang on a sleep.
|
||||
std::optional<std::jthread> reconciler;
|
||||
if (gRail) {
|
||||
if (gRails.Any()) {
|
||||
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
||||
}
|
||||
|
||||
|
|
@ -859,9 +1082,10 @@ int Serve(std::uint16_t port) {
|
|||
|
||||
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
|
||||
std::println("catcrafts-server: listening on 127.0.0.1:{} "
|
||||
"({} projects, {} posts, payments: {})",
|
||||
"({} projects, {} posts, payments: bank={} crypto={})",
|
||||
port, gContent.projects.size(), gContent.posts.size(),
|
||||
gRail ? gRail->Name() : "off");
|
||||
gRails.bank ? gRails.bank->Name() : "off",
|
||||
gRails.crypto ? gRails.crypto->Name() : "off");
|
||||
listener.Listen();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ The source code of this website is made available for viewing purposes only.
|
|||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// The Mollie payment rail.
|
||||
// The Mollie payment rail — the bank half of the checkout — plus the fake rail
|
||||
// the tests run on, and the roster that hands out both.
|
||||
//
|
||||
// Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on
|
||||
// cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not
|
||||
|
|
@ -14,22 +15,20 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a
|
||||
// hosted checkout so card data never touches this server.
|
||||
//
|
||||
// The API is refreshingly small next to bunq's: one bearer-token key, no
|
||||
// RSA signing, no session dance.
|
||||
// The API is small: one bearer-token key, no signing, no session dance.
|
||||
//
|
||||
// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
|
||||
// GET /v2/payments/{id} -> status, method
|
||||
//
|
||||
// Trust direction is unchanged from the design rule: the ?redirect back to
|
||||
// the order page is ignored; an order becomes paid ONLY when an authenticated
|
||||
// GET says status=paid with a covering amount. One deliberate difference from
|
||||
// the bunq tab model: a Mollie payment can EXPIRE (canceled/expired/failed are
|
||||
// terminal), so the poll distinguishes Pending / Paid / Dead and the
|
||||
// reconciler lapses orders whose payment can never arrive.
|
||||
// Trust direction is the design rule: the ?redirect back to the order page is
|
||||
// ignored; an order becomes paid ONLY when an authenticated GET says
|
||||
// status=paid with a covering amount. A Mollie payment can EXPIRE
|
||||
// (canceled/expired/failed are terminal), so the poll distinguishes
|
||||
// Pending / Paid / Dead and the reconciler lapses orders whose payment can
|
||||
// never arrive.
|
||||
//
|
||||
// A test API key (test_…) works against the real endpoints from the moment a
|
||||
// Mollie account is created — verify with that before going live; unlike the
|
||||
// bunq client this one need not ship on faith.
|
||||
// Mollie account is created — verify with that before going live.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
|
@ -67,6 +66,33 @@ std::string JsonEscapeM(std::string_view s) {
|
|||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||
// spaces, thousands separators — is rejected. Money parsing has no
|
||||
// "probably fine" mode. Both providers quote amounts as decimal strings,
|
||||
// so both come through here.
|
||||
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||
std::int64_t units = 0;
|
||||
std::size_t i = 0;
|
||||
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
units = units * 10 + (s[i] - '0');
|
||||
}
|
||||
std::int64_t cents = 0;
|
||||
if (i < s.size()) {
|
||||
if (s[i] != '.') return std::nullopt;
|
||||
++i;
|
||||
const std::size_t fracStart = i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
cents = cents * 10 + (s[i] - '0');
|
||||
}
|
||||
const std::size_t digits = i - fracStart;
|
||||
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||
if (digits == 1) cents *= 10;
|
||||
}
|
||||
return units * 100 + cents;
|
||||
}
|
||||
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
|
@ -98,6 +124,50 @@ std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
|||
|
||||
namespace {
|
||||
|
||||
// ── the fake rail ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||
// which the test creates when it wants "the customer has paid" to happen.
|
||||
//
|
||||
// It can stand in for EITHER slot, which is what lets the e2e suite drive the
|
||||
// bank and crypto paths through the same machinery without inventing a second
|
||||
// test double: what it proves is that the choice is carried from the form to
|
||||
// the ledger to the poll, and that is rail-independent by design.
|
||||
|
||||
class FakeRail final : public PaymentRail {
|
||||
public:
|
||||
FakeRail(std::filesystem::path marker, std::string name)
|
||||
: marker_(std::move(marker)), name_(std::move(name)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||
const std::string& redirectUrl) override {
|
||||
static std::atomic<std::int64_t> counter{1};
|
||||
PaymentLink link;
|
||||
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||
// send anyone to, so it points at the order page itself — which keeps
|
||||
// the browser flow usable in dev and the e2e redirect parseable.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||
std::error_code ec;
|
||||
return PaidStatus{
|
||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||
"fake" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return name_; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||
|
||||
private:
|
||||
std::filesystem::path marker_;
|
||||
std::string name_;
|
||||
};
|
||||
|
||||
class MollieRail final : public PaymentRail {
|
||||
public:
|
||||
explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
|
||||
|
|
@ -199,15 +269,23 @@ private:
|
|||
|
||||
} // namespace
|
||||
|
||||
// Defined here rather than in the bunq unit so the rail roster has one home;
|
||||
// the bunq and fake constructors are declared by their own units.
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config);
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config);
|
||||
// The roster has one home, here; the CoinGate constructor is declared by its
|
||||
// own unit. An unrecognised mode is "off" rather than an error, and the caller
|
||||
// (main) is what refuses to start on a mode it did not expect — a rail that
|
||||
// silently half-exists would be worse than either.
|
||||
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config);
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
|
||||
if (config.mode == "fake") return MakeFakeRail(config);
|
||||
// The fake rail keeps the slot's own name so the ledger, the startup line
|
||||
// and the logs still say which half of the checkout ran in a test.
|
||||
if (config.mode == "fake") {
|
||||
return std::make_unique<FakeRail>(config.statePath, "fake");
|
||||
}
|
||||
if (config.mode == "fake-crypto") {
|
||||
return std::make_unique<FakeRail>(config.statePath, "fake-crypto");
|
||||
}
|
||||
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
|
||||
if (config.mode == "bunq") return MakeBunqRail(config);
|
||||
if (config.mode == "coingate") return MakeCoingateRail(config);
|
||||
return nullptr; // "off"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// {"type":"invoice", "id":..,"number":..} the number assignment, at paid
|
||||
// {"type":"notified","id":..,"what":..} the confirmation email left
|
||||
//
|
||||
// New keys on the order event are additive: the fold defaults anything absent,
|
||||
// so a ledger written by an older build still reads correctly under a newer
|
||||
// one. That property is the reason nothing here is ever rewritten in place.
|
||||
//
|
||||
// Current state is a left fold over the file; later events win. Nothing is
|
||||
// ever rewritten, so the log doubles as the audit trail the tax records need,
|
||||
// and a crash mid-write costs at most its own line (a truncated last line is
|
||||
|
|
@ -117,6 +121,10 @@ std::vector<OrderRecord> FoldLocked() {
|
|||
r.totalMinor = doc->Int("total_minor");
|
||||
r.vatIncluded = doc->Bool("vat_included");
|
||||
r.status = std::string(doc->Str("status", "awaiting_payment"));
|
||||
// Read as written, with no default applied here: the ledger
|
||||
// should keep saying exactly what it recorded, and resolving an
|
||||
// unexpected value to a rail is PaymentRails::For's single job.
|
||||
r.payChoice = std::string(doc->Str("pay_choice"));
|
||||
r.payUrl = std::string(doc->Str("pay_url"));
|
||||
r.payId = std::string(doc->Str("pay_id"));
|
||||
if (r.token.empty()) continue;
|
||||
|
|
@ -165,14 +173,15 @@ bool CreateOrder(const OrderRecord& o) {
|
|||
R"("color":"{}","quantity":{},"unit_minor":{},)"
|
||||
R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
|
||||
R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},)"
|
||||
R"("status":"{}","pay_url":"{}","pay_id":"{}"}})",
|
||||
R"("status":"{}","pay_choice":"{}","pay_url":"{}","pay_id":"{}"}})",
|
||||
JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference),
|
||||
JsonEscape(o.product),
|
||||
JsonEscape(o.color), o.quantity, o.unitMinor,
|
||||
JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street),
|
||||
JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country),
|
||||
o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded,
|
||||
JsonEscape(o.status), JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
||||
JsonEscape(o.status), JsonEscape(o.payChoice),
|
||||
JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
||||
}
|
||||
|
||||
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||
|
|
|
|||
|
|
@ -6,21 +6,31 @@ The source code of this website is made available for viewing purposes only.
|
|||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// Live shipping rates from Sendcloud, with the zone table as the floor.
|
||||
// Live shipping rates from Sendcloud. The only source of shipping prices.
|
||||
//
|
||||
// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
|
||||
// account can book, each with a per-country price list. One configured method
|
||||
// (matched by name substring) becomes a country -> cents table, cached to disk
|
||||
// and refreshed daily by a background thread the HTTP layer starts.
|
||||
// account can book, each with its weight range and a per-country price list.
|
||||
// The SAME carrier service appears once per weight band, so the configured
|
||||
// methods (matched by name substring) become a country -> bracket-ladder
|
||||
// table, cached to disk and refreshed daily by a background thread the HTTP
|
||||
// layer starts.
|
||||
//
|
||||
// Failure posture mirrors the rest of the build pipeline: Sendcloud being
|
||||
// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
|
||||
// table (Catcrafts.Shared:Content) answers instead. A stale cached table
|
||||
// beats both, which is why the cache survives restarts.
|
||||
// Failure posture, and it is a real trade: there is no compiled-in fallback
|
||||
// any more. A country with no bracket, or a parcel heavier than every bracket,
|
||||
// is REFUSED at checkout. That is the honest answer — a rate the carrier does
|
||||
// not offer is a parcel that cannot be posted, and quoting one anyway sells an
|
||||
// order that has to be refunded or absorbed. The cost is that an empty table
|
||||
// means an unsellable shop, which is why the disk cache is load-bearing: it is
|
||||
// read at startup whether or not credentials exist, so an outage keeps selling
|
||||
// at the last known prices, and a hand-placed cache file is how dev and e2e
|
||||
// get a table with no account at all.
|
||||
//
|
||||
// Like the bunq rail, this code has not run against the real API — no
|
||||
// Like the CoinGate rail, this code has not run against the real API — no
|
||||
// credentials existed at build time. ParseSendcloudMethods is exercised by the
|
||||
// self-test against a canned response; the fetch around it is thin.
|
||||
// self-test against a canned response; the fetch around it is thin. The one
|
||||
// thing to verify against a live payload is the weight fields: this reads
|
||||
// `min_weight`/`max_weight` as kilogram strings, which is what the v2 docs
|
||||
// describe, and a method missing them is skipped rather than guessed at.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
|
@ -40,8 +50,8 @@ ShippingConfig gShipConfig;
|
|||
ShippingTable gShipTable;
|
||||
bool gShipConfigured = false;
|
||||
|
||||
// Same alphabet as the bunq helper; duplicated rather than shared because
|
||||
// each implementation unit keeps its internals to itself.
|
||||
// Duplicated rather than shared because each implementation unit keeps its
|
||||
// internals to itself.
|
||||
std::string Base64S(std::string_view in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
|
@ -69,6 +79,13 @@ std::string NowIsoS() {
|
|||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
// Cache format: {"method":..,"fetched_at":..,"per_country":{"NL":[[2000,895],..]}}
|
||||
// where each pair is [maxWeightGrams, consumerCents]. Grams and cents, not the
|
||||
// kilograms and euros the API speaks, because everything downstream of the
|
||||
// parse is integer.
|
||||
//
|
||||
// This is also the documented way to run without an account: writing this file
|
||||
// by hand gives the server a complete rate table.
|
||||
void SaveCacheLocked() {
|
||||
if (gShipConfig.cachePath.empty()) return;
|
||||
std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
|
||||
|
|
@ -76,8 +93,13 @@ void SaveCacheLocked() {
|
|||
out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
|
||||
gShipTable.method, gShipTable.fetchedAt);
|
||||
bool first = true;
|
||||
for (const auto& [cc, minor] : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
|
||||
for (const Money::ShipRates& row : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":[)", first ? "" : ",", row.cc);
|
||||
for (std::size_t i = 0; i < row.brackets.size(); ++i) {
|
||||
out << std::format("{}[{},{}]", i ? "," : "",
|
||||
row.brackets[i].maxWeightGrams, row.brackets[i].minor);
|
||||
}
|
||||
out << ']';
|
||||
first = false;
|
||||
}
|
||||
out << "}}\n";
|
||||
|
|
@ -95,21 +117,72 @@ void LoadCacheLocked() {
|
|||
t.fetchedAt = std::string(doc->Str("fetched_at"));
|
||||
if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
|
||||
for (const auto& [k, v] : m->object) {
|
||||
if (v.type == Json::Type::Number && v.number > 0) {
|
||||
t.perCountry.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||
// A pre-brackets cache (country -> flat cents) parses fine as JSON
|
||||
// but is not a ladder, so it lands here as "not an array" and is
|
||||
// dropped. Correct: those numbers were a single unknown weight
|
||||
// band, and re-serving them would price parcels by guess. The next
|
||||
// refresh rewrites the file.
|
||||
if (!v.IsArray() || k.size() != 2) continue;
|
||||
Money::ShipRates row;
|
||||
row.cc = k;
|
||||
for (const Json::Value& b : v.array) {
|
||||
if (!b.IsArray() || b.array.size() != 2) continue;
|
||||
if (b.array[0].type != Json::Type::Number
|
||||
|| b.array[1].type != Json::Type::Number) continue;
|
||||
const auto grams = static_cast<std::int64_t>(b.array[0].number);
|
||||
const auto minor = static_cast<std::int64_t>(b.array[1].number);
|
||||
if (grams > 0 && minor > 0) row.brackets.push_back({ grams, minor });
|
||||
}
|
||||
if (!row.brackets.empty()) t.perCountry.push_back(std::move(row));
|
||||
}
|
||||
}
|
||||
if (!t.perCountry.empty()) gShipTable = std::move(t);
|
||||
}
|
||||
|
||||
// "2.001" (kilograms, as the API sends them) -> 2001 grams. Accepts a JSON
|
||||
// number too, in case the field is not always a string. Returns 0 for anything
|
||||
// unparseable, which the caller treats as "this method has no usable weight
|
||||
// range" and skips — a bracket with an invented ceiling is exactly the kind of
|
||||
// guess this module no longer makes.
|
||||
std::int64_t KgFieldToGrams(const Json::Value* v) {
|
||||
if (!v) return 0;
|
||||
if (v->type == Json::Type::Number) return std::llround(v->number * 1000.0);
|
||||
if (v->type != Json::Type::String) return 0;
|
||||
// Hand-rolled rather than from_chars<double>: the value is a fixed-point
|
||||
// decimal and this keeps it exact, the same reason money never touches a
|
||||
// float here.
|
||||
std::string_view s = v->string;
|
||||
std::int64_t whole = 0, frac = 0, scale = 1;
|
||||
std::size_t i = 0;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
whole = whole * 10 + (s[i] - '0');
|
||||
if (whole > 1'000'000) return 0; // absurd; treat as unusable
|
||||
}
|
||||
if (i == 0) return 0;
|
||||
if (i < s.size() && s[i] == '.') {
|
||||
++i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
if (scale <= 100) { frac = frac * 10 + (s[i] - '0'); scale *= 10; }
|
||||
}
|
||||
}
|
||||
if (i != s.size()) return 0; // trailing junk
|
||||
while (scale <= 100) { frac *= 10; scale *= 10; } // normalise to 1/1000
|
||||
return whole * 1000 + frac;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `methodName` is a comma-separated list of name substrings, merged in order
|
||||
// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
|
||||
// market: the realistic setup is a courier inside Europe and post beyond
|
||||
// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
|
||||
// a country served by both gets the earlier method's price.
|
||||
// with the FIRST FILTER TO COVER A COUNTRY winning it. One method rarely
|
||||
// covers a whole market: the realistic setup is a courier inside Europe and
|
||||
// post beyond ("DPD Home,PostNL Parcels non-EU"), and the order encodes the
|
||||
// preference — a country served by both gets the earlier filter's prices.
|
||||
//
|
||||
// Every method matching a filter contributes, NOT just the first. Sendcloud
|
||||
// publishes one entry per weight band of the same service, so the matches for
|
||||
// "DPD Home" are that service's ladder and taking only one of them would price
|
||||
// every parcel at whichever band the response happened to list first — the qty
|
||||
// 1 rate included.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
|
||||
ShippingTable out;
|
||||
auto doc = Json::Parse(json);
|
||||
|
|
@ -131,23 +204,43 @@ ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view meth
|
|||
}
|
||||
}
|
||||
|
||||
auto ladderFor = [&](const std::string& cc) -> std::vector<Money::ShipBracket>& {
|
||||
for (Money::ShipRates& r : out.perCountry) {
|
||||
if (r.cc == cc) return r.brackets;
|
||||
}
|
||||
out.perCountry.push_back({ cc, {} });
|
||||
return out.perCountry.back().brackets;
|
||||
};
|
||||
|
||||
std::vector<std::string> names; // unique, for the operator log
|
||||
for (const std::string_view filter : filters) {
|
||||
// Snapshot of who is already covered: ownership is per FILTER, so a
|
||||
// later filter may not touch a country an earlier one priced, but the
|
||||
// bands within this filter must all reach the countries they cover.
|
||||
std::vector<std::string> owned;
|
||||
for (const Money::ShipRates& r : out.perCountry) owned.push_back(r.cc);
|
||||
|
||||
for (const Json::Value& method : methods->array) {
|
||||
if (!method.IsObject()) continue;
|
||||
const std::string_view name = method.Str("name");
|
||||
if (name.find(filter) == std::string_view::npos) continue;
|
||||
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += std::string(name);
|
||||
if (const Json::Value* countries = method.Find("countries");
|
||||
countries && countries->IsArray()) {
|
||||
// The band ceiling. A method that does not state one is unusable:
|
||||
// without it there is no way to know which parcels the price
|
||||
// covers, and assuming "any" is the guess this module exists to
|
||||
// avoid.
|
||||
const std::int64_t maxGrams = KgFieldToGrams(method.Find("max_weight"));
|
||||
if (maxGrams <= 0) continue;
|
||||
|
||||
if (std::ranges::find(names, name) == names.end()) names.emplace_back(name);
|
||||
|
||||
const Json::Value* countries = method.Find("countries");
|
||||
if (!countries || !countries->IsArray()) continue;
|
||||
for (const Json::Value& c : countries->array) {
|
||||
if (!c.IsObject()) continue;
|
||||
std::string cc(c.Str("iso_2"));
|
||||
if (cc.size() != 2) continue;
|
||||
// Earlier methods own their countries — a later method
|
||||
// never overrides.
|
||||
if (out.Find(cc) > 0) continue;
|
||||
if (std::ranges::find(owned, cc) != owned.end()) continue;
|
||||
// Sendcloud sends the price as a JSON number of euros.
|
||||
// Money stays integer everywhere else; this one boundary
|
||||
// rounds a decimal that is exact to the cent in a double
|
||||
|
|
@ -158,12 +251,30 @@ ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view meth
|
|||
if (!price || price->type != Json::Type::Number) continue;
|
||||
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||
if (minor <= 0) continue;
|
||||
out.perCountry.emplace_back(std::move(cc), minor);
|
||||
|
||||
std::vector<Money::ShipBracket>& ladder = ladderFor(cc);
|
||||
// Two methods under one filter can publish the same ceiling
|
||||
// (a service and its signed-for variant, say). Keep the
|
||||
// cheaper: both carry the parcel, so the dearer one is never
|
||||
// the right quote.
|
||||
auto same = std::ranges::find(ladder, maxGrams,
|
||||
&Money::ShipBracket::maxWeightGrams);
|
||||
if (same != ladder.end()) {
|
||||
same->minor = std::min(same->minor, minor);
|
||||
} else {
|
||||
ladder.push_back({ maxGrams, minor });
|
||||
}
|
||||
}
|
||||
break; // first method matching THIS filter wins; next filter
|
||||
}
|
||||
}
|
||||
|
||||
for (Money::ShipRates& r : out.perCountry) {
|
||||
std::ranges::sort(r.brackets, {}, &Money::ShipBracket::maxWeightGrams);
|
||||
}
|
||||
for (const std::string& n : names) {
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -183,15 +294,25 @@ void ConfigureShipping(const ShippingConfig& config) {
|
|||
gShipTable.perCountry.size(),
|
||||
gShipTable.fetchedAt));
|
||||
}
|
||||
// Said loudly because it is not a degraded mode, it is a shop that cannot
|
||||
// take an order: with no rate table every checkout refuses. Not fatal —
|
||||
// the rest of the site is worth serving, and the refresh below may fix it
|
||||
// seconds later — but an operator who sees this and does nothing has a
|
||||
// storefront selling nothing.
|
||||
if (gShipTable.perCountry.empty()) {
|
||||
std::println(std::cerr,
|
||||
"shipping: NO RATE TABLE — checkout will refuse every order "
|
||||
"until Sendcloud answers{}",
|
||||
gShipConfigured ? "" : " (no credentials configured; a "
|
||||
"hand-written cache file also works)");
|
||||
}
|
||||
}
|
||||
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld) {
|
||||
{
|
||||
std::optional<std::int64_t> ShipCostFor(std::string_view country, std::int64_t grams) {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
|
||||
}
|
||||
return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
|
||||
const std::int64_t rate = gShipTable.Find(country, grams);
|
||||
if (rate <= 0) return std::nullopt;
|
||||
return rate;
|
||||
}
|
||||
|
||||
ShippingTable CurrentShippingTable() {
|
||||
|
|
@ -200,7 +321,7 @@ ShippingTable CurrentShippingTable() {
|
|||
}
|
||||
|
||||
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||
// failure the previous table (cached or zone fallback) simply stays.
|
||||
// failure the previous table (cached, or none at all) simply stays.
|
||||
void RefreshShippingTable() {
|
||||
ShippingConfig cfg;
|
||||
{
|
||||
|
|
@ -237,9 +358,11 @@ void RefreshShippingTable() {
|
|||
// VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
|
||||
// is covered), non-EU postage is zero-rated so cost is charged as-is.
|
||||
// Done once at table build — the cache stores consumer prices, so a
|
||||
// cache reload must not (and does not) gross up again.
|
||||
for (auto& [cc, minor] : t.perCountry) {
|
||||
if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
|
||||
// cache reload must not (and does not) gross up again. Every bracket
|
||||
// gets it, since any of them can be the one a parcel is quoted at.
|
||||
for (Money::ShipRates& row : t.perCountry) {
|
||||
if (!Money::IsEuCountry(row.cc)) continue;
|
||||
for (Money::ShipBracket& b : row.brackets) b.minor = Money::GrossFromNet(b.minor);
|
||||
}
|
||||
t.fetchedAt = NowIsoS();
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// catcrafts-server — the native product.
|
||||
//
|
||||
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||||
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||||
// runs the shop — orders, the payment rails, the reconciler — and doubles
|
||||
// as the test harness for Catcrafts.Shared.
|
||||
//
|
||||
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||||
|
|
@ -704,6 +704,74 @@ void RunFormSelfTest() {
|
|||
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
|
||||
"checkout: oversized colour rejected");
|
||||
|
||||
// The payment choice. Absent is a form that offered none (one rail
|
||||
// configured, or the no-JS fallback page) and the handler resolves it to
|
||||
// bank — the validator's job is only to refuse a word it does not know
|
||||
// rather than let it fall through to a default the buyer never picked.
|
||||
Check(validate(kGoodOrder).value.payChoice.empty(),
|
||||
"checkout: absent payment choice stays empty");
|
||||
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
|
||||
== Catcrafts::Form::kPayBank,
|
||||
"checkout: bank choice parsed");
|
||||
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
|
||||
== Catcrafts::Form::kPayCrypto,
|
||||
"checkout: crypto choice parsed");
|
||||
{
|
||||
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
|
||||
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
|
||||
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
|
||||
"checkout: the payment refusal hangs off the payment field");
|
||||
}
|
||||
|
||||
// Destinations the shop refuses. Well-formed, real country codes — the
|
||||
// refusal is policy, so it has to survive every spelling the form accepts,
|
||||
// and it must not spill onto other non-EU destinations.
|
||||
auto withCountry = [&](std::string_view cc) {
|
||||
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
|
||||
+ std::string(cc));
|
||||
};
|
||||
Check(!withCountry("US").Ok(), "checkout: US refused");
|
||||
Check(!withCountry("CA").Ok(), "checkout: CA refused");
|
||||
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
|
||||
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
|
||||
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
|
||||
{
|
||||
auto us = withCountry("US");
|
||||
Check(us.errors.size() == 1 && us.errors[0].field == "country",
|
||||
"checkout: refusal is a country error, nothing else");
|
||||
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
|
||||
"checkout: refusal says where the shop does not sell");
|
||||
Check(us.value.country == "US", "checkout: refused country echoed back");
|
||||
}
|
||||
|
||||
// The shipping refusals. These are templates rather than plain strings
|
||||
// because the buy page fills the same ones client-side, so the substitution
|
||||
// has to work on both {cc} and {n} — a template that silently kept its
|
||||
// placeholder would ship "up to {n} per order" to a real buyer.
|
||||
{
|
||||
using namespace Catcrafts::Form;
|
||||
const std::string none = NoShippingMessage("BR");
|
||||
Check(none.find("BR") != std::string::npos
|
||||
&& none.find("{cc}") == std::string::npos,
|
||||
"shipping copy: the uncovered-country message names the country");
|
||||
const std::string heavy = TooHeavyMessage("JP", 3);
|
||||
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
|
||||
&& heavy.find("{n}") == std::string::npos,
|
||||
"shipping copy: the too-heavy message names the country and the limit");
|
||||
const std::string nofit = TooHeavyMessage("JP", 0);
|
||||
Check(nofit.find("JP") != std::string::npos
|
||||
&& nofit.find("up to") == std::string::npos,
|
||||
"shipping copy: with nothing fitting it does not promise a quantity");
|
||||
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
|
||||
"shipping copy: every placeholder is filled, not just the first");
|
||||
// Both messages must offer the way out, since the shop is refusing
|
||||
// business it would otherwise take.
|
||||
Check(none.find("orders@catcrafts.net") != std::string::npos
|
||||
&& heavy.find("orders@catcrafts.net") != std::string::npos
|
||||
&& nofit.find("orders@catcrafts.net") != std::string::npos,
|
||||
"shipping copy: every refusal names a human to email");
|
||||
}
|
||||
|
||||
// A rejected field must still come back, or the visitor has to retype the
|
||||
// one thing they got wrong — the fastest way to lose a submission.
|
||||
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
|
||||
|
|
@ -746,12 +814,49 @@ void RunMoneySelfTest() {
|
|||
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
|
||||
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
|
||||
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
|
||||
Check(ZoneFor("CA") == Zone::World, "zone: world");
|
||||
Check(ZoneFor("GB") == Zone::World, "zone: world");
|
||||
|
||||
// ── destinations the shop refuses ─────────────────────────────────
|
||||
// Zones still classify US and CA (the arithmetic is destination-blind, and
|
||||
// keeping it that way means one policy switch, not two); the sale is what
|
||||
// stops, in SellsTo.
|
||||
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
|
||||
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
|
||||
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
|
||||
"policy: the rest of the world still sells");
|
||||
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
|
||||
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
|
||||
|
||||
// ── carrier weight brackets ───────────────────────────────────────
|
||||
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
|
||||
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
|
||||
// real carrier tariffs do that, and picking the tightest band rather than
|
||||
// the cheapest one that carries the parcel would overcharge for it.
|
||||
{
|
||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
|
||||
{ 20000, 1490 } };
|
||||
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
|
||||
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
|
||||
Check(RateFor(ladder, 2001) == 1490,
|
||||
"brackets: cheapest band that CARRIES it, not the tightest");
|
||||
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
|
||||
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
|
||||
|
||||
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
|
||||
Check(MaxUnitsFor(ladder, 25000) == 0,
|
||||
"brackets: a unit heavier than every band fits nothing");
|
||||
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
|
||||
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
|
||||
|
||||
// The table-level lookups the handler and the page both go through.
|
||||
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
|
||||
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
|
||||
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
|
||||
"table: JP has one light band, so two units are unshippable");
|
||||
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
|
||||
}
|
||||
|
||||
// ── order totals ──────────────────────────────────────────────────
|
||||
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||||
|
|
@ -765,17 +870,17 @@ void RunMoneySelfTest() {
|
|||
"totals: EU");
|
||||
|
||||
// Export: net goods, world shipping, no VAT.
|
||||
auto ca = ComputeTotals(58000, 1, 5500, "CA");
|
||||
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
|
||||
auto gb = ComputeTotals(58000, 1, 5500, "GB");
|
||||
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
|
||||
"totals: export");
|
||||
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
|
||||
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
|
||||
|
||||
// Quantity: the export net is derived from the LINE total, not per unit —
|
||||
// per-unit rounding times qty would differ by a cent here, and the JS
|
||||
// preview mirrors this exact formula.
|
||||
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
|
||||
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(ca2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
|
||||
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(gb2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||||
|
||||
|
|
@ -814,7 +919,17 @@ void RunMoneySelfTest() {
|
|||
// with its ONE offer — built from the same integers the
|
||||
// checkout charges.
|
||||
{
|
||||
auto pp = Views::RenderProduct(pr, Rates{});
|
||||
// The listing's shipping block is now carrier data, so the
|
||||
// render needs a table. US is priced here on purpose: the
|
||||
// carrier will happily quote it and the shop still must not
|
||||
// advertise it.
|
||||
const std::vector<ShipRates> feedTable{
|
||||
{ "NL", { { 2000, 895 } } },
|
||||
{ "DE", { { 2000, 995 } } },
|
||||
{ "GB", { { 2000, 2450 } } },
|
||||
{ "US", { { 2000, 1794 } } },
|
||||
};
|
||||
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
|
||||
auto ld = Json::Parse(pp.meta.jsonLd);
|
||||
bool variantsOk = false;
|
||||
if (ld && ld->IsObject()) {
|
||||
|
|
@ -838,6 +953,24 @@ void RunMoneySelfTest() {
|
|||
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: variants carry shipping, returns, sku and group id");
|
||||
// The published rates ARE the carrier's, at one unit's weight.
|
||||
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
|
||||
"schema: shipping rates come from the carrier table");
|
||||
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
||||
"schema: a refused destination is never advertised, priced or not");
|
||||
|
||||
// No table: no shipping claim. The listing loses the merchant
|
||||
// block rather than inventing a rate — the whole point of
|
||||
// dropping the zone fallback.
|
||||
auto bare = Views::RenderProduct(pr, Rates{});
|
||||
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
||||
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
||||
"schema: with no carrier table the offer publishes no shipping");
|
||||
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
||||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: and the rest of the record still parses");
|
||||
}
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
|
|
@ -931,51 +1064,92 @@ void RunMoneySelfTest() {
|
|||
}
|
||||
|
||||
// ── the Sendcloud response parser ─────────────────────────────────
|
||||
// Weights are the kilogram strings the API sends; every Find() below asks
|
||||
// for a parcel weight, because a price without a weight is not a thing this
|
||||
// table has any more.
|
||||
{
|
||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","countries":[
|
||||
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"NL","price":6.25},
|
||||
{"iso_2":"DE","price":8.20},
|
||||
{"iso_2":"CA","price":42.50},
|
||||
{"iso_2":"XX","price":0},
|
||||
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||||
{"iso_2":"TOOLONG","price":5.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
|
||||
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
|
||||
"DHL For You");
|
||||
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||||
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||||
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
|
||||
// The bug the old parser had: it stopped at the FIRST matching method,
|
||||
// so every parcel was priced at whichever band came first and the
|
||||
// heavier bands were invisible.
|
||||
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
|
||||
"sendcloud: every weight band of a matched method is kept");
|
||||
Check(table.Find("NL", 11000) == 0,
|
||||
"sendcloud: past the heaviest band there is no price");
|
||||
Check(table.Find("BE", 700) == 0,
|
||||
"sendcloud: a method with no weight range is unusable, not unlimited");
|
||||
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||||
"sendcloud: malformed payload yields nothing");
|
||||
|
||||
// Comma-separated merge: courier for Europe, post for the world; the
|
||||
// earlier method keeps any country both cover.
|
||||
// earlier FILTER keeps any country both cover — including that
|
||||
// country's heavier bands, which must not leak in from the later one.
|
||||
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","countries":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
|
||||
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||||
{"name":"PostNL Parcels non-EU","countries":[
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||||
{"iso_2":"DE","price":99.99}]}]})",
|
||||
{"iso_2":"DE","price":99.99}]},
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
|
||||
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
|
||||
"DPD Home, PostNL Parcels non-EU");
|
||||
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||||
"sendcloud: merged table covers both methods");
|
||||
Check(merged.Find("DE") == 1049,
|
||||
"sendcloud: earlier method wins a shared country");
|
||||
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
|
||||
"sendcloud: merged table covers both filters");
|
||||
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
|
||||
Check(merged.Find("DE", 700) == 1049,
|
||||
"sendcloud: earlier filter wins a shared country");
|
||||
Check(merged.Find("DE", 12000) == 0,
|
||||
"sendcloud: and owns it outright — no band from the loser");
|
||||
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||||
"sendcloud: merged method names recorded");
|
||||
"sendcloud: merged method names recorded, deduplicated per band");
|
||||
|
||||
// Two services under one filter publishing the same ceiling: the
|
||||
// cheaper is the only sensible quote, since both carry the parcel.
|
||||
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.00}]},
|
||||
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
|
||||
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
|
||||
}
|
||||
|
||||
// ── indicative conversion ─────────────────────────────────────────
|
||||
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||||
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||||
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||||
auto ca$ = CurrencyFor("CA");
|
||||
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||||
auto gbp = CurrencyFor("GB");
|
||||
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
|
||||
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||||
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||||
if (ca$) {
|
||||
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||||
if (gbp) {
|
||||
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
|
||||
}
|
||||
// A country the shop refuses gets no localised price either — the two
|
||||
// tables are kept consistent on purpose, so this is a real invariant and
|
||||
// not a coincidence of the current list.
|
||||
for (const std::string_view cc : NoSaleCountries()) {
|
||||
Check(!CurrencyFor(cc).has_value(),
|
||||
"fx: refused destinations have no display currency", cc);
|
||||
}
|
||||
|
||||
// ── order tokens and references ───────────────────────────────────
|
||||
|
|
@ -989,7 +1163,7 @@ void RunMoneySelfTest() {
|
|||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||||
// ── the wire-amount parser (both providers quote strings) ─────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
|
|
@ -1033,6 +1207,98 @@ void RunMoneySelfTest() {
|
|||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
// ── the CoinGate order parser ─────────────────────────────────────
|
||||
//
|
||||
// The id is a JSON NUMBER at CoinGate, which is the one shape difference
|
||||
// from Mollie that could silently produce an empty payment id — an order
|
||||
// that can never be polled. Both spellings are pinned here.
|
||||
{
|
||||
const auto c1 = Server::ParseCoingateOrder(R"({
|
||||
"id":538,"status":"new","title":"CC-ABCDEF catcrafts.net",
|
||||
"price_amount":"578.30","price_currency":"EUR","receive_currency":"EUR",
|
||||
"payment_url":"https://pay.coingate.com/invoice/abc-123"})");
|
||||
Check(c1.has_value(), "coingate: new order parses");
|
||||
if (c1) {
|
||||
Check(c1->id == "538", "coingate: numeric id becomes decimal text");
|
||||
Check(c1->status == "new", "coingate: status");
|
||||
Check(c1->priceMinor == 57830, "coingate: price to cents");
|
||||
Check(c1->payUrl == "https://pay.coingate.com/invoice/abc-123",
|
||||
"coingate: payment url");
|
||||
Check(c1->payCurrency.empty(), "coingate: no coin picked yet");
|
||||
}
|
||||
const auto c2 = Server::ParseCoingateOrder(R"({
|
||||
"id":"539","status":"paid","pay_currency":"BTC",
|
||||
"price_amount":"578.3","price_currency":"EUR"})");
|
||||
Check(c2 && c2->id == "539", "coingate: string id also accepted");
|
||||
Check(c2 && c2->status == "paid" && c2->payCurrency == "BTC",
|
||||
"coingate: paid order carries the coin");
|
||||
Check(c2 && c2->priceMinor == 57830,
|
||||
"coingate: one-decimal amount is still cents");
|
||||
const auto c3 = Server::ParseCoingateOrder(R"({
|
||||
"id":540,"status":"paid","price_amount":"578.30","price_currency":"USD"})");
|
||||
Check(c3 && c3->priceMinor == 0, "coingate: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseCoingateOrder("garbage").has_value(),
|
||||
"coingate: malformed payload rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"status":"new"})").has_value(),
|
||||
"coingate: missing id rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"id":541})").has_value(),
|
||||
"coingate: missing status rejected");
|
||||
}
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// The rate limiter keys on this, so getting the WRONG end of the header
|
||||
// is not a cosmetic bug: the leftmost entry is client-controlled, and
|
||||
// trusting it would hand every attacker an endless supply of identities.
|
||||
{
|
||||
using Server::ClientAddressFromForwarded;
|
||||
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: single entry");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: rightmost entry wins");
|
||||
// The attack this exists to defeat: a client that sends its own header
|
||||
// to look like a different peer. Caddy appends the truth on the right.
|
||||
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: spoofed prefix ignored");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: padding trimmed");
|
||||
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
|
||||
"forwarded: ipv6 passes through");
|
||||
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
|
||||
// No header at all means nothing proxied this request; the caller must
|
||||
// see an empty peer and fall back to the global budget.
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
|
||||
"forwarded: empty last entry is no peer");
|
||||
}
|
||||
|
||||
{
|
||||
using Server::OriginAllowed;
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: same origin allowed");
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
|
||||
"origin: trailing slash on the base normalised");
|
||||
// A non-browser client (curl, the e2e suite) sends no Origin and
|
||||
// cannot be a cross-site forgery — there is no session to ride on.
|
||||
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
|
||||
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
|
||||
"origin: foreign origin refused");
|
||||
// Neither a subdomain nor a lookalike is us.
|
||||
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
|
||||
"origin: suffix lookalike refused");
|
||||
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: subdomain refused");
|
||||
// Scheme is part of an origin: http is not https.
|
||||
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: scheme mismatch refused");
|
||||
// A sandboxed iframe posts Origin: null. Present, and not us.
|
||||
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
|
||||
Check(!OriginAllowed("https://catcrafts.net", ""),
|
||||
"origin: unconfigured base refuses rather than accepts all");
|
||||
// dev.sh serves on localhost and sets --redirect-base to match.
|
||||
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
|
||||
"origin: dev localhost base matches");
|
||||
}
|
||||
|
||||
// ── the invoice builder ───────────────────────────────────────────
|
||||
{
|
||||
Server::OrderRecord o;
|
||||
|
|
@ -1071,7 +1337,7 @@ void RunMoneySelfTest() {
|
|||
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.goodsMinor = 93107;
|
||||
o.shippingMinor = 2395;
|
||||
o.totalMinor = 95502;
|
||||
|
|
@ -1123,7 +1389,7 @@ void RunMoneySelfTest() {
|
|||
|
||||
// The export wording mirrors the invoice's VAT treatment.
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.totalMinor = 95502;
|
||||
const std::string exMail = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
|
|
@ -1305,19 +1571,20 @@ int main(int argc, char** argv) {
|
|||
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
// Payment rail selection. Flags beat environment beats default. The
|
||||
// default is "whichever provider has a key, off otherwise" so a box
|
||||
// with no credentials serves the whole site minus checkout instead of
|
||||
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||||
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||||
// checkout; the client is kept for a possible future account sweep.
|
||||
// Payment rail selection, one slot per payment choice the buyer gets.
|
||||
// Flags beat environment beats default, and the default for each slot
|
||||
// is "the provider whose key is set, off otherwise" — so a box with no
|
||||
// credentials serves the whole site minus checkout instead of refusing
|
||||
// to start, and a box with only one key offers only that one method.
|
||||
//
|
||||
// bank MOLLIE_API_KEY iDEAL, cards, transfer
|
||||
// crypto COINGATE_API_KEY on-chain and Lightning, settled to EUR
|
||||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||||
: bunqKey && *bunqKey ? "bunq"
|
||||
: "off";
|
||||
bool bunqSandbox = [] {
|
||||
const char* v = std::getenv("BUNQ_SANDBOX");
|
||||
const char* coingateKey = std::getenv("COINGATE_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
|
||||
std::string cryptoMode = coingateKey && *coingateKey ? "coingate" : "off";
|
||||
bool coingateSandbox = [] {
|
||||
const char* v = std::getenv("COINGATE_SANDBOX");
|
||||
return v && std::string_view(v) == "1";
|
||||
}();
|
||||
std::filesystem::path railState;
|
||||
|
|
@ -1336,12 +1603,10 @@ int main(int argc, char** argv) {
|
|||
ordersPath = a.substr(9);
|
||||
} else if (a.starts_with("--rail=")) {
|
||||
railMode = a.substr(7);
|
||||
} else if (a.starts_with("--bunq=")) {
|
||||
railMode = a.substr(7); // legacy alias for --rail=
|
||||
} else if (a.starts_with("--crypto-rail=")) {
|
||||
cryptoMode = a.substr(14);
|
||||
} else if (a.starts_with("--rail-state=")) {
|
||||
railState = a.substr(13);
|
||||
} else if (a.starts_with("--bunq-state=")) {
|
||||
railState = a.substr(13); // legacy alias for --rail-state=
|
||||
} else if (a.starts_with("--redirect-base=")) {
|
||||
redirectBase = a.substr(16);
|
||||
} else {
|
||||
|
|
@ -1400,30 +1665,51 @@ int main(int argc, char** argv) {
|
|||
return 2;
|
||||
}
|
||||
|
||||
// The rail. State (bunq session context, or the fake rail's paid
|
||||
// marker; Mollie needs none) defaults next to the orders file — same
|
||||
// directory, same lifecycle, same backup.
|
||||
// The rails. State (only the fake rail has any — its paid marker;
|
||||
// neither real provider needs a session or a keypair) defaults next to
|
||||
// the orders file: same directory, same lifecycle, same backup.
|
||||
if (railState.empty()) {
|
||||
railState = ordersPath;
|
||||
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
||||
railState += ".fake-paid";
|
||||
}
|
||||
Server::RailConfig railCfg;
|
||||
railCfg.mode = railMode;
|
||||
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
||||
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
||||
: "";
|
||||
railCfg.sandbox = bunqSandbox;
|
||||
railCfg.statePath = railState;
|
||||
railCfg.redirectBase = redirectBase;
|
||||
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
||||
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
||||
// A mode whose credential is missing is a misconfiguration, not a
|
||||
// reason to quietly serve a checkout that 502s at the last step. Both
|
||||
// slots are checked the same way, and both name the env var they want.
|
||||
auto build = [&](const std::string& mode, const char* key, const char* keyName,
|
||||
bool sandbox, std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
||||
Server::RailConfig cfg;
|
||||
cfg.mode = mode;
|
||||
cfg.apiKey = key ? key : "";
|
||||
cfg.sandbox = sandbox;
|
||||
cfg.statePath = railState;
|
||||
cfg.redirectBase = redirectBase;
|
||||
const bool needsKey = mode == "mollie" || mode == "coingate";
|
||||
if (needsKey && cfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: --rail={} but its API key env is not set — "
|
||||
"refusing to start with a rail that cannot work", railMode);
|
||||
"catcrafts-server: rail '{}' selected but {} is not set — "
|
||||
"refusing to start with a rail that cannot work",
|
||||
mode, keyName);
|
||||
return false;
|
||||
}
|
||||
out = Server::MakeRail(cfg);
|
||||
// "off" is a legitimate choice and yields no rail; a mode nobody
|
||||
// recognises silently would too, which is how a typo becomes a
|
||||
// shop that quietly stops taking one kind of money.
|
||||
if (!out && mode != "off") {
|
||||
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Server::PaymentRails rails;
|
||||
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", false, rails.bank)) return 2;
|
||||
if (!build(cryptoMode, coingateKey, "COINGATE_API_KEY", coingateSandbox,
|
||||
rails.crypto)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
Server::ConfigurePayments(std::move(rail), redirectBase);
|
||||
Server::ConfigurePayments(std::move(rails), redirectBase);
|
||||
|
||||
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
||||
// keyring. Unset means unsigned dev invoices with a visible marker.
|
||||
|
|
@ -1442,9 +1728,11 @@ int main(int argc, char** argv) {
|
|||
Server::ConfigureMail(std::move(mailCfg));
|
||||
}
|
||||
|
||||
// Sendcloud is optional: without credentials the compiled-in zone
|
||||
// table prices all shipping, which is exactly how dev and e2e run.
|
||||
// With credentials the refresh thread fetches per-country rates.
|
||||
// Sendcloud is the ONLY source of shipping prices: no credentials and
|
||||
// no cached table means checkout refuses every order (loudly logged at
|
||||
// startup). Dev and e2e get a table by writing the cache file next to
|
||||
// the orders file by hand — same format the refresh writes, so no test
|
||||
// hook exists for this and none can drift from production.
|
||||
Server::ShippingConfig shipCfg;
|
||||
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
||||
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
||||
|
|
@ -1457,7 +1745,7 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
|
||||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||||
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
||||
// the escape hatch for a payment confirmed out-of-band (or a refund):
|
||||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||||
if (!args.empty() && args[0] == "--orders") {
|
||||
std::filesystem::path file = "orders.jsonl";
|
||||
|
|
@ -1499,14 +1787,21 @@ int main(int argc, char** argv) {
|
|||
std::println("orders: {}", orders.size());
|
||||
if (orders.empty()) return 0;
|
||||
std::println("");
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "via",
|
||||
"created", "token");
|
||||
// `pay` is the rail the order was created on, `via` what actually
|
||||
// settled it. Both, because they answer different questions: an order
|
||||
// stuck awaiting needs the first (which provider's dashboard to open),
|
||||
// and a paid one needs the second (whether the money can still be
|
||||
// pulled back — cards can, iDEAL and crypto cannot).
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "pay",
|
||||
"via", "created", "token");
|
||||
for (const auto& o : orders) {
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
||||
o.buyer.country, o.color.empty() ? "-" : o.color,
|
||||
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.quantity,
|
||||
o.payChoice.empty() ? "-" : o.payChoice,
|
||||
o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.createdAt, o.token);
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -1514,10 +1809,12 @@ int main(int argc, char** argv) {
|
|||
|
||||
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
||||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||||
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" [--rail=off|fake|mollie] [--crypto-rail=off|fake-crypto|coingate]\n"
|
||||
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail,\n"
|
||||
" COINGATE_API_KEY the crypto rail, COINGATE_SANDBOX=1,\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ The source code of this website is made available for viewing purposes only.
|
|||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||||
*/
|
||||
|
||||
// The native server: server-rendered pages, order storage, and the bunq
|
||||
// payment rail.
|
||||
// The native server: server-rendered pages, order storage, and the payment
|
||||
// rails.
|
||||
//
|
||||
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
||||
// needs — Crafter.Network here, OpenSSL for request signing. The division of
|
||||
// labour is that Shared decides what the markup IS and Server decides how it
|
||||
// reaches a socket, where orders live, and how money moves.
|
||||
// needs — Crafter.Network here. The division of labour is that Shared decides
|
||||
// what the markup IS and Server decides how it reaches a socket, where orders
|
||||
// live, and how money moves.
|
||||
|
||||
export module Catcrafts.Server;
|
||||
import std;
|
||||
|
|
@ -65,9 +65,14 @@ export namespace Catcrafts::Server {
|
|||
std::int64_t totalMinor = 0;
|
||||
bool vatIncluded = false;
|
||||
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
||||
std::string payChoice; // Form::kPayBank | Form::kPayCrypto; which
|
||||
// rail issued the link, and so which one
|
||||
// may confirm it. Always set: checkout
|
||||
// normalises before writing.
|
||||
std::string payUrl; // the provider's hosted checkout link
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie)
|
||||
std::string paidVia; // method that settled it ("ideal", "creditcard")
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie,
|
||||
// a decimal order id at CoinGate)
|
||||
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
||||
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
||||
|
|
@ -186,8 +191,12 @@ export namespace Catcrafts::Server {
|
|||
//
|
||||
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
||||
// answers "has it been paid?". Everything else — storage, rendering,
|
||||
// reconciling — is rail-agnostic, which is what will let a crypto rail
|
||||
// slot in later without reshaping orders.
|
||||
// reconciling — is rail-agnostic, which is what lets two of them run side
|
||||
// by side without reshaping orders.
|
||||
|
||||
// The buyer's choice at checkout is Form::kPayBank / Form::kPayCrypto —
|
||||
// defined in Shared because the form emits those strings and this module
|
||||
// stores them, and one wire format deserves one definition.
|
||||
|
||||
struct PaymentLink {
|
||||
std::string payUrl;
|
||||
|
|
@ -195,13 +204,14 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
|
||||
// What a poll learned about one payment. Pending and Dead are different
|
||||
// answers on purpose: a Mollie payment EXPIRES (unlike a bunq tab), and an
|
||||
// order whose payment can never arrive should lapse rather than sit
|
||||
// "awaiting" forever.
|
||||
// answers on purpose: both providers EXPIRE unpaid orders — Mollie after
|
||||
// its own window, CoinGate after two hours (twenty minutes once a coin is
|
||||
// picked) — and an order whose payment can never arrive should lapse
|
||||
// rather than sit "awaiting" forever.
|
||||
enum class PayState { Pending, Paid, Dead };
|
||||
struct PaidStatus {
|
||||
PayState state = PayState::Pending;
|
||||
std::string method; // "ideal" | "creditcard" | "banktransfer" | …
|
||||
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
|
||||
};
|
||||
|
||||
class PaymentRail {
|
||||
|
|
@ -224,16 +234,37 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
|
||||
struct RailConfig {
|
||||
std::string mode; // "off" | "fake" | "mollie" | "bunq"
|
||||
std::string apiKey; // mollie: live_… or test_…; bunq: its key
|
||||
bool sandbox = false; // bunq only: public-api.sandbox.bunq.com
|
||||
std::filesystem::path statePath; // bunq: session context; fake: paid marker
|
||||
std::string mode; // "off" | "fake" | "mollie" | "coingate"
|
||||
std::string apiKey; // mollie: live_… or test_…; coingate: its token
|
||||
bool sandbox = false; // coingate: api-sandbox.coingate.com
|
||||
std::filesystem::path statePath; // fake: the paid marker
|
||||
std::string redirectBase = "https://catcrafts.net";
|
||||
};
|
||||
|
||||
// nullptr for mode "off" — the shop then renders but refuses checkout.
|
||||
// nullptr for mode "off" — that slot then offers no payment choice.
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
||||
|
||||
// The two slots a buyer chooses between. Either may be null, which is how
|
||||
// a shop with only one provider configured offers only that one: the
|
||||
// checkout form renders the choices that exist, so a page can never
|
||||
// advertise a way to pay the server would then refuse.
|
||||
struct PaymentRails {
|
||||
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
|
||||
std::unique_ptr<PaymentRail> crypto; // CoinGate: on-chain and Lightning
|
||||
|
||||
bool Any() const { return bank != nullptr || crypto != nullptr; }
|
||||
// The rail that owns a stored order, by its recorded choice. Total on
|
||||
// purpose: anything that is not the crypto choice is the bank one, so
|
||||
// a hand-edited or truncated ledger line resolves somewhere safe
|
||||
// instead of nowhere. Null when that slot is not configured, and the
|
||||
// caller must then leave the order alone rather than ask the other
|
||||
// provider about an id it never issued.
|
||||
PaymentRail* For(std::string_view choice) const {
|
||||
if (choice == Form::kPayCrypto) return crypto.get();
|
||||
return bank.get();
|
||||
}
|
||||
};
|
||||
|
||||
// Parsed essentials of a Mollie /v2/payments object. Exported so the
|
||||
// self-test can drive the parser with canned responses — the HTTP around
|
||||
// it is thin.
|
||||
|
|
@ -246,66 +277,135 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for amounts coming back from
|
||||
// the bunq API ("614.00" -> 61400). Rejects anything that is not a plain
|
||||
// non-negative decimal with at most two fraction digits — no floats touch
|
||||
// money on the way in either. Exported for the self-test.
|
||||
// Parsed essentials of a CoinGate /api/v2/orders object. Same shape and
|
||||
// same reason as MolliePayment: the parser is the part worth testing.
|
||||
//
|
||||
// `id` is a JSON NUMBER on the wire ("id":538) rather than a string, so it
|
||||
// is rendered to decimal here and travels through the ledger as text like
|
||||
// every other payment id.
|
||||
struct CoingateOrder {
|
||||
std::string id;
|
||||
std::string status; // new|pending|confirming|paid|invalid|
|
||||
// expired|canceled|refunded|partially_refunded
|
||||
std::string payCurrency; // the coin the shopper picked; empty until then
|
||||
std::string payUrl; // the hosted invoice, present while payable
|
||||
std::int64_t priceMinor = 0; // price_amount, and only when EUR
|
||||
};
|
||||
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for the amounts both provider
|
||||
// APIs quote as strings ("614.00" -> 61400). Rejects anything that is not
|
||||
// a plain non-negative decimal with at most two fraction digits — no
|
||||
// floats touch money on the way in either. Exported for the self-test.
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
||||
|
||||
// ── shipping rates ────────────────────────────────────────────────
|
||||
//
|
||||
// Live per-country rates from Sendcloud's shipping_methods API, cached to
|
||||
// a state file and refreshed daily by a background thread. The compiled-in
|
||||
// zone table (Catcrafts.Shared:Content) remains the fallback for any country the carrier table
|
||||
// does not cover — and the whole feature when no credentials exist, so
|
||||
// the shop never depends on Sendcloud being up.
|
||||
// Live per-country, per-weight-bracket rates from Sendcloud's
|
||||
// shipping_methods API, cached to a state file and refreshed daily by a
|
||||
// background thread. This table is the SOLE source of shipping prices:
|
||||
// there is no compiled-in fallback, because a destination Sendcloud has no
|
||||
// rate for is a destination the shop cannot actually post a parcel to, and
|
||||
// inventing a price for it only sells an order that then has to be
|
||||
// refunded or absorbed.
|
||||
//
|
||||
// The consequence is deliberate and load-bearing: with no table, checkout
|
||||
// refuses everything. The disk cache is therefore the resilience layer
|
||||
// rather than an optimisation — it is written on every successful fetch,
|
||||
// read unconditionally at startup (credentials or not, which is also how
|
||||
// dev and e2e get a table), and a Sendcloud outage merely means the last
|
||||
// known prices keep selling.
|
||||
|
||||
struct ShippingConfig {
|
||||
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
||||
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
||||
std::string methodName; // substring match on the method name
|
||||
std::filesystem::path cachePath; // survives restarts
|
||||
std::filesystem::path cachePath; // survives restarts; also the dev seed
|
||||
};
|
||||
|
||||
// Country -> price in cents, EUR. Empty when nothing loaded.
|
||||
// Country -> weight-bracket ladder, prices in cents EUR, already grossed up
|
||||
// to consumer prices. Empty when nothing loaded, which means "cannot ship
|
||||
// anywhere" and is reported loudly at startup.
|
||||
struct ShippingTable {
|
||||
std::string method; // the matched Sendcloud method name
|
||||
std::string method; // the matched Sendcloud method name(s)
|
||||
std::string fetchedAt; // ISO 8601, for the operator
|
||||
std::vector<std::pair<std::string, std::int64_t>> perCountry;
|
||||
std::vector<Money::ShipRates> perCountry;
|
||||
|
||||
std::int64_t Find(std::string_view cc) const {
|
||||
for (const auto& [k, v] : perCountry) {
|
||||
if (k == cc) return v;
|
||||
// The rate for a parcel of `grams`, or 0 when this table cannot ship it
|
||||
// to `cc` at all. The bracket rule lives in Money so the page's total
|
||||
// preview picks the identical bracket.
|
||||
std::int64_t Find(std::string_view cc, std::int64_t grams) const {
|
||||
return Money::RateFor(Money::LadderFor(perCountry, cc), grams);
|
||||
}
|
||||
return 0;
|
||||
|
||||
// Units of `unitGrams` that fit the heaviest bracket for `cc`; 0 when
|
||||
// the destination is uncovered. This is the real quantity ceiling.
|
||||
std::int64_t MaxUnits(std::string_view cc, std::int64_t unitGrams) const {
|
||||
return Money::MaxUnitsFor(Money::LadderFor(perCountry, cc), unitGrams);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
|
||||
// the first method whose name contains `methodName` (case-sensitive).
|
||||
// Parse a Sendcloud /api/v2/shipping_methods response into a table.
|
||||
// Exported for the self-test — the network fetch is thin around this.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
||||
|
||||
// Install the config and start using it. Safe to skip entirely.
|
||||
// Install the config and start using it. Loads the cache even without
|
||||
// credentials, so a hand-placed cache file is a complete rate table.
|
||||
void ConfigureShipping(const ShippingConfig& config);
|
||||
|
||||
// One fetch attempt; failure leaves the previous table standing. The HTTP
|
||||
// layer's background thread calls this on start and daily after.
|
||||
void RefreshShippingTable();
|
||||
|
||||
// The rate the checkout charges for `country`: the live table's price if
|
||||
// present, the product's zone fallback otherwise.
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld);
|
||||
// The rate the checkout charges to send `grams` to `country`, or nullopt
|
||||
// when no bracket covers it — the caller must then refuse the order rather
|
||||
// than substitute a number.
|
||||
std::optional<std::int64_t> ShipCostFor(std::string_view country,
|
||||
std::int64_t grams);
|
||||
|
||||
// A snapshot of the live table for embedding into the checkout preview —
|
||||
// the page must show the same numbers the server will charge.
|
||||
ShippingTable CurrentShippingTable();
|
||||
|
||||
// Install the rail used by Serve()'s checkout handler and reconciler.
|
||||
// Call before Serve. Passing nullptr disables checkout. (Rates travel with
|
||||
// the content — LoadContent reads rates.json.)
|
||||
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase);
|
||||
// Install the rails used by Serve()'s checkout handler and reconciler.
|
||||
// Call before Serve. Two null rails disables checkout entirely. (Rates
|
||||
// travel with the content — LoadContent reads rates.json.)
|
||||
void ConfigurePayments(PaymentRails rails, std::string redirectBase);
|
||||
|
||||
// Whether the crypto slot is live, for the renderer: the checkout form
|
||||
// offers the crypto choice only when something can actually serve it.
|
||||
bool CryptoPaymentAvailable();
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// Two questions a reverse-proxied process has to answer carefully, both
|
||||
// pure string work, both exported for the self-test.
|
||||
|
||||
// The client address as the reverse proxy saw it, from X-Forwarded-For.
|
||||
//
|
||||
// Caddy APPENDS the real peer to whatever X-Forwarded-For the client sent,
|
||||
// so the value reads "<anything the client claimed>, <real peer>" and only
|
||||
// the RIGHTMOST entry is trustworthy. Taking the leftmost — the usual
|
||||
// mistake — would hand every client an unlimited supply of free rate-limit
|
||||
// identities, which is worse than not limiting at all.
|
||||
//
|
||||
// Trustworthy only because nothing but Caddy can reach this listener: it
|
||||
// binds loopback and the Caddyfile says in as many words not to expose the
|
||||
// port. Empty in, empty out — no header means nothing proxied this request
|
||||
// (dev, e2e, a direct curl), and the caller must fall back to the global
|
||||
// limit rather than invent a peer.
|
||||
std::string_view ClientAddressFromForwarded(std::string_view forwarded);
|
||||
|
||||
// Whether a state-changing POST may proceed, given its Origin header.
|
||||
//
|
||||
// A MISSING Origin is allowed: browsers have sent it on form POSTs for
|
||||
// years, so a request without one is a non-browser client (curl, the e2e
|
||||
// suite), and a non-browser client cannot be a cross-site forgery — there
|
||||
// is no victim's session to ride on. A PRESENT but mismatched Origin is
|
||||
// precisely the forgery case, and that is refused. "null" — a sandboxed
|
||||
// iframe or a privacy-stripped origin — is refused too: it is present, and
|
||||
// it is not us.
|
||||
bool OriginAllowed(std::string_view origin, std::string_view redirectBase);
|
||||
|
||||
// Bind and serve until killed. Blocks. Starts the payment reconciler
|
||||
// thread when a rail is configured.
|
||||
|
|
|
|||
|
|
@ -46,9 +46,12 @@ export const std::vector<Product>& Products() {
|
|||
// Launch day is this one line: "coming-soon" -> "available". The page
|
||||
// shows launch prices either way; only the order form is held back.
|
||||
p.status = "coming-soon";
|
||||
p.shipNlMinor = 1500; // zone FALLBACKS — the live
|
||||
p.shipEuMinor = 2500; // Sendcloud table overrides
|
||||
p.shipWorldMinor = 5500; // these per country
|
||||
// Boxed shipping weight, grams: the FP6 retail box (~450 g) plus the
|
||||
// outer carton and padding. Rounded UP on purpose — this number picks
|
||||
// the carrier's weight bracket, and guessing light is how an order gets
|
||||
// quoted a rate the parcel does not qualify for. Worth replacing with a
|
||||
// scale reading of a real outgoing parcel before launch.
|
||||
p.shipWeightGrams = 700;
|
||||
p.image = "/fp6-pmos.jpg";
|
||||
p.summary = "The Fairphone (Gen. 6), reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
|
||||
p.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. User inflicted damage, including software faults caused by relocking the bootloader do not fall under this policy. The full terms are on the terms page.";
|
||||
|
|
@ -209,7 +212,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{ "Orders",
|
||||
{
|
||||
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
|
||||
"Payment happens at Mollie, a Dutch licensed payment provider, on their pages. Catcrafts never sees card numbers or bank credentials. It learns only which order was paid, for how much, and by which method. What Mollie processes about you is between you and Mollie under their own privacy policy.",
|
||||
"Payment happens on the payment provider's own pages, never here. Choosing bank or card sends you to Mollie, a Dutch licensed payment institution; choosing cryptocurrency sends you to CoinGate, a Lithuanian payment institution licensed under the EU crypto-asset regulation. Either way Catcrafts never sees card numbers, bank credentials or wallet keys, and learns only which order was paid, for how much, and by which method. What the provider you choose processes about you is between you and them under their own privacy policy.",
|
||||
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
|
||||
} },
|
||||
{ "How long it is kept",
|
||||
|
|
@ -221,7 +224,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{ "What this site does not do",
|
||||
{
|
||||
"No analytics in your browser. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
|
||||
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie or CoinGate payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||
} },
|
||||
{ "Server logs",
|
||||
{
|
||||
|
|
@ -262,14 +265,15 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-04",
|
||||
.updated = "2026-08-13",
|
||||
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
|
||||
.sections = {
|
||||
{ "Ordering and payment",
|
||||
{
|
||||
"Submitting the order form creates an order and a Mollie payment link. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored.",
|
||||
"Submitting the order form creates an order and a payment link with the provider you picked. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A crypto invoice lapses quickly — within hours, and within minutes once a coin is selected — so an unfinished one usually means ordering again rather than waiting.",
|
||||
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
|
||||
"Payment is handled by Mollie, a Dutch licensed payment institution. Catcrafts never sees your card number or bank credentials.",
|
||||
"Payment is handled by a licensed provider, never on this site: bank and card payments by Mollie, a Dutch payment institution, and cryptocurrency by CoinGate, a Lithuanian payment institution authorised under the EU crypto-asset regulation. Catcrafts never sees your card number, bank credentials or wallet keys.",
|
||||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. The price is the euro amount; the provider fixes the exchange rate when you open the invoice, and that rate is what settles it. Every refund under the sections below is likewise calculated and owed in euros — if the coin has moved against you in the meantime, the euro figure is still the euro figure. A crypto payment can only be refunded to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
|
||||
"For support related to orders please contact orders@catcrafts.net"
|
||||
} },
|
||||
{ "Fulfilment",
|
||||
|
|
@ -277,6 +281,12 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
|
||||
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
|
||||
} },
|
||||
{ "Where Catcrafts sells",
|
||||
{
|
||||
"Catcrafts does not sell or ship to the United States or Canada. Checkout refuses a delivery address in either country, and no order can be placed from one. This is a deliberate decision about liability cover, which for a shop this size is written for the world excluding those two countries, and not a judgement about anyone living there.",
|
||||
"The same applies to an order that is bound for either country by another route: if a parcel is to be forwarded there, or the delivery address belongs to a forwarding service acting for a customer there, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around this — the point is that the sale does not happen, not that the address looks European.",
|
||||
"Everywhere else Catcrafts ships is served on the terms above, and the software itself remains free for anyone anywhere: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.",
|
||||
} },
|
||||
{ "Warranty",
|
||||
{
|
||||
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
export module Catcrafts.Shared:Form;
|
||||
import std;
|
||||
import :Money; // country policy: which destinations the shop sells to
|
||||
|
||||
namespace Catcrafts::Form {
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ export bool LooksLikeEmail(std::string_view s) {
|
|||
}
|
||||
|
||||
// ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there
|
||||
// is a policy question answered elsewhere, not a validation one.
|
||||
// is a policy question, answered by Money::SellsTo in ValidateCheckout below.
|
||||
export bool LooksLikeCountryCode(std::string_view s) {
|
||||
if (s.size() != 2) return false;
|
||||
for (const char c : s) {
|
||||
|
|
@ -192,14 +193,100 @@ export struct Checkout {
|
|||
std::string color; // variant slug; whether it EXISTS is the handler's
|
||||
// check against the catalogue, not a shape check
|
||||
std::int64_t quantity = 1;
|
||||
std::string payChoice; // kPayBank | kPayCrypto; empty means the form did
|
||||
// not offer a choice, which the handler reads as
|
||||
// bank. Whether the chosen rail is CONFIGURED is
|
||||
// the handler's check, like the colour: this is a
|
||||
// shape check and nothing more.
|
||||
};
|
||||
|
||||
// A technical sanity bound, not a business cap — bulk orders are welcome.
|
||||
// It exists because the integer math (here and mirrored in the preview
|
||||
// script) and a bunq payment link both need SOME ceiling, and an order of a
|
||||
// hundred phones deserves an email conversation more than a form submit.
|
||||
// How the buyer's money moves. Two KINDS of money movement, not two brand
|
||||
// names: which provider serves each is the server's configuration, and writing
|
||||
// the kind (rather than "mollie"/"coingate") into the form and the ledger means
|
||||
// swapping a provider cannot retroactively rewrite what a buyer picked.
|
||||
//
|
||||
// These strings are the wire format — they travel in the form post and land
|
||||
// verbatim in the order log — so they are defined once, here, where both the
|
||||
// form that emits them and the server that stores them can see them.
|
||||
export inline constexpr std::string_view kPayBank = "bank";
|
||||
export inline constexpr std::string_view kPayCrypto = "crypto";
|
||||
|
||||
// The outer sanity bound on the parsed integer — NOT the quantity a buyer can
|
||||
// actually order. That limit is physical and per-destination: one order is one
|
||||
// parcel, so what fits is the heaviest carrier bracket for that country
|
||||
// divided by the boxed unit weight (Money::MaxUnitsFor). The form renders the
|
||||
// best case across destinations as its `max`, the preview narrows it the
|
||||
// moment a country is typed, and the handler enforces the real one.
|
||||
//
|
||||
// This constant survives because validation has to reject a hostile
|
||||
// "quantity=99999999999" before any of that arithmetic runs.
|
||||
export inline constexpr std::int64_t kMaxQuantity = 99;
|
||||
|
||||
// What a buyer in a NoSaleCountries destination is told. Stated as a standing
|
||||
// fact about where the shop sells, not as an apology or an outage: someone
|
||||
// there should close the tab rather than retry tomorrow or hunt for a
|
||||
// workaround. The reason itself (insurance territory) is on the terms page —
|
||||
// a form field is the wrong place for it.
|
||||
//
|
||||
// One definition, three renderings: this error, the note above the form, and
|
||||
// the on-page total preview, so the page can never encourage an order the
|
||||
// server will refuse.
|
||||
export inline constexpr std::string_view kNoSaleMessage =
|
||||
"Catcrafts does not sell or ship to the United States or Canada.";
|
||||
|
||||
// The two shipping refusals, worded once.
|
||||
//
|
||||
// Since the shop stopped carrying its own rate table, the carrier's coverage
|
||||
// IS the shop's coverage: no bracket for a country means no price exists to
|
||||
// charge, and a parcel above every bracket is one the carrier will not take.
|
||||
// Both refuse rather than guess — a quote the shop cannot honour is worse than
|
||||
// a no — and both name a way forward, because a bare "can't" makes the buyer
|
||||
// guess whether to try again or give up.
|
||||
//
|
||||
// Kept as {cc}/{n} templates rather than format strings because they have two
|
||||
// consumers: the handler fills them for its field errors, and the buy page
|
||||
// hands them to the total preview verbatim to fill client-side. Same sentence
|
||||
// before and after the submit, from one definition.
|
||||
export inline constexpr std::string_view kNoShippingTemplate =
|
||||
"No carrier rate for {cc} is available right now, so this order can't be "
|
||||
"priced. Email orders@catcrafts.net and it gets arranged by hand.";
|
||||
|
||||
export inline constexpr std::string_view kTooHeavyTemplate =
|
||||
"That is more than fits one parcel to {cc} — up to {n} per order. For a "
|
||||
"larger order email orders@catcrafts.net.";
|
||||
|
||||
// The degenerate case: a destination whose heaviest bracket does not even carry
|
||||
// one boxed unit. "Order fewer" is not advice when fewer is zero, so it gets
|
||||
// its own sentence.
|
||||
export inline constexpr std::string_view kTooHeavyNoneTemplate =
|
||||
"A parcel this heavy can't be shipped to {cc} by any rate available. "
|
||||
"Email orders@catcrafts.net.";
|
||||
|
||||
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
|
||||
std::int64_t n) {
|
||||
std::string out(tmpl);
|
||||
for (const auto& [token, value] :
|
||||
std::initializer_list<std::pair<std::string_view, std::string>>{
|
||||
{ "{cc}", std::string(cc) }, { "{n}", std::to_string(n) } }) {
|
||||
for (std::size_t at = out.find(token); at != std::string::npos;
|
||||
at = out.find(token, at + value.size())) {
|
||||
out.replace(at, token.size(), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export std::string NoShippingMessage(std::string_view cc) {
|
||||
return FillShipMessage(kNoShippingTemplate, cc, 0);
|
||||
}
|
||||
|
||||
// `maxUnits` is what the heaviest bracket to that country actually fits, and
|
||||
// may be zero.
|
||||
export std::string TooHeavyMessage(std::string_view cc, std::int64_t maxUnits) {
|
||||
return FillShipMessage(maxUnits > 0 ? kTooHeavyTemplate : kTooHeavyNoneTemplate,
|
||||
cc, maxUnits);
|
||||
}
|
||||
|
||||
export struct CheckoutResult {
|
||||
Checkout value;
|
||||
std::vector<FieldError> errors;
|
||||
|
|
@ -259,6 +346,10 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
|
||||
} else if (!LooksLikeCountryCode(country)) {
|
||||
r.errors.push_back({ "country", "Country must be a two-letter code." });
|
||||
} else if (!Money::SellsTo(r.value.country)) {
|
||||
// The refusal happens here, in validation, rather than at the payment
|
||||
// step: no order record, no payment link, nothing charged to undo.
|
||||
r.errors.push_back({ "country", std::string(kNoSaleMessage) });
|
||||
}
|
||||
|
||||
// Colour: shape only (slug-ish, bounded). Whether it names a variant that
|
||||
|
|
@ -269,6 +360,20 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
r.errors.push_back({ "color", "That is not one of the colours." });
|
||||
}
|
||||
|
||||
// How they want to pay. Absent means the form did not render the choice
|
||||
// (only one rail configured, or the no-JS fallback page), which the
|
||||
// handler resolves to bank.
|
||||
//
|
||||
// An unrecognised value is rejected rather than defaulted: the two ways it
|
||||
// can happen are a tampered post and a form that has drifted from this
|
||||
// validator, and quietly charging someone through a rail they did not pick
|
||||
// is the wrong answer to both.
|
||||
const std::string_view pay = Trim(f.Get("pay"));
|
||||
r.value.payChoice = std::string(pay);
|
||||
if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) {
|
||||
r.errors.push_back({ "pay", "Pick one of the payment methods." });
|
||||
}
|
||||
|
||||
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
||||
// no-JS form default); anything unparseable or out of range is rejected
|
||||
// rather than clamped — silently changing how many phones someone buys is
|
||||
|
|
|
|||
|
|
@ -206,12 +206,13 @@ export struct Product {
|
|||
std::int64_t priceInclMinor = 0;
|
||||
std::vector<Variant> variants;
|
||||
std::string currency = "EUR";
|
||||
// Flat shipping per zone, in cents, consumer-facing (VAT-inclusive where
|
||||
// VAT applies). The fallback when Sendcloud has no rate for a country —
|
||||
// live carrier rates take precedence wherever they exist.
|
||||
std::int64_t shipNlMinor = 0;
|
||||
std::int64_t shipEuMinor = 0;
|
||||
std::int64_t shipWorldMinor = 0;
|
||||
// Shipping weight of ONE unit as it leaves here — device, retail box and
|
||||
// outer packaging — in grams. There are no shipping prices in this record
|
||||
// any more: the carrier prices by weight bracket, so this number plus the
|
||||
// quantity is what selects a rate from the live Sendcloud table, and what
|
||||
// decides how many units fit one parcel. A destination the carrier has no
|
||||
// bracket for is refused at checkout rather than priced from a guess.
|
||||
std::int64_t shipWeightGrams = 0;
|
||||
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
|
||||
// from our own origin like every other asset — the privacy notice's
|
||||
// "everything comes from catcrafts.net" applies to product images too.
|
||||
|
|
@ -291,7 +292,11 @@ export struct OrderView {
|
|||
std::string colorLabel; // "Forest Green", empty for variantless products
|
||||
std::int64_t quantity = 1;
|
||||
std::int64_t unitMinor = 0;
|
||||
std::string payUrl; // bunq.me link; empty once paid or when cancelled
|
||||
std::string payUrl; // the provider's hosted checkout; empty once paid
|
||||
// or when cancelled
|
||||
std::string payChoice; // Form::kPayBank | Form::kPayCrypto — decides what
|
||||
// the "resume payment" copy promises is waiting
|
||||
// there. Anything but crypto reads as bank.
|
||||
std::string createdAt; // ISO 8601, shown verbatim
|
||||
std::string country;
|
||||
std::int64_t goodsMinor = 0;
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
|
|||
return (netMinor * (10000 + rateBp) + 5000) / 10000;
|
||||
}
|
||||
|
||||
// "580.00" — the wire format bunq's amount objects use, and the unambiguous
|
||||
// way to show cents. Always two decimals, no thousands separator.
|
||||
// "580.00" — the wire format both payment providers quote amounts in, and the
|
||||
// unambiguous way to show cents. Always two decimals, no thousands separator.
|
||||
export std::string FormatMinor(std::int64_t minor) {
|
||||
const bool neg = minor < 0;
|
||||
if (neg) minor = -minor;
|
||||
|
|
@ -82,9 +82,33 @@ export bool IsEuCountry(std::string_view cc) {
|
|||
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
|
||||
}
|
||||
|
||||
// Shipping zones. Three tiers is deliberate — real carrier pricing has more
|
||||
// distinctions than anyone wants in a checkout, and the tiers only need to be
|
||||
// roughly right because the rates are set per product with margin.
|
||||
// Destinations this shop refuses outright.
|
||||
//
|
||||
// Not a carrier problem — parcels reach both fine, and the rate tables price
|
||||
// them. It is an insurance boundary: liability cover for a Dutch shop is
|
||||
// written "worldwide excluding USA/Canada", and a phone carrying a lithium
|
||||
// battery and a replaced OS is precisely the product-liability exposure that
|
||||
// exclusion exists for. A sale into either country would therefore be
|
||||
// uninsured, with the buyer's own courts deciding the damages, so checkout
|
||||
// declines it instead of pricing it. Declining also keeps Catcrafts from
|
||||
// having marketed into those jurisdictions at all, which is the other half of
|
||||
// why the answer is no rather than a surcharge.
|
||||
export std::span<const std::string_view> NoSaleCountries() {
|
||||
static constexpr std::array<std::string_view, 2> blocked{ "US", "CA" };
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
|
||||
// rather than comparing against "US" themselves, so the policy has exactly one
|
||||
// definition and adding a country later is a one-line change.
|
||||
export bool SellsTo(std::string_view cc) {
|
||||
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
}
|
||||
|
||||
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
|
||||
// (see ShipBracket below). This exists because Sendcloud's method list carries
|
||||
// no transit estimate, so the "1-2 / 2-5 / 5-14 days" the listing publishes is
|
||||
// ours to state, and distance is the only thing it can reasonably key on.
|
||||
export enum class Zone { Nl, Eu, World };
|
||||
|
||||
export Zone ZoneFor(std::string_view cc) {
|
||||
|
|
@ -92,6 +116,69 @@ export Zone ZoneFor(std::string_view cc) {
|
|||
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
|
||||
}
|
||||
|
||||
// ── carrier rates ─────────────────────────────────────────────────────
|
||||
//
|
||||
// One weight bracket of one carrier method: what a parcel up to
|
||||
// `maxWeightGrams` costs to this country, in cents, already grossed up to the
|
||||
// consumer price (the server does that once, when it builds the table).
|
||||
//
|
||||
// Brackets exist because a carrier prices by weight, and Sendcloud lists the
|
||||
// same service once per band — so a country's rates arrive as a ladder, not a
|
||||
// single number. Nothing here is hardcoded: an empty ladder means the shop
|
||||
// cannot ship there, which is a refusal, not a fallback.
|
||||
export struct ShipBracket {
|
||||
std::int64_t maxWeightGrams = 0;
|
||||
std::int64_t minor = 0;
|
||||
};
|
||||
|
||||
// One destination's ladder. Kept as a flat vector of these rather than a map
|
||||
// so the table is trivially serialisable and the order the carrier gave is
|
||||
// preserved.
|
||||
export struct ShipRates {
|
||||
std::string cc;
|
||||
std::vector<ShipBracket> brackets;
|
||||
};
|
||||
|
||||
// The rate for a parcel of `grams` to a destination whose ladder this is, or 0
|
||||
// when nothing covers it — too heavy, or no rates at all.
|
||||
//
|
||||
// The cheapest bracket that can CARRY the weight wins, which is not always the
|
||||
// tightest one: a carrier's 20 kg band is occasionally priced below its 10 kg
|
||||
// band, and quoting the higher of the two would overcharge for a parcel both
|
||||
// accept. A band always accepts a parcel lighter than its maximum, so this
|
||||
// stays bookable at whatever it quotes.
|
||||
export std::int64_t RateFor(std::span<const ShipBracket> ladder, std::int64_t grams) {
|
||||
std::int64_t best = 0;
|
||||
for (const ShipBracket& b : ladder) {
|
||||
if (b.maxWeightGrams < grams) continue;
|
||||
if (best == 0 || b.minor < best) best = b.minor;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// How many units of `unitGrams` fit the heaviest bracket this destination has.
|
||||
// The quantity ceiling the buy form offers and the checkout enforces: one
|
||||
// order is one parcel, so anything above this is an email conversation rather
|
||||
// than a quote the shop cannot honour.
|
||||
export std::int64_t MaxUnitsFor(std::span<const ShipBracket> ladder,
|
||||
std::int64_t unitGrams) {
|
||||
if (unitGrams <= 0) return 0;
|
||||
std::int64_t heaviest = 0;
|
||||
for (const ShipBracket& b : ladder) heaviest = std::max(heaviest, b.maxWeightGrams);
|
||||
return heaviest / unitGrams;
|
||||
}
|
||||
|
||||
// Ladder lookup across a whole table. Linear because the table is one entry
|
||||
// per country the method covers — a couple of hundred at most, walked once per
|
||||
// checkout.
|
||||
export std::span<const ShipBracket> LadderFor(std::span<const ShipRates> table,
|
||||
std::string_view cc) {
|
||||
for (const ShipRates& r : table) {
|
||||
if (r.cc == cc) return r.brackets;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// One order's money, fully derived. `goods` is what the buyer pays for the
|
||||
// device: the VAT-inclusive price inside the EU, the derived net outside it.
|
||||
// `vatCharged` is what the total contains in Dutch VAT — zero for exports —
|
||||
|
|
@ -106,8 +193,9 @@ export struct Totals {
|
|||
|
||||
// The single authority on what an order costs. The checkout handler calls this
|
||||
// with the buyer's country; nothing about the amount ever comes from the
|
||||
// client. `shippingMinor` arrives already resolved (live carrier table or the
|
||||
// zone fallback — the server decides which), so this function stays pure.
|
||||
// client. `shippingMinor` arrives already resolved from the carrier table (see
|
||||
// RateFor) — an order with no carrier rate is refused before it gets here, so
|
||||
// this function never has to invent a price and stays pure.
|
||||
//
|
||||
// The export net is derived from the LINE total (unit × qty), not per unit —
|
||||
// rounding per line is the invoice-correct convention, and it is also the
|
||||
|
|
@ -134,18 +222,10 @@ export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
|
|||
return t;
|
||||
}
|
||||
|
||||
// The zone-table shipping fallback, used when no live carrier table covers the
|
||||
// destination. Exported separately so the same lookup renders the shipping
|
||||
// table on the product page.
|
||||
export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu,
|
||||
std::int64_t shipWorld, std::string_view country) {
|
||||
const Zone z = ZoneFor(country);
|
||||
return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld;
|
||||
}
|
||||
|
||||
// ── indicative currency display ───────────────────────────────────────
|
||||
//
|
||||
// Orders are charged in euros, always — bunq collects EUR and the invoice is
|
||||
// Orders are charged in euros, always — both rails collect EUR (CoinGate
|
||||
// converts the coin at a locked rate and settles euro) and the invoice is
|
||||
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
||||
// their card will actually take, so the order page also shows an INDICATIVE
|
||||
// conversion in the buyer's national currency, from ECB reference rates baked
|
||||
|
|
@ -168,11 +248,12 @@ export struct CurrencyRow {
|
|||
|
||||
// Only currencies the ECB publishes reference rates for; anywhere else shows
|
||||
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
|
||||
// is noise.
|
||||
// is noise. So are USD and CAD: NoSaleCountries means no order can ever be
|
||||
// charged from those countries, and quoting a visitor a friendly price in
|
||||
// their own currency before refusing them at checkout is both a worse
|
||||
// experience and the kind of localisation that reads as marketing there.
|
||||
export std::span<const CurrencyRow> AllCurrencies() {
|
||||
static constexpr std::array<CurrencyRow, 16> rows{{
|
||||
{ "US", { "USD", "US$" } },
|
||||
{ "CA", { "CAD", "CA$" } },
|
||||
static constexpr std::array<CurrencyRow, 14> rows{{
|
||||
{ "GB", { "GBP", "£" } },
|
||||
{ "CH", { "CHF", "CHF " } },
|
||||
{ "NO", { "NOK", "kr " } },
|
||||
|
|
|
|||
|
|
@ -685,13 +685,22 @@ SafeHtml CustomsNote() {
|
|||
//
|
||||
// `errors` re-renders the form with the previous values preserved. Losing a
|
||||
// filled-in form on a validation error is the fastest way to lose the person.
|
||||
// `liveShipping` is the carrier rate table (country -> cents) when the server
|
||||
// has one; empty otherwise. It feeds the data-cc blob below so the on-page
|
||||
// total preview uses the exact numbers checkout will charge.
|
||||
// `liveShipping` is the carrier rate table (country -> weight-bracket ladder)
|
||||
// when the server has one; empty otherwise. It feeds the data-cc blob below so
|
||||
// the on-page total preview picks the exact bracket checkout will charge — and
|
||||
// refuses in exactly the places checkout refuses, since with no zone fallback
|
||||
// left there are now destinations and quantities that have no price at all.
|
||||
// `offerCrypto` renders the payment-method choice. It is false whenever the
|
||||
// crypto rail is not configured — and on the wasm fallback page, which cannot
|
||||
// know — so the form only ever advertises a way to pay that the server can
|
||||
// actually serve. With it false the form posts no `pay` field at all and the
|
||||
// handler takes the bank rail, which is exactly the behaviour that existed
|
||||
// before there was anything to choose.
|
||||
SafeHtml RenderCheckoutForm(const Product& product,
|
||||
std::span<const std::pair<std::string, std::int64_t>> liveShipping,
|
||||
std::span<const Money::ShipRates> liveShipping,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev) {
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
|
|
@ -724,37 +733,103 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
}
|
||||
|
||||
// Everything the total preview may show, pre-computed server-side into one
|
||||
// JSON attribute: per-colour unit prices, zone rates, the live carrier
|
||||
// table. The script multiplies and adds — it invents no number, so the
|
||||
// JSON attribute: per-colour unit prices, the boxed unit weight, and the
|
||||
// live carrier table as country -> [[maxGrams, cents], …]. The script
|
||||
// multiplies, picks a bracket and adds — it invents no number, so the
|
||||
// preview and the charge come from the same integers.
|
||||
std::string cc = R"({"v":{)";
|
||||
for (std::size_t i = 0; i < product.variants.size(); ++i) {
|
||||
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
||||
product.variants[i].slug, product.variants[i].priceInclMinor);
|
||||
}
|
||||
cc += std::format(R"(}},"from":{},"s":{{"nl":{},"eu":{},"w":{}}},"c":{{)",
|
||||
product.priceInclMinor, product.shipNlMinor,
|
||||
product.shipEuMinor, product.shipWorldMinor);
|
||||
cc += std::format(R"(}},"from":{},"g":{},"c":{{)",
|
||||
product.priceInclMinor, product.shipWeightGrams);
|
||||
for (std::size_t i = 0; i < liveShipping.size(); ++i) {
|
||||
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
||||
liveShipping[i].first, liveShipping[i].second);
|
||||
cc += std::format(R"({}"{}":[)", i ? "," : "", liveShipping[i].cc);
|
||||
for (std::size_t b = 0; b < liveShipping[i].brackets.size(); ++b) {
|
||||
cc += std::format("{}[{},{}]", b ? "," : "",
|
||||
liveShipping[i].brackets[b].maxWeightGrams,
|
||||
liveShipping[i].brackets[b].minor);
|
||||
}
|
||||
cc += ']';
|
||||
}
|
||||
// The two shipping refusals, as the same {cc}/{n} templates the handler
|
||||
// fills for its field errors — so the page cannot word a refusal
|
||||
// differently from the one that follows a submit.
|
||||
cc += std::format(R"(}},"q":{},"nm":{},"hm":{},"hm0":{})",
|
||||
Form::kMaxQuantity,
|
||||
JsonStr(Form::kNoShippingTemplate),
|
||||
JsonStr(Form::kTooHeavyTemplate),
|
||||
JsonStr(Form::kTooHeavyNoneTemplate));
|
||||
// The destinations checkout refuses, and the sentence that says so. The
|
||||
// preview has to refuse exactly where the server does — a page that quotes
|
||||
// a total for an order the server will reject is worse than one that never
|
||||
// quoted it.
|
||||
// The most units any destination's heaviest bracket can carry, clamped to
|
||||
// the parsing ceiling. With no table (the wasm fallback path) this stays at
|
||||
// kMaxQuantity — that page cannot quote a total or reach checkout anyway,
|
||||
// so narrowing its input would be theatre.
|
||||
std::int64_t bestUnits = 0;
|
||||
for (const Money::ShipRates& r : liveShipping) {
|
||||
bestUnits = std::max(bestUnits,
|
||||
Money::MaxUnitsFor(r.brackets, product.shipWeightGrams));
|
||||
}
|
||||
if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity;
|
||||
|
||||
cc += R"(,"x":[)";
|
||||
for (std::size_t i = 0; i < Money::NoSaleCountries().size(); ++i) {
|
||||
if (i) cc += ',';
|
||||
cc += JsonStr(Money::NoSaleCountries()[i]);
|
||||
}
|
||||
cc += std::format(R"(],"xm":{}}})", JsonStr(Form::kNoSaleMessage));
|
||||
|
||||
// The payment choice. A radio group rather than a <select> because both
|
||||
// options carry a sentence the buyer should read BEFORE choosing — one
|
||||
// settles in euro from their bank, the other locks a euro price against a
|
||||
// coin — and a collapsed dropdown hides exactly that. It also needs no
|
||||
// JavaScript, like everything else in this form.
|
||||
//
|
||||
// Bank is pre-selected: it is what nearly every buyer wants, and an
|
||||
// unselected group would let a distracted submit land on neither.
|
||||
SafeHtml payFieldset;
|
||||
if (offerCrypto) {
|
||||
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
|
||||
payFieldset = Format(
|
||||
R"(<fieldset class="field field--pay">)"
|
||||
R"(<legend>How you want to pay</legend>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Bank or card</strong> — iDEAL, card, or a plain )"
|
||||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong> — Bitcoin and Lightning, )"
|
||||
R"(stablecoins and the other coins CoinGate lists. You pay the euro )"
|
||||
R"(total at the exchange rate CoinGate locks when you open the )"
|
||||
R"(invoice; crypto invoices expire quickly, so pay soon after )"
|
||||
R"(ordering or simply order again.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"),
|
||||
Attr("value", std::string(Form::kPayCrypto)),
|
||||
wantsCrypto ? Raw(" checked") : SafeHtml{},
|
||||
errorFor("pay"));
|
||||
}
|
||||
cc += std::format(R"(}},"q":{}}})", Form::kMaxQuantity);
|
||||
|
||||
return Format(
|
||||
R"(<section class="checkout" id="buy">)"
|
||||
R"(<h2 class="section__title">Buy one</h2>)"
|
||||
R"(<p class="checkout__lede">Submitting creates the order and takes you )"
|
||||
R"(straight to the payment page: iDEAL, card, or a plain bank transfer, )"
|
||||
R"(handled by Mollie. Nothing is owed until you actually pay; an unpaid order )"
|
||||
R"(just lapses. The address is used to ship this order and for the invoice, )"
|
||||
R"(and for nothing else.</p>)"
|
||||
R"(straight to the payment page{}. Nothing is owed until you actually pay; )"
|
||||
R"(an unpaid order just lapses. The address is used to ship this order and )"
|
||||
R"(for the invoice, and for nothing else.</p>)"
|
||||
// No static rate table: real shipping is priced per country from the
|
||||
// carrier data and shown live in the total below once a country is
|
||||
// entered. A three-zone summary next to exact rates was misinformation.
|
||||
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
|
||||
R"(rates. Enter your country below and the exact total appears before )"
|
||||
R"(you order.</p>)"
|
||||
R"(you order. {}</p>)"
|
||||
R"({})"
|
||||
R"({})"
|
||||
R"(<form class="form" method="post"{}{} novalidate>)"
|
||||
|
|
@ -765,9 +840,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
R"({})"
|
||||
R"(</div>)"
|
||||
R"(<div class="field">)"
|
||||
// A free number input, not a dropdown: bulk orders are welcome. The
|
||||
// min/max mirror the server's validation bounds; kMaxQuantity is a
|
||||
// technical ceiling, not a sales policy.
|
||||
// A free number input, not a dropdown. The max is the BEST case across
|
||||
// destinations (see bestUnits above): the real ceiling depends on the
|
||||
// country's heaviest carrier bracket, so a stricter number here would
|
||||
// block orders that are perfectly shippable somewhere else. The preview
|
||||
// narrows it as soon as a country is typed, and the handler enforces it.
|
||||
R"(<label for="f-qty">Quantity</label>)"
|
||||
R"(<input id="f-qty" name="quantity" type="number" inputmode="numeric" )"
|
||||
R"(min="1"{}{}>)"
|
||||
|
|
@ -804,9 +881,10 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
|
||||
R"(maxlength="2" placeholder="NL" required{}>)"
|
||||
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
|
||||
R"(price includes VAT.</p>)"
|
||||
R"(price includes VAT. No US or CA — see the terms.</p>)"
|
||||
R"({})"
|
||||
R"(</div>)"
|
||||
R"({})"
|
||||
// Honeypot: off-screen rather than display:none, because some bots skip
|
||||
// hidden inputs. aria-hidden + tabindex keeps it away from screen
|
||||
// readers and the keyboard, so no real person can reach it.
|
||||
|
|
@ -822,13 +900,18 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
R"(<button class="btn btn--primary" type="submit">Order — continue to payment</button>)"
|
||||
R"(</form>)"
|
||||
R"(</section>)",
|
||||
// With the choice rendered below, the fieldset lists the methods and
|
||||
// the lede would only repeat half of them.
|
||||
offerCrypto ? SafeHtml{}
|
||||
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
|
||||
Escape(Form::kNoSaleMessage),
|
||||
CustomsNote(),
|
||||
formError,
|
||||
Url("action", "/shop/" + product.slug + "#buy"),
|
||||
Attr("data-cc", cc),
|
||||
Attr("value", product.slug),
|
||||
Join(colorOpts), errorFor("color"),
|
||||
Attr("max", std::to_string(Form::kMaxQuantity)),
|
||||
Attr("max", std::to_string(bestUnits)),
|
||||
Attr("value", std::to_string(prev.quantity)),
|
||||
errorFor("quantity"),
|
||||
Attr("value", prev.email), errorFor("email"),
|
||||
|
|
@ -836,14 +919,19 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
Attr("value", prev.street), errorFor("street"),
|
||||
Attr("value", prev.postal), errorFor("postal"),
|
||||
Attr("value", prev.city), errorFor("city"),
|
||||
Attr("value", prev.country), errorFor("country"));
|
||||
Attr("value", prev.country), errorFor("country"),
|
||||
payFieldset);
|
||||
}
|
||||
|
||||
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
|
||||
// defaults to false. Only the native server passes it true, because only the
|
||||
// server knows whether the crypto rail is configured.
|
||||
export RenderedPage RenderProduct(const Product& product,
|
||||
const Rates& rates,
|
||||
std::span<const std::pair<std::string, std::int64_t>> liveShipping = {},
|
||||
std::span<const Money::ShipRates> liveShipping = {},
|
||||
std::span<const Form::FieldError> errors = {},
|
||||
const Form::Checkout& prev = {}) {
|
||||
const Form::Checkout& prev = {},
|
||||
bool offerCrypto = false) {
|
||||
std::vector<SafeHtml> specRows;
|
||||
for (const Spec& s : product.specs) {
|
||||
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
|
||||
|
|
@ -871,9 +959,10 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
// Merchant-grade: each offer also carries shippingDetails and a return
|
||||
// policy, which is what Google Merchant Center's website-crawl feed needs
|
||||
// to list the product without a CSV in sight — productGroupID is what it
|
||||
// maps to item_group_id. Shipping uses the STATIC zone rates on purpose:
|
||||
// the checkout charges live carrier rates, which run at or below the
|
||||
// zone fallbacks — a listing may overstate shipping, never understate it.
|
||||
// maps to item_group_id. Shipping is published from the live carrier table
|
||||
// at single-unit weight, the same integers checkout charges, so the listing
|
||||
// and the till cannot disagree; a destination with no carrier rate is
|
||||
// simply not advertised, because it is not for sale.
|
||||
{
|
||||
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
||||
std::string_view availability =
|
||||
|
|
@ -881,23 +970,37 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
: product.ComingSoon() ? "https://schema.org/PreOrder"
|
||||
: "https://schema.org/OutOfStock";
|
||||
|
||||
// "NL", then the other 26 EU members, as JSON string lists.
|
||||
std::string euList;
|
||||
for (std::string_view cc : Money::EuCountries()) {
|
||||
if (cc == "NL") continue;
|
||||
if (!euList.empty()) euList += ',';
|
||||
euList += JsonStr(cc);
|
||||
// Every destination this listing may advertise: the carrier has a rate
|
||||
// for a single boxed unit, and the shop is willing to sell there.
|
||||
// US and CA drop out by policy, not oversight (Money::SellsTo):
|
||||
// listing a shipping rate to a country checkout refuses would publish
|
||||
// an offer that cannot be accepted, and feed it to shopping crawlers
|
||||
// as an invitation to buy from there. Everywhere else drops out
|
||||
// because no carrier rate exists — which is now the same sentence.
|
||||
struct FeedDest { std::string cc; std::int64_t rate; Money::Zone zone; };
|
||||
std::vector<FeedDest> dests;
|
||||
for (const Money::ShipRates& r : liveShipping) {
|
||||
if (!Money::SellsTo(r.cc)) continue;
|
||||
const std::int64_t rate = Money::RateFor(r.brackets, product.shipWeightGrams);
|
||||
if (rate > 0) dests.push_back({ r.cc, rate, Money::ZoneFor(r.cc) });
|
||||
}
|
||||
// The world tier can't say "everywhere else" in schema.org, so it
|
||||
// names the non-EU destinations the shop actually sees demand from.
|
||||
static constexpr std::string_view kWorldSample[] = {
|
||||
"US", "CA", "GB", "CH", "NO", "AU", "NZ", "JP",
|
||||
};
|
||||
std::string worldList;
|
||||
for (std::string_view cc : kWorldSample) {
|
||||
if (!worldList.empty()) worldList += ',';
|
||||
worldList += JsonStr(cc);
|
||||
|
||||
// One OfferShippingDetails per (transit tier, price) — the rates are
|
||||
// real per-country carrier prices now, so the grouping is whatever the
|
||||
// carrier's pricing happens to be rather than three tiers decided here.
|
||||
// Transit times still key on distance because Sendcloud's method list
|
||||
// carries no delivery estimate to read.
|
||||
std::vector<std::pair<std::pair<Money::Zone, std::int64_t>, std::string>> groups;
|
||||
for (const FeedDest& d : dests) {
|
||||
const auto key = std::make_pair(d.zone, d.rate);
|
||||
auto at = std::ranges::find(groups, key, &decltype(groups)::value_type::first);
|
||||
if (at == groups.end()) {
|
||||
groups.push_back({ key, JsonStr(d.cc) });
|
||||
} else {
|
||||
at->second += ',' + JsonStr(d.cc);
|
||||
}
|
||||
}
|
||||
|
||||
auto shipTier = [](std::string_view rate, const std::string& dests,
|
||||
int transitMin, int transitMax) {
|
||||
return std::format(
|
||||
|
|
@ -909,27 +1012,60 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
R"("transitTime":{{"@type":"QuantitativeValue","minValue":{},"maxValue":{},"unitCode":"DAY"}}}}}})",
|
||||
JsonStr(rate), dests, transitMin, transitMax);
|
||||
};
|
||||
const std::string shippingDetails = "["
|
||||
+ shipTier(Money::FormatMinor(product.shipNlMinor), JsonStr("NL"), 1, 2) + ","
|
||||
+ shipTier(Money::FormatMinor(product.shipEuMinor), euList, 2, 5) + ","
|
||||
+ shipTier(Money::FormatMinor(product.shipWorldMinor), worldList, 5, 14) + "]";
|
||||
// Empty when there is no rate table — the wasm fallback render, or a
|
||||
// server that has never reached Sendcloud. Publishing nothing is right:
|
||||
// the alternative is inventing a shipping price for a feed, which is
|
||||
// the exact claim this shop can no longer make.
|
||||
std::string shippingDetails;
|
||||
for (const auto& [key, list] : groups) {
|
||||
const auto [zone, rate] = key;
|
||||
const int tmin = zone == Money::Zone::Nl ? 1 : zone == Money::Zone::Eu ? 2 : 5;
|
||||
const int tmax = zone == Money::Zone::Nl ? 2 : zone == Money::Zone::Eu ? 5 : 14;
|
||||
if (!shippingDetails.empty()) shippingDetails += ',';
|
||||
shippingDetails += shipTier(Money::FormatMinor(rate), list, tmin, tmax);
|
||||
}
|
||||
if (!shippingDetails.empty()) shippingDetails = "[" + shippingDetails + "]";
|
||||
|
||||
// Returns, matching the terms page: EU consumers get the statutory
|
||||
// 14-day withdrawal (return shipping theirs); outside the EU sales
|
||||
// are final except defects, which are warranty, not returns.
|
||||
std::string euAll;
|
||||
for (std::string_view cc : Money::EuCountries()) {
|
||||
if (!euAll.empty()) euAll += ',';
|
||||
euAll += JsonStr(cc);
|
||||
// are final except defects, which are warranty, not returns. Both
|
||||
// lists name the destinations actually being offered, so the return
|
||||
// terms cover exactly the countries the shipping block advertises.
|
||||
std::string euAll, worldList;
|
||||
for (const FeedDest& d : dests) {
|
||||
std::string& into = Money::IsEuCountry(d.cc) ? euAll : worldList;
|
||||
if (!into.empty()) into += ',';
|
||||
into += JsonStr(d.cc);
|
||||
}
|
||||
const std::string returnPolicy = std::format(
|
||||
R"([{{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
|
||||
// Each half is emitted only if some offered destination falls under it
|
||||
// — an "applicableCountry":[] policy states a rule that applies to
|
||||
// nobody, which is worse than staying silent.
|
||||
std::string returnPolicy;
|
||||
if (!euAll.empty()) {
|
||||
returnPolicy += std::format(
|
||||
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
|
||||
R"("returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow",)"
|
||||
R"("merchantReturnDays":14,"returnMethod":"https://schema.org/ReturnByMail",)"
|
||||
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}},)"
|
||||
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}})",
|
||||
euAll);
|
||||
}
|
||||
if (!worldList.empty()) {
|
||||
if (!returnPolicy.empty()) returnPolicy += ',';
|
||||
returnPolicy += std::format(
|
||||
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
|
||||
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}}])",
|
||||
euAll, worldList);
|
||||
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}})",
|
||||
worldList);
|
||||
}
|
||||
if (!returnPolicy.empty()) returnPolicy = "[" + returnPolicy + "]";
|
||||
|
||||
// Both blocks describe destinations, so both disappear together when
|
||||
// there are none to describe.
|
||||
const std::string fulfilment =
|
||||
shippingDetails.empty() && returnPolicy.empty()
|
||||
? std::string{}
|
||||
: std::format(R"(,"shippingDetails":{},"hasMerchantReturnPolicy":{})",
|
||||
shippingDetails.empty() ? "[]" : shippingDetails,
|
||||
returnPolicy.empty() ? "[]" : returnPolicy);
|
||||
|
||||
const std::string offerTail = std::format(
|
||||
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
|
||||
|
|
@ -938,9 +1074,8 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
// what carries that registration onto the offer instead of
|
||||
// leaving a bare name a consumer has to resolve by string match.
|
||||
R"("url":{},"seller":{{"@id":"https://catcrafts.net/#organization",)"
|
||||
R"("@type":"Organization","name":"Catcrafts"}},)"
|
||||
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
|
||||
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
|
||||
R"("@type":"Organization","name":"Catcrafts"}}{}}})",
|
||||
availability, JsonStr(productUrl), fulfilment);
|
||||
|
||||
const std::string brand = product.brand.empty()
|
||||
? std::string{}
|
||||
|
|
@ -1011,7 +1146,7 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
SafeHtml buy;
|
||||
if (product.Buyable()) {
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev);
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
|
||||
} else if (product.ComingSoon()) {
|
||||
// The launch prices are already public, per colour, with the same
|
||||
// money terms the live form will carry. Only the form is held back,
|
||||
|
|
@ -1107,24 +1242,35 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// so it reads as "resume", not as an alarming limbo.
|
||||
SafeHtml payBlock;
|
||||
if (awaiting && !o.payUrl.empty()) {
|
||||
const bool crypto = o.payChoice == Form::kPayCrypto;
|
||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
||||
R"(the euro amount above; your bank or card sets the actual conversion )"
|
||||
R"(rate.</p>)",
|
||||
Escape(indicative));
|
||||
// What is waiting behind the button differs by rail, and so does what
|
||||
// "left uncompleted" costs the buyer: a Mollie payment can be resumed
|
||||
// for a good while, a crypto invoice expires in hours or minutes. A
|
||||
// page that promised the crypto buyer their link would keep would be
|
||||
// lying to exactly the person most likely to come back to it late.
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Complete your payment</h2>)"
|
||||
R"({})"
|
||||
R"(<p><a class="btn btn--primary" rel="noreferrer"{}>Resume payment — {}</a></p>)"
|
||||
R"(<p class="order__note">The payment page offers iDEAL, cards and a bank )"
|
||||
R"(transfer; your order reference is <strong>{}</strong>. If you just paid, )"
|
||||
R"(this page confirms it within seconds. A payment left uncompleted simply )"
|
||||
R"(lapses the order. Nothing is owed.</p>)"
|
||||
R"(<p class="order__note">{} your order reference is <strong>{}</strong>. )"
|
||||
R"(If you just paid, this page confirms it within seconds. {} Nothing )"
|
||||
R"(is owed.</p>)"
|
||||
R"(</section>)",
|
||||
indicativeLine,
|
||||
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
||||
Escape(o.reference));
|
||||
crypto ? Raw("The invoice takes Bitcoin, Lightning, stablecoins and the "
|
||||
"other coins CoinGate lists;")
|
||||
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
|
||||
Escape(o.reference),
|
||||
crypto ? Raw("Crypto invoices expire quickly — if this one has, "
|
||||
"the order simply lapses and you can order again.")
|
||||
: Raw("A payment left uncompleted simply lapses the order."));
|
||||
} else if (o.status == "paid") {
|
||||
payBlock = Format(
|
||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||
|
|
@ -1596,13 +1742,9 @@ inline constexpr std::string_view kGeoPriceHintScript =
|
|||
"\"Europe/Sofia\":\"bgn\","
|
||||
"\"Europe/London\":\"gbp\",\"Europe/Zurich\":\"chf\",\"Europe/Oslo\":\"nok\","
|
||||
"\"Atlantic/Reykjavik\":\"isk\",\"Asia/Tokyo\":\"jpy\","
|
||||
"\"America/Toronto\":\"cad\",\"America/Vancouver\":\"cad\",\"America/Edmonton\":\"cad\","
|
||||
"\"America/Winnipeg\":\"cad\",\"America/Halifax\":\"cad\",\"America/St_Johns\":\"cad\","
|
||||
"\"America/Regina\":\"cad\",\"America/Moncton\":\"cad\",\"America/Whitehorse\":\"cad\","
|
||||
"\"America/Yellowknife\":\"cad\",\"America/Iqaluit\":\"cad\","
|
||||
"\"America/New_York\":\"usd\",\"America/Chicago\":\"usd\",\"America/Denver\":\"usd\","
|
||||
"\"America/Los_Angeles\":\"usd\",\"America/Phoenix\":\"usd\",\"America/Anchorage\":\"usd\","
|
||||
"\"America/Detroit\":\"usd\",\"America/Boise\":\"usd\",\"Pacific/Honolulu\":\"usd\","
|
||||
// No America/* zones: USD and CAD left AllCurrencies with the sale itself,
|
||||
// so a visitor there reads the plain euro export price like anywhere the
|
||||
// shop has no local currency for.
|
||||
"\"Australia/Sydney\":\"aud\",\"Australia/Melbourne\":\"aud\",\"Australia/Brisbane\":\"aud\","
|
||||
"\"Australia/Perth\":\"aud\",\"Australia/Adelaide\":\"aud\",\"Australia/Hobart\":\"aud\","
|
||||
"\"Australia/Darwin\":\"aud\",\"Pacific/Auckland\":\"nzd\"};"
|
||||
|
|
@ -1615,10 +1757,17 @@ inline constexpr std::string_view kGeoPriceHintScript =
|
|||
"if(v)els[i].textContent=v;"
|
||||
"}"
|
||||
// The live checkout total. Reads only the data-cc blob the server rendered
|
||||
// (unit prices per colour, zone rates, live carrier table) and mirrors
|
||||
// ComputeTotals exactly: line total, floor((x*10000+6050)/12100) for the
|
||||
// export net, shipping by country then zone. Same integers, same formula,
|
||||
// so this preview and the charged amount cannot disagree.
|
||||
// (unit prices per colour, boxed unit weight, the carrier's per-country
|
||||
// weight-bracket ladder) and mirrors ComputeTotals exactly: line total,
|
||||
// floor((x*10000+6050)/12100) for the export net, and shipping from the
|
||||
// cheapest bracket that carries qty × weight — the same rule Money::RateFor
|
||||
// applies server-side. Same integers, same formula, so this preview and the
|
||||
// charged amount cannot disagree.
|
||||
//
|
||||
// It also has to REFUSE where checkout refuses, which since the zone
|
||||
// fallback went away is a real case rather than a theoretical one: no
|
||||
// ladder for the country, or no bracket heavy enough for the quantity. The
|
||||
// messages are the server's own templates, filled here.
|
||||
"var f=document.querySelector(\"form[data-cc]\");"
|
||||
"if(f){"
|
||||
"var d=JSON.parse(f.getAttribute(\"data-cc\"));"
|
||||
|
|
@ -1633,9 +1782,29 @@ inline constexpr std::string_view kGeoPriceHintScript =
|
|||
"var qty=qe?parseInt(qe.value,10)||1:1;"
|
||||
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
|
||||
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
|
||||
// Refused destination: say so where the total would have been, instead of
|
||||
// pricing an order the server will decline.
|
||||
"if(d.x&&d.x.indexOf(k)>-1){if(out){out.textContent=d.xm;out.hidden=false}return}"
|
||||
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
|
||||
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
|
||||
"var ship=(d.c&&d.c[k])||(k===\"NL\"?d.s.nl:eu?d.s.eu:d.s.w);"
|
||||
// No ladder for this destination: there is no price, and saying so beats
|
||||
// quoting a total the submit would then reject.
|
||||
"var lad=d.c&&d.c[k];"
|
||||
"var say=function(m){if(out){out.textContent=m;out.hidden=false}};"
|
||||
"if(!lad){say(d.nm.split(\"{cc}\").join(k));return}"
|
||||
// Cheapest bracket that carries the whole order, and the heaviest bracket
|
||||
// there is — the second one turns into \"up to N per order\" when nothing
|
||||
// carries this many.
|
||||
"var g=qty*d.g,ship=0,top=0;"
|
||||
"for(var i=0;i<lad.length;i++){"
|
||||
"if(lad[i][0]>=g&&(ship===0||lad[i][1]<ship))ship=lad[i][1];"
|
||||
"if(lad[i][0]>top)top=lad[i][0];"
|
||||
"}"
|
||||
"if(!ship){"
|
||||
"var fits=d.g>0?Math.floor(top/d.g):0;"
|
||||
"say(fits>0?d.hm.split(\"{cc}\").join(k).split(\"{n}\").join(fits)"
|
||||
":d.hm0.split(\"{cc}\").join(k));return"
|
||||
"}"
|
||||
"if(out){out.textContent=\"You pay \"+fmt(goods+ship)+\" \\u2014 \"+fmt(goods)"
|
||||
"+(qty>1?\" (\"+qty+\"\\u00d7)\":\"\")+\" + \"+fmt(ship)+\" shipping, \""
|
||||
"+(eu?\"incl. VAT\":\"ex VAT\");out.hidden=false}"
|
||||
|
|
|
|||
|
|
@ -817,6 +817,47 @@ treatment it replaces, which read as the wrong category for the work.
|
|||
.field__error { font-size: var(--step--1); color: var(--danger); }
|
||||
.field__error:empty { display: none; }
|
||||
|
||||
/* The payment choice. A <fieldset> so the radios are a labelled group for a
|
||||
screen reader, which means undoing the browser's default frame and
|
||||
margins before it can sit in the .field grid like everything else. */
|
||||
.field--pay { border: 0; padding: 0; margin: 0; min-inline-size: 0; }
|
||||
.field--pay legend {
|
||||
padding: 0;
|
||||
font-weight: 550;
|
||||
font-size: var(--step--1);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
/* The whole row is the label, so the target is the option rather than the
|
||||
8mm dot next to it — the difference between one tap and three on a phone. */
|
||||
.pay-option {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
align-items: start;
|
||||
padding: var(--s-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.pay-option + .pay-option { margin-top: var(--s-3); }
|
||||
.pay-option:has(input:checked) { border-color: var(--accent); }
|
||||
.pay-option:has(input:focus-visible) { border-color: var(--accent); }
|
||||
|
||||
/* .field input above is sized for text boxes — full width and 44px tall,
|
||||
which would turn a radio into a stripe across the form. */
|
||||
.field--pay input[type="radio"] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
/* Aligns the dot with the first line of its sentence rather than the
|
||||
middle of a three-line block. */
|
||||
margin-top: 0.15em;
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: var(--s-1) var(--s0);
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
|
|||
270
tools/e2e.sh
270
tools/e2e.sh
|
|
@ -60,7 +60,7 @@ trap cleanup EXIT INT TERM
|
|||
# Deterministic environment: a developer shell that sourced the repo .env
|
||||
# must not leak real provider keys into the test server — live Sendcloud
|
||||
# rates would silently change the shipping totals asserted below.
|
||||
unset MOLLIE_API_KEY BUNQ_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true
|
||||
unset MOLLIE_API_KEY COINGATE_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true
|
||||
|
||||
# An ephemeral GPG key so invoice signing runs the REAL signing path and the
|
||||
# suite can verify the signature. gpg is required (CI installs gnupg with the
|
||||
|
|
@ -86,7 +86,35 @@ chmod +x "$WORK/sendmail"
|
|||
export MAIL_COMMAND="$WORK/sendmail"
|
||||
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
|
||||
|
||||
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 &
|
||||
# The shipping rate table. Shipping has no compiled-in fallback any more — the
|
||||
# carrier table is the only source of prices — so without this file every
|
||||
# checkout correctly refuses and the whole order suite would be testing the
|
||||
# refusal path by accident.
|
||||
#
|
||||
# This is byte-for-byte the cache the daily Sendcloud refresh writes, so the
|
||||
# suite drives the production lookup with no test-only hook that could drift
|
||||
# from it: country -> [[maxWeightGrams, consumerCents], ...], prices already
|
||||
# VAT-inclusive (the gross-up happens at fetch, not at load).
|
||||
#
|
||||
# The single-unit rates are the €15 / €25 / €55 the totals below assert. The
|
||||
# second band exists so the too-heavy refusal has a real ceiling to hit:
|
||||
# 10 kg / 700 g per unit = 14 units per parcel.
|
||||
cat >"$ORDERS.shipping.json" <<'JSON'
|
||||
{"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{
|
||||
"NL":[[2000,1500],[10000,2900]],
|
||||
"DE":[[2000,2500],[10000,4200]],
|
||||
"GB":[[2000,5500],[10000,7900]]}}
|
||||
JSON
|
||||
|
||||
# BOTH slots on the fake rail, so the suite covers the payment CHOICE as well
|
||||
# as the lifecycle: that the form offers it, that the picked rail is what gets
|
||||
# written to the ledger, and that an order polls the provider that issued its
|
||||
# link. Which rail is behind each slot is exactly the part these tests should
|
||||
# not care about — that is what makes the same assertions valid for Mollie and
|
||||
# CoinGate. Both fakes share one marker file, so touching it below settles
|
||||
# whichever orders are outstanding.
|
||||
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake --crypto-rail=fake-crypto \
|
||||
>"$WORK/server.log" 2>&1 &
|
||||
SRV_PID=$!
|
||||
|
||||
# Wait for the listener rather than sleeping a fixed amount: a fixed sleep is
|
||||
|
|
@ -256,16 +284,32 @@ else
|
|||
fi
|
||||
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
|
||||
# Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||
# reads. Shipping uses the static zone rates (listing may overstate, never
|
||||
# understate what checkout charges); returns mirror the terms page.
|
||||
# reads. Shipping is published from the live carrier table at one unit's
|
||||
# weight — the same integers checkout charges, so the listing cannot quote a
|
||||
# rate the till won't honour; returns mirror the terms page.
|
||||
body_has /shop/fp6-pmos 'OfferShippingDetails' "offers carry shipping details"
|
||||
body_has /shop/fp6-pmos 'MerchantReturnPolicy' "offers carry a return policy"
|
||||
body_has /shop/fp6-pmos '"sku":"fp6-pmos-green"' "offers carry per-variant skus"
|
||||
body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand"
|
||||
# One entry per (transit tier, price) the carrier table produces — three, for
|
||||
# the fixture's NL/DE/GB. Not a fixed property of the code any more: it is
|
||||
# whatever the carrier prices, which is the point.
|
||||
if extract_ld /shop/fp6-pmos | jq -e '.hasVariant[0].offers.shippingDetails | length == 3' >/dev/null 2>&1; then
|
||||
ok "shipping details cover all three zones"
|
||||
ok "shipping details group the carrier's rates"
|
||||
else
|
||||
bad "shipping zones" "expected NL + EU + world tiers in the first variant's offer"
|
||||
bad "shipping groups" "expected 3 rate groups from the fixture table in the first variant's offer"
|
||||
fi
|
||||
# The advertised rate IS the carrier's single-unit price, and a destination the
|
||||
# table does not cover is never advertised.
|
||||
if extract_ld /shop/fp6-pmos | jq -e '[.hasVariant[0].offers.shippingDetails[].shippingRate.value] | sort == ["15.00","25.00","55.00"]' >/dev/null 2>&1; then
|
||||
ok "published shipping rates come from the carrier table"
|
||||
else
|
||||
bad "shipping rates" "expected the fixture's 15.00/25.00/55.00 in the offer"
|
||||
fi
|
||||
if extract_ld /shop/fp6-pmos | jq -e '[.hasVariant[0].offers.shippingDetails[].shippingDestination.addressCountry[]] | index("AU") == null' >/dev/null 2>&1; then
|
||||
ok "an uncovered destination is not advertised"
|
||||
else
|
||||
bad "shipping coverage" "AU has no carrier rate but appears in the listing"
|
||||
fi
|
||||
if [ "$SHOP_OPEN" = 1 ]; then
|
||||
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
|
||||
|
|
@ -289,15 +333,19 @@ fi
|
|||
|
||||
# The shop card: one euro number as the crawler/no-JS text, every supported
|
||||
# currency pre-formatted server-side as a data attribute for the script to
|
||||
# pick from. Converted amounts carry "~". CAD converts the ex-VAT price;
|
||||
# SEK (an EU member's currency) converts the VAT-inclusive price.
|
||||
# pick from. Converted amounts carry "~". GBP converts the ex-VAT price;
|
||||
# SEK (an EU member's currency) converts the VAT-inclusive price. USD and CAD
|
||||
# are absent on purpose — the shop refuses those destinations, so it does not
|
||||
# quote a local price to them either.
|
||||
body_has /shop 'class="price__single"' "shop card renders the single-number price"
|
||||
body_has /shop 'data-cad="~CA$' "shop card carries a CAD conversion"
|
||||
body_has /shop 'data-gbp="~£' "shop card carries a GBP conversion"
|
||||
body_has /shop 'data-sek="~kr ' "shop card carries an SEK conversion"
|
||||
body_has /shop 'data-world="€465.54"' "shop card carries the euro export fallback"
|
||||
# The product page gets the same headline element, so a Canadian sees ~CA$
|
||||
body_lacks /shop 'data-usd=' "no USD price for a country the shop refuses"
|
||||
body_lacks /shop 'data-cad=' "no CAD price for a country the shop refuses"
|
||||
# The product page gets the same headline element, so a British visitor sees ~£
|
||||
# at the top there too, and the buy card states the customs position plainly.
|
||||
body_has /shop/fp6-pmos 'data-cad="~CA$' "product page headline carries the conversion"
|
||||
body_has /shop/fp6-pmos 'data-gbp="~£' "product page headline carries the conversion"
|
||||
body_has /shop/fp6-pmos 'indicative only' "buy card says converted prices are indicative"
|
||||
body_has /shop/fp6-pmos 'customs authority' "buy card names whose problem import charges are"
|
||||
body_lacks /shop/fp6-pmos 'collected on arrival' "the vague customs phrasing is gone"
|
||||
|
|
@ -680,6 +728,13 @@ if grep -q '"country":"NL"' "$ORDERS" && grep -q '"total_minor":57830' "$ORDERS"
|
|||
else
|
||||
bad "order storage" "expected NL total_minor 57830 in $ORDERS"
|
||||
fi
|
||||
# No `pay` field in that submission, which is what a form with only one rail
|
||||
# configured posts: it must land on the bank rail rather than nothing.
|
||||
if grep -q '"pay_choice":"bank"' "$ORDERS"; then
|
||||
ok "a submission with no payment choice records bank"
|
||||
else
|
||||
bad "default payment choice" "expected pay_choice bank in $ORDERS"
|
||||
fi
|
||||
|
||||
# The order page: awaiting payment, pay link, reference, self-refreshing,
|
||||
# never indexed, never cached.
|
||||
|
|
@ -700,43 +755,100 @@ status /order/00000000000000000000000000000000 404
|
|||
status /order/not-a-token 404
|
||||
status /order/deadbeef 404
|
||||
|
||||
# ── the payment choice ────────────────────────────────────────────────
|
||||
#
|
||||
# Both slots are configured here, so the form must offer both and the picked
|
||||
# one must survive all the way into the ledger. The ledger is the assertion
|
||||
# that matters: it is what the reconciler later reads to decide WHICH provider
|
||||
# may confirm the order, so a choice that renders but is not stored would mean
|
||||
# crypto orders being asked about at Mollie.
|
||||
body_has /shop/fp6-pmos 'name="pay"' "the form offers a payment choice"
|
||||
body_has /shop/fp6-pmos 'value="crypto"' "crypto is one of the choices"
|
||||
body_has /shop/fp6-pmos 'value="bank" checked' "bank is the pre-selected choice"
|
||||
|
||||
LOC_C=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
-d "$GOOD&pay=crypto" "$BASE/shop/fp6-pmos")
|
||||
TOKEN_C=$(printf '%s' "$LOC_C" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||
if [ -n "$TOKEN_C" ]; then
|
||||
ok "a crypto order goes through"
|
||||
if grep -q "\"id\":\"$TOKEN_C\".*\"pay_choice\":\"crypto\"" "$ORDERS"; then
|
||||
ok "the crypto choice is what the ledger records"
|
||||
else
|
||||
bad "crypto choice storage" "no pay_choice crypto for $TOKEN_C in $ORDERS"
|
||||
fi
|
||||
# The order page has to promise what is actually behind the button — the
|
||||
# bank copy on a crypto order would send someone looking for iDEAL.
|
||||
CRYPTO_HTML=$(curl -s "$BASE/order/$TOKEN_C")
|
||||
if printf '%s' "$CRYPTO_HTML" | grep -qF 'Lightning'; then
|
||||
ok "the crypto order page describes the crypto invoice"
|
||||
else
|
||||
bad "crypto order copy" "no crypto wording on /order/$TOKEN_C"
|
||||
fi
|
||||
if printf '%s' "$CRYPTO_HTML" | grep -qF 'iDEAL'; then
|
||||
bad "crypto order copy" "the crypto order page still offers iDEAL"
|
||||
else
|
||||
ok "the crypto order page does not promise iDEAL"
|
||||
fi
|
||||
else
|
||||
bad "crypto checkout" "no token from Location: $LOC_C"
|
||||
fi
|
||||
|
||||
# A payment method nobody offers is refused, and refused as a FIELD error so
|
||||
# the form comes back with the choice highlighted rather than a bare 400.
|
||||
BOGUS_CODE=$(curl -s -o "$WORK/bogus-pay.html" -w '%{http_code}' -X POST \
|
||||
-d "$GOOD&pay=invoice-me-later" "$BASE/shop/fp6-pmos")
|
||||
if [ "$BOGUS_CODE" = "422" ]; then
|
||||
ok "an unknown payment method is refused"
|
||||
else
|
||||
bad "unknown payment method" "expected 422, got $BOGUS_CODE"
|
||||
fi
|
||||
if grep -qF 'Pick one of the payment methods' "$WORK/bogus-pay.html"; then
|
||||
ok "the refusal names the payment field"
|
||||
else
|
||||
bad "unknown payment method" "no payment field error in the response"
|
||||
fi
|
||||
|
||||
# A non-EU order: ex-VAT goods, world shipping, and the indicative national
|
||||
# currency line sourced from the build-time ECB rates.
|
||||
LOC_CA=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA' "$BASE/shop/fp6-pmos")
|
||||
TOKEN_CA=$(printf '%s' "$LOC_CA" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||
if [ -n "$TOKEN_CA" ]; then
|
||||
CA_HTML=$(curl -s "$BASE/order/$TOKEN_CA")
|
||||
# currency line sourced from the build-time ECB rates. GB rather than a North
|
||||
# American destination because those are refused outright (see the block below).
|
||||
LOC_GB=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB' "$BASE/shop/fp6-pmos")
|
||||
TOKEN_GB=$(printf '%s' "$LOC_GB" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||
if [ -n "$TOKEN_GB" ]; then
|
||||
GB_HTML=$(curl -s "$BASE/order/$TOKEN_GB")
|
||||
# €465.54 goods (green net) + €55 world shipping = €520.54
|
||||
if printf '%s' "$CA_HTML" | grep -qF '€520.54'; then
|
||||
if printf '%s' "$GB_HTML" | grep -qF '€520.54'; then
|
||||
ok "export order total is ex-VAT + world shipping"
|
||||
else
|
||||
bad "export order total" "€520.54 not on the page"
|
||||
fi
|
||||
if printf '%s' "$CA_HTML" | grep -qF 'Zero-rated export'; then
|
||||
if printf '%s' "$GB_HTML" | grep -qF 'Zero-rated export'; then
|
||||
ok "export order states the VAT treatment"
|
||||
else
|
||||
bad "export VAT copy" "missing zero-rated export note"
|
||||
fi
|
||||
if printf '%s' "$CA_HTML" | grep -qE '≈ CA\$[0-9]+'; then
|
||||
ok "export order shows the indicative CAD amount"
|
||||
if printf '%s' "$GB_HTML" | grep -qE '≈ £[0-9]+'; then
|
||||
ok "export order shows the indicative GBP amount"
|
||||
else
|
||||
# Rates are optional by design; their absence must not fail the file
|
||||
# check, but in this repo rates.json is committed so it must appear.
|
||||
bad "indicative currency" "no ≈ CA\$ line on the CA order page"
|
||||
bad "indicative currency" "no ≈ £ line on the GB order page"
|
||||
fi
|
||||
if printf '%s' "$CA_HTML" | grep -qF 'indicative'; then
|
||||
if printf '%s' "$GB_HTML" | grep -qF 'indicative'; then
|
||||
ok "conversion is labelled indicative"
|
||||
else
|
||||
bad "indicative label" "the conversion is not labelled indicative"
|
||||
fi
|
||||
else
|
||||
bad "CA checkout" "no token from Location: $LOC_CA"
|
||||
bad "GB checkout" "no token from Location: $LOC_GB"
|
||||
fi
|
||||
|
||||
# Destinations the shop refuses. A well-formed, real address in either country
|
||||
# must not produce an order, a payment link or a redirect — the 422 is the whole
|
||||
# point, and the page has to say why rather than failing silently.
|
||||
# A two-unit white export order: unit €665, line €1330, net from the LINE
|
||||
# total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
|
||||
LOC_W=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
-d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CA&color=white&quantity=2' \
|
||||
-d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2' \
|
||||
"$BASE/shop/fp6-pmos")
|
||||
TOKEN_W=$(printf '%s' "$LOC_W" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||||
if [ -n "$TOKEN_W" ]; then
|
||||
|
|
@ -774,7 +886,56 @@ else
|
|||
bad "bulk order" "quantity=9 did not create an order: $LOC_9"
|
||||
fi
|
||||
body_has /shop/fp6-pmos 'type="number"' "quantity is a number input, not a dropdown"
|
||||
body_has /shop/fp6-pmos 'max="99"' "quantity input carries the technical ceiling"
|
||||
# The ceiling is physical now: the heaviest band any destination offers
|
||||
# (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14. The
|
||||
# input advertises the BEST case across destinations; the per-country limit is
|
||||
# enforced on submit, below.
|
||||
body_has /shop/fp6-pmos 'max="14"' "quantity input carries the one-parcel ceiling"
|
||||
|
||||
# One order is one parcel. Fifteen units is 10.5 kg, past every band the
|
||||
# fixture has, so it must be refused rather than quoted a rate the carrier
|
||||
# would not accept — and the refusal has to say what WOULD fit, or the buyer is
|
||||
# left guessing.
|
||||
HEAVY_HTML=$(curl -s -X POST -d "$GOOD&quantity=15" "$BASE/shop/fp6-pmos")
|
||||
status /shop/fp6-pmos 422 POST "$GOOD&quantity=15"
|
||||
if printf '%s' "$HEAVY_HTML" | grep -qF 'up to 14 per order'; then
|
||||
ok "an over-weight order is refused with the quantity that fits"
|
||||
else
|
||||
bad "too-heavy refusal" "no 'up to 14 per order' in the response"
|
||||
fi
|
||||
if printf '%s' "$HEAVY_HTML" | grep -qF 'orders@catcrafts.net'; then
|
||||
ok "the too-heavy refusal offers a way to order anyway"
|
||||
else
|
||||
bad "too-heavy refusal" "no contact address in the response"
|
||||
fi
|
||||
|
||||
# A destination the carrier has no rate for. Since the zone fallback went away
|
||||
# there is no price to invent, so this is a refusal — and specifically NOT the
|
||||
# no-sale refusal, which is a different (policy) reason with different wording.
|
||||
AU_ADDR='email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU'
|
||||
AU_HTML=$(curl -s -X POST -d "$AU_ADDR" "$BASE/shop/fp6-pmos")
|
||||
status /shop/fp6-pmos 422 POST "$AU_ADDR"
|
||||
if printf '%s' "$AU_HTML" | grep -qF 'No carrier rate for AU'; then
|
||||
ok "an uncovered destination is refused, naming the country"
|
||||
else
|
||||
bad "uncovered destination" "no carrier-rate refusal for AU in the response"
|
||||
fi
|
||||
# ...and specifically the country FIELD ERROR must be the carrier message, not
|
||||
# the no-sale one. Matched on the error markup rather than the bare sentence:
|
||||
# the no-sale line is standing copy above every buy form, so its mere presence
|
||||
# on the page says nothing about why this order was refused.
|
||||
if printf '%s' "$AU_HTML" | grep -qF 'field__error">Catcrafts does not sell'; then
|
||||
bad "uncovered destination" "AU was refused as a policy no-sale, not a missing rate"
|
||||
else
|
||||
ok "an uncovered destination is not confused with a refused one"
|
||||
fi
|
||||
AU_ORDERS=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
|
||||
curl -s -o /dev/null -X POST -d "$AU_ADDR" "$BASE/shop/fp6-pmos"
|
||||
if [ "$(wc -l < "$ORDERS" 2>/dev/null || echo 0)" = "$AU_ORDERS" ]; then
|
||||
ok "a refused destination writes no order"
|
||||
else
|
||||
bad "uncovered destination" "an order was stored for a country with no rate"
|
||||
fi
|
||||
|
||||
# No invoice exists before the money does — awaiting orders answer 404.
|
||||
status "/order/$TOKEN/invoice.md" 404
|
||||
|
|
@ -880,19 +1041,23 @@ fi
|
|||
|
||||
echo "== the confirmation email =="
|
||||
# Every paid order gets exactly one confirmation with the signed invoice
|
||||
# attached. Four orders were paid above; the mailer sweeps the ledger every
|
||||
# 2 s, so all four messages should exist within a few sweeps.
|
||||
# attached. The expected count comes from the LEDGER rather than a number
|
||||
# written here: "one per paid order" is the actual property, and a literal
|
||||
# would have to be edited by anyone who adds an order above — which is a test
|
||||
# that fails for the wrong reason and gets bumped without being read. The
|
||||
# mailer sweeps every 2 s, so the messages land within a few sweeps.
|
||||
n_paid=$(grep -c '"status":"paid"' "$ORDERS" || true)
|
||||
i=0
|
||||
until [ "$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)" -ge 4 ]; do
|
||||
until [ "$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)" -ge "$n_paid" ]; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -gt 60 ]; then break; fi
|
||||
sleep 0.25
|
||||
done
|
||||
n_mail=$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)
|
||||
if [ "$n_mail" -eq 4 ]; then
|
||||
if [ "$n_mail" -eq "$n_paid" ]; then
|
||||
ok "one confirmation email per paid order ($n_mail sent)"
|
||||
else
|
||||
bad "confirmation email count" "expected 4, got $n_mail"
|
||||
bad "confirmation email count" "expected $n_paid, got $n_mail"
|
||||
fi
|
||||
|
||||
# The NL order's message, found by its own order link (the same email address
|
||||
|
|
@ -921,11 +1086,11 @@ else
|
|||
fi
|
||||
|
||||
# The export order's message states the VAT treatment its invoice carries.
|
||||
MAIL_CA=$(grep -l "/order/$TOKEN_CA" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
|
||||
if [ -n "$MAIL_CA" ] && grep -qF 'zero-rated export' "$MAIL_CA"; then
|
||||
MAIL_GB=$(grep -l "/order/$TOKEN_GB" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
|
||||
if [ -n "$MAIL_GB" ] && grep -qF 'zero-rated export' "$MAIL_GB"; then
|
||||
ok "export confirmation states the zero-rated treatment"
|
||||
else
|
||||
bad "export confirmation" "no message for the CA order, or no VAT note in it"
|
||||
bad "export confirmation" "no message for the GB order, or no VAT note in it"
|
||||
fi
|
||||
|
||||
# Idempotency comes from the ledger's notified event, not from luck in
|
||||
|
|
@ -962,6 +1127,25 @@ status /shop/fp6-pmos 422 POST 'name=Ada&street=x&postal=1&city=y&country=NL'
|
|||
status /shop/fp6-pmos 422 POST 'email=nonsense&'"$GOOD" # bad email (dup field keeps first)
|
||||
status /shop/fp6-pmos 422 POST 'email=a%40b.example&country=NL' # missing address
|
||||
status /shop/fp6-pmos 422 POST "$GOOD&website=spam" # honeypot
|
||||
# Destinations the shop refuses (Money::NoSaleCountries). Well-formed, real
|
||||
# addresses: the refusal is policy, not a shape check, so it has to hold for
|
||||
# every spelling the form accepts. Deliberately outside the SHOP_OPEN gate —
|
||||
# validation runs before the coming-soon check, so this must answer 422 whether
|
||||
# the shop is open or not, and it is the assertion that would catch the block
|
||||
# being lost in a refactor.
|
||||
US_ADDR='email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US'
|
||||
ORDERS_BEFORE=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
|
||||
status /shop/fp6-pmos 422 POST "$US_ADDR" # United States
|
||||
status /shop/fp6-pmos 422 POST 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA'
|
||||
status /shop/fp6-pmos 422 POST 'email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us'
|
||||
# Refused in validation means nothing reached the ledger and no payment link
|
||||
# was ever created.
|
||||
ORDERS_AFTER=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
|
||||
if [ "$ORDERS_BEFORE" = "$ORDERS_AFTER" ]; then
|
||||
ok "a refused destination creates no order record"
|
||||
else
|
||||
bad "refusal leaks an order" "orders went from $ORDERS_BEFORE to $ORDERS_AFTER"
|
||||
fi
|
||||
status /shop/nope 404 POST "$GOOD" # unknown product
|
||||
status /projects 405 POST 'x=1' # not a form target
|
||||
|
||||
|
|
@ -984,6 +1168,26 @@ if grep -qF 'field__error' "$WORK/rejected.html"; then
|
|||
else
|
||||
bad "rejected form error" "no .field__error in the response"
|
||||
fi
|
||||
# A refused destination says why, in the form, with the address still in it —
|
||||
# the visitor should learn the shop does not sell there, not that something
|
||||
# went wrong.
|
||||
curl -s -X POST -d "$US_ADDR" "$BASE/shop/fp6-pmos" > "$WORK/refused.html"
|
||||
if grep -qF 'does not sell or ship to the United States or Canada' "$WORK/refused.html"; then
|
||||
ok "refusal explains itself on the form"
|
||||
else
|
||||
bad "refusal copy" "the US/CA refusal message is not on the re-rendered page"
|
||||
fi
|
||||
if grep -qF 'value="Pat"' "$WORK/refused.html"; then
|
||||
ok "a refused submission keeps what was typed"
|
||||
else
|
||||
bad "refusal echo" "form values were dropped on refusal"
|
||||
fi
|
||||
# The buy panel warns before anyone fills it in, and the preview script carries
|
||||
# the same list so it cannot quote a total the server would refuse.
|
||||
body_has /shop/fp6-pmos 'does not sell or ship to the United States or Canada' \
|
||||
"buy panel states where the shop does not sell"
|
||||
body_has /shop/fp6-pmos '"x":["US","CA"]' \
|
||||
"total preview knows the refused destinations"
|
||||
# The honeypot message must not name the trap, or it teaches the next bot.
|
||||
# Only the ERROR NOTICE is inspected: the re-rendered form legitimately
|
||||
# contains the name="website" field itself — that IS the trap, re-armed.
|
||||
|
|
|
|||
Loading…
Reference in a new issue