This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
|
|
@ -16,32 +16,28 @@ jobs:
|
|||
runs-on: arch-latest
|
||||
steps:
|
||||
- name: Check live payment test credentials
|
||||
# The live-provider suites (ShouldCreateMollieTestPayments,
|
||||
# ShouldSettleEurcOnTestnet) are mandatory deploy gates, so a missing
|
||||
# or malformed secret fails HERE, before any build time is spent —
|
||||
# never as a silent skip. Secrets live in Forgejo: repo Settings ->
|
||||
# Actions -> Secrets. See deploy/README.md "Live payment suites in CI"
|
||||
# for what each one is and how to fund the testnet wallet.
|
||||
# ShouldSettleEurcOnTestnet is a mandatory deploy gate, so a missing
|
||||
# secret fails HERE, before any build time is spent — never as a silent
|
||||
# skip. The secret lives in Forgejo: repo Settings -> Actions ->
|
||||
# Secrets. See deploy/README.md "Live payment suites in CI" for what it
|
||||
# is and how to keep the testnet wallet funded.
|
||||
#
|
||||
# There is ONE live suite now, not two. The bank half used to be a
|
||||
# hosted provider with a test-mode API, and that gate died with the
|
||||
# account on 2026-08-20. Its replacement is a transfer to our own
|
||||
# account, which has no counterpart to call and therefore no credential
|
||||
# to check: ShouldSettleBankTransfers drives the real rail with no
|
||||
# network at all and runs unconditionally with the other black-box
|
||||
# suites. Losing a live gate here is a fact about the rail, not a
|
||||
# weakening of the standard.
|
||||
env:
|
||||
MOLLIE_TEST_API_KEY: ${{ secrets.MOLLIE_TEST_API_KEY }}
|
||||
EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
ok=1
|
||||
if [ -z "${MOLLIE_TEST_API_KEY:-}" ]; then
|
||||
echo "ERROR: MOLLIE_TEST_API_KEY secret is not set (Mollie dashboard -> Developers -> API keys, the test_ one)." >&2
|
||||
ok=0
|
||||
else
|
||||
case "$MOLLIE_TEST_API_KEY" in
|
||||
test_*) ;;
|
||||
*) echo "ERROR: MOLLIE_TEST_API_KEY is not a test_ key — the suite refuses to create payments on a live key." >&2; ok=0 ;;
|
||||
esac
|
||||
fi
|
||||
if [ -z "${EURC_E2E_PRIVATE_KEY:-}" ]; then
|
||||
echo "ERROR: EURC_E2E_PRIVATE_KEY secret is not set (a Base Sepolia key holding testnet EURC + gas)." >&2
|
||||
ok=0
|
||||
echo "ERROR: EURC_E2E_PRIVATE_KEY secret is not set (an Ethereum Sepolia key holding testnet EURC + gas)." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$ok" = 1 ] || exit 1
|
||||
echo "Live payment test credentials are present."
|
||||
|
||||
- name: Install build dependencies
|
||||
|
|
@ -186,18 +182,18 @@ jobs:
|
|||
# E2E_MEDIA_DIR points ShouldServePostPages at the mirror mount, where
|
||||
# the media files actually are.
|
||||
#
|
||||
# The two payment secrets feed the live-provider suites, which run as
|
||||
# part of this same gate: a deploy that cannot create a Mollie test
|
||||
# payment or settle a testnet EURC transfer does not ship. If the
|
||||
# EURC suite fails at `cast send`, the testnet wallet is empty — see
|
||||
# deploy/README.md "Live payment suites in CI" for the faucets.
|
||||
# EURC_E2E_PRIVATE_KEY feeds the live crypto suite, which runs as part
|
||||
# of this same gate: a deploy that cannot settle a testnet EURC
|
||||
# transfer does not ship. If it fails at `cast send`, the testnet
|
||||
# wallet is empty — see deploy/README.md "Live payment suites in CI"
|
||||
# for the faucets. The bank rail's own suite needs no secret; it drives
|
||||
# the real rail with a credits file instead of a provider.
|
||||
#
|
||||
# Same refuse-to-guess rule as the wasm bundle below: a variant
|
||||
# directory embeds a config hash, so more than one match means the tree
|
||||
# is ambiguous and picking the first would deploy an arbitrary build.
|
||||
env:
|
||||
E2E_MEDIA_DIR: ${{ steps.media.outputs.dir }}
|
||||
MOLLIE_TEST_API_KEY: ${{ secrets.MOLLIE_TEST_API_KEY }}
|
||||
EURC_E2E_PRIVATE_KEY: ${{ secrets.EURC_E2E_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
61
project.cpp
61
project.cpp
|
|
@ -87,7 +87,7 @@ static Configuration* SharedLibrary(std::span<const std::string_view> args) {
|
|||
// Catcrafts.ServerCore — every implementation unit of the server except
|
||||
// main.cpp, as a static library. The split exists for the tests: a test can
|
||||
// only LINK a library-type dependency (crafter-build exports -L/-l for
|
||||
// libraries alone), and the Mollie/EURC/Sendcloud parsers and the invoice
|
||||
// libraries alone), and the bunq/EURC/Sendcloud parsers and the invoice
|
||||
// builder all live here. The exe is main.cpp plus this library, so the
|
||||
// split costs nothing at deploy time.
|
||||
//
|
||||
|
|
@ -108,24 +108,29 @@ static Configuration* ServerCore(std::span<const std::string_view> args, Configu
|
|||
std::array<fs::path, 1> ifaces = {
|
||||
"server/interfaces/Catcrafts.Server",
|
||||
};
|
||||
std::array<fs::path, 8> impls = {
|
||||
std::array<fs::path, 10> impls = {
|
||||
"server/implementations/Catcrafts.Server-Http",
|
||||
"server/implementations/Catcrafts.Server-Orders",
|
||||
"server/implementations/Catcrafts.Server-Mollie",
|
||||
"server/implementations/Catcrafts.Server-Rails",
|
||||
"server/implementations/Catcrafts.Server-Invoice",
|
||||
"server/implementations/Catcrafts.Server-Eurc",
|
||||
"server/implementations/Catcrafts.Server-Transfer",
|
||||
"server/implementations/Catcrafts.Server-Bunq",
|
||||
"server/implementations/Catcrafts.Server-Shipping",
|
||||
"server/implementations/Catcrafts.Server-Mail",
|
||||
"server/implementations/Catcrafts.Server-Financials",
|
||||
};
|
||||
core->GetInterfacesAndImplementations(ifaces, impls);
|
||||
|
||||
// Both rails reach their provider over TLS, which is what libssl is for
|
||||
// here; libcrypto arrives as its dependency. (It was named explicitly
|
||||
// while the bunq callback verified RSA body signatures through EVP — that
|
||||
// integration is retired.) On the lib rather than the exe because link
|
||||
// flags propagate to consumers — the exe and every test get them from here.
|
||||
// The rails reach the outside world over TLS, which is what libssl is for.
|
||||
// libcrypto is named EXPLICITLY again because the bunq credit source signs
|
||||
// every request body with RSA-SHA256 through EVP: relying on it arriving as
|
||||
// libssl's transitive dependency worked while nothing called EVP directly,
|
||||
// and would break confusingly the moment a linker ordered them otherwise.
|
||||
// On the lib rather than the exe because link flags propagate to consumers
|
||||
// — the exe and every test get them from here.
|
||||
core->linkFlags.push_back("-lssl");
|
||||
core->linkFlags.push_back("-lcrypto");
|
||||
return core.get();
|
||||
}
|
||||
|
||||
|
|
@ -223,13 +228,14 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
|
||||
cfg.AddTest("ShouldParseSendcloudRates").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldMintOrderTokens").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldParseMolliePayments").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldParseEurcChains").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldBuildInvoices").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldPublishFinancials").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldFoldTheOrderLedger").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldIssueEurcAddresses").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldMatchBankTransfers").Dependencies({ core, shared });
|
||||
cfg.AddTest("ShouldParseBunqPayments").Dependencies({ core, shared });
|
||||
|
||||
// ── black-box suites (the tools/e2e.sh port) ──────────────────
|
||||
// Each spawns the REAL binary — depending on &cfg is what builds it
|
||||
|
|
@ -268,21 +274,30 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
.Requires("tool:gpg")
|
||||
.Timeout(std::chrono::seconds(180));
|
||||
|
||||
// ── live-provider suites ──────────────────────────────────────
|
||||
// The two payment rails against their REAL counterparts: Mollie's
|
||||
// API on a test-mode key, and the EURC rail settling an actual
|
||||
// on-chain transfer on Base Sepolia. Both are MANDATORY deploy
|
||||
// gates in CI (the workflow supplies MOLLIE_TEST_API_KEY and
|
||||
// EURC_E2E_PRIVATE_KEY, and the suites fail there if either is
|
||||
// missing); locally they skip unless those variables are exported.
|
||||
// That gate lives in the suites themselves rather than in a
|
||||
// Requires() — a Requires would skip silently when the credential
|
||||
// vanished, which is exactly the pass this gate must never give.
|
||||
// Timeouts cover real network: Mollie is a few round trips; the
|
||||
// EURC suite waits out block inclusion plus the rail's 30 s poll.
|
||||
cfg.AddTest("ShouldCreateMollieTestPayments", harness)
|
||||
// The bank rail's full lifecycle against the REAL transfer rail —
|
||||
// not a stand-in. It needs no credential and no network, because a
|
||||
// self-hosted rail has no provider to authenticate to: the rail reads
|
||||
// its credits from a file, so the suite can play the part of the bank
|
||||
// by writing one. Every step in between is production code. It waits
|
||||
// out the reconciler, hence the raised timeout.
|
||||
cfg.AddTest("ShouldSettleBankTransfers", harness)
|
||||
.Dependencies({ &cfg, shared, network }).Args({ serverBin })
|
||||
.Timeout(std::chrono::seconds(120));
|
||||
.Timeout(std::chrono::seconds(180));
|
||||
|
||||
// ── live-provider suite ───────────────────────────────────────
|
||||
// The EURC rail settling an actual on-chain transfer on a testnet: a
|
||||
// MANDATORY deploy gate in CI (the workflow supplies
|
||||
// EURC_E2E_PRIVATE_KEY and the suite FAILS there if it is missing);
|
||||
// locally it skips unless that variable is exported. The gate lives in
|
||||
// the suite itself rather than in a Requires() — a Requires would skip
|
||||
// silently when the credential vanished, which is exactly the pass
|
||||
// this gate must never give.
|
||||
//
|
||||
// It is the only live suite now. There were two, until the hosted bank
|
||||
// provider closed the account on 2026-08-20; the bank rail that
|
||||
// replaced it has no counterpart to call, which is why its coverage
|
||||
// sits above with the ordinary black-box suites instead. The timeout
|
||||
// waits out block inclusion plus the rail's own poll.
|
||||
cfg.AddTest("ShouldSettleEurcOnTestnet", harness)
|
||||
.Dependencies({ &cfg, shared, network }).Args({ serverBin })
|
||||
.Timeout(std::chrono::seconds(300));
|
||||
|
|
|
|||
598
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
598
server/implementations/Catcrafts.Server-Bunq.cpp
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
/*
|
||||
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 bunq credit source: reads the shop's OWN bank account and reports the
|
||||
// incoming credits, which is all the transfer rail needs to settle an order.
|
||||
//
|
||||
// This is a READER. It never moves money, and that asymmetry is the only
|
||||
// reason it can exist at all — see the key-policy note below, which is the
|
||||
// most important thing in this file.
|
||||
//
|
||||
// Descended from the bunq PAYMENT rail this repo carried until 2026-08-14
|
||||
// (deleted in 70668af): the four-call onboarding dance, the RSA body signing
|
||||
// and the context file are that code, kept because they were already proven
|
||||
// against the live API. What changed is the question asked at the end. The old
|
||||
// rail created bunq.me tabs and polled them; that route is closed to a webshop
|
||||
// on volume grounds (a €1,500/week ceiling across all of bunq.me, and no way
|
||||
// to disable its 2.5% card path), so this one reads the account's payment list
|
||||
// instead and lets the reference do the matching.
|
||||
//
|
||||
// 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) -> which account to read
|
||||
// payment (per poll) -> the credits
|
||||
//
|
||||
// State persists in ONE json file so the first four never repeat: bunq allows
|
||||
// as few as TEN calls PER DAY to the setup endpoints, so a client that
|
||||
// re-onboarded on every start would lock itself out by lunchtime. Delete the
|
||||
// file and it re-onboards from the API key.
|
||||
//
|
||||
// ── THE KEY POLICY, which this file cannot enforce on its own ─────────
|
||||
//
|
||||
// A bunq API key can INITIATE PAYMENTS. bunq offers no read-only scope, so
|
||||
// there is no such thing as a key that can only do what this file does. The
|
||||
// standing rule for this project is therefore that the bunq key does NOT live
|
||||
// on the internet-facing box.
|
||||
//
|
||||
// That rule and this code are compatible, but only in one deployment shape:
|
||||
// run this as `catcrafts-server --pull-credits` on a trusted machine, on a
|
||||
// timer, and ship the resulting credits file to the server, which reads it
|
||||
// with the file source and holds no key at all. The server then cannot be made
|
||||
// to move money even if it is fully compromised.
|
||||
//
|
||||
// Configuring BUNQ_API_KEY on the server itself also works and is one less
|
||||
// moving part, but it puts a payment-capable credential on a public host. The
|
||||
// startup path says so out loud rather than letting it pass unnoticed.
|
||||
|
||||
module;
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string Base64B(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 EscB(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 RandomHexB(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;
|
||||
}
|
||||
|
||||
// bunq wraps everything: {"Response":[{"Id":{…}},{"Token":{…}}]}. Find the
|
||||
// first object under `key` anywhere in that array.
|
||||
const Json::Value* InResponse(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> ParseSignedAmountToMinor(std::string_view s) {
|
||||
// bunq quotes an OUTGOING payment as a negative value ("-25.00"), and the
|
||||
// shared money parser rejects a sign by design — it guards amounts we
|
||||
// choose, where a minus would be nonsense. Here the sign is information:
|
||||
// it is what separates a customer paying us from us paying a supplier, and
|
||||
// dropping it would let a refund look like income. So the sign is peeled
|
||||
// off here and the magnitude goes through the strict parser unchanged.
|
||||
bool negative = false;
|
||||
if (!s.empty() && (s.front() == '-' || s.front() == '+')) {
|
||||
negative = s.front() == '-';
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
const std::optional<std::int64_t> magnitude = ParseAmountToMinor(s);
|
||||
if (!magnitude) return std::nullopt;
|
||||
return negative ? -*magnitude : *magnitude;
|
||||
}
|
||||
|
||||
std::string_view BunqMethodFor(std::string_view paymentType) {
|
||||
// bunq's Payment.type, mapped to the ledger's `via` vocabulary. The
|
||||
// distinction earns its keep at dispatch time: a SEPA credit transfer is
|
||||
// final, while a card payment can be reversed for months, so these are not
|
||||
// interchangeable labels for "money arrived".
|
||||
if (paymentType == "EBA_SCT") return "sepa";
|
||||
if (paymentType == "IDEAL") return "ideal";
|
||||
if (paymentType == "FIS") return "card";
|
||||
if (paymentType == "BUNQ") return "bunq";
|
||||
if (paymentType == "SWIFT") return "swift";
|
||||
if (paymentType == "EBA_SDD") return "directdebit";
|
||||
// An unknown type still settles — the money is on the account either way —
|
||||
// but it reaches the ledger verbatim so the `via` column shows what bunq
|
||||
// actually said instead of a comfortable guess.
|
||||
return paymentType.empty() ? std::string_view("bank") : paymentType;
|
||||
}
|
||||
|
||||
std::vector<BankCredit> ParseBunqPayments(std::string_view json) {
|
||||
std::vector<BankCredit> out;
|
||||
const auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return out;
|
||||
const Json::Value* resp = doc->Find("Response");
|
||||
if (!resp || !resp->IsArray()) return out;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
if (!item.IsObject()) continue;
|
||||
const Json::Value* p = item.Find("Payment");
|
||||
if (!p || !p->IsObject()) continue;
|
||||
const Json::Value* amount = p->Find("amount");
|
||||
if (!amount || !amount->IsObject()) continue;
|
||||
// Only euro amounts can pay a euro order. A foreign-currency credit is
|
||||
// skipped rather than counted at face value, which would silently
|
||||
// treat 25 of something else as 25 euro.
|
||||
if (amount->Str("currency") != "EUR") continue;
|
||||
const std::optional<std::int64_t> minor =
|
||||
ParseSignedAmountToMinor(amount->Str("value"));
|
||||
if (!minor) continue;
|
||||
BankCredit c;
|
||||
c.id = std::format("{}", p->Int("id"));
|
||||
c.reference = std::string(p->Str("description"));
|
||||
c.amountMinor = *minor;
|
||||
c.method = std::string(BunqMethodFor(p->Str("type")));
|
||||
out.push_back(std::move(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class BunqCreditSource final : public CreditSource {
|
||||
public:
|
||||
BunqCreditSource(std::string apiKey, std::filesystem::path statePath,
|
||||
std::string permittedIps, int count, std::string wantIban)
|
||||
: apiKey_(std::move(apiKey)), statePath_(std::move(statePath)),
|
||||
permittedIps_(std::move(permittedIps)), count_(count),
|
||||
wantIban_(std::move(wantIban)) {}
|
||||
|
||||
std::optional<std::vector<BankCredit>> Recent() override {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
// One page is enough and more would be worse: the reconciler only ever
|
||||
// asks about orders inside their payment window, so a credit old enough
|
||||
// to fall off this page is old enough to be settled by hand anyway.
|
||||
// Paging the whole account history every minute would also spend the
|
||||
// per-method rate limit on data nothing reads.
|
||||
const auto doc = Call("GET", std::format(
|
||||
"/v1/user/{}/monetary-account/{}/payment?count={}", userId_, accountId_,
|
||||
count_), {});
|
||||
if (!doc) return std::nullopt;
|
||||
return ParseBunqPayments(raw_);
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "bunq"; }
|
||||
|
||||
private:
|
||||
// ── state, so onboarding happens once ─────────────────────────────
|
||||
|
||||
void LoadState() {
|
||||
std::ifstream in(statePath_, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
const 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() {
|
||||
std::ofstream out(statePath_, std::ios::trunc | std::ios::binary);
|
||||
if (!out) {
|
||||
std::println(std::cerr, "bunq: cannot write context {}", statePath_.string());
|
||||
return false;
|
||||
}
|
||||
out << std::format(
|
||||
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
||||
R"("device_registered":{},"session_token":"{}","user_id":{},)"
|
||||
R"("account_id":{}}})",
|
||||
EscB(privateKeyPem_), EscB(installationToken_), deviceRegistered_,
|
||||
EscB(sessionToken_), userId_, accountId_);
|
||||
out.flush();
|
||||
// The file holds a private key. Narrow it even though the directory
|
||||
// should already be private: defence in depth costs one syscall.
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(statePath_,
|
||||
std::filesystem::perms::owner_read
|
||||
| std::filesystem::perms::owner_write,
|
||||
ec);
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
// ── the keypair bunq's installation call demands ──────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// bunq stopped REQUIRING body signatures years ago, but the keypair has to
|
||||
// exist for installation anyway and a signed request is valid whether or
|
||||
// not the server checks, so every body is signed.
|
||||
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 = Base64B(sig);
|
||||
} while (false);
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── transport ─────────────────────────────────────────────────────
|
||||
|
||||
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>(
|
||||
kHost, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = kHost;
|
||||
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"] = RandomHexB(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') {
|
||||
// 429 gets named, because the cure is different from every
|
||||
// other failure: bunq allows only a few reads per second per
|
||||
// method, and setup endpoints as few as ten per DAY.
|
||||
if (res.status == "429") {
|
||||
std::println(std::cerr,
|
||||
"bunq: {} {} -> 429 rate limited; reads are capped at "
|
||||
"a few per second and setup calls at ~10/day, so back "
|
||||
"off rather than retrying in a loop", method, path);
|
||||
} else {
|
||||
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path,
|
||||
res.status, res.body.substr(0, 200));
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
// Kept so the caller can re-parse into domain types without this
|
||||
// layer knowing about them.
|
||||
raw_ = res.body;
|
||||
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 call, with ONE automatic re-session on 401. Sessions expire
|
||||
// server-side on a schedule the bunq app controls, so expiry is routine
|
||||
// and must not surface as a payment 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, each step at most once ────────────────────────────
|
||||
|
||||
bool EnsureSession() {
|
||||
if (!loaded_) { LoadState(); loaded_ = true; }
|
||||
if (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":"{}"}})",
|
||||
EscB(PublicKeyPem()));
|
||||
const auto doc = DoCall("POST", "/v1/installation", body, {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = InResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
installationToken_ = std::string(token->Str("token"));
|
||||
if (installationToken_.empty()) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (!deviceRegistered_) {
|
||||
// permitted_ips decides what a leaked key is worth. bunq has no
|
||||
// read-only scope, so this key can move money: pinning it to the
|
||||
// one address that should ever use it is the difference between a
|
||||
// leak being survivable and being catastrophic. "*" is accepted
|
||||
// but announced, because silently unpinning a payment-capable
|
||||
// credential is exactly the kind of default nobody revisits.
|
||||
if (permittedIps_ == "*") {
|
||||
std::println(std::cerr,
|
||||
"bunq: registering this device with permitted_ips=* — a "
|
||||
"leaked key would then work from anywhere. Set "
|
||||
"BUNQ_PERMITTED_IPS to this machine's egress address to "
|
||||
"pin it (the registration is once-only, so changing it "
|
||||
"later means deleting the context file).");
|
||||
}
|
||||
std::string ipList;
|
||||
for (const auto part : std::views::split(permittedIps_, ',')) {
|
||||
const std::string_view ip(part.begin(), part.end());
|
||||
if (ip.empty()) continue;
|
||||
if (!ipList.empty()) ipList += ",";
|
||||
ipList += std::format("\"{}\"", EscB(ip));
|
||||
}
|
||||
if (ipList.empty()) ipList = "\"*\"";
|
||||
const std::string body = std::format(
|
||||
R"({{"description":"catcrafts.net credit reader","secret":"{}",)"
|
||||
R"("permitted_ips":[{}]}})", EscB(apiKey_), ipList);
|
||||
const 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":"{}"}})", EscB(apiKey_));
|
||||
const auto doc = DoCall("POST", "/v1/session-server", body,
|
||||
installationToken_);
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = InResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
sessionToken_ = std::string(token->Str("token"));
|
||||
// Which user object comes back depends on the account type, so try
|
||||
// each rather than assuming this is a company account.
|
||||
for (const std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
||||
if (const Json::Value* u = InResponse(*doc, k)) {
|
||||
userId_ = u->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionToken_.empty() || userId_ == 0) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (accountId_ == 0) {
|
||||
const 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;
|
||||
// With more than one account, guessing is how the shop ends up
|
||||
// reconciling against savings. Name the wanted one by IBAN when
|
||||
// there is a choice, and refuse rather than pick.
|
||||
std::vector<std::pair<std::int64_t, std::string>> active;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
||||
if (!acc || !acc->IsObject()) continue;
|
||||
if (acc->Str("status") != "ACTIVE") continue;
|
||||
std::string iban;
|
||||
if (const Json::Value* aliases = acc->Find("alias");
|
||||
aliases && aliases->IsArray()) {
|
||||
for (const Json::Value& a : aliases->array) {
|
||||
if (a.IsObject() && a.Str("type") == "IBAN") {
|
||||
iban = std::string(a.Str("value"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
active.emplace_back(acc->Int("id"), std::move(iban));
|
||||
}
|
||||
if (active.empty()) {
|
||||
std::println(std::cerr, "bunq: no active account found");
|
||||
return false;
|
||||
}
|
||||
if (!wantIban_.empty()) {
|
||||
for (const auto& [id, iban] : active) {
|
||||
if (Matches(iban, wantIban_)) { accountId_ = id; break; }
|
||||
}
|
||||
if (accountId_ == 0) {
|
||||
std::println(std::cerr,
|
||||
"bunq: no active account matches TRANSFER_IBAN; the "
|
||||
"key sees {} account(s)", active.size());
|
||||
return false;
|
||||
}
|
||||
} else if (active.size() == 1) {
|
||||
accountId_ = active.front().first;
|
||||
} else {
|
||||
std::println(std::cerr,
|
||||
"bunq: this key sees {} active accounts and no "
|
||||
"TRANSFER_IBAN was given to choose between them — "
|
||||
"refusing to guess which one the shop is paid into",
|
||||
active.size());
|
||||
return false;
|
||||
}
|
||||
SaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// IBANs are compared ignoring spacing and case: what a human pastes into
|
||||
// configuration and what the API returns differ cosmetically far more
|
||||
// often than they differ in substance.
|
||||
static bool Matches(std::string_view a, std::string_view b) {
|
||||
const auto fold = [](std::string_view s) {
|
||||
std::string out;
|
||||
for (const char c : s) {
|
||||
if (c == ' ' || c == '\t') continue;
|
||||
out += static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return !a.empty() && fold(a) == fold(b);
|
||||
}
|
||||
|
||||
struct PkeyDeleter {
|
||||
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||
};
|
||||
|
||||
static constexpr const char* kHost = "api.bunq.com";
|
||||
|
||||
std::string apiKey_;
|
||||
std::filesystem::path statePath_;
|
||||
std::string permittedIps_;
|
||||
int count_ = 50;
|
||||
std::string wantIban_;
|
||||
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
||||
std::string raw_;
|
||||
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
|
||||
|
||||
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config) {
|
||||
if (config.apiKey.empty()) return nullptr;
|
||||
if (config.statePath.empty()) {
|
||||
std::println(std::cerr, "bunq: no context path given");
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_unique<BunqCreditSource>(
|
||||
config.apiKey, config.statePath,
|
||||
config.permittedIps.empty() ? std::string("*") : config.permittedIps,
|
||||
config.count > 0 ? config.count : 50, config.iban);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
@ -203,7 +203,7 @@ std::optional<std::int64_t> Pow10(int n) {
|
|||
// so anything bigger is a broken or hostile node rather than a large balance,
|
||||
// and the one thing it must not do is satisfy the covering comparison.
|
||||
// Exported so the self-test can drive it with canned RPC bodies, the same way
|
||||
// ParseMolliePayment is driven — the HTTP around it is thin, the decoding is
|
||||
// ParseBunqPayments is driven — the HTTP around it is thin, the decoding is
|
||||
// where a mistake would cost money.
|
||||
// True when the reply carries exactly the numeric id we sent. Absent or
|
||||
// non-numeric is false: an answer that will not say which question it belongs
|
||||
|
|
@ -610,7 +610,7 @@ public:
|
|||
// chain — and the reconciler walks EVERY awaiting order per sweep,
|
||||
// each taking the same lock, while a real buyer's CreateLink (which
|
||||
// needs the mutex only to hand out a pool address, no network at all)
|
||||
// queued behind the whole procession. The Mollie side of this file's
|
||||
// queued behind the whole procession. The bank side of this file's
|
||||
// sibling had the identical incident; see the arrival-poll note in
|
||||
// Catcrafts.Server-Http.cpp.
|
||||
const std::optional<PayIdParts> parts = SplitPayId(payId);
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
const ShippingTable ship = CurrentShippingTable();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
|
||||
{}, {}, CryptoPaymentAvailable());
|
||||
{}, {}, CryptoPaymentAvailable(),
|
||||
BankPaymentAvailable());
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||
|
|
@ -266,7 +267,7 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
// 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.
|
||||
// paced by the bank rail'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())) {
|
||||
|
|
@ -314,6 +315,15 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
OrderCryptoPay pay;
|
||||
pay.address = instr->address;
|
||||
pay.amount = instr->amount;
|
||||
// Set only by bank-transfer rails, and what the renderer
|
||||
// switches on. The structured reference is derived from the
|
||||
// order token rather than carried by the rail, so the two
|
||||
// forms the page prints cannot disagree with each other.
|
||||
pay.beneficiary = instr->beneficiary;
|
||||
pay.bic = instr->bic;
|
||||
if (!instr->beneficiary.empty()) {
|
||||
pay.structuredReference = CreditorReferenceFromToken(order->token);
|
||||
}
|
||||
const std::int64_t now =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
|
@ -650,7 +660,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||
const Views::RenderedPage page = Views::RenderProduct(
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev,
|
||||
CryptoPaymentAvailable());
|
||||
CryptoPaymentAvailable(), BankPaymentAvailable());
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
|
|
@ -861,7 +871,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
// Straight to the payment page — the buyer clicked "buy", not "read an
|
||||
// interim status page". The order page stays the receipt/status URL that
|
||||
// Mollie redirects back to afterwards.
|
||||
// a hosted provider would redirect back to afterwards.
|
||||
res.status = "303";
|
||||
res.headers["location"] = order.payUrl;
|
||||
res.headers["cache-control"] = "no-store";
|
||||
|
|
@ -874,7 +884,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
// 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
|
||||
// buyer returning to this page 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,
|
||||
|
|
@ -882,7 +892,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
//
|
||||
// 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
|
||||
// live call against the shop's account, 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.
|
||||
|
|
@ -1056,6 +1066,8 @@ void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
|||
|
||||
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
||||
|
||||
bool BankPaymentAvailable() { return gRails.bank != nullptr; }
|
||||
|
||||
namespace {
|
||||
|
||||
// The reconciler: the ONLY thing that moves an order to paid.
|
||||
|
|
|
|||
|
|
@ -1,326 +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 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
|
||||
// a checkout). Mollie is a Dutch licensed PSP built for exactly this size of
|
||||
// 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 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 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.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string JsonEscapeM(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;
|
||||
}
|
||||
|
||||
} // 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;
|
||||
|
||||
MolliePayment p;
|
||||
p.id = std::string(doc->Str("id"));
|
||||
p.status = std::string(doc->Str("status"));
|
||||
p.method = std::string(doc->Str("method"));
|
||||
if (p.id.empty() || p.status.empty()) return std::nullopt;
|
||||
|
||||
if (const Json::Value* amount = doc->Find("amount"); amount && amount->IsObject()) {
|
||||
// Only euro amounts are ever created, so anything else failing to
|
||||
// parse to zero is the safe outcome — a zero amount never satisfies
|
||||
// an order total.
|
||||
if (amount->Str("currency") == "EUR") {
|
||||
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||
p.amountMinor = *minor;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (const Json::Value* links = doc->Find("_links"); links && links->IsObject()) {
|
||||
if (const Json::Value* checkout = links->Find("checkout");
|
||||
checkout && checkout->IsObject()) {
|
||||
p.checkoutUrl = std::string(checkout->Str("href"));
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
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;
|
||||
if (std::filesystem::exists(marker_, ec)) {
|
||||
return PaidStatus{ PayState::Paid, "fake" };
|
||||
}
|
||||
PaidStatus out;
|
||||
out.state = PayState::Pending;
|
||||
out.method = "fake";
|
||||
// "<marker>.seen" is the in-flight state: money visible on the
|
||||
// network, finality still pending. It exists so the e2e suite can
|
||||
// drive the order page's "your payment is on its way" notice the
|
||||
// same way the marker itself drives "paid".
|
||||
std::filesystem::path seenMarker = marker_;
|
||||
seenMarker += ".seen";
|
||||
out.seen = std::filesystem::exists(seenMarker, ec);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The crypto slot's fake renders payment INSTRUCTIONS, like the real
|
||||
// EURC rail, so the suites exercise the order page's self-hosted branch
|
||||
// (address, window, the in-flight notice) rather than the hosted button
|
||||
// that slot never shows in production. The bank fake keeps the button,
|
||||
// mirroring Mollie. Fixed values, so assertions can pin them.
|
||||
std::optional<PayInstructions> Instructions(const std::string&,
|
||||
std::int64_t totalMinor) const override {
|
||||
if (name_ != "fake-crypto" || totalMinor <= 0) return std::nullopt;
|
||||
PayInstructions out;
|
||||
out.address = "0x" + std::string(40, 'f');
|
||||
out.amount = Money::FormatMinor(totalMinor);
|
||||
out.deadlineUnix =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count()
|
||||
+ 24 * 3600;
|
||||
PayChainOption chain;
|
||||
chain.name = "fake-chain";
|
||||
chain.contract = "0x" + std::string(40, 'f');
|
||||
out.chains.push_back(std::move(chain));
|
||||
return out;
|
||||
}
|
||||
|
||||
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)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
const std::string body = std::format(
|
||||
R"({{"amount":{{"currency":"EUR","value":"{}"}},)"
|
||||
R"("description":"{}","redirectUrl":"{}"}})",
|
||||
Money::FormatMinor(amountMinor), JsonEscapeM(description),
|
||||
JsonEscapeM(redirectUrl));
|
||||
|
||||
const std::optional<std::string> res = Call("POST", "/v2/payments", body);
|
||||
if (!res) return std::nullopt;
|
||||
const auto payment = ParseMolliePayment(*res);
|
||||
if (!payment || payment->checkoutUrl.empty()) {
|
||||
std::println(std::cerr, "mollie: create returned no checkout url");
|
||||
return std::nullopt;
|
||||
}
|
||||
PaymentLink link;
|
||||
link.payId = payment->id;
|
||||
link.payUrl = payment->checkoutUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// The id came from Mollie, but it travels through our ledger — keep
|
||||
// the path composition strict anyway.
|
||||
for (const char c : payId) {
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '_';
|
||||
if (!ok) return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
|
||||
const std::optional<std::string> res = Call("GET", "/v2/payments/" + payId, {});
|
||||
if (!res) return std::nullopt;
|
||||
const auto payment = ParseMolliePayment(*res);
|
||||
if (!payment) return std::nullopt;
|
||||
|
||||
PaidStatus out;
|
||||
out.method = payment->method;
|
||||
if (payment->status == "paid" && payment->amountMinor >= expectedMinor) {
|
||||
out.state = PayState::Paid;
|
||||
} else if (payment->status == "canceled" || payment->status == "expired"
|
||||
|| payment->status == "failed") {
|
||||
out.state = PayState::Dead;
|
||||
} else {
|
||||
// open / pending / authorized — still in flight.
|
||||
out.state = PayState::Pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "mollie"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(10); }
|
||||
|
||||
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>(
|
||||
"api.mollie.com", static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = "api.mollie.com";
|
||||
req.body = body;
|
||||
req.headers["authorization"] = "Bearer " + cfg_.apiKey;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
if (!body.empty()) req.headers["content-type"] = "application/json";
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "mollie: {} {} -> {} {}", method, path,
|
||||
res.status, res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "mollie: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The roster has one home, here. 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> MakeRail(const RailConfig& 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);
|
||||
// "eurc" is the one mode that can fail to construct for a reason other than
|
||||
// a typo: its chains file or address pool may not load. It returns nullptr
|
||||
// there, which main reports as an unknown rail — see the note in main about
|
||||
// why that message names the files.
|
||||
if (config.mode == "eurc") return MakeEurcRail(config);
|
||||
return nullptr; // "off"
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
@ -359,4 +359,66 @@ std::string ReferenceFromToken(std::string_view token) {
|
|||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// ISO 7064 mod-97-10 over an alphanumeric string, the same arithmetic that
|
||||
// checks an IBAN: letters become two digits (A=10 … Z=35), everything is read
|
||||
// as one long decimal number, and the remainder mod 97 is taken. Folded
|
||||
// incrementally so no big-integer type is needed — the running value never
|
||||
// exceeds 97*100+35, which fits an int comfortably.
|
||||
//
|
||||
// Returns nullopt on any character that is not [0-9A-Z], because silently
|
||||
// skipping one would make two different references check out identically.
|
||||
std::optional<int> Mod97(std::string_view s) {
|
||||
int rem = 0;
|
||||
for (const char c : s) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
rem = (rem * 10 + (c - '0')) % 97;
|
||||
} else if (c >= 'A' && c <= 'Z') {
|
||||
const int v = c - 'A' + 10;
|
||||
rem = (rem * 100 + v) % 97;
|
||||
} else {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
return rem;
|
||||
}
|
||||
|
||||
// The body an RF reference carries: the CC- reference with its hyphen dropped,
|
||||
// because ISO 11649 permits only alphanumerics. "CC-2B6457" -> "CC2B6457".
|
||||
std::string ReferenceBody(std::string_view token) {
|
||||
const std::string human = ReferenceFromToken(token);
|
||||
std::string body;
|
||||
body.reserve(human.size());
|
||||
for (const char c : human) {
|
||||
if (c != '-') body += c;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string CreditorReferenceFromToken(std::string_view token) {
|
||||
const std::string body = ReferenceBody(token);
|
||||
// The check digits are computed over the body followed by "RF00" — the
|
||||
// standard's rearrangement, prefix and placeholder moved to the end.
|
||||
const std::optional<int> rem = Mod97(body + "RF00");
|
||||
if (!rem) return {}; // unreachable for our own token alphabet
|
||||
const int check = 98 - *rem;
|
||||
return std::format("RF{:02}{}", check, body);
|
||||
}
|
||||
|
||||
bool IsValidCreditorReference(std::string_view s) {
|
||||
// "RF" + 2 check digits + 1..21 body characters.
|
||||
if (s.size() < 5 || s.size() > 25) return false;
|
||||
if (s[0] != 'R' || s[1] != 'F') return false;
|
||||
if (s[2] < '0' || s[2] > '9' || s[3] < '0' || s[3] > '9') return false;
|
||||
// Rearranged the same way the generator does it, then the whole thing must
|
||||
// leave a remainder of exactly 1 — that is what mod-97-10 verification is.
|
||||
std::string rearranged(s.substr(4));
|
||||
rearranged += s.substr(0, 4);
|
||||
const std::optional<int> rem = Mod97(rearranged);
|
||||
return rem && *rem == 1;
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
|
|||
216
server/implementations/Catcrafts.Server-Rails.cpp
Normal file
216
server/implementations/Catcrafts.Server-Rails.cpp
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
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-rail roster, the fake rail the suites run on, and the one money
|
||||
// parser both real rails share.
|
||||
//
|
||||
// Every rail this shop has is SELF-HOSTED: a bank transfer to its own account,
|
||||
// or EURC to an address it generated itself. There is no hosted provider in the
|
||||
// payment path and no credential that a third party can revoke.
|
||||
//
|
||||
// That is a scar, not a philosophy. The shop ran on a hosted payment provider
|
||||
// until 2026-08-20, when that provider closed the account after a risk review,
|
||||
// with no appeal and no reason beyond "outside our acceptance criteria". Every
|
||||
// payment method died in one email: iDEAL, cards, the lot. Its rail
|
||||
// implementation was removed once the decision proved final — keeping a dead
|
||||
// integration alive costs a CI gate, a secret, and a steady trickle of
|
||||
// confusion about which rail is actually serving.
|
||||
//
|
||||
// What that history is worth remembering FOR: a hosted rail can be switched
|
||||
// off by someone else, and a self-hosted one cannot. The bank can still close
|
||||
// the account, because every euro has to land somewhere, but it cannot decline
|
||||
// a payment method while leaving the business running. That is the property
|
||||
// the two current rails were chosen for, and the reason not to trade it away
|
||||
// for convenience later.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
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.
|
||||
//
|
||||
// Shared because every amount that arrives from outside comes as a decimal
|
||||
// string: the bank quotes "57.38", and the crypto rail's own decoding
|
||||
// reduces to the same question. The sign is deliberately NOT accepted here
|
||||
// (see ParseSignedAmountToMinor, which peels it off first) so that a stray
|
||||
// minus can never quietly halve a total.
|
||||
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.
|
||||
//
|
||||
// It can stand in for EITHER slot, which is what lets the e2e suite drive the
|
||||
// bank and crypto paths through the same machinery: 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.
|
||||
//
|
||||
// Note what it is NOT for any more. Both live rails are self-hosted and render
|
||||
// instructions rather than a button, and each has its own black-box coverage
|
||||
// against the real implementation (ShouldSettleBankTransfers,
|
||||
// ShouldSettleEurcOnTestnet). So the fake rail's remaining job is the parts
|
||||
// that are about the SHOP rather than about a provider: the choice, the
|
||||
// ledger, the reconciler, and the hosted-button branch that no live rail takes
|
||||
// but the renderer still has to be able to draw.
|
||||
|
||||
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;
|
||||
if (std::filesystem::exists(marker_, ec)) {
|
||||
return PaidStatus{ PayState::Paid, "fake" };
|
||||
}
|
||||
PaidStatus out;
|
||||
out.state = PayState::Pending;
|
||||
out.method = "fake";
|
||||
// "<marker>.seen" is the in-flight state: money visible on the
|
||||
// network, finality still pending. It exists so the e2e suite can
|
||||
// drive the order page's "your payment is on its way" notice the
|
||||
// same way the marker itself drives "paid".
|
||||
std::filesystem::path seenMarker = marker_;
|
||||
seenMarker += ".seen";
|
||||
out.seen = std::filesystem::exists(seenMarker, ec);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The crypto slot's fake renders payment INSTRUCTIONS, like the real EURC
|
||||
// rail, so the suites exercise the order page's self-hosted branch
|
||||
// (address, window, the in-flight notice). The bank slot's fake keeps the
|
||||
// BUTTON on purpose: no live rail takes that branch any more, and without
|
||||
// one fake still drawing it, the renderer's hosted-payment path would go
|
||||
// completely uncovered. Fixed values, so assertions can pin them.
|
||||
std::optional<PayInstructions> Instructions(const std::string&,
|
||||
std::int64_t totalMinor) const override {
|
||||
if (name_ != "fake-crypto" || totalMinor <= 0) return std::nullopt;
|
||||
PayInstructions out;
|
||||
out.address = "0x" + std::string(40, 'f');
|
||||
out.amount = Money::FormatMinor(totalMinor);
|
||||
out.deadlineUnix =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count()
|
||||
+ 24 * 3600;
|
||||
PayChainOption chain;
|
||||
chain.name = "fake-chain";
|
||||
chain.contract = "0x" + std::string(40, 'f');
|
||||
out.chains.push_back(std::move(chain));
|
||||
return out;
|
||||
}
|
||||
|
||||
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_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The roster has one home, here. 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> MakeRail(const RailConfig& 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");
|
||||
}
|
||||
// The two live rails. Both can fail to construct for a reason other than a
|
||||
// typo — a missing IBAN or beneficiary name, a chains file or address pool
|
||||
// that will not load — and both return nullptr there, which main reports
|
||||
// as a rail that could not load rather than as an unknown one.
|
||||
if (config.mode == "transfer") {
|
||||
// Which credit source depends on where the operator chose to keep the
|
||||
// bank key. With BUNQ_API_KEY set here, this process reads the account
|
||||
// itself — simpler, and strictly worse, because that key can move
|
||||
// money and this process is reachable from the internet. Without it,
|
||||
// the rail reads the credits file that `--pull-credits` fills from a
|
||||
// trusted machine, and this host holds nothing that can spend.
|
||||
std::unique_ptr<CreditSource> credits;
|
||||
if (const char* key = std::getenv("BUNQ_API_KEY"); key && *key) {
|
||||
std::println(std::cerr,
|
||||
"transfer: WARNING — reading the bank directly with "
|
||||
"BUNQ_API_KEY present in this process. A bunq key can "
|
||||
"initiate payments and bunq has no read-only scope, so this "
|
||||
"host now holds a credential that can spend the account. The "
|
||||
"intended shape is `--pull-credits` on a trusted machine "
|
||||
"writing the credits file this rail reads.");
|
||||
BunqConfig bunq;
|
||||
bunq.apiKey = key;
|
||||
bunq.iban = config.transferIban;
|
||||
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) {
|
||||
bunq.permittedIps = v;
|
||||
}
|
||||
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
|
||||
bunq.statePath = v;
|
||||
} else {
|
||||
bunq.statePath = config.transferCreditsPath;
|
||||
bunq.statePath += ".bunq-context.json";
|
||||
}
|
||||
credits = MakeBunqCreditSource(bunq);
|
||||
if (!credits) return nullptr;
|
||||
} else {
|
||||
credits = MakeFileCreditSource(config.transferCreditsPath);
|
||||
}
|
||||
return MakeTransferRail(config, std::move(credits));
|
||||
}
|
||||
if (config.mode == "eurc") return MakeEurcRail(config);
|
||||
return nullptr; // "off"
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
448
server/implementations/Catcrafts.Server-Transfer.cpp
Normal file
448
server/implementations/Catcrafts.Server-Transfer.cpp
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
/*
|
||||
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 bank-transfer rail: the buyer sends a plain SEPA transfer to our own
|
||||
// IBAN quoting the order's creditor reference, and the order settles when a
|
||||
// credit carrying that reference shows up on the account.
|
||||
//
|
||||
// Why this rail exists at all: after the bank payment provider closed the
|
||||
// shop's account on 2026-08-20 with no appeal, the lesson taken was not "find
|
||||
// a better provider" but "stop putting a party with unilateral offboarding
|
||||
// power in the payment path". A transfer to our own account has no such party.
|
||||
// The bank can still close the account — that is unavoidable, every euro has
|
||||
// to land somewhere — but it cannot decline a payment method while leaving the
|
||||
// business running, which is what actually happened.
|
||||
//
|
||||
// Shape: self-hosted, like the EURC rail and unlike the hosted ones. There is
|
||||
// no checkout to redirect to, so CreateLink makes no network call at all and
|
||||
// Instructions() is what the buyer actually acts on. Three facts go on the
|
||||
// order page — IBAN, amount, reference — and the beneficiary name, which is
|
||||
// load-bearing rather than decorative (see PayInstructions).
|
||||
//
|
||||
// Trust direction is the same rule as every other rail: an order becomes paid
|
||||
// only when the rail's own authenticated read of the account says a covering
|
||||
// credit arrived. Nothing the buyer tells us is evidence, including "I paid".
|
||||
//
|
||||
// The bank is behind a CreditSource so it is swappable. That is a deliberate
|
||||
// hedge and not speculative generality: the account being reconciled is also
|
||||
// the shop's own bank account, so the day that relationship ends, the rail
|
||||
// must survive with one new adapter rather than a rewrite.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// Reduce remittance text to the alphabet a reference lives in: upper-case
|
||||
// letters and digits, everything else dropped. Banks are free to reformat the
|
||||
// field — spacing, punctuation, case — and a payer types it by hand, so
|
||||
// comparing raw strings would fail on cosmetics. Dropping separators is also
|
||||
// what makes one needle match every form the payer might have used.
|
||||
std::string Fold(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (const char c : s) {
|
||||
if (c >= '0' && c <= '9') out += c;
|
||||
else if (c >= 'A' && c <= 'Z') out += c;
|
||||
else if (c >= 'a' && c <= 'z') out += static_cast<char>(c - 'a' + 'A');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Minimal JSON string escaping for the credits file. The reference field is
|
||||
// text a stranger typed into their banking app, so a raw quote or newline in
|
||||
// it would corrupt the line-per-record format and silently truncate the
|
||||
// evidence a settlement decision reads.
|
||||
std::string EscT(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;
|
||||
}
|
||||
|
||||
// Does the folded remittance text carry a SECOND thing shaped like one of our
|
||||
// references, beyond the one at `foundAt`? "CC" followed by six characters of
|
||||
// the token alphabet is the shape. Used only to raise the advisory ambiguous
|
||||
// flag: one transfer quoting two orders cannot be attributed per-order, and a
|
||||
// human should look rather than two orders settling on the same money.
|
||||
bool HasOtherReferenceShape(std::string_view folded, std::size_t foundAt,
|
||||
std::size_t needleLen) {
|
||||
for (std::size_t i = 0; i + 8 <= folded.size(); ++i) {
|
||||
if (i >= foundAt && i < foundAt + needleLen) continue; // the known one
|
||||
if (folded[i] != 'C' || folded[i + 1] != 'C') continue;
|
||||
bool hex = true;
|
||||
for (std::size_t j = i + 2; j < i + 8; ++j) {
|
||||
const char c = folded[j];
|
||||
const bool isHex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
|
||||
if (!isHex) { hex = false; break; }
|
||||
}
|
||||
if (hex) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransferMatch MatchCredits(std::span<const BankCredit> credits,
|
||||
std::string_view reference) {
|
||||
TransferMatch out;
|
||||
const std::string needle = Fold(reference);
|
||||
// An empty needle would match every credit on the account. Refuse rather
|
||||
// than settle the whole ledger from one payment.
|
||||
if (needle.size() < 4) return out;
|
||||
|
||||
for (const BankCredit& c : credits) {
|
||||
// Only money coming IN can pay for something. A negative amount is an
|
||||
// outgoing payment that happens to quote the reference — a refund we
|
||||
// sent, most likely — and counting it would be a refund paying for
|
||||
// the order it refunded.
|
||||
if (c.amountMinor <= 0) continue;
|
||||
const std::string folded = Fold(c.reference);
|
||||
const std::size_t at = folded.find(needle);
|
||||
if (at == std::string::npos) continue;
|
||||
out.paidMinor += c.amountMinor;
|
||||
++out.count;
|
||||
out.method = c.method;
|
||||
if (HasOtherReferenceShape(folded, at, needle.size())) out.ambiguous = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class FileCreditSource final : public CreditSource {
|
||||
public:
|
||||
explicit FileCreditSource(std::filesystem::path path) : path_(std::move(path)) {}
|
||||
|
||||
std::optional<std::vector<BankCredit>> Recent() override {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(path_, ec)) {
|
||||
// Not an error: a shop that has taken no transfers yet has no
|
||||
// file. Distinct from a read failure below, which IS unknown.
|
||||
return std::vector<BankCredit>{};
|
||||
}
|
||||
std::ifstream in(path_, std::ios::binary);
|
||||
if (!in) {
|
||||
std::println(std::cerr, "transfer: cannot read credits file {}",
|
||||
path_.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
std::vector<BankCredit> out;
|
||||
std::string line;
|
||||
int lineNo = 0;
|
||||
while (std::getline(in, line)) {
|
||||
++lineNo;
|
||||
if (line.empty()) continue;
|
||||
const auto doc = Json::Parse(line);
|
||||
if (!doc || !doc->IsObject()) {
|
||||
// One malformed line must not silently shrink the evidence a
|
||||
// settlement decision rests on, so say so and keep the rest:
|
||||
// dropping the whole file would strand every paid order.
|
||||
std::println(std::cerr, "transfer: {}:{} is not a JSON object, skipped",
|
||||
path_.string(), lineNo);
|
||||
continue;
|
||||
}
|
||||
BankCredit c;
|
||||
c.id = std::string(doc->Str("id"));
|
||||
c.reference = std::string(doc->Str("reference"));
|
||||
c.method = std::string(doc->Str("method"));
|
||||
if (c.method.empty()) c.method = "sepa";
|
||||
c.amountMinor = doc->Int("amount_minor");
|
||||
out.push_back(std::move(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "file"; }
|
||||
|
||||
private:
|
||||
std::filesystem::path path_;
|
||||
};
|
||||
|
||||
class TransferRail final : public PaymentRail {
|
||||
public:
|
||||
TransferRail(RailConfig cfg, std::unique_ptr<CreditSource> credits)
|
||||
: cfg_(std::move(cfg)), credits_(std::move(credits)) {}
|
||||
|
||||
// No network call, and nothing to reserve: unlike the EURC rail, which
|
||||
// burns a receiving address per order, a transfer reuses one IBAN forever
|
||||
// and the reference is what separates orders. So this cannot fail, which
|
||||
// is worth noticing — checkout can never lose a sale to a provider being
|
||||
// down, because there is no provider.
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
PaymentLink link;
|
||||
// payId carries the reference AND the deadline, the same trick the
|
||||
// EURC rail uses: CheckPaid is given only the id and the amount, and
|
||||
// this rail has to know its own window. Both halves are wanted in the
|
||||
// ledger anyway.
|
||||
const std::int64_t deadline = NowUnix() + WindowSeconds();
|
||||
link.payId = std::format("{}@{}", ReferenceOf(description), deadline);
|
||||
// Self-hosted: there is nowhere to send the buyer but the order page,
|
||||
// which is where Instructions() renders.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
const auto [reference, deadline] = SplitPayId(payId);
|
||||
if (reference.empty()) {
|
||||
// Not one of ours. This is the shape a ledger line from the OLD
|
||||
// hosted provider has, and this rail genuinely cannot decide it:
|
||||
// Pending lets the reconciler age it out instead of pretending to
|
||||
// know it is dead.
|
||||
return PaidStatus{ PayState::Pending, {} };
|
||||
}
|
||||
|
||||
const std::optional<std::vector<BankCredit>> all = Fetch();
|
||||
if (!all) return std::nullopt; // unknown, retry — never "unpaid"
|
||||
|
||||
const TransferMatch m = MatchCredits(*all, reference);
|
||||
if (m.ambiguous) {
|
||||
std::println(std::cerr,
|
||||
"transfer: {} matched a credit that also quotes another "
|
||||
"order reference — settle this one by hand", reference);
|
||||
}
|
||||
|
||||
PaidStatus out;
|
||||
out.method = m.method.empty() ? std::string("sepa") : m.method;
|
||||
if (m.paidMinor >= expectedMinor) {
|
||||
out.state = PayState::Paid;
|
||||
return out;
|
||||
}
|
||||
// Money arrived but does not cover the order. The buyer is told to
|
||||
// send the difference to the same IBAN with the same reference, which
|
||||
// is why partials stay Pending and keep accumulating rather than
|
||||
// failing: the next credit adds to this sum.
|
||||
out.seen = m.paidMinor > 0;
|
||||
if (deadline > 0 && NowUnix() > deadline) {
|
||||
// The window closed. NOT bounced money: the IBAN is ours and a
|
||||
// late transfer still lands there — say so in the log, because the
|
||||
// operator settling it by hand is the one who needs to know.
|
||||
std::println(std::cerr,
|
||||
"transfer: {} lapsed with {} of {} received; the IBAN "
|
||||
"remains ours, a late payment still arrives and is "
|
||||
"settled with --mark-paid", reference,
|
||||
Money::FormatMinor(m.paidMinor),
|
||||
Money::FormatMinor(expectedMinor));
|
||||
out.state = PayState::Dead;
|
||||
return out;
|
||||
}
|
||||
out.state = PayState::Pending;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<PayInstructions> Instructions(const std::string& payId,
|
||||
std::int64_t totalMinor) const override {
|
||||
const auto [reference, deadline] = SplitPayId(payId);
|
||||
if (reference.empty() || totalMinor <= 0) return std::nullopt;
|
||||
PayInstructions out;
|
||||
out.address = cfg_.transferIban;
|
||||
out.beneficiary = cfg_.transferBeneficiary;
|
||||
out.bic = cfg_.transferBic;
|
||||
out.reference = reference;
|
||||
out.amount = Money::FormatMinor(totalMinor);
|
||||
out.deadlineUnix = deadline;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "transfer"; }
|
||||
|
||||
// A transfer does not arrive in seconds even under instant payments, and
|
||||
// the bank's API is rate limited per method — the shared cache below is
|
||||
// what keeps a sweep of many orders down to one request, but a slow
|
||||
// cadence is the other half of being a good citizen there. Configurable so
|
||||
// a suite reading a local file can run at a speed a test can wait for.
|
||||
std::chrono::seconds PollInterval() const override {
|
||||
return std::chrono::seconds(cfg_.transferPollSeconds > 0
|
||||
? cfg_.transferPollSeconds : 60);
|
||||
}
|
||||
|
||||
private:
|
||||
static std::int64_t NowUnix() {
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
std::int64_t WindowSeconds() const {
|
||||
const int hours = cfg_.transferWindowHours > 0 ? cfg_.transferWindowHours
|
||||
: 14 * 24;
|
||||
return static_cast<std::int64_t>(hours) * 3600;
|
||||
}
|
||||
|
||||
// "CC-2B6457 catcrafts.net" -> "CC-2B6457". The description checkout
|
||||
// builds starts with the reference; take the first token so the payId
|
||||
// stays short and the '@' split below cannot be confused by a space.
|
||||
static std::string ReferenceOf(std::string_view description) {
|
||||
const std::size_t sp = description.find(' ');
|
||||
return std::string(sp == std::string_view::npos ? description
|
||||
: description.substr(0, sp));
|
||||
}
|
||||
|
||||
static std::pair<std::string, std::int64_t> SplitPayId(std::string_view payId) {
|
||||
const std::size_t at = payId.rfind('@');
|
||||
if (at == std::string_view::npos) return { {}, 0 };
|
||||
const std::string_view ref = payId.substr(0, at);
|
||||
// The reference must look like ours before this rail claims the order.
|
||||
if (ref.size() < 4 || !ref.starts_with("CC")) return { {}, 0 };
|
||||
std::int64_t deadline = 0;
|
||||
const std::string_view tail = payId.substr(at + 1);
|
||||
const auto [ptr, ec] = std::from_chars(tail.data(), tail.data() + tail.size(),
|
||||
deadline);
|
||||
if (ec != std::errc{} || ptr != tail.data() + tail.size()) return { {}, 0 };
|
||||
return { std::string(ref), deadline };
|
||||
}
|
||||
|
||||
// ONE bank read per sweep, shared by every order in it. Not an
|
||||
// optimisation: the bank rate-limits reads to a few per second, and the
|
||||
// reconciler asks per order, so without this a shop with a dozen open
|
||||
// orders would throttle itself and the answers would start coming back as
|
||||
// "unknown" — which is indistinguishable, from the outside, from a shop
|
||||
// whose payments have stopped working.
|
||||
//
|
||||
// The window only has to span ONE sweep, not one poll interval. Setting it
|
||||
// to PollInterval() is the tempting mistake and it doubles the worst-case
|
||||
// wait: a sweep that lands just after a fetch would answer every order
|
||||
// from data already a minute old, so money could sit visible at the bank
|
||||
// for two minutes before any order noticed. A few seconds is enough to
|
||||
// collapse a sweep into a single request, and at one request per window
|
||||
// the rate limit is nowhere in sight.
|
||||
// Never longer than the poll interval itself: at the default 60 s cadence
|
||||
// five seconds comfortably spans one sweep, but a rail polling every
|
||||
// second would otherwise answer from data older than its own interval.
|
||||
std::chrono::seconds CacheWindow() const {
|
||||
return std::min(std::chrono::seconds(5), PollInterval());
|
||||
}
|
||||
|
||||
std::optional<std::vector<BankCredit>> Fetch() {
|
||||
std::lock_guard lock(mutex_);
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (cached_ && now - fetchedAt_ < CacheWindow()) return cached_;
|
||||
std::optional<std::vector<BankCredit>> fresh = credits_->Recent();
|
||||
if (!fresh) {
|
||||
// Keep serving the last good answer rather than turning a blip
|
||||
// into "unknown" for every order at once. Only when there has
|
||||
// never been one does the caller get nullopt.
|
||||
return cached_ ? cached_ : std::nullopt;
|
||||
}
|
||||
cached_ = std::move(fresh);
|
||||
fetchedAt_ = now;
|
||||
return cached_;
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::unique_ptr<CreditSource> credits_;
|
||||
std::mutex mutex_;
|
||||
std::optional<std::vector<BankCredit>> cached_;
|
||||
std::chrono::steady_clock::time_point fetchedAt_{};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path) {
|
||||
return std::make_unique<FileCreditSource>(std::move(path));
|
||||
}
|
||||
|
||||
std::optional<int> PullCreditsInto(CreditSource& source,
|
||||
const std::filesystem::path& creditsPath) {
|
||||
const std::optional<std::vector<BankCredit>> fresh = source.Recent();
|
||||
if (!fresh) return std::nullopt;
|
||||
|
||||
// Which ids the file already holds. Append-only and deduplicated by the
|
||||
// bank's own payment id, so running this twice — or on an overlapping
|
||||
// window, which every run does — adds nothing the second time. That
|
||||
// matters more than it sounds: the file is evidence for settling money,
|
||||
// and a duplicated credit would double a payment and settle an order
|
||||
// nobody paid twice for.
|
||||
std::set<std::string> known;
|
||||
{
|
||||
std::ifstream in(creditsPath, std::ios::binary);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.empty()) continue;
|
||||
const auto doc = Json::Parse(line);
|
||||
if (!doc || !doc->IsObject()) continue;
|
||||
if (const std::string_view id = doc->Str("id"); !id.empty()) {
|
||||
known.emplace(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string add;
|
||||
int appended = 0;
|
||||
for (const BankCredit& c : *fresh) {
|
||||
if (c.id.empty() || known.contains(c.id)) continue;
|
||||
add += std::format(
|
||||
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})"
|
||||
"\n",
|
||||
EscT(c.id), EscT(c.reference), c.amountMinor, EscT(c.method));
|
||||
++appended;
|
||||
}
|
||||
if (appended == 0) return 0;
|
||||
|
||||
// Append rather than rewrite: the file may also hold lines an operator
|
||||
// added by hand to settle something, and a rewrite would lose them.
|
||||
std::ofstream out(creditsPath, std::ios::app | std::ios::binary);
|
||||
if (!out) {
|
||||
std::println(std::cerr, "transfer: cannot append to {}", creditsPath.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
out << add;
|
||||
out.flush();
|
||||
if (!out) {
|
||||
std::println(std::cerr, "transfer: write to {} failed", creditsPath.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
|
||||
std::unique_ptr<CreditSource> credits) {
|
||||
if (!credits) return nullptr;
|
||||
// The two facts the buyer is told. Without them the order page would
|
||||
// render an incomplete instruction, which loses the money rather than the
|
||||
// sale — refuse to start instead, the same way the EURC rail refuses
|
||||
// without its chains file.
|
||||
if (config.transferIban.empty()) {
|
||||
std::println(std::cerr,
|
||||
"transfer: TRANSFER_IBAN is not set — the order page would "
|
||||
"have no account to name");
|
||||
return nullptr;
|
||||
}
|
||||
if (config.transferBeneficiary.empty()) {
|
||||
std::println(std::cerr,
|
||||
"transfer: TRANSFER_BENEFICIARY is not set — Verification of "
|
||||
"Payee shows the payer a mismatch warning without the exact "
|
||||
"account-holder name");
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_unique<TransferRail>(config, std::move(credits));
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
@ -162,20 +162,24 @@ int main(int argc, char** argv) {
|
|||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
// 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.
|
||||
// Flags beat environment beats default, and a slot whose configuration
|
||||
// is absent is simply off — so a box with nothing configured serves the
|
||||
// whole site minus checkout instead of refusing to start, and a box
|
||||
// with one rail configured offers only that one method.
|
||||
//
|
||||
// bank MOLLIE_API_KEY iDEAL, cards, transfer
|
||||
// crypto EURC_CHAINS self-hosted EURC, no processor, no key
|
||||
// bank TRANSFER_IBAN SEPA transfer to our own account
|
||||
// crypto EURC_CHAINS self-hosted EURC, on our own addresses
|
||||
//
|
||||
// The crypto slot is selected by the presence of a chains FILE rather
|
||||
// than a credential: the self-hosted rail has no credential, which is
|
||||
// the feature.
|
||||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||
// NEITHER slot is selected by a credential, and that is the point
|
||||
// rather than an accident. Both rails are self-hosted, so each is
|
||||
// selected by naming where the money lands: there is no provider to
|
||||
// authenticate to, and therefore no key anyone can revoke. The shop
|
||||
// ran on a hosted provider until 2026-08-20, when it closed the
|
||||
// account after a risk review with no appeal and took every payment
|
||||
// method with it. These two rails are the answer to that.
|
||||
const char* transferIban = std::getenv("TRANSFER_IBAN");
|
||||
const char* eurcChains = std::getenv("EURC_CHAINS");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
|
||||
std::string railMode = transferIban && *transferIban ? "transfer" : "off";
|
||||
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
|
||||
std::filesystem::path railState;
|
||||
std::string redirectBase = [] {
|
||||
|
|
@ -306,24 +310,66 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
}
|
||||
|
||||
auto build = [&](const std::string& mode, const char* key, const char* keyName,
|
||||
// The bank-transfer rail's configuration. Credential-free like the EURC
|
||||
// rail — there is no provider to authenticate to, only our own account
|
||||
// to name — so TRANSFER_IBAN is what SELECTS it, for the same reason
|
||||
// EURC_CHAINS selects the crypto slot: a value that appeared by
|
||||
// convention rather than by intent must not switch a payment method on.
|
||||
// The credits file is derived, though, because it is state rather than
|
||||
// intent, and it hangs off the orders path like everything else.
|
||||
std::filesystem::path transferCreditsPath;
|
||||
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
|
||||
transferCreditsPath = v;
|
||||
} else {
|
||||
transferCreditsPath = ordersPath;
|
||||
transferCreditsPath += ".transfer-credits.jsonl";
|
||||
}
|
||||
int transferPollSeconds = 60;
|
||||
if (const char* v = std::getenv("TRANSFER_POLL_SECONDS"); v && *v) {
|
||||
const std::string_view sv(v);
|
||||
int parsed = 0;
|
||||
if (std::from_chars(sv.data(), sv.data() + sv.size(), parsed).ec == std::errc{}
|
||||
&& parsed > 0 && parsed <= 3600) {
|
||||
transferPollSeconds = parsed;
|
||||
} else {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: TRANSFER_POLL_SECONDS='{}' is not a "
|
||||
"sane second count — refusing to start", sv);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
int transferWindowHours = 14 * 24;
|
||||
if (const char* v = std::getenv("TRANSFER_WINDOW_HOURS"); v && *v) {
|
||||
const std::string_view s(v);
|
||||
int parsed = 0;
|
||||
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
|
||||
&& parsed > 0 && parsed <= 24 * 90) {
|
||||
transferWindowHours = parsed;
|
||||
} else {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: TRANSFER_WINDOW_HOURS='{}' is not a "
|
||||
"sane hour count — refusing to start", s);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
auto build = [&](const std::string& mode,
|
||||
std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
||||
Server::RailConfig cfg;
|
||||
cfg.mode = mode;
|
||||
cfg.apiKey = key ? key : "";
|
||||
cfg.statePath = railState;
|
||||
cfg.redirectBase = redirectBase;
|
||||
cfg.eurcChainsPath = eurcChainsPath;
|
||||
cfg.eurcPoolPath = eurcPoolPath;
|
||||
cfg.eurcWindowHours = eurcWindowHours;
|
||||
const bool needsKey = mode == "mollie";
|
||||
if (needsKey && cfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: rail '{}' selected but {} is not set — "
|
||||
"refusing to start with a rail that cannot work",
|
||||
mode, keyName);
|
||||
return false;
|
||||
if (const char* v = std::getenv("TRANSFER_IBAN"); v) cfg.transferIban = v;
|
||||
if (const char* v = std::getenv("TRANSFER_BENEFICIARY"); v) {
|
||||
cfg.transferBeneficiary = v;
|
||||
}
|
||||
if (const char* v = std::getenv("TRANSFER_BIC"); v) cfg.transferBic = v;
|
||||
cfg.transferCreditsPath = transferCreditsPath;
|
||||
cfg.transferPollSeconds = transferPollSeconds;
|
||||
cfg.transferWindowHours = transferWindowHours;
|
||||
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
|
||||
|
|
@ -333,7 +379,7 @@ int main(int argc, char** argv) {
|
|||
// and must not be answered the same way (see below).
|
||||
if (!out && mode != "off") {
|
||||
static constexpr std::string_view kKnown[] = {
|
||||
"mollie", "eurc", "fake", "fake-crypto"
|
||||
"eurc", "transfer", "fake", "fake-crypto"
|
||||
};
|
||||
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
|
||||
if (!known) {
|
||||
|
|
@ -373,10 +419,10 @@ int main(int argc, char** argv) {
|
|||
};
|
||||
|
||||
Server::PaymentRails rails;
|
||||
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", rails.bank)) return 2;
|
||||
if (!build(railMode, rails.bank)) return 2;
|
||||
// The crypto slot carries no credential at all; what it needs instead
|
||||
// rode in on cfg.eurc* above.
|
||||
if (!build(cryptoMode, nullptr, "", rails.crypto)) return 2;
|
||||
if (!build(cryptoMode, rails.crypto)) return 2;
|
||||
|
||||
Server::ConfigurePayments(std::move(rails), redirectBase);
|
||||
|
||||
|
|
@ -413,6 +459,78 @@ int main(int argc, char** argv) {
|
|||
return Server::Serve(port);
|
||||
}
|
||||
|
||||
// --pull-credits: read the bank account once and append anything new to the
|
||||
// credits file the transfer rail settles from. Prints how many arrived.
|
||||
//
|
||||
// A SEPARATE ENTRY POINT ON PURPOSE, and the reason is the whole point of
|
||||
// the design. A bunq API key can initiate payments — bunq has no read-only
|
||||
// scope — so the project's rule is that it never lives on the public host.
|
||||
// Run this on a trusted machine on a timer, ship the credits file over, and
|
||||
// the server settles orders while holding no credential that can move a
|
||||
// cent. Configuring BUNQ_API_KEY on the server works too and is simpler,
|
||||
// but it is strictly worse and this program will say so when it starts.
|
||||
//
|
||||
// catcrafts-server --pull-credits [--orders FILE] [--credits FILE]
|
||||
if (!args.empty() && args[0] == "--pull-credits") {
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
std::filesystem::path creditsPath;
|
||||
std::filesystem::path statePath;
|
||||
for (std::size_t i = 1; i < args.size(); ++i) {
|
||||
const std::string_view a = args[i];
|
||||
auto next = [&]() -> std::string {
|
||||
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
|
||||
};
|
||||
if (a == "--orders") ordersPath = next();
|
||||
else if (a == "--credits") creditsPath = next();
|
||||
else if (a == "--state") statePath = next();
|
||||
}
|
||||
if (creditsPath.empty()) {
|
||||
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
|
||||
creditsPath = v;
|
||||
} else {
|
||||
creditsPath = ordersPath;
|
||||
creditsPath += ".transfer-credits.jsonl";
|
||||
}
|
||||
}
|
||||
if (statePath.empty()) {
|
||||
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
|
||||
statePath = v;
|
||||
} else {
|
||||
statePath = ordersPath;
|
||||
statePath += ".bunq-context.json";
|
||||
}
|
||||
}
|
||||
|
||||
Server::BunqConfig bunq;
|
||||
if (const char* v = std::getenv("BUNQ_API_KEY"); v) bunq.apiKey = v;
|
||||
if (const char* v = std::getenv("TRANSFER_IBAN"); v) bunq.iban = v;
|
||||
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) bunq.permittedIps = v;
|
||||
if (bunq.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: BUNQ_API_KEY is not set — nothing to pull "
|
||||
"with. This command reads the bank account; it never pays "
|
||||
"anyone.");
|
||||
return 2;
|
||||
}
|
||||
bunq.statePath = statePath;
|
||||
|
||||
std::unique_ptr<Server::CreditSource> source = Server::MakeBunqCreditSource(bunq);
|
||||
if (!source) return 2;
|
||||
const std::optional<int> added =
|
||||
Server::PullCreditsInto(*source, creditsPath);
|
||||
if (!added) {
|
||||
// Distinct from "nothing new": a timer that cannot tell these
|
||||
// apart will report success while the shop silently stops
|
||||
// noticing payments.
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: could not read the account — nothing was "
|
||||
"written; the credits file still holds what it did");
|
||||
return 1;
|
||||
}
|
||||
std::println("pulled {} new credit(s) into {}", *added, creditsPath.string());
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||||
// the escape hatch for a payment confirmed out-of-band (or a refund):
|
||||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||||
|
|
@ -478,15 +596,24 @@ int main(int argc, char** argv) {
|
|||
|
||||
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
|
||||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||||
" [--rail=off|fake|mollie]\n"
|
||||
" [--rail=off|fake|transfer]\n"
|
||||
" [--crypto-rail=off|fake-crypto|eurc]\n"
|
||||
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
" --pull-credits [--orders FILE] [--credits FILE] [--state FILE]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail.\n"
|
||||
" EURC_CHAINS=FILE selects the self-hosted crypto rail (no key:\n"
|
||||
" that is the point); EURC_POOL=FILE of receiving addresses,\n"
|
||||
" default <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
|
||||
"environment: TRANSFER_IBAN selects the bank-transfer rail (no key: that is\n"
|
||||
" the point), with TRANSFER_BENEFICIARY the account-holder name\n"
|
||||
" EXACTLY as the bank holds it — payers' banks name-check it —\n"
|
||||
" TRANSFER_CREDITS=FILE (default <orders>.transfer-credits.jsonl)\n"
|
||||
" and TRANSFER_WINDOW_HOURS (336).\n"
|
||||
" EURC_CHAINS=FILE selects the self-hosted crypto rail (also no\n"
|
||||
" key); EURC_POOL=FILE of receiving addresses, default\n"
|
||||
" <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
|
||||
" BUNQ_API_KEY + BUNQ_PERMITTED_IPS, BUNQ_STATE are for\n"
|
||||
" --pull-credits. That key CAN MOVE MONEY (bunq has no read-only\n"
|
||||
" scope), so run --pull-credits on a trusted machine and ship the\n"
|
||||
" credits file here, rather than setting it on this host.\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ export namespace Catcrafts::Server {
|
|||
// normalises before writing.
|
||||
std::string payUrl; // the provider's hosted checkout link;
|
||||
// for the EURC rail, the order page itself
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie,
|
||||
// "<address>@<deadline>" at the EURC rail)
|
||||
std::string payId; // the rail's own handle on the payment:
|
||||
// "<reference>@<deadline>" for a bank
|
||||
// transfer, "<address>@<deadline>" for EURC
|
||||
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
||||
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
|
||||
// never paid. A later cancel (a refund)
|
||||
|
|
@ -198,6 +199,28 @@ export namespace Catcrafts::Server {
|
|||
std::string NewOrderToken();
|
||||
std::string ReferenceFromToken(std::string_view token);
|
||||
|
||||
// The same reference again, as an ISO 11649 structured creditor reference:
|
||||
// "RF" + two ISO 7064 mod-97-10 check digits + the CC-style body. Derived
|
||||
// from the same token for the same reason, so the two can never disagree.
|
||||
//
|
||||
// Why it is worth the arithmetic: Dutch consumer banking gives this a
|
||||
// dedicated payment-reference field, and the PAYER'S OWN BANK verifies the
|
||||
// check digits before the transfer leaves. A correct reference travels as
|
||||
// structured remittance information; a mistyped one is caught at the other
|
||||
// end rather than arriving here as money nobody can match to an order.
|
||||
// That is what makes an unattended bank-transfer reconciler trustworthy —
|
||||
// exact match on a validated key instead of a substring hunt through
|
||||
// free text a human retyped.
|
||||
std::string CreditorReferenceFromToken(std::string_view token);
|
||||
|
||||
// Whether `s` is a well-formed ISO 11649 reference: the RF prefix, a length
|
||||
// within the standard, an alphanumeric body and check digits that verify.
|
||||
// Exported because this is where a mistake is invisible — a generator that
|
||||
// computes the digits wrong still produces something that LOOKS like a
|
||||
// reference, and every payer's bank would then reject it while our own
|
||||
// logs showed nothing wrong at all.
|
||||
bool IsValidCreditorReference(std::string_view s);
|
||||
|
||||
// ── the order confirmation email ──────────────────────────────────
|
||||
//
|
||||
// A paid order gets ONE email: the confirmation, with the clearsigned
|
||||
|
|
@ -254,9 +277,11 @@ export namespace Catcrafts::Server {
|
|||
|
||||
// What a poll learned about one payment. Pending and Dead are different
|
||||
// answers on purpose: an unpaid order does not stay payable forever —
|
||||
// Mollie expires its payments after its own window, and the EURC rail
|
||||
// closes its own (24 hours by default) — and an order whose payment can
|
||||
// never arrive should lapse rather than sit "awaiting" forever.
|
||||
// each rail closes its own window (14 days for a bank transfer, 24 hours
|
||||
// for EURC) — and an order whose payment can never arrive should lapse
|
||||
// rather than sit "awaiting" forever. Neither window means bounced money:
|
||||
// the account and the address stay ours, so a late payment still lands and
|
||||
// is settled by hand.
|
||||
enum class PayState { Pending, Paid, Dead };
|
||||
struct PaidStatus {
|
||||
PayState state = PayState::Pending;
|
||||
|
|
@ -282,10 +307,27 @@ export namespace Catcrafts::Server {
|
|||
std::string note; // optional display hint ("lowest fees")
|
||||
};
|
||||
struct PayInstructions {
|
||||
std::string address; // where the money goes
|
||||
std::string amount; // decimal token amount ("570.43")
|
||||
std::string address; // where the money goes: a token address, or an IBAN
|
||||
std::string amount; // decimal amount ("570.43")
|
||||
std::int64_t deadlineUnix = 0;
|
||||
std::vector<PayChainOption> chains;
|
||||
|
||||
// ── bank-transfer rails only; empty for on-chain ones ──────────
|
||||
//
|
||||
// The beneficiary name is NOT decoration. Since 2025-10-09 every
|
||||
// euro-area transfer is checked by Verification of Payee, and the
|
||||
// payer sees a mismatch warning at the moment of payment if the name
|
||||
// they were given does not match the one holding the IBAN. So this
|
||||
// must be the name the BANK holds, character for character, not the
|
||||
// trading name — a well-meaning "Catcrafts" where the bank says
|
||||
// something else scares buyers off at the last step.
|
||||
std::string beneficiary;
|
||||
// The structured creditor reference the payer must quote, which is
|
||||
// what makes the incoming money matchable to this order.
|
||||
std::string reference;
|
||||
// Empty unless configured; only a payer sending from outside SEPA
|
||||
// needs it. See RailConfig::transferBic.
|
||||
std::string bic;
|
||||
};
|
||||
|
||||
class PaymentRail {
|
||||
|
|
@ -317,8 +359,8 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
|
||||
struct RailConfig {
|
||||
std::string mode; // "off" | "fake" | "mollie" | "eurc"
|
||||
std::string apiKey; // mollie: live_… or test_…
|
||||
std::string mode; // "off" | "fake" | "fake-crypto"
|
||||
// | "transfer" | "eurc"
|
||||
std::filesystem::path statePath; // fake: the paid marker
|
||||
std::string redirectBase = "https://catcrafts.net";
|
||||
|
||||
|
|
@ -330,6 +372,33 @@ export namespace Catcrafts::Server {
|
|||
std::filesystem::path eurcChainsPath;
|
||||
std::filesystem::path eurcPoolPath;
|
||||
int eurcWindowHours = 24; // 0 or less means the 24h default
|
||||
|
||||
// transfer: also credential-free. What it needs is where the money
|
||||
// goes and what the payer must be told — see PayInstructions on why
|
||||
// the beneficiary name is load-bearing rather than cosmetic.
|
||||
std::string transferIban;
|
||||
std::string transferBeneficiary;
|
||||
// Optional. Inside SEPA an IBAN is sufficient and has been since 2016,
|
||||
// so this is shown only when set, and labelled for the case that
|
||||
// actually needs it: a payer whose bank is outside SEPA and who is
|
||||
// sending by SWIFT, where the form asks for a BIC and cannot proceed
|
||||
// without one. Rendering it unconditionally would invite every Dutch
|
||||
// buyer to type a field their bank does not want.
|
||||
std::string transferBic;
|
||||
std::filesystem::path transferCreditsPath;
|
||||
// How often the reconciler asks this rail about an order. 60 s suits a
|
||||
// real bank: money does not arrive faster than that even under instant
|
||||
// payments, and the rail shares ONE account read across a whole sweep
|
||||
// so the cadence is about politeness rather than cost. Configurable
|
||||
// because a suite driving a local credits FILE has nobody to be polite
|
||||
// to, and a 60 s wait per assertion makes a test unusable.
|
||||
int transferPollSeconds = 60;
|
||||
// A bank transfer has no provider-side expiry, so this window is
|
||||
// purely ours: how long an order waits before it is treated as
|
||||
// abandoned. Generous on purpose, and lapsing is NOT bounced money —
|
||||
// the IBAN stays ours and a late payment still arrives, to be settled
|
||||
// by hand. Same semantics as the EURC window.
|
||||
int transferWindowHours = 14 * 24;
|
||||
};
|
||||
|
||||
// nullptr for mode "off" — that slot then offers no payment choice.
|
||||
|
|
@ -340,7 +409,7 @@ export namespace Catcrafts::Server {
|
|||
// 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> bank; // SEPA transfer to our own account
|
||||
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
|
||||
|
||||
bool Any() const { return bank != nullptr || crypto != nullptr; }
|
||||
|
|
@ -356,22 +425,13 @@ export namespace Catcrafts::Server {
|
|||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
struct MolliePayment {
|
||||
std::string id;
|
||||
std::string status; // open|pending|authorized|paid|canceled|expired|failed
|
||||
std::string method; // may be empty until the payer picks one
|
||||
std::string checkoutUrl; // present while payable
|
||||
std::int64_t amountMinor = 0;
|
||||
};
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
|
||||
// quotes 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.
|
||||
// Exact decimal-string-to-minor-units parser for amounts that arrive as
|
||||
// strings ("614.00" -> 61400), which is how every external source quotes
|
||||
// them. Rejects anything that is not a plain non-negative decimal with at
|
||||
// most two fraction digits — no floats touch money on the way in, and a
|
||||
// sign is refused here so it cannot quietly halve a total (see
|
||||
// ParseSignedAmountToMinor, which handles the one case where the sign is
|
||||
// meaningful). Exported for the self-test.
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
||||
|
||||
// One chain the EURC rail watches. Every field is configuration because
|
||||
|
|
@ -431,6 +491,125 @@ export namespace Catcrafts::Server {
|
|||
// was given.
|
||||
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
|
||||
|
||||
// ── the bank-transfer rail ────────────────────────────────────────
|
||||
//
|
||||
// The other self-hosted rail: the buyer sends a plain SEPA transfer to our
|
||||
// own IBAN quoting the order's creditor reference, and the rail settles the
|
||||
// order when a matching credit shows up on the account. No provider stands
|
||||
// in the payment path, which is the entire point — the only third party is
|
||||
// the bank the money was always going to land in anyway.
|
||||
|
||||
// One incoming credit on the account, reduced to what matching needs.
|
||||
struct BankCredit {
|
||||
std::string id; // the bank's own payment id; dedupe and logs
|
||||
std::string reference; // remittance information, as the bank has it
|
||||
std::int64_t amountMinor = 0;
|
||||
std::string method; // ledger `via`: "sepa", "ideal", …
|
||||
};
|
||||
|
||||
// What the credits say about one order.
|
||||
struct TransferMatch {
|
||||
std::int64_t paidMinor = 0; // summed over every credit carrying the reference
|
||||
int count = 0; // how many credits carried it
|
||||
std::string method; // the method of the last matching credit
|
||||
// Set when a matching credit's remittance text ALSO carries something
|
||||
// shaped like a second order reference. One transfer quoting two
|
||||
// references cannot be attributed by a per-order matcher, and at that
|
||||
// point a human should look rather than two orders both settling on the
|
||||
// same money. Advisory: the caller logs it, it does not block.
|
||||
bool ambiguous = false;
|
||||
};
|
||||
|
||||
// Sum the credits that quote `reference`, in whatever form the payer typed
|
||||
// it. Pure, and exported for the self-test, because this is the decision
|
||||
// that releases goods — the same reason ParseEthCallUint is exported.
|
||||
//
|
||||
// Matching is on the reference BODY ("CC2B6457") after reducing both sides
|
||||
// to upper-case alphanumerics. That one choice covers every form a payer
|
||||
// might quote — "CC-2B6457", "cc2b6457", or the full structured
|
||||
// "RF70CC2B6457" — because the body is a substring of all of them, and it
|
||||
// survives whatever spacing a bank puts in the field. The RF check digits
|
||||
// deliberately do NO work here: their job was done at the payer's own bank,
|
||||
// which refuses a mistyped reference before the transfer ever leaves.
|
||||
TransferMatch MatchCredits(std::span<const BankCredit> credits,
|
||||
std::string_view reference);
|
||||
|
||||
// Where a transfer rail gets its incoming credits. One implementation talks
|
||||
// to the bank; the suites use a file-backed one so the whole rail — payment
|
||||
// instructions, matching, settlement, the window — runs with no network.
|
||||
class CreditSource {
|
||||
public:
|
||||
virtual ~CreditSource() = default;
|
||||
// nullopt = the bank could not be reached. NEVER an empty vector for
|
||||
// that case: "no credits yet" and "cannot ask" must not look alike, or
|
||||
// an outage would read as a shop full of unpaid orders.
|
||||
virtual std::optional<std::vector<BankCredit>> Recent() = 0;
|
||||
virtual std::string_view Name() const = 0;
|
||||
};
|
||||
|
||||
// The rail itself. Takes its credit source so the bank is swappable — a
|
||||
// deliberate hedge, since the account that reconciles the shop is also the
|
||||
// shop's bank account, and replacing one adapter must not mean rewriting
|
||||
// the rail.
|
||||
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
|
||||
std::unique_ptr<CreditSource> credits);
|
||||
|
||||
// A CreditSource reading newline-delimited JSON from a file, one credit per
|
||||
// line: {"id":…,"reference":…,"amount_minor":…,"method":…}. This is how the
|
||||
// suites drive real settlement, and how an operator can settle a transfer
|
||||
// by hand without touching the ledger. A missing file is an EMPTY list, not
|
||||
// a failure: no transfers yet is a normal state.
|
||||
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path);
|
||||
|
||||
// ── the bunq credit source ────────────────────────────────────────
|
||||
|
||||
struct BunqConfig {
|
||||
std::string apiKey; // BUNQ_API_KEY; can MOVE MONEY, see below
|
||||
std::filesystem::path statePath; // keypair + tokens, 0600
|
||||
// Which account, when the key can see more than one. Compared to the
|
||||
// account's IBAN ignoring spacing and case; without it a key that sees
|
||||
// several active accounts is a refusal rather than a guess, because
|
||||
// guessing means reconciling the shop against its savings.
|
||||
std::string iban;
|
||||
// Registered with bunq ONCE, at device-server time. bunq has no
|
||||
// read-only key scope, so this is the only thing standing between a
|
||||
// leaked key and someone spending the balance. "*" works and is
|
||||
// announced loudly; an explicit egress address is what should be used.
|
||||
std::string permittedIps;
|
||||
int count = 50; // payments per read
|
||||
};
|
||||
|
||||
// nullptr when no key is configured. NOTE the deployment rule that goes
|
||||
// with this: a bunq key can initiate payments and bunq offers no read-only
|
||||
// scope, so the project's standing policy is that it does NOT live on the
|
||||
// internet-facing host. Run `--pull-credits` on a trusted machine and ship
|
||||
// the credits file to the server, which then holds no credential at all.
|
||||
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config);
|
||||
|
||||
// bunq's payment list -> credits. Exported for the self-test: the money
|
||||
// decisions downstream are only as good as this decoding, and the HTTP
|
||||
// around it is thin. Same reasoning as ParseEthCallUint.
|
||||
std::vector<BankCredit> ParseBunqPayments(std::string_view json);
|
||||
|
||||
// Amount strings as bunq quotes them, INCLUDING the leading minus an
|
||||
// outgoing payment carries. Exported because the sign is the difference
|
||||
// between income and a refund, and getting it wrong would let a refund pay
|
||||
// for the order it reversed.
|
||||
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
|
||||
|
||||
// bunq's Payment.type -> the ledger's `via` vocabulary. Exported so the
|
||||
// suite can pin the mapping that decides whether an order is safe to ship:
|
||||
// "sepa" is final, "card" can be reversed for months.
|
||||
std::string_view BunqMethodFor(std::string_view paymentType);
|
||||
|
||||
// One pull: read the account and append every credit not already in the
|
||||
// file to it, newest last. Returns the number appended, or nullopt if the
|
||||
// bank could not be reached. This is what `--pull-credits` runs, and it is
|
||||
// deliberately a separate entry point from the rail so the machine holding
|
||||
// the key need not be the machine serving the shop.
|
||||
std::optional<int> PullCreditsInto(CreditSource& source,
|
||||
const std::filesystem::path& creditsPath);
|
||||
|
||||
// ── shipping rates ────────────────────────────────────────────────
|
||||
//
|
||||
// Live per-country, per-weight-bracket rates from Sendcloud's
|
||||
|
|
@ -508,6 +687,12 @@ export namespace Catcrafts::Server {
|
|||
// offers the crypto choice only when something can actually serve it.
|
||||
bool CryptoPaymentAvailable();
|
||||
|
||||
// The same question for the bank slot. It exists because the asymmetry
|
||||
// was an outage: when the bank rail went away the form kept rendering a
|
||||
// pre-selected "Bank or card" option that checkout could only refuse with
|
||||
// a 503. Every renderer that knows must pass both.
|
||||
bool BankPaymentAvailable();
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// Two questions a reverse-proxied process has to answer carefully, both
|
||||
|
|
|
|||
|
|
@ -23,11 +23,13 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// them at build time (fediverse fetch, ECB rates), and shell writes JSON,
|
||||
// not C++.
|
||||
//
|
||||
// PRICING RULE (the user's): retail = supplier price + markup, exactly, and
|
||||
// PRICING RULE (the owner's): retail = supplier price + markup, exactly, and
|
||||
// the markup is what Catcrafts walks away with AFTER shipping and VAT — €50
|
||||
// net, with Mollie's transaction fee as the only accepted leak. Supplier
|
||||
// prices are what the retailer currently charges (incl VAT); change one
|
||||
// number when the supplier moves and the margin stays put.
|
||||
// net. The stated leak used to be the payment provider's transaction fee;
|
||||
// since both rails became self-hosted (2026-08-20) the leaks are a bank
|
||||
// transfer's zero and, on the crypto rail, gas plus whatever an exchange takes
|
||||
// at cash-out. Supplier prices are what the retailer currently charges (incl
|
||||
// VAT); change one number when the supplier moves and the margin stays put.
|
||||
|
||||
export module Catcrafts.Shared:Content;
|
||||
import std;
|
||||
|
|
@ -70,6 +72,28 @@ export const std::vector<Product>& Products() {
|
|||
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
|
||||
// Launch day is this one line: "coming-soon" -> "available". The page
|
||||
// shows launch prices either way; only the order form is held back.
|
||||
//
|
||||
// ── BEFORE FLIPPING THIS, TWO THINGS ARE OWED ────────────────────
|
||||
//
|
||||
// 1. THE WITHDRAWAL BUTTON (herroepingsknop). Since 2026-06-19, art.
|
||||
// 11a of the Consumer Rights Directive — art. 6:230oa BW — has
|
||||
// required a clearly labelled button in the interface for a
|
||||
// consumer to withdraw from a distance contract. Email alone is no
|
||||
// longer enough. It must stay available for the whole withdrawal
|
||||
// period, ask only for essentials, NOT require an account, and
|
||||
// confirm immediately on a durable medium.
|
||||
// **If it is missing, the 14-day withdrawal period becomes ONE
|
||||
// YEAR** — every EU phone sale unwindable for twelve months.
|
||||
// It does not bite today only because the sole purchasable item is
|
||||
// the donation, and a gift carries no withdrawal right. Flipping
|
||||
// this line is exactly the moment it starts to.
|
||||
// The order page is the natural home: it already identifies one
|
||||
// order by an unguessable link, so no account is needed.
|
||||
//
|
||||
// 2. THE SHIPPING RATE TABLE. Without SENDCLOUD_* credentials (or a
|
||||
// hand-placed cache) there is no rate table, and checkout refuses
|
||||
// every order that ships. Donations skip that branch, which is why
|
||||
// the shop looks fine today. See deploy/README.md.
|
||||
p.status = "coming-soon";
|
||||
// Boxed shipping weight, grams: the FP6 retail box (~450 g) plus the
|
||||
// outer carton and padding. Rounded UP on purpose — this number picks
|
||||
|
|
@ -351,7 +375,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "privacy",
|
||||
.title = "Privacy",
|
||||
.updated = "2026-08-14",
|
||||
.updated = "2026-08-20",
|
||||
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
|
||||
.sections = {
|
||||
{ "Who is responsible",
|
||||
|
|
@ -361,7 +385,8 @@ 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.",
|
||||
"Bank and card payment happens on Mollie's own pages, Mollie is a Dutch licensed payment institution, and Catcrafts never sees card numbers or bank credentials, only which order was paid, for how much, and by which method. What Mollie processes about you is between you and them under their own privacy policy. Cryptocurrency payment involves no provider at all: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Only the order record described above connects them to you, and that record stays with Catcrafts.",
|
||||
"Neither payment method involves a payment processor, so there is no third party that Catcrafts hands your data to in order to get paid. Bank payment is an ordinary transfer from your bank to the account named on the order page: your own bank and Catcrafts' bank handle it, as they would any transfer you make, and Catcrafts has engaged nobody in between. Cryptocurrency payment involves no provider either: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Only the order record described above connects a payment to you, and that record stays with Catcrafts.",
|
||||
"What Catcrafts records about a bank transfer is deliberately narrow: the payment reference you quoted, the amount, the date and the method. Your name and IBAN are visible to Catcrafts on its own bank statement, as they are to anyone who receives a transfer, but they are not copied into the shop's records and are not needed to confirm an order. The reference is what matches your payment to your order.",
|
||||
"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",
|
||||
|
|
@ -373,7 +398,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. Paying does not send you anywhere else either: both payment methods are completed from the order page itself, so there is no payment provider's site in the middle. Following a link out, to a fediverse thread or to Forgejo, puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
|
||||
} },
|
||||
{ "Server logs",
|
||||
{
|
||||
|
|
@ -428,15 +453,16 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
.sections = {
|
||||
{ "Ordering and payment",
|
||||
{
|
||||
"Submitting the order form creates an order with a Mollie payment link for bank and card, or with a receiving address of its own for cryptocurrency. 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 cryptocurrency order's address stays reserved for about a day; after that the order lapses like any other unpaid one.",
|
||||
"Submitting the order form creates an order and shows you how to pay it: account details and a payment reference for a bank transfer, or a receiving address for cryptocurrency. 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. An unpaid bank-transfer order is held for about two weeks and a cryptocurrency order's address stays reserved for about a day; after that each lapses like any other unpaid order. In both cases a payment that arrives late is not lost, because the account and the address remain Catcrafts', and it is settled by hand on request.",
|
||||
"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.",
|
||||
"Bank and card payments are handled by Mollie, a Dutch licensed payment institution, on its own pages. Catcrafts never sees your card number or bank credentials. Cryptocurrency payments involve no provider: they are made in EURC, a euro-denominated stablecoin, directly to a wallet address held by Catcrafts and shown on the order page. Catcrafts never sees your wallet keys.",
|
||||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists. A payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
|
||||
"Neither method involves a payment provider. A bank payment is an ordinary transfer you make yourself, from your own bank to the account shown on the order page, quoting the reference shown with it: that reference is what matches your payment to your order, and a transfer without it cannot be matched. Cryptocurrency payments are made in EURC, a euro-denominated stablecoin, directly to a wallet address held by Catcrafts and shown on the order page. Catcrafts never sees your card details, your bank credentials or your wallet keys, because at no point are you asked for them. Cards are not accepted.",
|
||||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists. A payment split across several networks cannot be accepted automatically.",
|
||||
"Every refund under the sections below is owed in euros, and is paid back the way you paid. A bank transfer is refunded by transfer to the account it came from. A cryptocurrency payment is refunded in EURC 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"
|
||||
} },
|
||||
{ "Donations",
|
||||
{
|
||||
"The shop also takes donations: you name the amount, and the same payment methods apply. Mollie for bank and card, EURC for cryptocurrency. A donation is a gift that funds the open-source work; nothing is supplied in return, so no VAT is charged and no invoice is issued. The order page for a donation is its receipt.",
|
||||
"The shop also takes donations: you name the amount, and the same two payment methods apply, a bank transfer or EURC. A donation is a gift that funds the open-source work; nothing is supplied in return, so no VAT is charged and no invoice is issued. The order page for a donation is its receipt.",
|
||||
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
|
||||
"A donation is not a purchase, so the returns section below does not apply to it.",
|
||||
} },
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export struct Checkout {
|
|||
|
||||
// How the buyer's money moves. Two KINDS of money movement, not two brand
|
||||
// names: which rail serves each is the server's configuration, and writing
|
||||
// the kind (rather than "mollie"/"eurc") into the form and the ledger means
|
||||
// the kind (rather than "transfer"/"eurc") into the form and the ledger means
|
||||
// swapping a rail cannot retroactively rewrite what a buyer picked.
|
||||
//
|
||||
// These strings are the wire format — they travel in the form post and land
|
||||
|
|
|
|||
|
|
@ -361,10 +361,34 @@ export Financials LoadFinancials(std::string_view json) {
|
|||
// text: the renderer should not know what a chain id or a token decimal is, any
|
||||
// more than it knows where ECB rates come from.
|
||||
export struct OrderCryptoPay {
|
||||
std::string address; // where the money goes, "0x…"
|
||||
std::string address; // where the money goes: "0x…", or an IBAN
|
||||
std::string amount; // decimal EURC amount ("570.43") — equals the
|
||||
// euro total; EURC is euro-denominated at par
|
||||
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
|
||||
|
||||
// ── bank transfer instead of on-chain ─────────────────────────────
|
||||
//
|
||||
// Both live rails are self-hosted, so both render instructions here rather
|
||||
// than a button. Non-empty `beneficiary` is what marks this a bank
|
||||
// transfer: `address` is then an IBAN, `chains` is empty, and the copy
|
||||
// must not mention networks or tokens.
|
||||
//
|
||||
// The name is not decoration. Since 2025-10-09 every euro-area transfer is
|
||||
// name-checked against the IBAN by Verification of Payee, and the payer
|
||||
// gets a mismatch warning mid-payment if what we printed is not what the
|
||||
// bank holds. Printing a friendly trading name here would scare buyers off
|
||||
// at the last step, so this carries the bank's own spelling.
|
||||
std::string beneficiary;
|
||||
// The structured ISO 11649 form of the reference. Quoted alongside the
|
||||
// short one because the payer's bank validates its check digits and
|
||||
// rejects a mistyped one before the money leaves — which is what makes
|
||||
// unattended matching on our side safe.
|
||||
std::string structuredReference;
|
||||
// Shown only when configured, and labelled as the field a payer sending
|
||||
// from OUTSIDE SEPA needs. An IBAN alone has been sufficient within SEPA
|
||||
// since 2016, so putting a BIC in front of a Dutch buyer is one more box
|
||||
// to fill and one more thing to mistype.
|
||||
std::string bic;
|
||||
// The transfer is visible on the network but not yet in a finalized
|
||||
// block. Display-only: the buyer whose wallet said "success" needs to
|
||||
// hear "we see it, it is finalizing" or their next step is a support
|
||||
|
|
|
|||
|
|
@ -738,17 +738,47 @@ SafeHtml CustomsNote() {
|
|||
// euro price against a coin — and a collapsed dropdown hides exactly that. It
|
||||
// also needs no JavaScript, like everything else in these forms.
|
||||
//
|
||||
// Bank is pre-selected: it is what nearly every buyer wants, and an
|
||||
// unselected group would let a distracted submit land on neither.
|
||||
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
||||
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
|
||||
// Bank is pre-selected WHEN IT IS OFFERED: it is what nearly every buyer
|
||||
// wants, and an unselected group would let a distracted submit land on
|
||||
// neither.
|
||||
//
|
||||
// `offerBank` is the mirror of the `offerCrypto` that gates this whole
|
||||
// fieldset, and it defaults to true so every caller that cannot know (the
|
||||
// wasm fallback page, the suites) keeps the two-option form it always had.
|
||||
// It exists because the asymmetry was a real outage: when the bank rail went
|
||||
// away on 2026-08-20 the form went on rendering a pre-selected "Bank or card"
|
||||
// option that checkout could only answer with a 503, which is the majority of
|
||||
// buyers walking into a wall. With it false the bank option is not rendered at
|
||||
// all, so the crypto radio is the only one present AND is checked — the form
|
||||
// must still POST a `pay` value, because an absent one resolves to the bank
|
||||
// rail by design.
|
||||
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError,
|
||||
bool offerBank = true) {
|
||||
// With one option left there is nothing to choose, so it is pre-selected
|
||||
// regardless of what the buyer picked on a previous, rejected submit.
|
||||
const bool wantsCrypto = !offerBank || prev.payChoice == Form::kPayCrypto;
|
||||
// This copy has to describe whatever rail is actually in the bank slot,
|
||||
// and today that is the self-hosted transfer rail: a plain SEPA transfer
|
||||
// to the shop's own account, with the details on the order page. It
|
||||
// deliberately does NOT promise iDEAL or cards, which is what it said
|
||||
// while a hosted provider served the slot. Promising a method the rail
|
||||
// cannot take is the same class of bug as offering a rail that is not
|
||||
// configured: the buyer finds out at the last step.
|
||||
const SafeHtml bankOption = offerBank
|
||||
? Format(
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Bank transfer</strong><br>A normal transfer from )"
|
||||
R"(your own bank. Works with any bank, there is nothing to sign )"
|
||||
R"(up for, and the account details and reference appear on the )"
|
||||
R"(order page.</span></label>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"))
|
||||
: SafeHtml{};
|
||||
return 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><br>iDEAL, card, or a plain )"
|
||||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"({})"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong><br>EURC, a euro )"
|
||||
|
|
@ -758,8 +788,7 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
|||
R"(reserved for about a day.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"),
|
||||
bankOption,
|
||||
Attr("value", std::string(Form::kPayCrypto)),
|
||||
wantsCrypto ? Raw(" checked") : SafeHtml{},
|
||||
payError);
|
||||
|
|
@ -788,11 +817,15 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
|||
// 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.
|
||||
// `offerBank` is the same promise for the other slot, and defaults to true so
|
||||
// the callers that cannot know keep their previous behaviour. See
|
||||
// RenderPayFieldset for why the asymmetry had to be closed.
|
||||
SafeHtml RenderCheckoutForm(const Product& product,
|
||||
std::span<const Money::ShipRates> liveShipping,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
bool offerCrypto,
|
||||
bool offerBank = true) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
|
|
@ -886,7 +919,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage));
|
||||
|
||||
const SafeHtml payFieldset =
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
|
||||
: SafeHtml{};
|
||||
|
||||
return Format(
|
||||
R"(<section class="checkout" id="buy">)"
|
||||
|
|
@ -974,7 +1008,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
// 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 bank transfer, handled by Mollie"),
|
||||
: Raw(", paid by bank transfer"),
|
||||
Escape(Form::kShipsToMessage),
|
||||
Escape(Form::kSanctionsMessage),
|
||||
CustomsNote(),
|
||||
|
|
@ -1003,7 +1037,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
SafeHtml RenderDonationForm(const Product& product,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
bool offerCrypto,
|
||||
bool offerBank = true) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
|
|
@ -1054,26 +1089,30 @@ SafeHtml RenderDonationForm(const Product& product,
|
|||
R"(</form>)"
|
||||
R"(</section>)",
|
||||
offerCrypto ? SafeHtml{}
|
||||
: Raw(", paid by iDEAL, card, or a plain bank transfer, "
|
||||
"handled by Mollie"),
|
||||
: Raw(", paid by a plain bank transfer"),
|
||||
formError,
|
||||
Url("action", "/shop/" + product.slug + "#buy"),
|
||||
prev.amountMinor > 0
|
||||
? Attr("value", Money::FormatMinor(prev.amountMinor)) : SafeHtml{},
|
||||
errorFor("amount"),
|
||||
Attr("value", prev.email), errorFor("email"),
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{});
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay"), offerBank)
|
||||
: SafeHtml{});
|
||||
}
|
||||
|
||||
// `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.
|
||||
// server knows whether the crypto rail is configured. `offerBank` is the same
|
||||
// fact about the other slot and defaults to TRUE rather than false, because a
|
||||
// caller that cannot know must keep advertising the rail that has always been
|
||||
// there — see RenderPayFieldset.
|
||||
export RenderedPage RenderProduct(const Product& product,
|
||||
const Rates& rates,
|
||||
std::span<const Money::ShipRates> liveShipping = {},
|
||||
std::span<const Form::FieldError> errors = {},
|
||||
const Form::Checkout& prev = {},
|
||||
bool offerCrypto = false) {
|
||||
bool offerCrypto = false,
|
||||
bool offerBank = true) {
|
||||
std::vector<SafeHtml> specRows;
|
||||
for (const Spec& s : product.specs) {
|
||||
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
|
||||
|
|
@ -1295,9 +1334,10 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
SafeHtml buy;
|
||||
if (product.donation && product.Buyable()) {
|
||||
buy = RenderDonationForm(product, errors, prev, offerCrypto);
|
||||
buy = RenderDonationForm(product, errors, prev, offerCrypto, offerBank);
|
||||
} else if (product.Buyable()) {
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto,
|
||||
offerBank);
|
||||
} 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,
|
||||
|
|
@ -1404,18 +1444,28 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// badge itself, not only in the note below: the badge is where a buyer
|
||||
// who just paid looks first, and "awaiting payment" there reads as "your
|
||||
// money did not arrive" no matter what a paragraph underneath explains.
|
||||
// `seen` means different things on the two rails, so the badge must not
|
||||
// use one wording for both. On the crypto rail it means the transfer is
|
||||
// visible on chain but not yet in a finalized block. On the bank rail it
|
||||
// means money arrived that does NOT cover the order — a part payment —
|
||||
// and telling that buyer about "network confirmation" would be nonsense
|
||||
// about a mechanism their bank transfer never touches. What they need to
|
||||
// know is that their money landed and what is still outstanding.
|
||||
const bool inFlight = awaiting && o.cryptoPay && o.cryptoPay->seen;
|
||||
const bool inFlightBank = inFlight && !o.cryptoPay->beneficiary.empty();
|
||||
SafeHtml statusLine =
|
||||
inFlight ? Raw(R"(<span class="badge badge--experiment">payment detected, awaiting network confirmation</span>)")
|
||||
inFlightBank ? Raw(R"(<span class="badge badge--experiment">part payment received, waiting for the balance</span>)")
|
||||
: inFlight ? Raw(R"(<span class="badge badge--experiment">payment detected, awaiting network confirmation</span>)")
|
||||
: awaiting ? Raw(R"(<span class="badge badge--experiment">awaiting payment</span>)")
|
||||
: o.status == "paid" ? SafeHtml{}
|
||||
: o.status == "shipped" ? Raw(R"(<span class="badge badge--active">shipped</span>)")
|
||||
: Raw(R"(<span class="badge">cancelled</span>)");
|
||||
|
||||
// The buyer normally never sees the awaiting state: checkout sends them
|
||||
// straight to Mollie, and coming back the server has already confirmed
|
||||
// the payment on arrival. Reaching it means they abandoned the payment,
|
||||
// so it reads as "resume", not as an alarming limbo.
|
||||
// to the payment instructions, and both live rails render those on this
|
||||
// very page. Reaching the awaiting state therefore just means the money
|
||||
// has not arrived yet, so it reads as "here is how to pay", not as an
|
||||
// alarming limbo.
|
||||
SafeHtml payBlock;
|
||||
// Self-hosted crypto first: its payUrl is this very page, so the button
|
||||
// branch would render a link to where the buyer already stands.
|
||||
|
|
@ -1450,20 +1500,45 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
// says "success" within seconds, this shop only believes finalized
|
||||
// blocks, and a buyer left to discover that ~15-minute gap alone
|
||||
// discovers the support address instead.
|
||||
// Same two truths for either rail, in each rail's own words. The
|
||||
// lapsed branch matters more than it looks: in BOTH cases the
|
||||
// destination stays ours, so money already sent is not lost, and a
|
||||
// buyer told only "the window closed" would reasonably conclude it
|
||||
// was. The transfer wording also has to survive the case where the
|
||||
// money is simply slow — a non-instant transfer from outside the euro
|
||||
// area can arrive after the window on its own.
|
||||
const bool bank = !pay.beneficiary.empty();
|
||||
const SafeHtml windowLine = pay.minutesLeft > 0
|
||||
? Format(
|
||||
R"(<p class="order__note">This address is reserved for this order )"
|
||||
R"(for about {} more {}. This page checks automatically and )"
|
||||
R"(confirms once the full amount has arrived and the network has )"
|
||||
R"(finalized it: your wallet will report success well before then, )"
|
||||
R"(and confirmation here typically follows in 10 to 25 minutes.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
||||
: Raw(R"(<p class="order__note">The payment window for this order has )"
|
||||
R"(closed and the order will lapse. If you already sent EURC it )"
|
||||
R"(is not lost: it arrived at the address above; contact )"
|
||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||
R"(and it will be settled by hand.</p>)");
|
||||
? (bank
|
||||
? Format(
|
||||
R"(<p class="order__note">This order is held for about {} )"
|
||||
R"(more {}. Transfers inside the Netherlands usually arrive )"
|
||||
R"(within seconds, elsewhere in Europe it can take a )"
|
||||
R"(business day. This page checks automatically and confirms )"
|
||||
R"(as soon as the money lands, so there is nothing to send )"
|
||||
R"(us and nothing to wait for here.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
||||
: Format(
|
||||
R"(<p class="order__note">This address is reserved for this order )"
|
||||
R"(for about {} more {}. This page checks automatically and )"
|
||||
R"(confirms once the full amount has arrived and the network has )"
|
||||
R"(finalized it: your wallet will report success well before then, )"
|
||||
R"(and confirmation here typically follows in 10 to 25 minutes.</p>)",
|
||||
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes")))
|
||||
: (bank
|
||||
? Raw(R"(<p class="order__note">The payment window for this order has )"
|
||||
R"(closed and the order will lapse. If you already sent the )"
|
||||
R"(transfer it is not lost: the account above is ours and the )"
|
||||
R"(money arrived there. Contact )"
|
||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||
R"(with your order reference and it will be settled by hand.</p>)")
|
||||
: Raw(R"(<p class="order__note">The payment window for this order has )"
|
||||
R"(closed and the order will lapse. If you already sent EURC it )"
|
||||
R"(is not lost: it arrived at the address above; contact )"
|
||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||
R"(and it will be settled by hand.</p>)"));
|
||||
|
||||
// The in-flight state renders as the status badge up top
|
||||
// ("confirming payment"), where a buyer who just paid looks first;
|
||||
|
|
@ -1473,6 +1548,66 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(the euro amount above.</p>)",
|
||||
Escape(indicative));
|
||||
|
||||
// A non-empty beneficiary marks a BANK TRANSFER: the address is an
|
||||
// IBAN, there are no networks, and none of the token copy applies.
|
||||
// Both live rails are self-hosted and so both land in this branch;
|
||||
// what separates them is which of these two blocks renders.
|
||||
if (!pay.beneficiary.empty()) {
|
||||
// Three fields, in the order a banking app asks for them, so the
|
||||
// buyer can work straight down the page instead of hunting.
|
||||
//
|
||||
// The name comes FIRST and is labelled as exact on purpose. Every
|
||||
// euro-area transfer is now name-checked against the IBAN, and a
|
||||
// payer who types anything else gets a mismatch warning at the
|
||||
// moment of paying. Telling them why the spelling looks odd is
|
||||
// cheaper than losing the payment to a scary red banner.
|
||||
//
|
||||
// Both reference forms are offered because banks disagree about
|
||||
// where a reference goes: those with a dedicated payment-reference
|
||||
// field validate the RF form's check digits and refuse a mistyped
|
||||
// one before the money moves, which is the safer path; the rest
|
||||
// only have a free-text description, where the short code is what
|
||||
// a human will actually copy correctly.
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Pay by bank transfer</h2>)"
|
||||
R"(<p>Transfer <strong>{}</strong> to this account:)"
|
||||
R"(<dl class="order__bank">)"
|
||||
R"(<dt>Account holder</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"(<dt>IBAN</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"({})"
|
||||
R"(<dt>Payment reference</dt><dd><code class="order__address">{}</code></dd>)"
|
||||
R"(</dl>)"
|
||||
R"({})"
|
||||
R"(<p class="order__note">Please copy the the fields )"
|
||||
R"(exactly as written above. If your bank )"
|
||||
R"(has a separate field for a payment reference, use )"
|
||||
R"(<strong>{}</strong> there, it is checked for typing errors. )"
|
||||
R"(Otherwise put <strong>{}</strong> in the description. )"
|
||||
R"(Without the reference the payment cannot be matched to your )"
|
||||
R"(order. If you send too little, transfer the rest the same )"
|
||||
R"(way and the order confirms once the total arrives.</p>)"
|
||||
R"({})"
|
||||
R"(</section>)",
|
||||
Escape(Money::FormatEuro(o.totalMinor)),
|
||||
Escape(pay.beneficiary),
|
||||
Escape(pay.address),
|
||||
// Only for the payer who actually needs it. Inside SEPA the
|
||||
// IBAN is enough, so this row is absent rather than being a
|
||||
// field every Dutch buyer feels obliged to fill in.
|
||||
pay.bic.empty() ? SafeHtml{} : Format(
|
||||
R"(<dt>BIC <span class="order__bank-hint">(only if your bank )"
|
||||
R"(asks for it, usually outside Europe)</span></dt>)"
|
||||
R"(<dd><code class="order__address">{}</code></dd>)",
|
||||
Escape(pay.bic)),
|
||||
Escape(pay.structuredReference.empty() ? o.reference
|
||||
: pay.structuredReference),
|
||||
indicativeLine,
|
||||
Escape(pay.structuredReference.empty() ? o.reference
|
||||
: pay.structuredReference),
|
||||
Escape(o.reference),
|
||||
windowLine);
|
||||
} else {
|
||||
payBlock = Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Pay with EURC</h2>)"
|
||||
|
|
@ -1495,6 +1630,7 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
indicativeLine,
|
||||
Escape(o.reference),
|
||||
windowLine);
|
||||
}
|
||||
} else if (awaiting && !o.payUrl.empty()) {
|
||||
const bool crypto = o.payChoice == Form::kPayCrypto;
|
||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||
|
|
@ -1517,8 +1653,14 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(</section>)",
|
||||
indicativeLine,
|
||||
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
||||
// Both live rails render their instructions above rather than a
|
||||
// button, so reaching this branch means a HOSTED stand-in is in
|
||||
// the slot: the fake rails in the suites, or the reference
|
||||
// a hosted rail, if one is ever added again. The copy therefore
|
||||
// stays generic about methods instead of naming any, since what
|
||||
// waits behind the button is exactly what this branch cannot know.
|
||||
crypto ? Raw("The payment page completes your crypto payment;")
|
||||
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
|
||||
: Raw("The payment page completes your payment;"),
|
||||
Escape(o.reference),
|
||||
Raw("A payment left uncompleted simply lapses the order."));
|
||||
} else if (o.status == "paid") {
|
||||
|
|
|
|||
|
|
@ -1,194 +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 Mollie rail against the REAL api.mollie.com, on a test-mode key: a €1
|
||||
// donation whose payment is actually created at Mollie, polled by the real
|
||||
// reconciler, and read back by this suite with its own authenticated GET.
|
||||
// The fake-rail suites prove the lifecycle; the parser suite proves the
|
||||
// decoding; what neither can prove is the live conversation — the bearer
|
||||
// auth, the amount formatting Mollie accepts, the JSON shape they answer
|
||||
// with today. This suite is a MANDATORY deploy gate in CI (the operator's
|
||||
// call — caution over convenience); locally it skips unless
|
||||
// MOLLIE_TEST_API_KEY is exported, and in CI that same missing secret is a
|
||||
// FAILURE, never a quiet skip.
|
||||
//
|
||||
// Coverage stops at Pending, deliberately. Mollie has no API that marks a
|
||||
// test payment paid — the test-mode checkout page is where a human (or a
|
||||
// headless browser this repo does not carry) picks the outcome. So the paid
|
||||
// transition stays covered by the fake rail and by the pre-launch manual
|
||||
// click-through; what this suite pins is everything up to it: create, the
|
||||
// checkout URL, the ledger record, the poll reading "open" as still-awaiting
|
||||
// rather than as dead, and no errors on the wire. Test-mode payments expire
|
||||
// at Mollie on their own; nothing is left behind.
|
||||
//
|
||||
// The key must be a test_ key. A live_ key is refused outright, in every
|
||||
// environment: this suite creates payments, and a payment created on the
|
||||
// live key is a real invoice in the shop's Mollie dashboard.
|
||||
|
||||
import std;
|
||||
import Crafter.Network;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsCi() {
|
||||
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
|
||||
if (const char* s = std::getenv(v); s && *s) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string FirstMatch(const std::string& text, const std::string& pattern) {
|
||||
std::smatch m;
|
||||
if (std::regex_search(text, m, std::regex(pattern))) return m[1].str();
|
||||
return {};
|
||||
}
|
||||
|
||||
// One authenticated GET against the live API, the suite's own eyes on the
|
||||
// payment the server created — same endpoint the reconciler polls, but read
|
||||
// here independently so a server-side bug cannot vouch for itself.
|
||||
std::string MollieGet(const std::string& key, const std::string& path) {
|
||||
try {
|
||||
Crafter::ClientHTTP1 client("api.mollie.com", 443,
|
||||
Crafter::TLSClientCredentials{});
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = "GET";
|
||||
req.path = path;
|
||||
req.authority = "api.mollie.com";
|
||||
req.headers["authorization"] = "Bearer " + key;
|
||||
req.headers["user-agent"] = "catcrafts.net-e2e/1.0 (+https://catcrafts.net)";
|
||||
const Crafter::HTTPResponse res = client.Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "mollie e2e: GET {} -> {} {}", path, res.status,
|
||||
res.body.substr(0, 200));
|
||||
return {};
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "mollie e2e: GET {} failed: {}", path, e.what());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::println(std::cerr, "usage: ShouldCreateMollieTestPayments <server-binary>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const char* keyEnv = std::getenv("MOLLIE_TEST_API_KEY");
|
||||
if (!keyEnv || !*keyEnv) {
|
||||
if (IsCi()) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: MOLLIE_TEST_API_KEY is not set. This suite is a mandatory "
|
||||
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
|
||||
"Secrets) — the test_ key from the Mollie dashboard, Developers -> "
|
||||
"API keys. See deploy/README.md \"Live payment suites in CI\".");
|
||||
return 1;
|
||||
}
|
||||
std::println("ShouldCreateMollieTestPayments: skipped — MOLLIE_TEST_API_KEY is "
|
||||
"not set (mandatory in CI, opt-in locally)");
|
||||
return 0;
|
||||
}
|
||||
const std::string key(keyEnv);
|
||||
if (!key.starts_with("test_")) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: MOLLIE_TEST_API_KEY does not start with test_ — refusing to run "
|
||||
"a payment-creating suite on anything but a test-mode key.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
ServerOptions options;
|
||||
options.extraArgs = { "--rail=mollie", "--crypto-rail=fake-crypto" };
|
||||
options.env = { { "MOLLIE_API_KEY", key } };
|
||||
TestServer srv(argv[1], 8219, options);
|
||||
|
||||
// ── checkout: a €1 donation on the bank rail ─────────────────────
|
||||
// The 303 goes to Mollie's hosted checkout, not the order page — that IS
|
||||
// the assertion: a real payment now exists and has somewhere to be paid.
|
||||
const auto created = srv.Post("/shop/donation", "amount=1");
|
||||
Check(created.status == "303", "a €1 donation 303s to the payment",
|
||||
created.status);
|
||||
std::string checkoutUrl;
|
||||
if (const auto it = created.headers.find("location"); it != created.headers.end()) {
|
||||
checkoutUrl = it->second;
|
||||
}
|
||||
Check(checkoutUrl.starts_with("https://")
|
||||
&& checkoutUrl.find("mollie.com") != std::string::npos,
|
||||
"the redirect is Mollie's hosted checkout", checkoutUrl);
|
||||
if (created.status != "303" || checkoutUrl.empty()) {
|
||||
std::println(std::cerr, "server log:\n{}",
|
||||
ReadFile(srv.Work() / "server.log"));
|
||||
return Finish();
|
||||
}
|
||||
|
||||
// ── the ledger records the live payment ──────────────────────────
|
||||
const std::string ledger = srv.OrdersText();
|
||||
const std::string token = FirstMatch(ledger, R"lit("type":"order".*?"id":"([0-9a-f]{32})")lit");
|
||||
const std::string payId = FirstMatch(ledger, R"lit("pay_id":"(tr_[A-Za-z0-9]+)")lit");
|
||||
Check(!token.empty(), "the order reached the ledger");
|
||||
Check(!payId.empty(), "the ledger carries Mollie's tr_ payment id");
|
||||
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
|
||||
"a donation with no pay field lands on the bank rail");
|
||||
Check(ledger.find("\"total_minor\":100") != std::string::npos,
|
||||
"€1 is stored as 100 cents");
|
||||
if (token.empty() || payId.empty()) return Finish();
|
||||
const std::string orderPath = std::format("/order/{}", token);
|
||||
|
||||
// ── the suite's own read of the payment at Mollie ─────────────────
|
||||
// status open (fresh, method not yet chosen), the exact amount format
|
||||
// Mollie accepted, and OUR redirect back to this order — the round trip
|
||||
// that proves FormatMinor and the create body against the live API.
|
||||
{
|
||||
const std::string payment = MollieGet(key, "/v2/payments/" + payId);
|
||||
Check(!payment.empty(), "the payment the server created exists at Mollie");
|
||||
if (!payment.empty()) {
|
||||
Check(payment.find("\"status\":\"open\"") != std::string::npos,
|
||||
"a fresh test payment reads as open",
|
||||
FirstMatch(payment, R"lit("status":"([a-z]+)")lit"));
|
||||
Check(payment.find("\"currency\":\"EUR\"") != std::string::npos
|
||||
&& payment.find("\"value\":\"1.00\"") != std::string::npos,
|
||||
"the amount arrived as EUR 1.00");
|
||||
Check(payment.find("/order/" + token) != std::string::npos,
|
||||
"the payment's redirectUrl returns to this order");
|
||||
}
|
||||
}
|
||||
|
||||
// ── the real poll reads open as still-awaiting ────────────────────
|
||||
// Rendering the page triggers the arrival poll and the reconciler polls
|
||||
// on Mollie's 10 s cadence; give both time for at least two live GETs.
|
||||
// "Open" must stay awaiting — parsed as Dead it would cancel the order,
|
||||
// parsed as an error it would log below.
|
||||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
Check(page.find("awaiting payment") != std::string::npos,
|
||||
"the fresh order page shows awaiting payment");
|
||||
Check(page.find("Resume payment") != std::string::npos,
|
||||
"the order page offers the resume link");
|
||||
Check(page.find("mollie.com") != std::string::npos,
|
||||
"the resume link points at the hosted checkout");
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::seconds(12));
|
||||
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
|
||||
"an open payment is still awaiting after live reconciler polls");
|
||||
{
|
||||
// Any "mollie:" line is a failed call — auth, transport, or a JSON
|
||||
// shape the parser refused. Create succeeded (the checkout URL above),
|
||||
// so a clean log here means the polls succeeded too.
|
||||
const std::string log = ReadFile(srv.Work() / "server.log");
|
||||
Check(log.find("mollie:") == std::string::npos,
|
||||
"no Mollie call failed during create or polling",
|
||||
FirstMatch(log, R"((mollie:[^\n]*))"));
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
212
tests/ShouldMatchBankTransfers/main.cpp
Normal file
212
tests/ShouldMatchBankTransfers/main.cpp
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// MatchCredits: the decision that releases goods on the bank-transfer rail.
|
||||
//
|
||||
// Everything here is a case a real payer or a real bank actually produces. The
|
||||
// reference travels through a human retyping it and a bank reformatting the
|
||||
// field, so the interesting failures are all cosmetic-looking: a lower-case
|
||||
// reference, a space inserted every four characters, the structured RF form
|
||||
// quoted instead of the short one. Each of those arriving as "unpaid" would be
|
||||
// money sitting in the account against an order the shop thinks was abandoned.
|
||||
//
|
||||
// The other half is refusing to over-match. A matcher that credits an order
|
||||
// from money that was not for it is worse than one that misses: it ships goods
|
||||
// nobody paid for.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
using Server::BankCredit;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
BankCredit Credit(std::string reference, std::int64_t amountMinor,
|
||||
std::string method = "sepa", std::string id = "p1") {
|
||||
BankCredit c;
|
||||
c.id = std::move(id);
|
||||
c.reference = std::move(reference);
|
||||
c.amountMinor = amountMinor;
|
||||
c.method = std::move(method);
|
||||
return c;
|
||||
}
|
||||
|
||||
std::int64_t PaidFor(std::vector<BankCredit> credits, std::string_view reference) {
|
||||
return Server::MatchCredits(credits, reference).paidMinor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const std::string ref = "CC-2B6457";
|
||||
|
||||
// ── the forms a payer actually quotes ─────────────────────────────
|
||||
Check(PaidFor({ Credit("CC-2B6457 catcrafts.net", 5000) }, ref) == 5000,
|
||||
"the reference exactly as we printed it");
|
||||
Check(PaidFor({ Credit("cc2b6457", 5000) }, ref) == 5000,
|
||||
"lower case and no hyphen");
|
||||
Check(PaidFor({ Credit("RF70CC2B6457", 5000) }, ref) == 5000,
|
||||
"the structured RF form, whose body IS the reference");
|
||||
Check(PaidFor({ Credit("CC 2B 64 57", 5000) }, ref) == 5000,
|
||||
"a bank grouping the field into pairs");
|
||||
Check(PaidFor({ Credit("Payment for order CC-2B6457, thanks!", 5000) }, ref) == 5000,
|
||||
"the reference buried in a sentence");
|
||||
Check(PaidFor({ Credit("betaling cc/2b/6457", 5000) }, ref) == 5000,
|
||||
"separators a payer invented");
|
||||
|
||||
// ── refusing to over-match ────────────────────────────────────────
|
||||
Check(PaidFor({ Credit("CC-2B6458", 5000) }, ref) == 0,
|
||||
"one character different is a different order");
|
||||
Check(PaidFor({ Credit("no reference at all", 5000) }, ref) == 0,
|
||||
"a transfer with no reference pays for nothing");
|
||||
Check(PaidFor({ Credit("", 5000) }, ref) == 0,
|
||||
"an empty remittance field pays for nothing");
|
||||
// The needle guard: an empty or near-empty reference must not match every
|
||||
// credit on the account. This is the difference between one order settling
|
||||
// and the whole ledger settling from a single payment.
|
||||
Check(PaidFor({ Credit("anything", 5000), Credit("something", 900) }, "") == 0,
|
||||
"an empty reference matches nothing");
|
||||
Check(PaidFor({ Credit("anything", 5000) }, "CC") == 0,
|
||||
"a too-short reference matches nothing");
|
||||
|
||||
// Outgoing money must never pay for an order. A refund we sent quotes the
|
||||
// very reference of the order it refunds, so counting signed amounts
|
||||
// blindly would let a refund settle the thing it reversed.
|
||||
Check(PaidFor({ Credit("CC-2B6457 refund", -5000) }, ref) == 0,
|
||||
"an outgoing payment quoting the reference is not income");
|
||||
Check(PaidFor({ Credit("CC-2B6457", 5000), Credit("CC-2B6457 refund", -2000) },
|
||||
ref) == 5000,
|
||||
"a later refund does not reduce what arrived");
|
||||
|
||||
// ── partials accumulate ───────────────────────────────────────────
|
||||
// The buyer is told to send the difference to the same IBAN with the same
|
||||
// reference, so two credits for one order is a supported path and not an
|
||||
// anomaly. Summing is what makes that instruction true.
|
||||
Check(PaidFor({ Credit("CC-2B6457", 3000, "sepa", "a"),
|
||||
Credit("CC-2B6457", 2000, "sepa", "b") }, ref) == 5000,
|
||||
"two credits for one order are summed");
|
||||
{
|
||||
const Server::TransferMatch m = Server::MatchCredits(
|
||||
{ Credit("CC-2B6457", 3000, "sepa", "a"),
|
||||
Credit("CC-2B6457", 2000, "sepa", "b") }, ref);
|
||||
Check(m.count == 2, "the count reports how many credits carried it",
|
||||
std::format("{}", m.count));
|
||||
}
|
||||
|
||||
// ── the method reaches the ledger ─────────────────────────────────
|
||||
// The `via` column decides whether an order is safe to ship: a plain SEPA
|
||||
// transfer is final, and anything with a dispute window is not.
|
||||
{
|
||||
const Server::TransferMatch m =
|
||||
Server::MatchCredits({ Credit("CC-2B6457", 5000, "ideal") }, ref);
|
||||
Check(m.method == "ideal", "the settling method is carried out", m.method);
|
||||
}
|
||||
|
||||
// ── other orders' money is left alone ────────────────────────────
|
||||
Check(PaidFor({ Credit("CC-AAAAAA", 90000), Credit("CC-2B6457", 5000),
|
||||
Credit("CC-BBBBBB", 12345) }, ref) == 5000,
|
||||
"only the credits quoting THIS reference are counted");
|
||||
|
||||
// ── the ambiguity tripwire ───────────────────────────────────────
|
||||
// One transfer quoting two orders cannot be attributed by a per-order
|
||||
// matcher: both orders would see the full amount and both would settle on
|
||||
// the same money. The matcher cannot fix it, so it flags it for a human.
|
||||
{
|
||||
const Server::TransferMatch m =
|
||||
Server::MatchCredits({ Credit("CC-2B6457 and CC-AAAAAA", 10000) }, ref);
|
||||
Check(m.ambiguous,
|
||||
"a credit quoting a second order reference is flagged ambiguous");
|
||||
const Server::TransferMatch clean =
|
||||
Server::MatchCredits({ Credit("CC-2B6457 thanks", 10000) }, ref);
|
||||
Check(!clean.ambiguous, "an ordinary credit is not flagged");
|
||||
}
|
||||
|
||||
// An empty account is a clean zero rather than anything alarming: it is
|
||||
// simply the state of every order between checkout and payment.
|
||||
Check(PaidFor({}, ref) == 0, "no credits at all is zero, not an error");
|
||||
|
||||
// ── pulling credits into the file the rail reads ──────────────────
|
||||
//
|
||||
// Every pull re-reads an overlapping window of the account, so the SAME
|
||||
// credit arrives on every run. Appending it twice would double a payment
|
||||
// and settle an order nobody paid twice for, which makes deduplication by
|
||||
// the bank's own payment id the load-bearing property here. A file source
|
||||
// stands in for the bank so this needs no network.
|
||||
{
|
||||
const std::filesystem::path dir =
|
||||
std::filesystem::temp_directory_path() / "cc-transfer-pull-test";
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(dir, ec);
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
const std::filesystem::path bank = dir / "bank.jsonl";
|
||||
const std::filesystem::path target = dir / "credits.jsonl";
|
||||
|
||||
{
|
||||
std::ofstream out(bank);
|
||||
out << R"({"id":"p1","reference":"CC-2B6457","amount_minor":2500,)"
|
||||
R"("method":"sepa"})" << "\n";
|
||||
out << R"({"id":"p2","reference":"CC-AAAAAA","amount_minor":900,)"
|
||||
R"("method":"ideal"})" << "\n";
|
||||
}
|
||||
|
||||
auto pull = [&] {
|
||||
auto src = Server::MakeFileCreditSource(bank);
|
||||
return Server::PullCreditsInto(*src, target);
|
||||
};
|
||||
|
||||
const std::optional<int> first = pull();
|
||||
Check(first.has_value() && *first == 2, "the first pull appends both credits",
|
||||
first ? std::format("{}", *first) : "nullopt");
|
||||
const std::optional<int> second = pull();
|
||||
Check(second.has_value() && *second == 0,
|
||||
"pulling the same window again appends nothing",
|
||||
second ? std::format("{}", *second) : "nullopt");
|
||||
|
||||
// A new payment arrives at the bank; only it should be appended.
|
||||
{
|
||||
std::ofstream out(bank, std::ios::app);
|
||||
out << R"({"id":"p3","reference":"CC-2B6457","amount_minor":100,)"
|
||||
R"("method":"sepa"})" << "\n";
|
||||
}
|
||||
const std::optional<int> third = pull();
|
||||
Check(third.has_value() && *third == 1, "only the new credit is appended",
|
||||
third ? std::format("{}", *third) : "nullopt");
|
||||
|
||||
// And what the rail now reads settles correctly: 25.00 + 1.00.
|
||||
{
|
||||
auto reader = Server::MakeFileCreditSource(target);
|
||||
const auto all = reader->Recent();
|
||||
Check(all.has_value() && all->size() == 3,
|
||||
"the credits file holds exactly the three distinct credits",
|
||||
all ? std::format("{}", all->size()) : "nullopt");
|
||||
if (all) {
|
||||
Check(Server::MatchCredits(*all, ref).paidMinor == 2600,
|
||||
"the deduplicated file sums to the real total");
|
||||
}
|
||||
}
|
||||
std::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
std::println("ShouldMatchBankTransfers: all checks passed");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -41,6 +41,70 @@ int main() {
|
|||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
// ── the ISO 11649 creditor reference ──────────────────────────────
|
||||
//
|
||||
// The payer's own bank verifies these digits before the transfer leaves,
|
||||
// so a generator that computes them wrong is invisible here and rejected
|
||||
// at every bank in the country. Hence: verify our own output, verify the
|
||||
// verifier rejects tampering, and pin one value literally so a refactor
|
||||
// cannot quietly change the arithmetic.
|
||||
const std::string rf = Server::CreditorReferenceFromToken(
|
||||
"abcdef0123456789abcdef0123456789");
|
||||
Check(Server::IsValidCreditorReference(rf),
|
||||
"creditor ref: generator output verifies", rf);
|
||||
Check(rf.starts_with("RF") && rf.size() == 12,
|
||||
"creditor ref: RF + 2 check digits + CCABCDEF", rf);
|
||||
Check(rf.substr(4) == "CCABCDEF",
|
||||
"creditor ref: body is the human reference without the hyphen", rf);
|
||||
Check(Server::CreditorReferenceFromToken("abcdef0123456789abcdef0123456789") == rf,
|
||||
"creditor ref: derived, so it is stable for one token");
|
||||
|
||||
// The guarantee mod-97-10 actually gives, asserted as the theorem it is
|
||||
// rather than as an empirical count: every single-character substitution
|
||||
// that keeps the character's CLASS is always caught. A letter contributes
|
||||
// two decimal digits (A=10 … Z=35) and a digit contributes one, so a
|
||||
// same-class change shifts the remainder by d*10^k or d*100^k with
|
||||
// |d| < 97; since 97 is prime that product is never ≡ 0, so the checksum
|
||||
// always moves. This is the case that matters — a donor retyping one
|
||||
// character of a reference is stopped by their own bank.
|
||||
//
|
||||
// A change that crosses classes (letter to digit) alters the length of the
|
||||
// decimal expansion and is therefore an ordinary 1-in-97 checksum bet, not
|
||||
// a guarantee. Exactly one such mutation of this reference does slip
|
||||
// through, which is the standard behaving as designed and not a defect;
|
||||
// asserting otherwise would be pinning an accident.
|
||||
int caught = 0, mutations = 0;
|
||||
for (std::size_t i = 2; i < rf.size(); ++i) {
|
||||
const bool isDigit = rf[i] >= '0' && rf[i] <= '9';
|
||||
const std::string_view sameClass =
|
||||
isDigit ? "0123456789" : "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
for (const char c : sameClass) {
|
||||
if (rf[i] == c) continue;
|
||||
std::string bad = rf;
|
||||
bad[i] = c;
|
||||
++mutations;
|
||||
if (!Server::IsValidCreditorReference(bad)) ++caught;
|
||||
}
|
||||
}
|
||||
Check(mutations > 0 && caught == mutations,
|
||||
"creditor ref: every same-class one-character change fails verification",
|
||||
std::format("{} of {} caught", caught, mutations));
|
||||
|
||||
Check(!Server::IsValidCreditorReference("RF00CCABCDEF"),
|
||||
"creditor ref: wrong check digits rejected");
|
||||
Check(!Server::IsValidCreditorReference("CCABCDEF"),
|
||||
"creditor ref: missing RF prefix rejected");
|
||||
Check(!Server::IsValidCreditorReference("RF"),
|
||||
"creditor ref: too short rejected");
|
||||
Check(!Server::IsValidCreditorReference(rf + "TOOLONGTOOLONGTOOLONGTOOLONG"),
|
||||
"creditor ref: over 25 characters rejected");
|
||||
Check(!Server::IsValidCreditorReference("RF18CC-ABCDEF"),
|
||||
"creditor ref: non-alphanumeric body rejected");
|
||||
// The canonical example from the standard's own documentation, so this is
|
||||
// pinned against an outside source and not only against ourselves.
|
||||
Check(Server::IsValidCreditorReference("RF18539007547034"),
|
||||
"creditor ref: the published ISO 11649 example verifies");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
157
tests/ShouldParseBunqPayments/main.cpp
Normal file
157
tests/ShouldParseBunqPayments/main.cpp
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Decoding bunq's payment list into credits. The HTTP around this is thin; the
|
||||
// decoding is where a mistake costs money and says nothing, which is the same
|
||||
// reason ParseEthCallUint and ParseEurcChains are pinned here rather than
|
||||
// trusted to integration.
|
||||
//
|
||||
// Two properties carry real weight:
|
||||
//
|
||||
// * The SIGN. bunq quotes an outgoing payment as a negative value, and the
|
||||
// shop sends refunds quoting the very reference of the order they refund.
|
||||
// Lose the minus and a refund pays for the order it reversed.
|
||||
// * The METHOD. `Payment.type` decides whether an order is safe to post: a
|
||||
// SEPA credit transfer is final, a card payment can be reversed for
|
||||
// months. Collapsing them to "paid" is how a chargeback becomes a
|
||||
// shipped parcel.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
// The shape bunq actually answers with: everything wrapped in Response, one
|
||||
// object per entry keyed by type.
|
||||
constexpr std::string_view kList = R"({
|
||||
"Response": [
|
||||
{"Payment": {
|
||||
"id": 4155551,
|
||||
"type": "EBA_SCT",
|
||||
"description": "CC-2B6457 catcrafts.net",
|
||||
"amount": {"currency": "EUR", "value": "57.38"}
|
||||
}},
|
||||
{"Payment": {
|
||||
"id": 4155552,
|
||||
"type": "IDEAL",
|
||||
"description": "donation cc2b6457",
|
||||
"amount": {"currency": "EUR", "value": "5.00"}
|
||||
}},
|
||||
{"Payment": {
|
||||
"id": 4155553,
|
||||
"type": "EBA_SCT",
|
||||
"description": "supplier invoice",
|
||||
"amount": {"currency": "EUR", "value": "-513.00"}
|
||||
}},
|
||||
{"Payment": {
|
||||
"id": 4155554,
|
||||
"type": "FIS",
|
||||
"description": "card payment",
|
||||
"amount": {"currency": "USD", "value": "20.00"}
|
||||
}}
|
||||
]
|
||||
})";
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const std::vector<Server::BankCredit> credits = Server::ParseBunqPayments(kList);
|
||||
|
||||
// The USD entry is dropped: counting 20 of something else as 20 euro is
|
||||
// the kind of bug that only shows up as a shortfall nobody can explain.
|
||||
Check(credits.size() == 3, "only the euro payments are decoded",
|
||||
std::format("{}", credits.size()));
|
||||
|
||||
if (credits.size() == 3) {
|
||||
Check(credits[0].id == "4155551", "the bank's own id is kept, for dedupe",
|
||||
credits[0].id);
|
||||
Check(credits[0].reference == "CC-2B6457 catcrafts.net",
|
||||
"the description is the remittance text, verbatim", credits[0].reference);
|
||||
Check(credits[0].amountMinor == 5738, "57.38 decodes to 5738 minor units",
|
||||
std::format("{}", credits[0].amountMinor));
|
||||
Check(credits[0].method == "sepa", "EBA_SCT is a plain SEPA transfer",
|
||||
credits[0].method);
|
||||
Check(credits[1].method == "ideal", "IDEAL is carried as its own method",
|
||||
credits[1].method);
|
||||
// The load-bearing one.
|
||||
Check(credits[2].amountMinor == -51300,
|
||||
"an outgoing payment keeps its minus sign",
|
||||
std::format("{}", credits[2].amountMinor));
|
||||
}
|
||||
|
||||
// End to end through the matcher: the outgoing line must not pay for
|
||||
// anything, and the two incoming ones must sum.
|
||||
{
|
||||
const Server::TransferMatch m = Server::MatchCredits(credits, "CC-2B6457");
|
||||
Check(m.paidMinor == 6238,
|
||||
"the two incoming credits sum and the outgoing one is ignored",
|
||||
std::format("{}", m.paidMinor));
|
||||
Check(m.count == 2, "two credits matched", std::format("{}", m.count));
|
||||
}
|
||||
|
||||
// ── the method mapping, pinned individually ───────────────────────
|
||||
Check(Server::BunqMethodFor("EBA_SCT") == "sepa", "EBA_SCT -> sepa");
|
||||
Check(Server::BunqMethodFor("IDEAL") == "ideal", "IDEAL -> ideal");
|
||||
Check(Server::BunqMethodFor("FIS") == "card", "FIS -> card (reversible!)");
|
||||
Check(Server::BunqMethodFor("BUNQ") == "bunq", "BUNQ -> bunq");
|
||||
Check(Server::BunqMethodFor("SWIFT") == "swift", "SWIFT -> swift");
|
||||
Check(Server::BunqMethodFor("EBA_SDD") == "directdebit", "EBA_SDD -> directdebit");
|
||||
// An unknown type reaches the ledger verbatim rather than as a comfortable
|
||||
// guess: the `via` column should show what bunq said, so a new payment
|
||||
// type is visible instead of silently filed as an ordinary transfer.
|
||||
Check(Server::BunqMethodFor("SOMETHING_NEW") == "SOMETHING_NEW",
|
||||
"an unknown type is passed through, not guessed at");
|
||||
Check(Server::BunqMethodFor("") == "bank", "an absent type falls back to 'bank'");
|
||||
|
||||
// ── signed amount parsing ─────────────────────────────────────────
|
||||
Check(Server::ParseSignedAmountToMinor("0.01") == 1, "one cent");
|
||||
Check(Server::ParseSignedAmountToMinor("-0.01") == -1, "minus one cent");
|
||||
Check(Server::ParseSignedAmountToMinor("57.4") == 5740, "one decimal is tenths");
|
||||
Check(Server::ParseSignedAmountToMinor("665") == 66500, "no decimal point");
|
||||
Check(!Server::ParseSignedAmountToMinor("1.234").has_value(),
|
||||
"three decimals is not money");
|
||||
Check(!Server::ParseSignedAmountToMinor("1,00").has_value(),
|
||||
"a comma decimal is refused rather than guessed");
|
||||
Check(!Server::ParseSignedAmountToMinor("1e2").has_value(), "no exponents");
|
||||
Check(!Server::ParseSignedAmountToMinor("").has_value(), "empty is not zero");
|
||||
Check(!Server::ParseSignedAmountToMinor("-").has_value(), "a bare sign is not zero");
|
||||
Check(!Server::ParseSignedAmountToMinor(" 1.00").has_value(), "no leading space");
|
||||
|
||||
// ── malformed input yields nothing, never a wrong number ──────────
|
||||
Check(Server::ParseBunqPayments("").empty(), "empty input decodes to nothing");
|
||||
Check(Server::ParseBunqPayments("not json").empty(), "garbage decodes to nothing");
|
||||
Check(Server::ParseBunqPayments(R"({"Response":[]})").empty(),
|
||||
"an empty account decodes to nothing");
|
||||
Check(Server::ParseBunqPayments(R"({"Response":"nope"})").empty(),
|
||||
"a Response that is not an array decodes to nothing");
|
||||
// A payment whose amount will not parse is SKIPPED, not counted as zero:
|
||||
// an unparseable amount means we do not know what arrived.
|
||||
Check(Server::ParseBunqPayments(
|
||||
R"({"Response":[{"Payment":{"id":1,"type":"EBA_SCT","description":"x",)"
|
||||
R"("amount":{"currency":"EUR","value":"1.234"}}}]})").empty(),
|
||||
"an unparseable amount is skipped rather than read as zero");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
std::println("ShouldParseBunqPayments: all checks passed");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,82 +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 wire-amount parser (Mollie quotes amounts as strings) and the Mollie
|
||||
// payment parser — the line between "the buyer paid" and "the provider said
|
||||
// something we did not understand". Both refuse rather than guess.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// ── the wire-amount parser ────────────────────────────────────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
|
||||
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
|
||||
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
|
||||
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
|
||||
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
|
||||
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
|
||||
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
|
||||
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
|
||||
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
|
||||
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
|
||||
|
||||
// ── the Mollie payment parser ─────────────────────────────────────
|
||||
{
|
||||
const auto p1 = Server::ParseMolliePayment(R"({
|
||||
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
|
||||
"amount":{"value":"578.30","currency":"EUR"},
|
||||
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
|
||||
Check(p1.has_value(), "mollie: open payment parses");
|
||||
if (p1) {
|
||||
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
|
||||
Check(p1->status == "open", "mollie: status");
|
||||
Check(p1->amountMinor == 57830, "mollie: amount to cents");
|
||||
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
|
||||
"mollie: checkout link");
|
||||
Check(p1->method.empty(), "mollie: null method is empty");
|
||||
}
|
||||
const auto p2 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_x","status":"paid","method":"ideal",
|
||||
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
|
||||
Check(p2 && p2->status == "paid" && p2->method == "ideal",
|
||||
"mollie: paid payment carries the method");
|
||||
const auto p3 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
|
||||
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseMolliePayment("garbage").has_value(),
|
||||
"mollie: malformed payload rejected");
|
||||
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
|
||||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
// 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.
|
||||
// not stored would mean crypto orders being asked about at the bank.
|
||||
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
|
||||
|
|
@ -815,6 +815,39 @@ void RejectedFormEcho(TestServer& srv) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── one rail down ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The regression guard for a real outage: on 2026-08-20 the bank provider
|
||||
// closed the shop's account, the operator correctly dropped the credential,
|
||||
// and the form went on rendering a PRE-SELECTED "Bank or card" option that
|
||||
// checkout could then only answer with a 503 — the majority of buyers walking
|
||||
// into a wall. The crypto slot had always been rendered conditionally; the
|
||||
// bank slot never was, because until that day it had never been absent.
|
||||
//
|
||||
// Its own server, because a rail roster is fixed at startup. Donations are
|
||||
// what this asserts against: they are open in BOTH shop states, so this runs
|
||||
// whatever fp6-pmos's status is.
|
||||
void OneRailDown(const char* binary) {
|
||||
ServerOptions options;
|
||||
options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" };
|
||||
TestServer srv(binary, 8221, options);
|
||||
|
||||
srv.BodyHas("/shop/donation", "name=\"pay\"",
|
||||
"with one rail down the form still names the choice it has");
|
||||
srv.BodyHas("/shop/donation", "value=\"crypto\" checked",
|
||||
"the surviving rail is pre-selected, so a plain submit is payable");
|
||||
srv.BodyLacks("/shop/donation", "value=\"bank\"",
|
||||
"the dead rail is not offered at all");
|
||||
|
||||
// The proof that the rendering and the handler agree: submitting the form
|
||||
// exactly as rendered — no pay field touched — must create an order rather
|
||||
// than be refused. An absent `pay` resolves to the BANK rail by design, so
|
||||
// this is what would fail if the fieldset ever stopped pre-selecting.
|
||||
const auto created = srv.Post("/shop/donation", "amount=5&pay=crypto");
|
||||
Check(created.status == "303",
|
||||
"a donation on the surviving rail goes through");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
|
@ -838,5 +871,7 @@ int main(int argc, char** argv) {
|
|||
RejectedFormEcho(srv);
|
||||
}
|
||||
|
||||
OneRailDown(argv[1]);
|
||||
|
||||
return Finish();
|
||||
}
|
||||
|
|
|
|||
272
tests/ShouldSettleBankTransfers/main.cpp
Normal file
272
tests/ShouldSettleBankTransfers/main.cpp
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
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 bank-transfer rail end to end, against the REAL rail rather than a
|
||||
// stand-in: a donation placed over HTTP, the account details read back off the
|
||||
// order page exactly as a buyer would, then the credit the bank would have
|
||||
// reported written to the credits file, and the reconciler settling the order.
|
||||
//
|
||||
// The counterpart of ShouldSettleEurcOnTestnet, with one structural difference
|
||||
// worth stating plainly. That suite has to talk to real nodes because the rail
|
||||
// depends on a conversation with machines we do not control, so a mistake in it
|
||||
// costs money and only a live call can catch it. This rail depends on no such
|
||||
// conversation: the money arrives at our own account, and the only thing
|
||||
// between a payer and a settled order is OUR code — the reference matching, the
|
||||
// covering-amount rule, the window, the ledger. All of which is exactly what a
|
||||
// suite can drive with no network and no credential at all.
|
||||
//
|
||||
// So this needs no secret, never flakes on someone else's RPC, and runs on
|
||||
// every deploy unconditionally. The bank stands in for itself only in the sense
|
||||
// that the suite writes the credits file — the same file `--pull-credits` fills
|
||||
// from the real account, read by the same parser, matched by the same matcher.
|
||||
// Everything downstream of that line is production code.
|
||||
//
|
||||
// What the unit suites already cover, and this one deliberately does not
|
||||
// re-prove: the matching table (ShouldMatchBankTransfers) and bunq's payload
|
||||
// decoding (ShouldParseBunqPayments). What only this suite can show is that the
|
||||
// pieces are wired to each other — that the reference the PAGE prints is the
|
||||
// reference the MATCHER looks for, which is the seam where a rename or a
|
||||
// refactor would quietly break settlement while every unit test still passed.
|
||||
|
||||
import std;
|
||||
import Crafter.Network;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Deliberately not the shop's real account: a suite that embedded the live IBAN
|
||||
// would publish it into CI logs on every run, and would keep passing if
|
||||
// production's configuration silently changed.
|
||||
constexpr std::string_view kIban = "NL00TEST0123456789";
|
||||
constexpr std::string_view kBeneficiary = "Catcrafts E2E, not a real account";
|
||||
constexpr std::string_view kBic = "TESTNL2A";
|
||||
|
||||
// What a PAID donation page says. There is no "paid" badge to look for — the
|
||||
// renderer deliberately omits one, because the thank-you notice below already
|
||||
// carries the state and two stacked pills read as a bug. So this copy is the
|
||||
// signal, and if it ever changes this suite is supposed to fail: a buyer who
|
||||
// paid and sees no acknowledgement is the failure being guarded against.
|
||||
constexpr std::string_view kPaidCopy = "Your donation funds";
|
||||
|
||||
// Append one credit exactly as the bank would have reported it. `reference` is
|
||||
// free text on purpose: the whole point is to send it through the same mangling
|
||||
// a real payer and a real bank apply.
|
||||
void Credit(const fs::path& creditsFile, std::string_view id,
|
||||
std::string_view reference, std::int64_t amountMinor,
|
||||
std::string_view method = "sepa") {
|
||||
std::ofstream out(creditsFile, std::ios::app | std::ios::binary);
|
||||
out << std::format(
|
||||
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})",
|
||||
id, reference, amountMinor, method) << "\n";
|
||||
}
|
||||
|
||||
// The order reference ("CC-XXXXXX") out of the ledger's newest order event.
|
||||
std::string NewestReference(const TestServer& srv) {
|
||||
const std::string text = srv.OrdersText();
|
||||
const std::string key = "\"ref\":\"";
|
||||
std::string found;
|
||||
for (std::size_t at = text.find(key); at != std::string::npos;
|
||||
at = text.find(key, at + 1)) {
|
||||
const std::size_t start = at + key.size();
|
||||
const std::size_t end = text.find('"', start);
|
||||
if (end == std::string::npos) break;
|
||||
found = text.substr(start, end - start);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
std::string TokenOf(const Crafter::HTTPResponse& res) {
|
||||
const auto loc = res.headers.find("location");
|
||||
if (loc == res.headers.end()) return {};
|
||||
const std::string& url = loc->second;
|
||||
const std::size_t at = url.rfind('/');
|
||||
return at == std::string::npos ? std::string{} : url.substr(at + 1);
|
||||
}
|
||||
|
||||
// Every order this suite places is a donation, for the same reason the donation
|
||||
// item exists in the other suites: it is purchasable in BOTH shop states, so
|
||||
// this runs whether or not fp6-pmos has been opened, and it needs no shipping
|
||||
// address or rate table.
|
||||
struct Order {
|
||||
std::string token;
|
||||
std::string reference;
|
||||
};
|
||||
|
||||
Order PlaceDonation(TestServer& srv, std::string_view amount) {
|
||||
const auto res = srv.Post("/shop/donation", std::format("amount={}", amount));
|
||||
Order out;
|
||||
if (res.status != "303") {
|
||||
Check(false, "a donation on the transfer rail is accepted",
|
||||
std::format("status {}", res.status));
|
||||
return out;
|
||||
}
|
||||
out.token = TokenOf(res);
|
||||
out.reference = NewestReference(srv);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::println(std::cerr, "usage: ShouldSettleBankTransfers <server-binary>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
ServerOptions options;
|
||||
// The REAL transfer rail in the bank slot. The crypto slot is off, so any
|
||||
// order this suite places must have gone through the rail under test.
|
||||
options.extraArgs = { "--rail=transfer", "--crypto-rail=off" };
|
||||
options.env = {
|
||||
{ "TRANSFER_IBAN", std::string(kIban) },
|
||||
{ "TRANSFER_BENEFICIARY", std::string(kBeneficiary) },
|
||||
{ "TRANSFER_BIC", std::string(kBic) },
|
||||
// Short enough that the lapse case below does not need a fifteen-minute
|
||||
// suite, long enough that nothing else races it.
|
||||
{ "TRANSFER_WINDOW_HOURS", "1" },
|
||||
// The rail's production cadence is 60 s, which would make this suite
|
||||
// minutes long for no benefit: there is no real bank here to be polite
|
||||
// to, only a local file.
|
||||
{ "TRANSFER_POLL_SECONDS", "1" },
|
||||
};
|
||||
TestServer srv(argv[1], 8222, options);
|
||||
|
||||
const fs::path credits(srv.Orders().string() + ".transfer-credits.jsonl");
|
||||
|
||||
// ── what the buyer is told ────────────────────────────────────────
|
||||
//
|
||||
// Asserted from the rendered page rather than from configuration, because
|
||||
// the failure being guarded against is the page and the rail disagreeing.
|
||||
{
|
||||
const Order order = PlaceDonation(srv, "12.50");
|
||||
Check(!order.token.empty(), "checkout returns an order page URL");
|
||||
if (order.token.empty()) return Finish();
|
||||
const std::string path = "/order/" + order.token;
|
||||
|
||||
srv.BodyHas(path, "Pay by bank transfer", "the page names the method");
|
||||
srv.BodyHas(path, kIban, "the IBAN the money must go to");
|
||||
srv.BodyHas(path, "Catcrafts E2E", "the beneficiary name is shown");
|
||||
srv.BodyHas(path, kBic, "the BIC is shown when one is configured");
|
||||
srv.BodyHas(path, order.reference, "the short reference is shown");
|
||||
// The structured form is what a payer's own bank check-digit-validates,
|
||||
// so its absence would quietly remove the protection that makes
|
||||
// unattended matching safe.
|
||||
srv.BodyHas(path, "RF", "the structured ISO 11649 reference is shown");
|
||||
srv.BodyHas(path, "€12.50", "the exact amount to transfer");
|
||||
// No hosted checkout exists, so nothing may invite the buyer to leave.
|
||||
srv.BodyLacks(path, "Resume payment",
|
||||
"a self-hosted rail offers no hosted checkout button");
|
||||
srv.BodyLacks(path, "EURC",
|
||||
"the bank rail's page says nothing about tokens");
|
||||
|
||||
// ── settlement, through a reference a human retyped ────────────
|
||||
//
|
||||
// Lower case, the hyphen replaced by a space, and buried in words: the
|
||||
// shape a real remittance field arrives in. If this fails while
|
||||
// ShouldMatchBankTransfers passes, the page and the matcher have drifted
|
||||
// apart, which is the whole reason this assertion is here and not there.
|
||||
std::string mangled = order.reference;
|
||||
for (char& c : mangled) {
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
if (c == '-') c = ' ';
|
||||
}
|
||||
// "ideal" rather than "sepa" on purpose, and it is not fiction even
|
||||
// though checkout offers no iDEAL. `via` is not a method this shop
|
||||
// OFFERS — it is what the bank reports about how the money reached the
|
||||
// account, straight out of bunq's Payment.type. Money can arrive there
|
||||
// iDEAL-funded without our checkout being involved: an old bunq.me
|
||||
// link, or a bunq-to-bunq payment, both land in the same account, and
|
||||
// the rail settles on the REFERENCE regardless of how the payer funded
|
||||
// it. Using a non-default method here is what proves that passthrough
|
||||
// works, which matters because the `via` column is what tells the
|
||||
// operator whether an order is safe to ship: sepa is final, anything
|
||||
// card- or Wero-funded carries a dispute window.
|
||||
Credit(credits, "e2e-1", std::format("betaling {} bedankt", mangled),
|
||||
1250, "ideal");
|
||||
|
||||
// Deliberately NOT looking for the word "paid": the paid state renders
|
||||
// no badge on purpose (see RenderOrder — two stacked "paid" pills read
|
||||
// as a rendering bug), so the thank-you copy is what marks it.
|
||||
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
|
||||
Check(body.find(kPaidCopy) != std::string::npos,
|
||||
"the order settles once the credit appears",
|
||||
"the order page never reached its paid state");
|
||||
srv.BodyLacks(path, "awaiting payment",
|
||||
"and it stops asking to be paid");
|
||||
// The method has to survive into the ledger: it is what tells the
|
||||
// operator whether an order is safe to ship.
|
||||
Check(srv.OrdersText().find("\"via\":\"ideal\"") != std::string::npos,
|
||||
"the settling method reaches the ledger");
|
||||
}
|
||||
|
||||
// ── a partial payment does not settle, and then completes ─────────
|
||||
//
|
||||
// The page promises that sending too little can be topped up with a second
|
||||
// transfer. That promise is only true if the rail sums credits, so it is
|
||||
// worth proving over HTTP rather than trusting the unit test alone.
|
||||
{
|
||||
const Order order = PlaceDonation(srv, "40.00");
|
||||
if (order.token.empty()) return Finish();
|
||||
const std::string path = "/order/" + order.token;
|
||||
|
||||
Credit(credits, "e2e-2a", order.reference, 1500);
|
||||
// Long enough for several reconciler sweeps to have seen it.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(4));
|
||||
srv.BodyLacks(path, kPaidCopy,
|
||||
"a part payment does not settle the order");
|
||||
// And the buyer is told what actually happened. This assertion caught a
|
||||
// real bug: the in-flight badge was written for the crypto rail and
|
||||
// told a bank payer their transfer was "awaiting network confirmation",
|
||||
// which is nonsense about a mechanism a SEPA transfer never touches.
|
||||
srv.BodyHas(path, "part payment received",
|
||||
"a part payment says so, in bank terms");
|
||||
|
||||
Credit(credits, "e2e-2b", order.reference, 2500);
|
||||
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
|
||||
Check(body.find(kPaidCopy) != std::string::npos,
|
||||
"the balance arriving later settles it",
|
||||
"two credits summing to the total did not settle");
|
||||
}
|
||||
|
||||
// ── money that is not for this order is left alone ────────────────
|
||||
//
|
||||
// The dangerous failure is the opposite of a missed payment: an order
|
||||
// settling on somebody else's money, which ships goods nobody paid for.
|
||||
{
|
||||
const Order order = PlaceDonation(srv, "25.00");
|
||||
if (order.token.empty()) return Finish();
|
||||
const std::string path = "/order/" + order.token;
|
||||
|
||||
// Enough money, wrong reference.
|
||||
Credit(credits, "e2e-3a", "CC-ZZZZZZ", 2500);
|
||||
// The right reference, but leaving the account rather than entering it:
|
||||
// a refund quotes the very reference of the order it reverses.
|
||||
Credit(credits, "e2e-3b", order.reference, -2500);
|
||||
// A reference that merely contains ours as a prefix must not match
|
||||
// either — this is the assertion that a sloppier "starts with" rule
|
||||
// would fail.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(4));
|
||||
srv.BodyLacks(path, kPaidCopy,
|
||||
"another order's credit and an outgoing payment settle nothing");
|
||||
// Nothing landed for THIS order, so it is still plainly awaiting: not
|
||||
// the part-payment state, which would mean we had counted money that
|
||||
// was not for it.
|
||||
srv.BodyHas(path, "awaiting payment",
|
||||
"and the order still reads as simply unpaid");
|
||||
|
||||
Credit(credits, "e2e-3c", order.reference, 2500);
|
||||
const std::string body = srv.WaitForBody(path, kPaidCopy, 120);
|
||||
Check(body.find(kPaidCopy) != std::string::npos,
|
||||
"the order's own credit still settles it afterwards");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
|
|
@ -62,7 +62,8 @@ void CatalogueContract() {
|
|||
// variant, in the same arithmetic the invoice and checkout use:
|
||||
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
|
||||
// has its own round-trip guarantee in ShouldComputeMoney, and
|
||||
// Mollie's per-transaction fee is the one accepted deviation.
|
||||
// Payment costs are the one accepted deviation, and on a bank
|
||||
// transfer they are zero.
|
||||
for (const auto& [slug, supplier] :
|
||||
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
|
||||
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
// The black-box test harness: spawns the REAL catcrafts-server binary on a
|
||||
// scratch port with a temporary orders file and the FAKE payment rails, so a
|
||||
// suite never touches real data, never dials Mollie, and needs no setup.
|
||||
// suite never touches real data, never dials a bank, and needs no setup.
|
||||
//
|
||||
// This is the C++ port of what tools/e2e.sh used to set up in shell. Each
|
||||
// suite is its own process (crafter-build runs them in PARALLEL), so every
|
||||
|
|
@ -136,7 +136,7 @@ public:
|
|||
// .env must not leak real provider keys into the test server — live
|
||||
// Sendcloud rates would silently change the shipping totals the
|
||||
// suites assert.
|
||||
for (const char* v : { "MOLLIE_API_KEY", "EURC_CHAINS", "EURC_POOL",
|
||||
for (const char* v : { "TRANSFER_IBAN", "EURC_CHAINS", "EURC_POOL",
|
||||
"SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY",
|
||||
"SENDCLOUD_METHOD",
|
||||
"INVOICE_GPG_KEY", "MAIL_COMMAND", "MAIL_FROM" }) {
|
||||
|
|
|
|||
57
tools/dev-credit.sh
Executable file
57
tools/dev-credit.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/sh
|
||||
# Pretend the bank reported an incoming transfer, so a dev order can be paid
|
||||
# without a bank.
|
||||
#
|
||||
# The transfer rail settles an order when a credit quoting its reference shows
|
||||
# up in the credits file. In production that file is filled by
|
||||
# `catcrafts-server --pull-credits` reading the real account; here it is filled
|
||||
# by hand. Same file, same format, same matching code — which is the point:
|
||||
# this exercises the real settlement path rather than a test double.
|
||||
#
|
||||
# tools/dev-credit.sh <orders-file> <reference> <amount-in-cents> [method]
|
||||
#
|
||||
# Example, paying order CC-2B6457 the €570.43 it is waiting for:
|
||||
# tools/dev-credit.sh /tmp/dev/orders.jsonl CC-2B6457 57043
|
||||
#
|
||||
# The reference is matched forgivingly (case, spacing and punctuation are
|
||||
# ignored, and the RF… form works too), so it is worth deliberately mangling it
|
||||
# to watch that hold:
|
||||
# tools/dev-credit.sh /tmp/dev/orders.jsonl "betaling cc 2b6457 bedankt" 57043
|
||||
set -eu
|
||||
|
||||
if [ "$#" -lt 3 ]; then
|
||||
echo "usage: $0 <orders-file> <reference> <amount-in-cents> [method]" >&2
|
||||
echo " method defaults to sepa; try 'ideal' or 'card' to see the ledger's" >&2
|
||||
echo " via column change, which is what decides if an order is safe to ship." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ORDERS="$1"
|
||||
REFERENCE="$2"
|
||||
CENTS="$3"
|
||||
METHOD="${4:-sepa}"
|
||||
CREDITS="$ORDERS.transfer-credits.jsonl"
|
||||
|
||||
case "$CENTS" in
|
||||
''|*[!0-9]*) echo "$0: amount must be whole cents, got '$CENTS'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# A unique id per line, because the rail deduplicates on it: appending the same
|
||||
# id twice is deliberately a no-op, which is what stops a repeated pull from
|
||||
# double-crediting an order. Using a counter keeps each hand-made credit
|
||||
# distinct without needing a clock.
|
||||
n=1
|
||||
if [ -f "$CREDITS" ]; then
|
||||
n=$(( $(wc -l < "$CREDITS") + 1 ))
|
||||
fi
|
||||
|
||||
# Escape the two characters that would break the line-per-record format. The
|
||||
# reference is free text on purpose so it can be mangled realistically.
|
||||
escaped=$(printf '%s' "$REFERENCE" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
|
||||
printf '{"id":"dev-%s","reference":"%s","amount_minor":%s,"method":"%s"}\n' \
|
||||
"$n" "$escaped" "$CENTS" "$METHOD" >> "$CREDITS"
|
||||
|
||||
echo "credited $CENTS cents quoting '$REFERENCE' (method $METHOD)"
|
||||
echo "wrote $CREDITS"
|
||||
echo "the reconciler sweeps about once a minute; watch the dev log for 'paid'"
|
||||
62
tools/dev.sh
62
tools/dev.sh
|
|
@ -133,30 +133,41 @@ cat > "$WORK/Caddyfile" <<EOF
|
|||
EOF
|
||||
|
||||
# Rail selection for dev:
|
||||
# * a repo-root .env (gitignored, never committed) is sourced if present —
|
||||
# put MOLLIE_API_KEY=test_… there to point dev at Mollie's real test mode;
|
||||
# * DEV_RAIL=fake|mollie overrides the automatic choice;
|
||||
# * default with no key is the fake rail: full order lifecycle, no network.
|
||||
# "Pay" an order with: touch $WORK/orders.jsonl.fake-paid
|
||||
# * a repo-root .env (gitignored, never committed) is sourced if present;
|
||||
# * DEV_RAIL=transfer|fake overrides the automatic choice;
|
||||
# * default is TRANSFER, because that is what production serves. Dev used to
|
||||
# default to the fake rail, which meant the bank rail's actual page — the
|
||||
# IBAN, the beneficiary name, the reference, the whole thing a buyer reads
|
||||
# — could not be looked at locally at all.
|
||||
#
|
||||
# A live_ key is refused outright. Dev creates throwaway orders; pointing them
|
||||
# at real money collection is never what anyone meant.
|
||||
# The transfer rail needs no credential, so dev can run the REAL rail rather
|
||||
# than a stand-in: it only needs an account to name. If .env does not name one,
|
||||
# obviously-fake values are used, and they are labelled as such on purpose —
|
||||
# never make fixture data look real (a sample financials file was once mistaken
|
||||
# for the actual books).
|
||||
#
|
||||
# Nothing here can collect real money: the transfer rail only RENDERS account
|
||||
# details, and settlement comes from a credits file this machine writes.
|
||||
if [ -f .env ]; then
|
||||
set -a; . ./.env; set +a
|
||||
fi
|
||||
RAIL="${DEV_RAIL:-}"
|
||||
if [ -z "$RAIL" ]; then
|
||||
RAIL=fake
|
||||
[ -n "${MOLLIE_API_KEY:-}" ] && RAIL=mollie
|
||||
fi
|
||||
if [ "$RAIL" = mollie ]; then
|
||||
case "${MOLLIE_API_KEY:-}" in
|
||||
test_*) echo "dev: payments via Mollie TEST mode" ;;
|
||||
live_*) echo "dev: refusing to run dev against a LIVE Mollie key." >&2
|
||||
echo "dev: live keys belong in /etc/catcrafts/payments.env on the server." >&2
|
||||
exit 1 ;;
|
||||
*) echo "dev: MOLLIE_API_KEY is not a test_ or live_ key" >&2; exit 1 ;;
|
||||
esac
|
||||
RAIL="${DEV_RAIL:-transfer}"
|
||||
if [ "$RAIL" = transfer ]; then
|
||||
# Refuse a real-looking IBAN that is not yours to be paid into by mistake?
|
||||
# No — the opposite risk matters here. These values are only ever RENDERED
|
||||
# in dev; nothing can be paid. What must not happen is dev quietly showing
|
||||
# the PRODUCTION account while someone screenshots the page, so when .env
|
||||
# supplies nothing the placeholders say plainly that they are placeholders.
|
||||
: "${TRANSFER_IBAN:=NL00DEVB0000000000}"
|
||||
: "${TRANSFER_BENEFICIARY:=DEV PLACEHOLDER, not a real account}"
|
||||
: "${TRANSFER_BIC:=DEVBNL2A}"
|
||||
export TRANSFER_IBAN TRANSFER_BENEFICIARY TRANSFER_BIC
|
||||
if [ "$TRANSFER_IBAN" = NL00DEVB0000000000 ]; then
|
||||
echo "dev: bank transfer rail with PLACEHOLDER account details"
|
||||
echo "dev: put TRANSFER_IBAN / TRANSFER_BENEFICIARY / TRANSFER_BIC in .env to preview the real ones"
|
||||
else
|
||||
echo "dev: bank transfer rail, account $TRANSFER_IBAN ($TRANSFER_BENEFICIARY)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# /financials has two inputs and a fresh dev run has neither: sales fold out of
|
||||
|
|
@ -247,7 +258,16 @@ cat <<EOF
|
|||
Edit the aggregates and just refresh — they are re-read every request:
|
||||
\$EDITOR $WORK/orders.jsonl.financials.json")
|
||||
Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with:
|
||||
touch $WORK/orders.jsonl.fake-paid")
|
||||
touch $WORK/orders.jsonl.fake-paid")$([ "$RAIL" = transfer ] && printf '%s' " — the order page shows
|
||||
the account details a buyer would transfer to. To simulate the money arriving,
|
||||
append the credit the bank would have reported, using the order's own
|
||||
reference (the CC-… code on the order page):
|
||||
|
||||
tools/dev-credit.sh $WORK/orders.jsonl CC-XXXXXX 57043
|
||||
|
||||
The reconciler picks it up within a minute and the order flips to paid.
|
||||
Reference matching is deliberately forgiving, so try lower case, spaces, or
|
||||
the RF… form to see that they all still settle the same order.")
|
||||
Ctrl-C to stop.
|
||||
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ ORDERS=$(systemctl cat "$UNIT" | sed -n 's/^[[:space:]]*--orders=\([^ \\]*\).*/\
|
|||
[ -n "$ORDERS" ] || ORDERS=/var/lib/catcrafts/orders.jsonl
|
||||
ENVF=$(systemctl cat "$UNIT" | sed -n 's/^EnvironmentFile=-\{0,1\}\(.*\)/\1/p' | head -1)
|
||||
[ -n "$ENVF" ] || ENVF=/etc/catcrafts/payments.env
|
||||
[ -e "$ENVF" ] || { echo "apply: $ENVF does not exist — is the Mollie side even configured?" >&2; exit 1; }
|
||||
[ -e "$ENVF" ] || { echo "apply: $ENVF does not exist — is the bank rail even configured?" >&2; exit 1; }
|
||||
|
||||
# Respect an explicit EURC_POOL override if one is already configured;
|
||||
# otherwise the server's default: the pool hangs off the orders path.
|
||||
|
|
@ -198,8 +198,8 @@ install -m 0644 "$work/chains.json" "$CHAINS_DEST"
|
|||
#
|
||||
# Two bugs lived in the one-liner this replaces. First, payments.env is
|
||||
# hand-maintained, so its last line may have no trailing newline — and then a
|
||||
# bare >> concatenated onto it, turning MOLLIE_API_KEY=live_abc into
|
||||
# MOLLIE_API_KEY=live_abcEURC_CHAINS=/etc/... : both rails broken, and the
|
||||
# bare >> concatenated onto it, turning TRANSFER_IBAN=NL00 into
|
||||
# TRANSFER_IBAN=NL00EURC_CHAINS=/etc/... : both rails broken, and the
|
||||
# rollback below could not even see it because the line no longer started with
|
||||
# EURC_CHAINS. Second, the rollback deleted EVERY EURC_CHAINS= line, including
|
||||
# one the operator had set themselves pointing at a different chains file — so
|
||||
|
|
|
|||
Loading…
Reference in a new issue