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:
@@ -137,10 +137,46 @@ async fn ensure_license(
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?
|
||||
.is_some()
|
||||
{
|
||||
// Even if the license already exists, the reconciler may be
|
||||
// running because the webhook never delivered. In that case
|
||||
// `on_invoice_settled` (which runs the Zaprite-saved-profile
|
||||
// capture for recurring first-cycle subs) never fired either.
|
||||
// Try the post-settle hook now — it's idempotent (early-returns
|
||||
// if the sub already has a captured profile, or if the active
|
||||
// provider isn't Zaprite, or if no matching profile exists on
|
||||
// the contact). Without this, a subscription created via the
|
||||
// reconciler path never gets its `zaprite_payment_profile_id`
|
||||
// populated, and renewals fall back to manual-pay forever
|
||||
// even though the saved profile is sitting on Zaprite's side.
|
||||
if let Err(e) =
|
||||
crate::subscriptions::on_invoice_settled(state, invoice).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
invoice_id = %invoice.id,
|
||||
"reconciler post-settle hook failed (non-fatal — license already exists)"
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
crate::api::webhook::issue_license_for_invoice(state, invoice)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||
|
||||
// Same rationale as the early-return branch above — if the
|
||||
// reconciler is running, the webhook may have missed; run the
|
||||
// post-settle hook so a brand-new recurring sub also captures its
|
||||
// Zaprite saved profile. issue_license_for_invoice already created
|
||||
// the subscription row by this point, so on_invoice_settled can
|
||||
// find it.
|
||||
if let Err(e) =
|
||||
crate::subscriptions::on_invoice_settled(state, invoice).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
invoice_id = %invoice.id,
|
||||
"reconciler post-settle hook failed (non-fatal — license issued ok)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user