This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
256
deploy/README.md
256
deploy/README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue