Replaced mollie
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 20:15:47 +02:00
commit df91762271
29 changed files with 3079 additions and 838 deletions

View file

@ -272,22 +272,145 @@ 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 and EURC
## Payments: bank transfer and EURC
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 |
| `bank` | transfer | `TRANSFER_IBAN` | a plain SEPA transfer to our own account |
| `crypto` | EURC | `EURC_CHAINS` | self-hosted EURC on the configured EVM chains |
**Both rails are self-hosted, and that is deliberate rather than
incidental.** On 2026-08-20 the shop's hosted payment provider closed its
account after a risk review, with no appeal and no reason given beyond
"outside our acceptance criteria". Every payment method the shop offered
through that provider died in one email. The rails above cannot be switched
off by a third party: one is a transfer to our own bank account, the other is
an address we generated ourselves. The hosted rail's implementation has been
removed from the tree — the decision was final, and a dead integration costs a
CI gate, a secret, and confusion about which rail is actually serving.
What this costs, stated honestly so nobody re-litigates it from memory: there
is no iDEAL and there are no cards. iDEAL requires an acquiring contract with
a bank (or a PSP), which is the same kind of relationship that just ended, and
cards additionally require PCI DSS SAQ D plus an EMVCo-certified 3DS server.
In the last measured week before the change, cards and Bancontact were 40% of
donation value, so this is a real loss of reach and not a free win.
The slots are independent. Set one 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 credential is missing is a **startup refusal**, not a silent
mode whose configuration 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.
### The bank-transfer rail
No processor and no credential: the buyer sends a normal SEPA transfer to our
IBAN quoting the order's reference, and the server settles the order when a
matching credit appears on the account. The order page is the payment page.
- `TRANSFER_IBAN=NL..` — the account the money lands in. Setting this IS
selecting the rail, the same convention as `EURC_CHAINS`.
- `TRANSFER_BENEFICIARY='J. van der Graaf'` — **the account-holder name
exactly as the bank holds it, character for character.** Not the trading
name. Since 2025-10-09 every euro-area transfer is name-checked against the
IBAN by Verification of Payee, and the payer sees a mismatch warning at the
moment of paying; a friendly-looking "Catcrafts" here would scare buyers off
at the last step. Startup refuses without it, because an order page missing
this loses the money rather than the sale.
- `TRANSFER_BIC` — optional. Shown on the order page only when set, and
labelled "only if your bank asks for it, usually outside Europe". Inside SEPA
an IBAN alone has been sufficient since 2016, so an unconditional BIC row is
just one more field for a Dutch buyer to fill in and mistype; a payer sending
by SWIFT from outside SEPA genuinely cannot proceed without it.
- `TRANSFER_WINDOW_HOURS` (default 336, i.e. 14 days) — how long an order
waits before it lapses. Generous on purpose: a non-instant transfer from
outside the euro area can legitimately take a business day, and there is no
provider-side expiry forcing our hand. A lapsed order is **not** bounced
money: the IBAN stays ours, a late payment still arrives, and it is settled
with `--mark-paid`. The server logs exactly this when it lapses one.
- `TRANSFER_CREDITS` (default `<orders>.transfer-credits.jsonl`) — where the
rail reads incoming credits from. One JSON object per line:
`{"id":"…","reference":"…","amount_minor":2500,"method":"sepa"}`. A missing
file is an empty list, not an error, since a shop that has taken no
transfers yet has no file.
#### Filling the credits file: the bunq reader
`--pull-credits` reads the bunq account once and appends anything new to the
credits file, deduplicated by bunq's own payment id, then prints how many
arrived. Every pull re-reads an overlapping window, so that dedupe is what
stops a credit being counted twice and settling an order nobody paid twice
for. It appends rather than rewrites, so lines added by hand survive.
```sh
BUNQ_API_KEY=… TRANSFER_IBAN=NL.. \
catcrafts-server --pull-credits --orders /var/lib/catcrafts/orders.jsonl
# -> pulled 2 new credit(s) into /var/lib/catcrafts/orders.jsonl.transfer-credits.jsonl
```
**Read this before deciding where to put the key.** A bunq API key can
**initiate payments**, and bunq offers no read-only scope, so there is no such
thing as a key that can only read. That gives two deployment shapes, and they
are not equivalent:
1. **Key off the public host (intended).** Run `--pull-credits` on a trusted
machine on a timer, and ship the credits file to the server (rsync, scp,
whatever). The server settles orders while holding **no credential that can
move a cent**, so even a full compromise of the web host cannot spend the
account. This is the same rule the EURC rail follows by never holding a
wallet key.
2. **Key on the server (simpler, strictly worse).** Set `BUNQ_API_KEY` in
`payments.env` and the rail reads the bank itself, no file shipping. The
startup log prints a warning saying exactly what has been traded away.
Either way, set **`BUNQ_PERMITTED_IPS`** to the egress address of whatever
machine holds the key. bunq registers it once, at device-server time, and it is
the only thing standing between a leaked key and someone spending the balance.
The default is `*`, which works and is announced loudly. Changing it later
means deleting the context file and re-onboarding.
Other bunq settings: `BUNQ_STATE` (default `<credits>.bunq-context.json`) holds
the generated RSA keypair and the session tokens at 0600 — **back it up or at
least know that deleting it forces a re-onboard**, and bunq allows as few as
ten setup calls PER DAY, so do not delete it casually. If the key can see more
than one active account, `TRANSFER_IBAN` picks which; without it, a key seeing
several accounts is a refusal rather than a guess, because guessing means
reconciling the shop against its savings.
Rate limits worth respecting: bunq caps reads at roughly **3 GET per 3
seconds** per method. One pull is one request, and the rail additionally shares
a single read across all orders in a sweep, so neither path is anywhere near
the cap. What WOULD hit it is retrying the onboarding calls in a loop; the
context file is what prevents that.
The file source stays regardless — it is how the suites drive real settlement
with no network, and it is the manual escape hatch for settling a transfer by
hand. `--mark-paid` remains the other one.
Two matching properties worth knowing, both pinned in
`ShouldMatchBankTransfers`:
* **The reference matches in whatever form the payer typed it.** Both sides
are reduced to upper-case alphanumerics and the match is on the reference
*body*, so `CC-2B6457`, `cc2b6457`, `CC 2B 64 57` and the structured
`RF70CC2B6457` all settle the same order. The order page prints the
structured ISO 11649 form as well, because banks with a dedicated
payment-reference field validate its check digits and refuse a mistyped one
*before the money leaves* — which is what makes unattended matching safe.
* **Partial payments accumulate.** Two credits quoting one reference are
summed, which is what makes the page's "send the rest the same way" true.
Outgoing amounts are ignored, so a refund quoting the reference cannot pay
for the order it refunded.
One known limitation, logged rather than solved: a single transfer quoting
**two** order references cannot be attributed by a per-order matcher, and both
orders would settle on the same money. The rail detects the shape and logs
"settle this one by hand"; at this volume that is proportionate, but do not
assume the matcher is total.
### The EURC rail
No processor, no API key, no account anywhere: the buyer sends EURC (Circle's
@ -335,32 +458,36 @@ payment page. Three pieces of configuration:
hand with `--mark-paid`.
```sh
# Mollie keys: dashboard -> Developers -> API keys. Mollie's test mode works
# against the real endpoints — verify the whole flow before swapping in the
# live key. The EURC rail has no key and no test mode: point EURC_CHAINS at a
# testnet chains file (Base Sepolia / Ethereum Sepolia contracts are in
# Circle's list) to rehearse against worthless tokens instead.
# Neither rail has a credential, so neither has a "test mode" to rehearse in:
# what selects each one is naming where the money lands. To rehearse the crypto
# rail against worthless tokens, point EURC_CHAINS at a testnet chains file
# (Ethereum Sepolia / Base Sepolia contracts are in Circle's list). To rehearse
# the bank rail, run it and write a credits line by hand — see the transfer
# section above, or tools/dev-credit.sh locally.
install -d -m 0755 /etc/catcrafts
cat > /etc/catcrafts/payments.env <<'ENV'
MOLLIE_API_KEY=test_your-key-here
TRANSFER_IBAN=NL..
TRANSFER_BENEFICIARY=exactly as the bank holds it
TRANSFER_BIC=BUNQNL2A
EURC_CHAINS=/etc/catcrafts/eurc-chains.json
ENV
chmod 0600 /etc/catcrafts/payments.env
systemctl restart catcrafts-server
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=eurc"
journalctl -u catcrafts-server | tail # "payments: bank=transfer crypto=eurc"
```
Mechanics worth knowing:
* 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, the EURC rail every 30 s (a finalized block will not
arrive faster), both backing off with age. Arriving back on the order page
is ignored as evidence by design: only the authenticated poll (Mollie) or
the rail's own RPC check (EURC) moves an order to paid, and the paid event
the bank rail every 60 s (and it shares ONE account read across the whole
sweep, so a dozen open orders is still one request), the EURC rail every
30 s (a finalized block will not arrive faster), both backing off with age. Arriving back on the order page
is ignored as evidence by design: only the rail's own authenticated read of
the bank account, or its RPC check on-chain, moves an order to paid, and the paid event
records the method (`ideal`, `creditcard`, `eurc-base`, …) in the ledger.
* **Unpaid orders lapse automatically** and the buyer just orders again.
Mollie expires its own payments; the EURC rail closes its window after
the bank rail closes its window after 14 days; the EURC rail after
`EURC_WINDOW_HOURS` (24 by default). A lapsed EURC order is NOT bounced
money — see above.
* The shop **holds EURC**: crypto revenue sits at the pool addresses until
@ -370,15 +497,24 @@ Mechanics worth knowing:
but the financials bank-callback never sees it, so crypto sales exist in
the ledger only until the sweep's SEPA leg arrives.
* Card money stays disputable for months even after "paid": before shipping a
large or exported order, glance at the `via` column in `--orders`. iDEAL,
bank transfers and EURC 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.
* 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 ShouldStayScriptFree fails the build if a PLACEHOLDER marker ever
reaches a rendered page again. The EURC rail has no onboarding, no KYB and
no account anywhere; that is the reason it exists.
large or exported order, glance at the `via` column in `--orders`. Bank
transfers and EURC 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.
* **iDEAL's finality has an expiry date, and it is inside the launch window.**
Written here because it invalidates the obvious shipping rule: "iDEAL says
paid, so it is safe to post" stops being true as iDEAL becomes Wero through
2026-2027. Wero carries a **120-calendar-day dispute window** for online
purchases (goods not delivered, not as described), and the bank acts as
facilitator, NOT as insurer — verified in bunq's business terms ch. 8.1, and
it is a scheme property rather than one provider's policy, so changing PSP
does not avoid it. Consequence for whoever runs this shop: treat a settled
iDEAL/Wero order like a card order, not like a bank transfer, and keep the
proof-of-dispatch that a dispute is answered with.
* Neither rail has onboarding, a KYB review, or an account that a provider can
close. That is the reason both exist. The imprint, terms and privacy pages
still have to be real, of course, and ShouldStayScriptFree fails the build if
a PLACEHOLDER marker ever reaches a rendered page again.
* Accepting crypto for goods does **not** make this shop a CASP under MiCA —
no custody for others, no transfer for third parties, so no licence is
required, with or without a processor. Cashing EURC out to the bank goes
@ -387,26 +523,34 @@ Mechanics worth knowing:
* VAT is unchanged by payment method: the sale is priced and invoiced in euro
and taxed on the euro value, whichever rail settled it.
### Live payment suites in CI
### Payment rail suites in CI
Every deploy runs both rails against their real counterparts, as a mandatory
gate — a push that cannot create a Mollie test payment or settle a real EURC
transfer on Ethereum Sepolia does not ship. That is a deliberate
caution-over-convenience trade: a public RPC hiccup can fail a deploy (re-run
the workflow), but the rail code can never drift from the live providers
unnoticed. The suites are `ShouldCreateMollieTestPayments` (create + poll on a
test-mode key; Mollie has no API to mark a test payment paid, so the paid flip
stays with the fake-rail suite and the pre-launch click-through) and
`ShouldSettleEurcOnTestnet` (a €1 donation paid with 1 real testnet EURC,
settled by the same two-endpoint quorum as mainnet). Locally both skip unless
their variable is exported; in CI a missing secret is a failure, checked as
the workflow's first step.
Every deploy exercises both rails end to end, as a mandatory gate. What differs
between them is whether a real counterparty is involved, and that difference is
a fact about the rails rather than a difference in standard.
Two Forgejo secrets feed them (repo → Settings → Actions → Secrets):
**`ShouldSettleBankTransfers` needs nothing and always runs.** A self-hosted
rail has no provider to authenticate to, so there is nothing to call and no
secret to hold. The suite drives the REAL transfer rail — not a stand-in — and
plays the part of the bank by writing a credits line, the same file
`--pull-credits` fills in production. It covers the whole lifecycle: the order
page's account details, the reference in all the mangled forms a payer might
type, partial payments accumulating, an outgoing amount being ignored, and the
reconciler flipping the order to paid with the right `via`.
**`ShouldSettleEurcOnTestnet` is the one live-counterparty suite**: a €1
donation paid with 1 real testnet EURC on Ethereum Sepolia, settled by the same
two-endpoint quorum as mainnet. It is a deliberate caution-over-convenience
trade — a public RPC hiccup can fail a deploy (re-run the workflow), but the
rail code can never drift from the chain unnoticed. Locally it skips unless
`EURC_E2E_PRIVATE_KEY` is exported; in CI a missing secret is a failure,
checked as the workflow's first step.
There used to be a second live suite, against the hosted bank provider's
test-mode API. It went when the account did, on 2026-08-20.
One Forgejo secret feeds this (repo → Settings → Actions → Secrets):
* `MOLLIE_TEST_API_KEY` — the `test_` key from the Mollie dashboard
(Developers → API keys). The suite refuses anything not starting `test_`,
so the live key can never be pasted in by mistake without the gate saying so.
* `EURC_E2E_PRIVATE_KEY` — a throwaway Ethereum Sepolia key
(`cast wallet new`), used ONLY for this: it holds worthless testnet tokens,
never mainnet funds. Fund it with testnet EURC at
@ -429,6 +573,36 @@ live once from a dev shell:
EURC_E2E_PRIVATE_KEY=0x... crafter-build test ShouldSettleEurcOnTestnet --product=server
```
## Opening the shop: what is owed before the status flips
Launch is one line in `Catcrafts.Shared-Content.cppm` (`p.status` from
`coming-soon` to `available`), and the comment above that line repeats this
list. Both items are invisible today because the only purchasable thing is the
donation, which ships nothing and is a gift rather than a purchase.
1. **The withdrawal button (herroepingsknop) — legally required, currently
absent.** Since **19 June 2026**, art. 11a of the Consumer Rights Directive,
implemented as **art. 6:230oa BW**, requires a clearly labelled button in the
online interface by which a consumer can withdraw from a distance contract.
Pointing them at an email address, which is what the terms page does today,
is no longer sufficient on its own. The button must stay available for the
whole withdrawal period, ask only for what identifies the order, **not**
require creating an account, and confirm immediately on a durable medium.
**Sanction for not having one: the withdrawal period extends from 14 days to
twelve months**, so every EU phone sale stays unwindable for a year.
The order page is the natural home, since it already identifies exactly one
order through an unguessable link and therefore needs no login. Sketch:
`POST /order/<token>/withdraw`, a button rendered while the window is open
on orders where the right applies, a `withdrawn` event in the ledger, and the
confirmation through the existing mailer.
Note this is easy to miss because the directive that introduced it is
otherwise about financial services; the ACM states it applies to webshops.
Worth confirming with a lawyer in the same pass as anything else.
2. **The shipping rate table.** Covered in the next section: with no
`SENDCLOUD_*` credentials and no cache file there is no table, and checkout
refuses every order that ships.
## Shipping rates: Sendcloud (REQUIRED to sell)
Sendcloud is the only source of shipping prices. There is no compiled-in

View file

@ -75,7 +75,7 @@ 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 BANK rail
# TRANSFER_IBAN / TRANSFER_BENEFICIARY / TRANSFER_BIC — the BANK rail
# EURC_CHAINS=/etc/catcrafts/eurc-chains.json — the CRYPTO rail (self-hosted
# EURC; no key). Omit and checkout offers only bank.
# The address pool defaults to