This commit is contained in:
parent
5d3a3adb86
commit
1a38cfe5d7
7 changed files with 169 additions and 17 deletions
|
|
@ -630,6 +630,13 @@ public:
|
||||||
const bool decisive = now >= deadline;
|
const bool decisive = now >= deadline;
|
||||||
|
|
||||||
bool anyUnknown = false;
|
bool anyUnknown = false;
|
||||||
|
// Money visible at "latest" while the settlement tag still reads
|
||||||
|
// short. Display-only, and deliberately held to a LOWER standard
|
||||||
|
// than settling: one endpoint's unfinalized word is plenty for
|
||||||
|
// "we see it, hold on" and worthless for "paid". The probe is
|
||||||
|
// skipped when the chain already settles on "latest" (dev, the
|
||||||
|
// testnet suite): there is no gap between seen and paid to report.
|
||||||
|
bool seenInFlight = false;
|
||||||
for (const EurcChain& chain : chains_) {
|
for (const EurcChain& chain : chains_) {
|
||||||
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
|
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
|
||||||
if (!required) {
|
if (!required) {
|
||||||
|
|
@ -654,6 +661,14 @@ public:
|
||||||
anyUnknown = true;
|
anyUnknown = true;
|
||||||
break;
|
break;
|
||||||
case ChainVerdict::NotCovered:
|
case ChainVerdict::NotCovered:
|
||||||
|
// Not settled at the settlement tag — but is it in flight?
|
||||||
|
// One extra call on the first endpoint, only while the order
|
||||||
|
// is live and only until something is spotted.
|
||||||
|
if (!decisive && !seenInFlight && chain.blockTag != "latest") {
|
||||||
|
const std::optional<std::int64_t> unfinalized =
|
||||||
|
BalanceOf(chain, chain.rpcUrls.front(), address, "latest");
|
||||||
|
if (unfinalized && *unfinalized >= *required) seenInFlight = true;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -686,7 +701,10 @@ public:
|
||||||
"arrive there and needs settling by hand", address);
|
"arrive there and needs settling by hand", address);
|
||||||
return PaidStatus{ PayState::Dead, {} };
|
return PaidStatus{ PayState::Dead, {} };
|
||||||
}
|
}
|
||||||
return PaidStatus{ PayState::Pending, {} };
|
PaidStatus pending;
|
||||||
|
pending.state = PayState::Pending;
|
||||||
|
pending.seen = seenInFlight;
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the order page renders in place of a hosted-checkout button. Reads
|
// What the order page renders in place of a hosted-checkout button. Reads
|
||||||
|
|
@ -823,18 +841,24 @@ private:
|
||||||
return ChainVerdict::NotCovered;
|
return ChainVerdict::NotCovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagOverride replaces the chain's settlement tag for this one call; the
|
||||||
|
// in-flight probe uses it to peek at "latest". Empty means the settlement
|
||||||
|
// tag, which every settling caller passes implicitly.
|
||||||
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
|
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
|
||||||
const std::string& url,
|
const std::string& url,
|
||||||
const std::string& address) {
|
const std::string& address,
|
||||||
|
std::string_view tagOverride = {}) {
|
||||||
// A node that is not serving this chain does not get to answer.
|
// A node that is not serving this chain does not get to answer.
|
||||||
if (!EndpointServesChain(chain, url)) return std::nullopt;
|
if (!EndpointServesChain(chain, url)) return std::nullopt;
|
||||||
// eth_call to the token contract; the calldata encoding lives in
|
// eth_call to the token contract; the calldata encoding lives in
|
||||||
// BalanceOfCallData, where the self-test can pin it.
|
// BalanceOfCallData, where the self-test can pin it.
|
||||||
const std::string data = BalanceOfCallData(address);
|
const std::string data = BalanceOfCallData(address);
|
||||||
|
const std::string_view tag =
|
||||||
|
tagOverride.empty() ? std::string_view(chain.blockTag) : tagOverride;
|
||||||
|
|
||||||
const std::string body =
|
const std::string body =
|
||||||
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
|
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
|
||||||
+ chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})";
|
+ chain.contract + R"(","data":")" + data + R"("},")" + std::string(tag) + R"("]})";
|
||||||
|
|
||||||
const std::optional<std::string> res = Call(chain, url, body);
|
const std::optional<std::string> res = Call(chain, url, body);
|
||||||
if (!res) return std::nullopt;
|
if (!res) return std::nullopt;
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ struct AdvanceResult {
|
||||||
};
|
};
|
||||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
||||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
||||||
|
bool MoneySeenRecently(std::string_view token);
|
||||||
std::string NowIso8601();
|
std::string NowIso8601();
|
||||||
|
|
||||||
// Common headers on every HTML response.
|
// Common headers on every HTML response.
|
||||||
|
|
@ -317,6 +318,7 @@ HTTPResponse RenderPage(std::string_view target) {
|
||||||
std::chrono::duration_cast<std::chrono::seconds>(
|
std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||||
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
|
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
|
||||||
|
pay.seen = MoneySeenRecently(order->token);
|
||||||
for (auto& c : instr->chains) {
|
for (auto& c : instr->chains) {
|
||||||
pay.chains.push_back({ std::move(c.name), std::move(c.contract),
|
pay.chains.push_back({ std::move(c.name), std::move(c.contract),
|
||||||
std::move(c.link), std::move(c.note) });
|
std::move(c.link), std::move(c.note) });
|
||||||
|
|
@ -894,6 +896,34 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
||||||
std::mutex gArrivalPollMutex;
|
std::mutex gArrivalPollMutex;
|
||||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
||||||
|
|
||||||
|
// Orders whose payment the rail has SEEN in flight (visible on the network,
|
||||||
|
// finality pending), stamped by whichever poll noticed — the reconciler or an
|
||||||
|
// arrival check. Read at render time so the page can acknowledge the money
|
||||||
|
// without the render path ever dialing a provider itself: the page shows the
|
||||||
|
// most recent poll's knowledge, which is exactly as fresh as "paid" would be.
|
||||||
|
// Entries only ever accumulate truth ("seen" is never un-seen by one flaky
|
||||||
|
// probe) and age out; a settled or lapsed order stops rendering the section
|
||||||
|
// that reads this, so stale entries are harmless.
|
||||||
|
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gMoneySeen;
|
||||||
|
|
||||||
|
void RememberMoneySeen(std::string_view token) {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
std::lock_guard lock(gArrivalPollMutex);
|
||||||
|
std::erase_if(gMoneySeen, [&](const auto& entry) {
|
||||||
|
return now - entry.second > std::chrono::hours(1);
|
||||||
|
});
|
||||||
|
gMoneySeen[std::string(token)] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MoneySeenRecently(std::string_view token) {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
std::lock_guard lock(gArrivalPollMutex);
|
||||||
|
const auto it = gMoneySeen.find(std::string(token));
|
||||||
|
// Ten minutes covers several finality epochs; a payment seen longer ago
|
||||||
|
// that still has not settled is a claim this page should stop making.
|
||||||
|
return it != gMoneySeen.end() && now - it->second < std::chrono::minutes(10);
|
||||||
|
}
|
||||||
|
|
||||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
|
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
|
||||||
const auto now = std::chrono::steady_clock::now();
|
const auto now = std::chrono::steady_clock::now();
|
||||||
std::lock_guard lock(gArrivalPollMutex);
|
std::lock_guard lock(gArrivalPollMutex);
|
||||||
|
|
@ -924,6 +954,9 @@ std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
|
||||||
if (!rail) return std::nullopt;
|
if (!rail) return std::nullopt;
|
||||||
const std::optional<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
|
const std::optional<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
|
||||||
if (!paid.has_value()) return std::nullopt;
|
if (!paid.has_value()) return std::nullopt;
|
||||||
|
if (paid->state == PayState::Pending && paid->seen) {
|
||||||
|
RememberMoneySeen(order.token);
|
||||||
|
}
|
||||||
if (paid->state == PayState::Paid) {
|
if (paid->state == PayState::Paid) {
|
||||||
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
||||||
// The invoice number exists from the moment the money does —
|
// The invoice number exists from the moment the money does —
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,42 @@ public:
|
||||||
|
|
||||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
return PaidStatus{
|
if (std::filesystem::exists(marker_, ec)) {
|
||||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
return PaidStatus{ PayState::Paid, "fake" };
|
||||||
"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::string_view Name() const override { return name_; }
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,12 @@ export namespace Catcrafts::Server {
|
||||||
struct PaidStatus {
|
struct PaidStatus {
|
||||||
PayState state = PayState::Pending;
|
PayState state = PayState::Pending;
|
||||||
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
|
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
|
||||||
|
// Pending only: the money is visible but not yet trusted — for the
|
||||||
|
// EURC rail, a covering balance at "latest" while the settlement tag
|
||||||
|
// still reads short. NEVER a settlement input; it exists so the order
|
||||||
|
// page can tell a buyer their in-flight payment has been noticed
|
||||||
|
// during the ~15 minutes finality takes.
|
||||||
|
bool seen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Self-hosted payment instructions for the order page. A hosted rail sends
|
// Self-hosted payment instructions for the order page. A hosted rail sends
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,11 @@ export struct OrderCryptoPay {
|
||||||
std::string amount; // decimal EURC amount ("570.43") — equals the
|
std::string amount; // decimal EURC amount ("570.43") — equals the
|
||||||
// euro total; EURC is euro-denominated at par
|
// euro total; EURC is euro-denominated at par
|
||||||
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
|
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
|
||||||
|
// 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
|
||||||
|
// email — the first live payment proved exactly this.
|
||||||
|
bool seen = false;
|
||||||
struct Chain {
|
struct Chain {
|
||||||
std::string name; // "base"
|
std::string name; // "base"
|
||||||
std::string contract; // token contract, shown so the buyer can verify
|
std::string contract; // token contract, shown so the buyer can verify
|
||||||
|
|
|
||||||
|
|
@ -1438,20 +1438,36 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
||||||
// The window line tells the truth for both signs of minutesLeft. A
|
// The window line tells the truth for both signs of minutesLeft. A
|
||||||
// closed window does NOT mean sent money is gone — the address stays
|
// closed window does NOT mean sent money is gone — the address stays
|
||||||
// ours — so the copy says where it went instead of leaving a buyer
|
// ours — so the copy says where it went instead of leaving a buyer
|
||||||
// staring at money that "vanished".
|
// staring at money that "vanished". The open branch also owns the
|
||||||
|
// expectation the first live payment proved buyers need: a wallet
|
||||||
|
// 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.
|
||||||
const SafeHtml windowLine = pay.minutesLeft > 0
|
const SafeHtml windowLine = pay.minutesLeft > 0
|
||||||
? Format(
|
? Format(
|
||||||
R"(<p class="order__note">This address is reserved for this order )"
|
R"(<p class="order__note">This address is reserved for this order )"
|
||||||
R"(for about {} more {}. This page checks automatically and )"
|
R"(for about {} more {}. This page checks automatically and )"
|
||||||
R"(confirms once the full amount has arrived.</p>)",
|
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),
|
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
|
||||||
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
|
||||||
: Raw(R"(<p class="order__note">The payment window for this order has )"
|
: 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"(closed and the order will lapse. If you already sent EURC it )"
|
||||||
R"(is not lost it arrived at the address above; contact )"
|
R"(is not lost: it arrived at the address above; contact )"
|
||||||
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
|
||||||
R"(and it will be settled by hand.</p>)");
|
R"(and it will be settled by hand.</p>)");
|
||||||
|
|
||||||
|
// The in-flight acknowledgement: the rail has SEEN the full amount on
|
||||||
|
// the network, finality is the only thing missing. Rendered first,
|
||||||
|
// because a buyer who just paid scans for exactly this sentence.
|
||||||
|
const SafeHtml seenLine = pay.seen && pay.minutesLeft > 0
|
||||||
|
? Raw(R"(<p class="order__note"><strong>Your payment is on its way.</strong> )"
|
||||||
|
R"(It is visible on the network and is being finalized: this )"
|
||||||
|
R"(usually takes 10 to 25 minutes, and this page will confirm )"
|
||||||
|
R"(it automatically.</p>)")
|
||||||
|
: SafeHtml{};
|
||||||
|
|
||||||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||||||
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
||||||
R"(the euro amount above.</p>)",
|
R"(the euro amount above.</p>)",
|
||||||
|
|
@ -1460,19 +1476,21 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
||||||
payBlock = Format(
|
payBlock = Format(
|
||||||
R"(<section class="section">)"
|
R"(<section class="section">)"
|
||||||
R"(<h2 class="section__title">Pay with EURC</h2>)"
|
R"(<h2 class="section__title">Pay with EURC</h2>)"
|
||||||
R"(<p>Send <strong>{} EURC</strong> to this address one )"
|
R"({})"
|
||||||
|
R"(<p>Send <strong>{} EURC</strong> to this address. One )"
|
||||||
R"(network, one payment:</p>)"
|
R"(network, one payment:</p>)"
|
||||||
R"(<p><code class="order__address">{}</code></p>)"
|
R"(<p><code class="order__address">{}</code></p>)"
|
||||||
R"(<ul class="order__chains">{}</ul>)"
|
R"(<ul class="order__chains">{}</ul>)"
|
||||||
R"({})"
|
R"({})"
|
||||||
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
|
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
|
||||||
R"(is exactly the euro total no exchange rate. Send EURC only, )"
|
R"(is exactly the euro total, no exchange rate. Send EURC only, )"
|
||||||
R"(and only on a network listed above. If your exchange deducts a )"
|
R"(and only on a network listed above. If your exchange deducts a )"
|
||||||
R"(withdrawal fee, send the missing rest to the same address; the )"
|
R"(withdrawal fee, send the missing rest to the same address; the )"
|
||||||
R"(order confirms once the full amount sits on one network. Your )"
|
R"(order confirms once the full amount sits on one network. Your )"
|
||||||
R"(order reference is <strong>{}</strong>.</p>)"
|
R"(order reference is <strong>{}</strong>.</p>)"
|
||||||
R"({})"
|
R"({})"
|
||||||
R"(</section>)",
|
R"(</section>)",
|
||||||
|
seenLine,
|
||||||
Escape(pay.amount),
|
Escape(pay.amount),
|
||||||
Escape(pay.address),
|
Escape(pay.address),
|
||||||
Join(items),
|
Join(items),
|
||||||
|
|
|
||||||
|
|
@ -143,14 +143,16 @@ void OpenShopLifecycle(TestServer& srv) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Check(recorded, "the crypto choice is what the ledger records");
|
Check(recorded, "the crypto choice is what the ledger records");
|
||||||
// The order page has to promise what is actually behind the button —
|
// The order page has to promise what is actually behind the choice —
|
||||||
// the bank copy on a crypto order would send someone looking for
|
// the bank copy on a crypto order would send someone looking for
|
||||||
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
|
// iDEAL. The fake crypto slot renders instructions like the real
|
||||||
// that had already left the codebase — this is the check that caught
|
// EURC rail, so the probe is the instructions block. (The shell
|
||||||
// the drift when the port first ran against an open shop.)
|
// suite pinned 'Lightning' here, CoinGate-era copy that had already
|
||||||
|
// left the codebase — this is the check that caught the drift when
|
||||||
|
// the port first ran against an open shop.)
|
||||||
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
|
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
|
||||||
Check(page.find("completes your crypto payment") != std::string::npos,
|
Check(page.find("Pay with EURC") != std::string::npos,
|
||||||
"the crypto order page describes the crypto payment");
|
"the crypto order page renders the payment instructions");
|
||||||
Check(page.find("iDEAL") == std::string::npos,
|
Check(page.find("iDEAL") == std::string::npos,
|
||||||
"the crypto order page does not promise iDEAL");
|
"the crypto order page does not promise iDEAL");
|
||||||
}
|
}
|
||||||
|
|
@ -546,6 +548,37 @@ void DonationLifecycle(TestServer& srv) {
|
||||||
"the amount refusal names the bounds");
|
"the amount refusal names the bounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the in-flight state ───────────────────────────────────────────
|
||||||
|
// Between the wallet's "success" and finality the rail reports the money
|
||||||
|
// as SEEN but not yet evidence, and the page must say so — the first
|
||||||
|
// live EURC payment proved that a buyer staring at "awaiting payment"
|
||||||
|
// after their wallet said done is a support email in the making. The
|
||||||
|
// fake rail's "<marker>.seen" file is that state's test handle.
|
||||||
|
{
|
||||||
|
const std::string tokenSeen =
|
||||||
|
TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto"));
|
||||||
|
Check(!tokenSeen.empty(), "a crypto donation goes through");
|
||||||
|
if (!tokenSeen.empty()) {
|
||||||
|
const std::string seenPath = std::format("/order/{}", tokenSeen);
|
||||||
|
// Before anything is seen: the awaiting page owns the timing
|
||||||
|
// expectation, so the gap between wallet and shop is explained
|
||||||
|
// even to a buyer who never reloads.
|
||||||
|
srv.BodyHas(seenPath, "typically follows in 10 to 25 minutes",
|
||||||
|
"the awaiting page states the confirmation timing");
|
||||||
|
WriteFile(std::filesystem::path(
|
||||||
|
srv.Orders().string() + ".fake-paid.seen"), "");
|
||||||
|
const std::string page =
|
||||||
|
srv.WaitForBody(seenPath, "Your payment is on its way");
|
||||||
|
Check(page.find("Your payment is on its way") != std::string::npos,
|
||||||
|
"an in-flight payment is acknowledged on the order page");
|
||||||
|
Check(page.find("awaiting payment") != std::string::npos,
|
||||||
|
"an in-flight payment is still awaiting, not paid");
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(std::filesystem::path(
|
||||||
|
srv.Orders().string() + ".fake-paid.seen"), ec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── a donation with no email at all ───────────────────────────────
|
// ── a donation with no email at all ───────────────────────────────
|
||||||
// Identity is optional: the capability URL is the receipt.
|
// Identity is optional: the capability URL is the receipt.
|
||||||
const auto created = srv.Post("/shop/donation", "amount=25");
|
const auto created = srv.Post("/shop/donation", "amount=25");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue