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
+54 -4
View File
@@ -573,6 +573,41 @@ async fn thank_you(
.or(state.config.operator_name.as_deref()) .or(state.config.operator_name.as_deref())
.unwrap_or("Keysat"); .unwrap_or("Keysat");
let operator = html_escape(operator_str); 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!( let body = format!(
r#"<!doctype html> r#"<!doctype html>
<html lang="en"> <html lang="en">
@@ -748,7 +783,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<div class="wrap"> <div class="wrap">
<div class="eyebrow">Payment received</div> <div class="eyebrow">Payment received</div>
<h1 id="page-title">Issuing your license&hellip;</h1> <h1 id="page-title">Issuing your license&hellip;</h1>
<p class="lede" id="page-lede">Your Bitcoin payment was received. We&rsquo;re waiting for it to settle and for the license to be signed. Lightning settles in seconds; on-chain typically settles in 10&ndash;20 minutes (one block confirmation).</p> <p class="lede" id="page-lede">{lede_text}</p>
<!-- pending state (default): polling for the license --> <!-- pending state (default): polling for the license -->
<div class="pending-card" id="pending-card"> <div class="pending-card" id="pending-card">
@@ -788,6 +823,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
<script> <script>
(function() {{ (function() {{
const INVOICE_ID = {invoice_id_json}; const INVOICE_ID = {invoice_id_json};
// 'zaprite' | 'btcpay' — selects which payment-rail copy the
// polling status uses (Zaprite: card + Lightning + on-chain; BTCPay:
// Lightning + on-chain only).
const PROVIDER_KIND = {provider_kind_json};
if (!INVOICE_ID) {{ if (!INVOICE_ID) {{
document.getElementById('pending-card').classList.add('hide'); document.getElementById('pending-card').classList.add('hide');
document.getElementById('error-card').classList.remove('hide'); document.getElementById('error-card').classList.remove('hide');
@@ -857,10 +896,21 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
function waitingCopy(status) {{ function waitingCopy(status) {{
const min = Math.floor(elapsedMs / 60000); const min = Math.floor(elapsedMs / 60000);
const isZaprite = PROVIDER_KIND === 'zaprite';
if (status === 'pending' || status === 'processing') {{ if (status === 'pending' || status === 'processing') {{
if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).'; if (min < 2) {{
if (min < 10) return 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.'; return isZaprite
return 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.'; ? 'invoice ' + status + ' — card payments confirm in seconds; Bitcoin Lightning in seconds; on-chain takes a block (~10 min).'
: 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
}}
if (min < 10) {{
return isZaprite
? 'invoice ' + status + ' — waiting for confirmation. Card auth or on-chain Bitcoin can take a few minutes. Safe to leave this tab open or bookmark this URL.'
: 'invoice ' + status + ' — looks like an on-chain payment, waiting for block confirmation. Safe to leave this tab open or bookmark this URL.';
}}
return isZaprite
? 'invoice ' + status + ' — slow confirmation. Still polling. Bookmark this URL and refresh later if you close the tab.'
: 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
}} }}
return 'invoice status: ' + (status || 'pending'); return 'invoice status: ' + (status || 'pending');
}} }}
+20 -1
View File
@@ -484,8 +484,27 @@ pub async fn start(
if let Some(code) = &reservation { if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await; let _ = repo::release_code_slot(&state.db, &code.id).await;
} }
// `{e:#}` (alternate format) walks the anyhow error chain so
// the buy page surfaces the underlying provider error directly
// — e.g. "Zaprite create_order returned HTTP 400: {...}" —
// instead of just the outermost `context()` wrapper. Without
// this, a failed create-invoice shows only
// "ZapriteProvider.create_invoice" to the operator, and the
// real cause (currency mismatch / missing payment rail / API-
// key scope / Zaprite-side validation error) is hidden. We
// ALSO emit an explicit tracing::error! before returning so
// the same chain shows up in the daemon logs — without this
// line, the provider's underlying error string is never
// logged anywhere (the trait method just RETURNS the
// anyhow error; only the tower trace layer fires, and it
// only sees the HTTP status code, not the body).
tracing::error!(
product_id = %product.id,
error = format!("{e:#}"),
"payment provider create_invoice failed"
);
return Err(AppError::Upstream(format!( return Err(AppError::Upstream(format!(
"payment provider create-invoice failed: {e}" "payment provider create-invoice failed: {e:#}"
))); )));
} }
}; };
@@ -51,6 +51,15 @@ pub struct CreateOrderBody<'a> {
/// recurring charges. Set when the policy is recurring. /// recurring charges. Set when the policy is recurring.
#[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")] #[serde(rename = "allowSavePaymentProfile", skip_serializing_if = "Option::is_none")]
pub allow_save_payment_profile: Option<bool>, 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 { impl ZapriteClient {
@@ -157,6 +166,56 @@ impl ZapriteClient {
serde_json::from_str(&raw).context("parse charge response") 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 /// `GET /v1/contacts/{id}` — fetch a Zaprite contact, which
/// includes the `paymentProfiles[]` array we mine for the /// includes the `paymentProfiles[]` array we mine for the
/// saved-card id after a recurring first-cycle settle. Each /// saved-card id after a recurring first-cycle settle. Each
+149 -12
View File
@@ -61,6 +61,63 @@ impl PaymentProvider for ZapriteProvider {
} }
}; };
// If we're going to ask Zaprite to save the buyer's payment
// profile (recurring first cycle), Zaprite REQUIRES an
// explicit `contactId` on the order — passing only
// `customerData: { email }` returns
// `400 contactId is required when allowSavePaymentProfile is true`
// even though their llms.txt docs claim contactId is
// optional. The API is the source of truth, so we create a
// contact first and pass its id below.
//
// Three paths:
// 1. Recurring + buyer_email present → create contact,
// attach contactId, set allow_save_payment_profile=true.
// 2. Recurring + buyer_email MISSING → can't create a
// contact (Zaprite requires email). Log a warning and
// degrade to one-shot mode for THIS cycle — the buyer
// gets a license, but subsequent renewals will fall
// through to manual-pay (zaprite_payment_profile_id
// stays NULL). Reason for degrading rather than failing:
// blocking the purchase entirely is worse than letting
// the operator collect cycle-1 revenue and prompt the
// buyer for an email at next renewal.
// 3. Non-recurring → no contact needed; pass customerData
// only (current behavior preserved).
let want_save_profile = params.allow_save_payment_profile == Some(true);
let (contact_id, effective_allow_save) = if want_save_profile {
match params.buyer_email {
Some(email) => {
let contact = self
.client
.create_contact(email, None)
.await
.context("ZapriteProvider.create_invoice: create_contact")?;
let id = contact
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow!(
"Zaprite create_contact response missing 'id': {contact}"
)
})?
.to_string();
(Some(id), Some(true))
}
None => {
tracing::warn!(
external_order_id = %params.external_order_id,
"recurring purchase has no buyer_email; degrading to one-shot \
(allow_save_payment_profile=false). Renewals for this \
subscription will fall back to manual-pay."
);
(None, None)
}
}
} else {
(None, params.allow_save_payment_profile)
};
// Build the Zaprite order. externalUniqId carries OUR // Build the Zaprite order. externalUniqId carries OUR
// invoice UUID; this is what the webhook handler uses as // invoice UUID; this is what the webhook handler uses as
// the trust anchor (see `validate_webhook` below). // the trust anchor (see `validate_webhook` below).
@@ -82,8 +139,11 @@ impl PaymentProvider for ZapriteProvider {
// shows the save-payment-profile prompt; subsequent // shows the save-payment-profile prompt; subsequent
// cycles are then merchant-initiated charges against // cycles are then merchant-initiated charges against
// the saved profile via // the saved profile via
// `charge_order_with_profile`. // `charge_order_with_profile`. May be reset to None
allow_save_payment_profile: params.allow_save_payment_profile, // above if we couldn't satisfy Zaprite's contactId
// requirement.
allow_save_payment_profile: effective_allow_save,
contact_id,
}; };
let order = self let order = self
@@ -179,19 +239,47 @@ impl PaymentProvider for ZapriteProvider {
let v: Value = serde_json::from_slice(body) let v: Value = serde_json::from_slice(body)
.context("Zaprite webhook body must be JSON")?; .context("Zaprite webhook body must be JSON")?;
// Zaprite event shape (from OpenAPI excerpt + ecosystem // Zaprite event shape: their docs don't enumerate event names
// conventions): top-level `event` string + `data.id` // or payload shape. The `:49` sandbox test surfaced an empty
// (the order UUID). Examples expected: // event_type because we were only checking the top-level
// order.paid, order.complete, order.overpaid, order.underpaid, // `event` field; Zaprite seems to put it elsewhere. We now
// order.pending, order.expired, order.refunded // probe four common top-level field names — first non-empty
// We map liberally and let unknowns fall through to Other. // string wins. If even that fails, dump the raw payload at
let event_type = v // WARN so we can see what Zaprite actually sends and add the
.get("event") // correct field name here.
let event_type = ["event", "eventType", "type", "name"]
.iter()
.find_map(|field| {
v.get(*field)
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
.unwrap_or("") .filter(|s| !s.is_empty())
.to_string(); .map(|s| s.to_string())
})
.unwrap_or_default();
if event_type.is_empty() {
// Truncated to 2KB to bound log volume on weird payloads.
let raw_preview = String::from_utf8_lossy(body);
let truncated = if raw_preview.len() > 2048 {
format!(
"{}…[truncated {} bytes]",
&raw_preview[..2048],
raw_preview.len() - 2048
)
} else {
raw_preview.to_string()
};
tracing::warn!(
payload = %truncated,
"Zaprite webhook: no event/eventType/type/name field found at top \
level — webhook will be treated as non-actionable. Inspect the \
payload above to find the actual event-name field and add it to \
the probe list in validate_webhook."
);
}
let provider_invoice_id = v let provider_invoice_id = v
.pointer("/data/id") .pointer("/data/id")
.or_else(|| v.pointer("/data/object/id"))
.or_else(|| v.get("orderId")) .or_else(|| v.get("orderId"))
.or_else(|| v.get("id")) .or_else(|| v.get("id"))
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
@@ -217,6 +305,55 @@ impl PaymentProvider for ZapriteProvider {
provider_invoice_id: id, provider_invoice_id: id,
refunded_amount: None, // amount field shape TBD when we see a real refund event refunded_amount: None, // amount field shape TBD when we see a real refund event
}, },
// Zaprite's primary delivery shape (sandbox-confirmed :50):
// 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 (empirically) send the convention-suggested
// `order.paid` / `order.complete` events — every state
// transition comes through as `order.change` and the
// payload's status field tells the story. Branch on
// status here so we dispatch the right action.
//
// Status values from Zaprite's get_invoice_status mapping:
// PAID | COMPLETE | OVERPAID → settled
// EXPIRED → expired
// INVALID | CANCELLED → invalid
// PENDING | PROCESSING |
// UNDERPAID → in-flight; no action yet
// <anything else> → Other (logged + ignored)
"order.change" => {
let status = v
.pointer("/data/status")
.and_then(|s| s.as_str())
.unwrap_or("");
match status {
"PAID" | "COMPLETE" | "OVERPAID" => {
ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: id,
}
}
"EXPIRED" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: id,
},
"INVALID" | "CANCELLED" => {
ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: id,
}
}
// In-flight transitions (PENDING/PROCESSING/UNDERPAID)
// and anything unfamiliar fall through to Other — the
// handler logs them as non-actionable, which is right:
// we don't want to fire the settle hook every time
// Zaprite transitions an order from PENDING to
// PROCESSING on the way to PAID. The terminal-state
// delivery is what actually drives our state machine.
_ => ProviderWebhookEvent::Other {
kind: format!("order.change[status={status}]"),
provider_invoice_id: provider_invoice_id,
},
}
}
other => ProviderWebhookEvent::Other { other => ProviderWebhookEvent::Other {
kind: other.to_string(), kind: other.to_string(),
provider_invoice_id: provider_invoice_id, provider_invoice_id: provider_invoice_id,
+36
View File
@@ -137,10 +137,46 @@ async fn ensure_license(
.map_err(|e| anyhow::anyhow!("{e:?}"))? .map_err(|e| anyhow::anyhow!("{e:?}"))?
.is_some() .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(()); return Ok(());
} }
crate::api::webhook::issue_license_for_invoice(state, invoice) crate::api::webhook::issue_license_for_invoice(state, invoice)
.await .await
.map_err(|e| anyhow::anyhow!("{e:?}"))?; .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(()) Ok(())
} }
+82 -4
View File
@@ -1082,6 +1082,13 @@ pub async fn capture_zaprite_payment_profile(
) -> Result<()> { ) -> Result<()> {
use crate::payment::ProviderKind; use crate::payment::ProviderKind;
tracing::info!(
sub_id = %sub_id,
invoice_id = %invoice.id,
provider_invoice_id = %invoice.btcpay_invoice_id,
"capture_zaprite_payment_profile: starting"
);
// Idempotency: already captured? // Idempotency: already captured?
let existing: Option<String> = sqlx::query_scalar( let existing: Option<String> = sqlx::query_scalar(
"SELECT zaprite_payment_profile_id FROM subscriptions WHERE id = ?", "SELECT zaprite_payment_profile_id FROM subscriptions WHERE id = ?",
@@ -1092,6 +1099,7 @@ pub async fn capture_zaprite_payment_profile(
.context("read existing zaprite_payment_profile_id")? .context("read existing zaprite_payment_profile_id")?
.flatten(); .flatten();
if existing.is_some() { if existing.is_some() {
tracing::info!(sub_id = %sub_id, "capture: already captured, skipping");
return Ok(()); return Ok(());
} }
@@ -1099,9 +1107,19 @@ pub async fn capture_zaprite_payment_profile(
// meaningful — `as_any` downcast keeps the trait clean. // meaningful — `as_any` downcast keeps the trait clean.
let provider = match state.payment_provider().await { let provider = match state.payment_provider().await {
Ok(p) => p, Ok(p) => p,
Err(_) => return Ok(()), Err(e) => {
tracing::warn!(
sub_id = %sub_id, error = %e,
"capture: no active payment provider — skipping"
);
return Ok(());
}
}; };
if provider.kind() != ProviderKind::Zaprite { if provider.kind() != ProviderKind::Zaprite {
tracing::info!(
sub_id = %sub_id, kind = ?provider.kind(),
"capture: active provider is not Zaprite — skipping"
);
return Ok(()); return Ok(());
} }
let zaprite = match provider let zaprite = match provider
@@ -1109,7 +1127,13 @@ pub async fn capture_zaprite_payment_profile(
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>() .downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
{ {
Some(z) => z, Some(z) => z,
None => return Ok(()), None => {
tracing::warn!(
sub_id = %sub_id,
"capture: provider kind is Zaprite but downcast failed — skipping"
);
return Ok(());
}
}; };
let client = zaprite.client(); let client = zaprite.client();
@@ -1129,9 +1153,22 @@ pub async fn capture_zaprite_payment_profile(
// Order has no contact — buyer paid without an email / // Order has no contact — buyer paid without an email /
// Zaprite didn't materialize a contact. No profile to // Zaprite didn't materialize a contact. No profile to
// capture; renewals fall back to manual pay. // capture; renewals fall back to manual pay.
tracing::warn!(
sub_id = %sub_id,
order_status = order.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
order_has_contact = order.get("contact").is_some(),
order_has_contactId = order.get("contactId").is_some(),
"capture: order has no contact.id / contactId — cannot capture profile. \
Check that buyer_email was present at purchase + that :47+ contact \
creation ran."
);
return Ok(()); return Ok(());
} }
}; };
tracing::info!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: resolved contact_id from order"
);
// 2. Fetch the contact and enumerate its payment profiles. // 2. Fetch the contact and enumerate its payment profiles.
let contact = client let contact = client
@@ -1140,8 +1177,21 @@ pub async fn capture_zaprite_payment_profile(
.context("fetch Zaprite contact for profile capture")?; .context("fetch Zaprite contact for profile capture")?;
let profiles = match contact.get("paymentProfiles").and_then(|v| v.as_array()) { let profiles = match contact.get("paymentProfiles").and_then(|v| v.as_array()) {
Some(arr) => arr, Some(arr) => arr,
None => return Ok(()), // no profiles array — nothing to capture None => {
tracing::warn!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: contact has no paymentProfiles array — likely the buyer \
didn't check 'save card' at Zaprite checkout, OR profile creation \
is async on Zaprite's side and not yet visible at webhook time"
);
return Ok(());
}
}; };
tracing::info!(
sub_id = %sub_id, contact_id = %contact_id,
profile_count = profiles.len(),
"capture: enumerated contact's payment profiles"
);
// 3. Find the profile whose sourceOrder.externalUniqId is // 3. Find the profile whose sourceOrder.externalUniqId is
// THIS invoice. Zaprite scopes saved profiles to a contact, // THIS invoice. Zaprite scopes saved profiles to a contact,
@@ -1162,13 +1212,41 @@ pub async fn capture_zaprite_payment_profile(
// (no autopay-supporting rail) OR declined the save- // (no autopay-supporting rail) OR declined the save-
// payment-profile prompt on the card form. Both are // payment-profile prompt on the card form. Both are
// legitimate; renewals fall back to manual pay. // legitimate; renewals fall back to manual pay.
//
// Also possible: race condition — Zaprite's profile-save
// step hasn't finished by the time the order.paid webhook
// fires. If you see this with profile_count > 0 but no
// match for invoice.id, that's the race.
let sample = profiles.iter().take(3).map(|p| {
p.pointer("/sourceOrder/externalUniqId")
.and_then(|v| v.as_str())
.unwrap_or("<none>")
.to_string()
}).collect::<Vec<_>>();
tracing::warn!(
sub_id = %sub_id,
contact_id = %contact_id,
invoice_id = %invoice.id,
profile_count = profiles.len(),
sample_source_external_uniq_ids = ?sample,
"capture: no profile matches sourceOrder.externalUniqId == invoice.id — \
either the buyer declined the save-card prompt, paid via a non-saving \
rail (BTC/Lightning), OR Zaprite's profile-attach is racing the \
webhook delivery"
);
return Ok(()); return Ok(());
} }
}; };
let profile_id = match profile.get("id").and_then(|v| v.as_str()) { let profile_id = match profile.get("id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(), Some(s) => s.to_string(),
None => return Ok(()), None => {
tracing::warn!(
sub_id = %sub_id, contact_id = %contact_id,
"capture: matched profile has no 'id' field — skipping"
);
return Ok(());
}
}; };
let method = profile.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()); let method = profile.get("method").and_then(|v| v.as_str()).map(|s| s.to_string());
let expires_at = profile let expires_at = profile
+13 -1
View File
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions // in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here. // append here.
const ROUTINE_NOTES = [ const ROUTINE_NOTES = [
'0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
'',
'0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
'',
'0.2.0:49 — **Zaprite saved-profile capture: full diagnostic logging + reconciler path.** Sandbox testing of `:47` revealed five recurring subscriptions all settled successfully but with NULL `zaprite_payment_profile_id` — even though Zaprite confirmed the saved card on the contact. Two root causes addressed: (1) `capture_zaprite_payment_profile` had six different early-return-Ok branches (no provider, not Zaprite, downcast fail, no contact_id, no profiles array, no matching profile) that ALL silently returned with no logging, so there was no way to know which branch fired. Every branch now emits a `tracing::info!` or `tracing::warn!` explaining what it found, including a sample of the profiles\' `sourceOrder.externalUniqId` values when no match is found (to detect the timing race where Zaprite\'s profile-attach lags the order.paid webhook). (2) The polling reconciler (which catches missed webhook deliveries) called `issue_license_for_invoice` to recover the license + subscription, but never called `on_invoice_settled` — so a recurring sub created via the reconciler path NEVER got its Zaprite profile captured even though the saved profile was sitting on Zaprite\'s contact. Fixed in `reconcile.rs::ensure_license`: now invokes `on_invoice_settled` after license issuance (and on the idempotency early-return, in case a prior license-exists run missed the hook). The hook is itself idempotent and a no-op for BTCPay subs, so this is safe to call from both webhook and reconciler paths. Together these mean: even if your Zaprite webhook never delivers, the reconciler will pick up the slack within ~60 seconds AND capture the saved profile so auto-charge still works on the next renewal cycle.',
'',
'0.2.0:48 — **Thank-you page copy is now provider-aware.** The `/thank-you` landing page (where buyers wait while their license is signed) hardcoded "Your Bitcoin payment was received" + "Lightning settles in seconds; on-chain typically settles in 1020 minutes" — true for BTCPay-routed purchases, awkward for Zaprite-routed card payments where the buyer never touched Bitcoin. Fixed in `api/mod.rs::thank_you`: read `SETTING_ACTIVE_PROVIDER`, branch the lede copy on it. For Zaprite: "Your payment was received. Card payments confirm in seconds; Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically settles in 1020 minutes." For BTCPay (and the unconfigured fallback): unchanged Bitcoin-only copy. Also passed the provider kind into the polling JS so the running-status copy (`waitingCopy()`) makes the same distinction at every elapsed-time threshold (2 min, 10 min, slow-block). When the planned multi-provider work lands, this lookup will switch from the singleton setting to the invoice\'s own `payment_provider_id` so the copy matches the rail that actually settled THIS purchase rather than whatever\'s currently active on the daemon.',
'',
'0.2.0:47 — **Zaprite recurring purchases now create the contact upfront.** First-time test purchase against a live Zaprite sandbox surfaced the gap: when the order body has `allowSavePaymentProfile: true`, Zaprite\'s API requires an explicit `contactId` and returns `400 contactId is required when allowSavePaymentProfile is true` if you only pass `customerData: { email }`. Their llms.txt docs say contactId is optional in that case; the API itself disagrees, and the API is the source of truth. Fixed in `payment/zaprite/provider.rs`: when about to send `allowSavePaymentProfile: true`, first call a new `client.create_contact(email, name)` helper (`POST /v1/contacts`), then pass the returned id as `contactId` on the subsequent `create_order` call. Three handling paths: (1) recurring + buyer_email present → create contact + save profile, the happy path; (2) recurring + buyer_email MISSING → degrade to one-shot for THIS cycle (buyer gets a license, renewals fall back to manual-pay, warn-logged); (3) non-recurring → unchanged (no contact created, customerData only). Known minor: Zaprite\'s duplicate-email behavior on `POST /v1/contacts` is undocumented, so the same buyer purchasing recurring twice may end up with duplicate contacts in the operator\'s Zaprite dashboard until the multi-provider work introduces a Keysat-side dedup cache.',
'',
'0.2.0:46 — **Provider create-invoice failures now surface the underlying cause.** When `provider.create_invoice` failed (Zaprite or BTCPay rejection, network error, currency validation), the buy page rendered only "payment provider create-invoice failed: ZapriteProvider.create_invoice" — the outermost `context()` wrapper — and the actual cause (HTTP status + response body from the upstream) was never logged anywhere either. The trait method returned the anyhow error; only the tower trace layer fired, and it only sees the HTTP status code, not the body. Fixed in `api/purchase.rs`: switch user-facing format from `{e}` to `{e:#}` so the full anyhow chain shows up on the buy page, and add an explicit `tracing::error!` before returning so the same chain lands in daemon logs. Operator-visible: failed checkouts now actually tell you what went wrong ("Zaprite create_order returned HTTP 400: missing payment_methods", etc.) without log-spelunking.',
'',
'0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.', '0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
'', '',
'0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.', '0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
@@ -525,7 +537,7 @@ const ROUTINE_NOTES = [
].join('\n\n') ].join('\n\n')
export const v0_2_0 = VersionInfo.of({ export const v0_2_0 = VersionInfo.of({
version: '0.2.0:45', version: '0.2.0:51',
releaseNotes: { en_US: ROUTINE_NOTES }, releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change. // No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under // SQLite-level migrations live separately under