From 4cde540b6027c43584ac881745304e72d409d67f Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 3 Jun 2026 21:23:09 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:51=20=E2=80=94=20Zaprite=20recurring=20p?= =?UTF-8?q?olish=20from=20sandbox=20testing=20(:46-:51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six routine bumps land together, all driven by end-to-end sandbox testing of the Zaprite recurring auto-charge path that shipped in :45: :46 Provider create-invoice failures now surface the underlying cause. Switched user-facing format from `{e}` to `{e:#}` so the full anyhow chain reaches the buy page; added `tracing::error!` for symmetric daemon-log visibility. Without this, failed checkouts showed only "ZapriteProvider.create_invoice" with no clue what actually went wrong. :47 Zaprite recurring purchases now create the contact upfront. Sandbox surfaced that `allowSavePaymentProfile: true` requires an explicit `contactId` on the order — passing only `customerData: { email }` returns 400. Added `client.create_contact(email, name)` + threaded the returned id as `contactId`. Graceful degradation: recurring + no buyer_email → one-shot mode with a warn log; renewals fall back to manual-pay. :48 Thank-you page copy is now provider-aware. The wait-page lede hardcoded "Your Bitcoin payment was received" + Lightning/on-chain timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER and branches the copy + the JS polling-status text accordingly. :49 Zaprite saved-profile capture: full diagnostic logging + reconciler path. Discovered five recurring subscriptions settled successfully but with NULL `zaprite_payment_profile_id`. Root cause: capture hook had six silent early-return paths, AND the reconciler (which catches missed webhooks) never called `on_invoice_settled` so subs created via that path never got their profile captured. Added warn logs on every early-return + wired capture into `reconcile.rs`'s post-license-issuance flow. :50 Webhook event-type extraction probes multiple field names. Confirmed deliveries were arriving but all logged as "non-actionable event_type= " — Zaprite doesn't use the convention-suggested `event` field. Now probes `event` / `eventType` / `type` / `name`, first non-empty wins. Also widened the order-id probe to include `data.object.id`. On a miss, warn-logs the raw payload truncated to 2KB so the actual field name can be added to the probe list. :51 Zaprite `order.change` event is now actionable. The :50 probe-fix surfaced that Zaprite's primary delivery shape is a generic `order.change` event that just says "something about this order changed" — the receiver has to look at `/data/status` to figure out what actually changed. They do NOT send the convention-suggested `order.paid` / `order.complete` events. Added an `order.change` match arm that branches on status (PAID/COMPLETE/OVERPAID → InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED → InvoiceInvalid, in-flight states → Other). End result: webhook- driven settles now flip subscriptions within seconds of Zaprite's callback instead of waiting ~45s for the reconciler. Net effect of the batch: the recurring auto-charge flow is now validated end-to-end against the Zaprite sandbox. Buyers paying with a card via Stripe-backed Zaprite trigger contact + saved-profile creation, the webhook fires `order.change` with status PAID, Keysat captures the saved-profile id within seconds, and the renewal worker is wired to auto-charge subsequent cycles. Manual-pay fallback intact for buyers who decline save-card or pay via Bitcoin/Lightning. Co-Authored-By: Claude Opus 4.7 (1M context) --- licensing-service/src/api/mod.rs | 58 ++++++- licensing-service/src/api/purchase.rs | 21 ++- .../src/payment/zaprite/client.rs | 59 +++++++ .../src/payment/zaprite/provider.rs | 163 ++++++++++++++++-- licensing-service/src/reconcile.rs | 36 ++++ licensing-service/src/subscriptions.rs | 86 ++++++++- startos/versions/v0.2.0.ts | 14 +- 7 files changed, 414 insertions(+), 23 deletions(-) diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 86e5efe..d17c94f 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -573,6 +573,41 @@ async fn thank_you( .or(state.config.operator_name.as_deref()) .unwrap_or("Keysat"); let operator = html_escape(operator_str); + + // Provider-aware confirmation copy. BTCPay is Bitcoin-only (Lightning + // + on-chain); Zaprite brokers card payments too (Stripe / etc.) plus + // Bitcoin. The lede and the polling-status copy should reflect which + // payment rails are actually in play so a buyer who paid by card + // doesn't see "your Bitcoin payment was received" while their Stripe + // transaction shows up in the operator's dashboard. + // + // Today this reads `SETTING_ACTIVE_PROVIDER` (the singleton model). + // When the multi-provider work lands, swap this for a lookup of the + // invoice's own `payment_provider_id` so the copy matches the rail + // that actually settled THIS purchase, not whatever's currently + // active on the daemon. + let provider_kind = crate::payment::read_active_provider_preference(&state.db).await; + let (lede_text, provider_kind_str) = match provider_kind { + Some(crate::payment::ProviderKind::Zaprite) => ( + "Your payment was received. We\u{2019}re waiting for it to settle and \ + for the license to be signed. Card payments confirm in seconds; \ + Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically \ + settles in 10\u{2013}20 minutes (one block confirmation).", + "zaprite", + ), + // BTCPay or unconfigured → original Bitcoin-only copy. Unconfigured + // is rare on this page (operator hit /thank-you without a provider + // connected) so we keep it Bitcoin-flavored rather than introducing + // a third "unknown" branch. + _ => ( + "Your Bitcoin payment was received. We\u{2019}re waiting for it to settle \ + and for the license to be signed. Lightning settles in seconds; on-chain \ + typically settles in 10\u{2013}20 minutes (one block confirmation).", + "btcpay", + ), + }; + let provider_kind_json = serde_json::to_string(provider_kind_str) + .unwrap_or_else(|_| "\"btcpay\"".into()); let body = format!( r#" @@ -748,7 +783,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
Payment received

Issuing your license…

-

Your Bitcoin payment was received. We’re waiting for it to settle and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10–20 minutes (one block confirmation).

+

{lede_text}

@@ -788,6 +823,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}