v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
Two routine bumps land together in this release: :44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and hamburger-driven off-canvas drawer (≤720px) to the embedded web/index.html so triage flows (status check, license lookup, revoke) work from a phone. Tables now scroll horizontally inside their card, tap targets bump to ~40px, stats grid collapses to 1-up, toolbar inputs go full-width. Desktop layout unchanged. CSS + small JS toggle. :45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap the subscriptions.rs module comment promised but never delivered: first-cycle invoices on recurring policies set allow_save_payment_profile, the on-settle hook captures the resulting Zaprite paymentProfileId into four new nullable columns on the subscriptions table (migration 0019, additive only), and the renewal worker calls POST /v1/orders/charge against the saved profile instead of waiting for manual pay. On charge failure (declined card, expired profile, network) the worker logs + audits + falls through to the existing subscription.renewal_pending event so the buyer still has a recovery path. Two new operator webhook events: subscription.auto_charge_initiated and subscription.auto_charge_failed. BTCPay subs and Zaprite subs whose buyer paid with Bitcoin/Lightning or declined the save-card prompt are untouched. NOT yet end-to-end tested against the Zaprite sandbox — control flow follows api.zaprite.com/llms.txt but exact failure-body shapes for declined cards aren't documented; sandbox validation pass recommended before relying in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,34 @@ impl ZapriteClient {
|
||||
serde_json::from_str(&raw).context("parse charge 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
|
||||
/// profile has `id`, `method`, `expiresAt`, and a `sourceOrder`
|
||||
/// nested object whose `externalUniqId` is the invoice UUID we
|
||||
/// passed when creating the order — that's how we identify the
|
||||
/// profile the buyer just saved on the order that triggered
|
||||
/// this lookup.
|
||||
pub async fn get_contact(&self, contact_id: &str) -> Result<Value> {
|
||||
let encoded = urlencoding::encode(contact_id);
|
||||
let url = format!("{}/v1/contacts/{encoded}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.auth_headers()?)
|
||||
.send()
|
||||
.await
|
||||
.context("Zaprite get_contact request")?;
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.context("read get_contact body")?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
"Zaprite get_contact({contact_id}) returned HTTP {status}: {raw}"
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&raw).context("parse get_contact response")
|
||||
}
|
||||
|
||||
/// Smoke test for Connect-flow validation. Pings `GET /v1/orders`
|
||||
/// (the list endpoint) — auth-guarded, so a 200 confirms the
|
||||
/// API key works against the right org.
|
||||
|
||||
Reference in New Issue
Block a user