This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue