v0.2.0:51 — Zaprite recurring polish from sandbox testing (:46-:51)

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) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-06-03 21:23:09 -05:00
parent fea6995192
commit 4cde540b60
7 changed files with 414 additions and 23 deletions
@@ -51,6 +51,15 @@ pub struct CreateOrderBody<'a> {
/// recurring charges. Set when the policy is recurring.
#[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")]
pub allow_save_payment_profile: Option<bool>,
/// Zaprite contact id to attach this order to. REQUIRED by
/// Zaprite when `allow_save_payment_profile` is true — without
/// it the create-order call returns
/// `400 contactId is required when allowSavePaymentProfile is true`.
/// Optional otherwise; passing it for one-shot purchases just
/// associates the order with a known contact in the operator's
/// Zaprite dashboard.
#[serde(rename = "contactId", skip_serializing_if = "Option::is_none")]
pub contact_id: Option<String>,
}
impl ZapriteClient {
@@ -157,6 +166,56 @@ impl ZapriteClient {
serde_json::from_str(&raw).context("parse charge response")
}
/// `POST /v1/contacts` — create a Zaprite contact. Required
/// upstream step before creating an order with
/// `allowSavePaymentProfile: true` (Zaprite needs to know which
/// contact the saved profile attaches to). Returns the full
/// contact JSON; the caller extracts `id` to pass as
/// `contactId` on the subsequent order create.
///
/// `legal_name` is required by Zaprite's schema; we fall back to
/// the email itself when the buyer didn't supply a name. The
/// operator can rename the contact in the Zaprite dashboard if
/// they care about display polish.
///
/// NOTE on duplicates: Zaprite's duplicate-email behavior on
/// `POST /v1/contacts` is undocumented (their llms.txt explicitly
/// says "Not documented"). Empirically we accept whatever Zaprite
/// does — if they create a duplicate, the operator's Zaprite
/// contact list gets a row per recurring purchase from the same
/// buyer. The multi-provider work (planned `:47+`) will introduce
/// a Keysat-side `zaprite_contacts` cache keyed on (email,
/// provider_id) to dedup upfront. For sandbox testing + early
/// production this is acceptable noise.
pub async fn create_contact(
&self,
email: &str,
name: Option<&str>,
) -> Result<Value> {
let legal_name = name.unwrap_or(email);
let url = format!("{}/v1/contacts", self.base_url);
let body = serde_json::json!({
"email": email,
"legalName": legal_name,
});
let resp = self
.http
.post(&url)
.headers(self.auth_headers()?)
.json(&body)
.send()
.await
.context("Zaprite create_contact request")?;
let status = resp.status();
let raw = resp.text().await.context("read create_contact body")?;
if !status.is_success() {
return Err(anyhow!(
"Zaprite create_contact returned HTTP {status}: {raw}"
));
}
serde_json::from_str(&raw).context("parse create_contact response")
}
/// `GET /v1/contacts/{id}` — fetch a Zaprite contact, which
/// includes the `paymentProfiles[]` array we mine for the
/// saved-card id after a recurring first-cycle settle. Each