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:
Grant
2026-05-18 18:20:53 -05:00
parent c71345f002
commit fea6995192
9 changed files with 610 additions and 18 deletions
+10
View File
@@ -148,6 +148,12 @@ pub async fn start(
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Tier-change invoices ride on an existing license; if
// the underlying subscription already captured a saved
// payment profile on its first cycle, we keep using it
// for future renewals. No need to re-prompt for
// save-card here.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
@@ -470,6 +476,10 @@ pub async fn admin_change(
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Admin-driven tier change — same as the buyer-driven
// tier-change path above: existing subscription keeps
// its saved profile (if any), so no re-prompt.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;