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:
@@ -1082,6 +1082,13 @@ pub async fn capture_zaprite_payment_profile(
|
||||
) -> Result<()> {
|
||||
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?
|
||||
let existing: Option<String> = sqlx::query_scalar(
|
||||
"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")?
|
||||
.flatten();
|
||||
if existing.is_some() {
|
||||
tracing::info!(sub_id = %sub_id, "capture: already captured, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1099,9 +1107,19 @@ pub async fn capture_zaprite_payment_profile(
|
||||
// meaningful — `as_any` downcast keeps the trait clean.
|
||||
let provider = match state.payment_provider().await {
|
||||
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 {
|
||||
tracing::info!(
|
||||
sub_id = %sub_id, kind = ?provider.kind(),
|
||||
"capture: active provider is not Zaprite — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let zaprite = match provider
|
||||
@@ -1109,7 +1127,13 @@ pub async fn capture_zaprite_payment_profile(
|
||||
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -1129,9 +1153,22 @@ pub async fn capture_zaprite_payment_profile(
|
||||
// Order has no contact — buyer paid without an email /
|
||||
// Zaprite didn't materialize a contact. No profile to
|
||||
// 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(());
|
||||
}
|
||||
};
|
||||
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.
|
||||
let contact = client
|
||||
@@ -1140,8 +1177,21 @@ pub async fn capture_zaprite_payment_profile(
|
||||
.context("fetch Zaprite contact for profile capture")?;
|
||||
let profiles = match contact.get("paymentProfiles").and_then(|v| v.as_array()) {
|
||||
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
|
||||
// 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-
|
||||
// payment-profile prompt on the card form. Both are
|
||||
// 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(());
|
||||
}
|
||||
};
|
||||
|
||||
let profile_id = match profile.get("id").and_then(|v| v.as_str()) {
|
||||
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 expires_at = profile
|
||||
|
||||
Reference in New Issue
Block a user