donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -23,18 +23,36 @@ 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.
// Supplier prices are what the retailer currently charges (incl VAT);
// change one number when the supplier moves and the margin stays put.
// PRICING RULE (the user'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.
export module Catcrafts.Shared:Content;
import std;
import :Model;
import :Money;
namespace Catcrafts::Content {
// The flat markup on every variant — "whatever it costs me + 50".
inline constexpr std::int64_t kMarkupMinor = 5000;
// The flat markup on every variant — "whatever it costs me + 50", where the
// €50 is what Catcrafts KEEPS. Retail is VAT-inclusive, so a markup added to
// it is taxed with the rest of the price: a flat +5000 would net only €41.32
// once the VAT on it is remitted. Grossed up once here (€60.50 on the
// sticker), the walk-away margin is exactly €50 per unit — on EU sales and
// on exports alike, since the export price is the net of the same retail.
inline constexpr std::int64_t kMarkupNetMinor = 5000;
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
// Exactness is not luck: NetFromGross strips a gross amount additively only
// when markup × 10000 divides evenly by the VAT denominator, and 6050 does
// (6050 × 10000 = 12100 × 5000). That makes NetFromGross(supplier + markup)
// equal NetFromGross(supplier) + 5000 for EVERY supplier price — no variant
// can round the margin away.
static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
"gross markup must net back exactly for every supplier price");
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
"the exact net of the markup must be the €50 the rule promises");
export const std::vector<Product>& Products() {
static const std::vector<Product> products = [] {
@ -89,7 +107,25 @@ export const std::vector<Product>& Products() {
if (const Variant* cheapest = p.CheapestVariant()) {
p.priceInclMinor = cheapest->priceInclMinor;
}
return std::vector<Product>{ std::move(p) };
// The donation item: the shop's soft opening. It is "available" while
// the phone above stays "coming-soon" on purpose — the two rails, the
// ledger, the reconciler and the mailer all run for real money before
// the first phone order depends on them. The buyer names the amount
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
// charged, because a gift with nothing supplied in return is not a
// taxable supply — which is also why a donation never gets an invoice.
// fp6-pmos stays FIRST in this vector: it is the shop's headline, and
// tests address Products()[0] as the priced product.
Product d;
d.slug = "donation";
d.name = "Donation";
d.tagline = "Fund the open-source work directly: any amount, no goods, "
"no VAT.";
d.status = "available";
d.donation = true;
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
return std::vector<Product>{ std::move(p), std::move(d) };
}();
return products;
}
@ -206,12 +242,12 @@ export const LegalPage& FinancialsPage() {
static const LegalPage page{
.slug = "financials",
.title = "Financials",
.updated = "2026-08-14",
.updated = "2026-08-17",
.lede = "Catcrafts believes in openness, that's why its financials are open as well. As a supporter you deserve to know where your money is going.",
.sections = {
{ "How this page works",
{
"Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
"Sales, and donations made through the shop, come straight from the shop's order ledger and update the moment they are paid. Donations to the bank account and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
"Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged.",
} },
{ "What is never published",
@ -291,7 +327,7 @@ export const std::vector<LegalPage>& LegalPages() {
{
.slug = "terms",
.title = "Terms",
.updated = "2026-08-15",
.updated = "2026-08-17",
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
.sections = {
{ "Ordering and payment",
@ -302,6 +338,12 @@ export const std::vector<LegalPage>& LegalPages() {
"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.",
"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.",
"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. A mistaken donation — a typo in the amount, a double payment — is refunded on request: email info@catcrafts.net.",
} },
{ "Fulfilment",
{
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",