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

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

View file

@ -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.",
} },

View file

@ -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

View file

@ -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

View file

@ -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") {