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:
@@ -450,6 +450,20 @@ pub async fn start(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Recurring policy: ask the provider to prompt the buyer to
|
||||
// save their payment profile at checkout so the renewal worker
|
||||
// can later auto-charge it via `charge_order_with_profile`.
|
||||
// Zaprite honors this for autopay-supporting rails (Stripe card
|
||||
// via a connected merchant account); BTCPay has no equivalent
|
||||
// and silently ignores the flag. We always set this on
|
||||
// recurring purchases — if the buyer ends up paying with
|
||||
// Bitcoin / Lightning, or declines the save-card prompt at
|
||||
// Zaprite's checkout, no profile gets created and the post-
|
||||
// settle profile-capture step finds nothing. The sub then
|
||||
// behaves like a pre-feature recurring sub: renewals still
|
||||
// create fresh invoices the buyer pays manually.
|
||||
let allow_save_profile =
|
||||
chosen_policy.as_ref().map(|p| p.is_recurring).unwrap_or(false);
|
||||
let created = match provider
|
||||
.create_invoice(CreateInvoiceParams {
|
||||
amount: Money::sats(final_price),
|
||||
@@ -461,6 +475,7 @@ pub async fn start(
|
||||
metadata: json!({ "productId": product.id }),
|
||||
external_order_id: &internal_id,
|
||||
buyer_email: req.buyer_email.as_deref(),
|
||||
allow_save_payment_profile: if allow_save_profile { Some(true) } else { None },
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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:#}")))?;
|
||||
|
||||
Reference in New Issue
Block a user