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

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