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:
@@ -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#"<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -748,7 +783,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">Payment received</div>
|
||||
<h1 id="page-title">Issuing your license…</h1>
|
||||
<p class="lede" id="page-lede">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).</p>
|
||||
<p class="lede" id="page-lede">{lede_text}</p>
|
||||
|
||||
<!-- pending state (default): polling for the license -->
|
||||
<div class="pending-card" id="pending-card">
|
||||
@@ -788,6 +823,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
<script>
|
||||
(function() {{
|
||||
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) {{
|
||||
document.getElementById('pending-card').classList.add('hide');
|
||||
document.getElementById('error-card').classList.remove('hide');
|
||||
@@ -857,10 +896,21 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
|
||||
function waitingCopy(status) {{
|
||||
const min = Math.floor(elapsedMs / 60000);
|
||||
const isZaprite = PROVIDER_KIND === 'zaprite';
|
||||
if (status === 'pending' || status === 'processing') {{
|
||||
if (min < 2) return 'invoice ' + status + ' — Lightning settles in seconds; on-chain takes a block (~10 min).';
|
||||
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 'invoice ' + status + ' — slow block. Still polling. Bookmark this URL and refresh later if you close the tab.';
|
||||
if (min < 2) {{
|
||||
return isZaprite
|
||||
? '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');
|
||||
}}
|
||||
|
||||
@@ -484,8 +484,27 @@ pub async fn start(
|
||||
if let Some(code) = &reservation {
|
||||
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!(
|
||||
"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.
|
||||
#[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
|
||||
|
||||
@@ -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
|
||||
// invoice UUID; this is what the webhook handler uses as
|
||||
// the trust anchor (see `validate_webhook` below).
|
||||
@@ -82,8 +139,11 @@ impl PaymentProvider for ZapriteProvider {
|
||||
// shows the save-payment-profile prompt; subsequent
|
||||
// cycles are then merchant-initiated charges against
|
||||
// the saved profile via
|
||||
// `charge_order_with_profile`.
|
||||
allow_save_payment_profile: params.allow_save_payment_profile,
|
||||
// `charge_order_with_profile`. May be reset to None
|
||||
// above if we couldn't satisfy Zaprite's contactId
|
||||
// requirement.
|
||||
allow_save_payment_profile: effective_allow_save,
|
||||
contact_id,
|
||||
};
|
||||
|
||||
let order = self
|
||||
@@ -179,19 +239,47 @@ impl PaymentProvider for ZapriteProvider {
|
||||
let v: Value = serde_json::from_slice(body)
|
||||
.context("Zaprite webhook body must be JSON")?;
|
||||
|
||||
// Zaprite event shape (from OpenAPI excerpt + ecosystem
|
||||
// conventions): top-level `event` string + `data.id`
|
||||
// (the order UUID). Examples expected:
|
||||
// order.paid, order.complete, order.overpaid, order.underpaid,
|
||||
// order.pending, order.expired, order.refunded
|
||||
// We map liberally and let unknowns fall through to Other.
|
||||
let event_type = v
|
||||
.get("event")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Zaprite event shape: their docs don't enumerate event names
|
||||
// or payload shape. The `:49` sandbox test surfaced an empty
|
||||
// event_type because we were only checking the top-level
|
||||
// `event` field; Zaprite seems to put it elsewhere. We now
|
||||
// probe four common top-level field names — first non-empty
|
||||
// string wins. If even that fails, dump the raw payload at
|
||||
// WARN so we can see what Zaprite actually sends and add the
|
||||
// correct field name here.
|
||||
let event_type = ["event", "eventType", "type", "name"]
|
||||
.iter()
|
||||
.find_map(|field| {
|
||||
v.get(*field)
|
||||
.and_then(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.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
|
||||
.pointer("/data/id")
|
||||
.or_else(|| v.pointer("/data/object/id"))
|
||||
.or_else(|| v.get("orderId"))
|
||||
.or_else(|| v.get("id"))
|
||||
.and_then(|s| s.as_str())
|
||||
@@ -217,6 +305,55 @@ impl PaymentProvider for ZapriteProvider {
|
||||
provider_invoice_id: id,
|
||||
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 {
|
||||
kind: other.to_string(),
|
||||
provider_invoice_id: provider_invoice_id,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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