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
@@ -0,0 +1,49 @@
-- Zaprite saved-payment-profile metadata for recurring subscriptions.
--
-- Wires up the auto-charge path that the v0.2.0:1+ subscriptions
-- module comment promised but never delivered: when a buyer pays the
-- FIRST cycle of a recurring subscription via Zaprite (Stripe card),
-- Keysat asks Zaprite to save the payment profile and persists the
-- profile id here. The renewal worker then calls
-- `POST /v1/orders/charge` against the saved profile instead of
-- waiting for the buyer to manually pay each renewal.
--
-- All four columns are nullable + nothing in the existing read path
-- requires them, so this migration is a pure additive drop-in:
-- - BTCPay subscriptions stay NULL on all four (BTCPay has no
-- equivalent concept; renewals continue to require manual pay).
-- - Pre-feature Zaprite subscriptions stay NULL — the renewal
-- worker falls through to the existing "buyer pays manually"
-- branch when `zaprite_payment_profile_id IS NULL`.
-- - Zaprite subscriptions whose buyer either paid with Bitcoin/
-- Lightning instead of card, OR declined the save-card prompt,
-- also stay NULL. Same fallback.
--
-- Decisions encoded here:
-- - `zaprite_contact_id`: needed because Zaprite's order endpoint
-- doesn't surface the profile id directly. After settle we fetch
-- the contact, find the profile whose `sourceOrder.externalUniqId`
-- matches our invoice id, and persist both.
-- - `zaprite_payment_profile_method` / `expires_at`: informational
-- only — the admin UI uses them to render "card ending 4242,
-- expires 03/27" on the subscription detail. The renewal worker
-- doesn't gate on either today; if Zaprite returns expired-card
-- errors on the auto-charge we fall through to manual pay and
-- log the failure, same as any other decline.
PRAGMA foreign_keys = ON;
ALTER TABLE subscriptions
ADD COLUMN zaprite_contact_id TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_id TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_method TEXT;
ALTER TABLE subscriptions
ADD COLUMN zaprite_payment_profile_expires_at TEXT;
-- Helps the admin-UI "subs with auto-charge configured" filter and
-- any future "subs whose saved card is about to expire" sweep.
CREATE INDEX IF NOT EXISTS idx_subs_zaprite_profile
ON subscriptions(zaprite_payment_profile_id)
WHERE zaprite_payment_profile_id IS NOT NULL;
+15
View File
@@ -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
{
+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:#}")))?;
+9
View File
@@ -137,6 +137,15 @@ pub struct CreateInvoiceParams<'a> {
pub external_order_id: &'a str,
/// Buyer email if known. Some providers use this for receipts.
pub buyer_email: Option<&'a str>,
/// Ask the provider to prompt the buyer to save their payment
/// profile for future merchant-initiated charges. Zaprite honors
/// this for autopay-supporting rails (Stripe card, etc.); BTCPay
/// has no equivalent concept and silently ignores it. Set
/// `Some(true)` on the FIRST cycle of a recurring purchase so the
/// renewal worker can later call `charge_order_with_profile`
/// against the saved profile. `None` / `Some(false)` is the
/// one-shot default.
pub allow_save_payment_profile: Option<bool>,
}
/// Result of `create_invoice`. Whatever the provider returned, narrowed
@@ -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.
@@ -75,12 +75,15 @@ impl PaymentProvider for ZapriteProvider {
customer_data: params.buyer_email.map(|email| {
serde_json::json!({ "email": email })
}),
// For one-shot purchases, don't prompt the buyer to
// save their card. The recurring-subscriptions
// renewal flow sets this to true on the FIRST
// purchase of a sub so subsequent cycles can charge
// the saved profile.
allow_save_payment_profile: None,
// For one-shot purchases (`None` / `Some(false)`) we
// don't prompt the buyer to save their card. The
// recurring-subscriptions purchase path sets this to
// `Some(true)` on the FIRST cycle of a sub so Zaprite
// 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,
};
let order = self
+383 -7
View File
@@ -35,10 +35,29 @@
//! through the end of the current cycle.
//!
//! Auto-charge via saved payment profiles (Zaprite's
//! `paymentProfileId` flow) is NOT in this version. The first
//! renewal-worker iteration creates fresh invoices that the buyer
//! pays manually. v0.2.0:5+ adds the auto-charge path so cycles
//! after the first don't require buyer interaction.
//! `paymentProfileId` flow) is now wired. When a buyer pays the
//! first cycle of a recurring subscription via Zaprite AND saves
//! a card at checkout, the renewal worker calls
//! `POST /v1/orders/charge` against the saved profile on each
//! cycle instead of waiting for manual pay. The wiring lives in
//! three places:
//! - `api::purchase` sets `allow_save_payment_profile=Some(true)`
//! on the first-cycle invoice when the policy is recurring,
//! prompting Zaprite to show the save-card UI at checkout.
//! - `on_invoice_settled` here calls
//! `capture_zaprite_payment_profile`, which fetches the
//! buyer's contact from Zaprite and persists the resulting
//! profile id onto the subscriptions row.
//! - `renew_one` here invokes `try_auto_charge_zaprite` after
//! creating each renewal order. On success the buyer does
//! nothing — the order settles via the usual webhook. On
//! failure (decline, expired card, network) we fall through
//! to the existing manual-pay `subscription.renewal_pending`
//! event so the buyer can still recover the cycle.
//! BTCPay subscriptions and Zaprite subscriptions whose buyer
//! paid with Bitcoin / declined the save-card prompt have NULL
//! profile fields and continue to use the manual-pay branch
//! exclusively.
use crate::api::AppState;
use crate::db::repo;
@@ -80,6 +99,29 @@ pub struct Subscription {
pub next_renewal_at: Option<String>,
pub cancelled_at: Option<String>,
pub consecutive_failures: i64,
/// Zaprite contact id for the buyer who paid the first cycle.
/// Only ever populated for subs whose first-cycle invoice was
/// settled via Zaprite AND whose buyer saved a payment profile
/// at checkout. NULL otherwise (BTCPay subs, Bitcoin-paid
/// Zaprite subs, declined-the-save-prompt Zaprite subs).
pub zaprite_contact_id: Option<String>,
/// Zaprite saved-profile id used by the renewal worker to
/// auto-charge subsequent cycles via
/// `POST /v1/orders/charge`. NULL means "no saved profile,
/// fall through to manual-pay renewal" — the pre-feature
/// behavior.
pub zaprite_payment_profile_id: Option<String>,
/// e.g. "CARD" / "BANK" — informational for the admin UI's
/// subscription detail card. Not consulted by the worker
/// today; Zaprite returns a decline error if the method
/// doesn't support merchant-initiated charges.
pub zaprite_payment_profile_method: Option<String>,
/// ISO-8601. Informational for the admin UI ("card expires
/// 03/27"). The renewal worker doesn't gate on this — if
/// Zaprite reports the profile as expired we'll see it as
/// an `/v1/orders/charge` failure and fall through to the
/// manual-pay branch.
pub zaprite_payment_profile_expires_at: Option<String>,
}
fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
@@ -96,12 +138,20 @@ fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
next_renewal_at: row.get("next_renewal_at"),
cancelled_at: row.get("cancelled_at"),
consecutive_failures: row.get("consecutive_failures"),
zaprite_contact_id: row.try_get("zaprite_contact_id").ok(),
zaprite_payment_profile_id: row.try_get("zaprite_payment_profile_id").ok(),
zaprite_payment_profile_method: row.try_get("zaprite_payment_profile_method").ok(),
zaprite_payment_profile_expires_at: row
.try_get("zaprite_payment_profile_expires_at")
.ok(),
}
}
const SUB_COLS: &str = "id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
cancelled_at, consecutive_failures";
cancelled_at, consecutive_failures, \
zaprite_contact_id, zaprite_payment_profile_id, \
zaprite_payment_profile_method, zaprite_payment_profile_expires_at";
/// Subs that are due for the worker to act on right now: status
/// is `active` or `past_due`, `next_renewal_at` is in the past,
@@ -143,7 +193,10 @@ pub async fn find_lapsing_subscriptions(
let rows = sqlx::query(&format!(
"SELECT s.id AS id, s.license_id, s.policy_id, s.product_id, s.period_days, \
s.listed_currency, s.listed_value, s.status, s.started_at, \
s.next_renewal_at, s.cancelled_at, s.consecutive_failures \
s.next_renewal_at, s.cancelled_at, s.consecutive_failures, \
s.zaprite_contact_id, s.zaprite_payment_profile_id, \
s.zaprite_payment_profile_method, \
s.zaprite_payment_profile_expires_at \
FROM subscriptions s \
JOIN policies p ON p.id = s.policy_id \
WHERE s.status = 'past_due' \
@@ -375,6 +428,14 @@ pub async fn create_subscription(
next_renewal_at: Some(next_renewal_at),
cancelled_at: None,
consecutive_failures: 0,
// Zaprite saved-profile metadata is populated by a separate
// post-settle hook (see `capture_zaprite_payment_profile`),
// not here — at create-subscription time we don't yet know
// whether the buyer saved a card.
zaprite_contact_id: None,
zaprite_payment_profile_id: None,
zaprite_payment_profile_method: None,
zaprite_payment_profile_expires_at: None,
})
}
@@ -679,6 +740,14 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
metadata,
external_order_id: &internal_invoice_id,
buyer_email: None, // renewal email comes from the license, not solicited fresh
// The save-card prompt only matters on the FIRST cycle.
// By the time we're here the sub either already has a
// `zaprite_payment_profile_id` (we'll auto-charge below)
// or doesn't (it never will — buyer paid with Bitcoin /
// declined the prompt). Either way, re-prompting on
// every renewal would be confusing UX; renewals always
// pass `None` here.
allow_save_payment_profile: None,
})
.await
.context("provider.create_invoice for renewal")?;
@@ -765,7 +834,93 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.await
.context("UPDATE subscriptions on renewal create")?;
// 9. Webhook event: operator's app gets notified that a
// 9. If this subscription has a saved Zaprite payment profile
// (captured on first-cycle settle via
// `capture_zaprite_payment_profile`), try to merchant-
// initiate the charge against it now. On success, the buyer
// is NOT expected to do anything — Zaprite will run the
// charge and fire the usual `order.paid` webhook, which
// `on_invoice_settled` will pick up to flip the sub back to
// `active` and dispatch `subscription.renewed`. On failure
// (declined card, expired profile, Zaprite hiccup) we log
// + audit + fall through to the manual-pay
// `subscription.renewal_pending` event below so the buyer
// still has a path to recover this cycle.
let auto_charged = match try_auto_charge_zaprite(
state,
sub,
&handle.provider_invoice_id,
)
.await
{
Ok(charged) => charged,
Err(e) => {
tracing::warn!(
sub_id = %sub.id,
invoice_id = %internal_invoice_id,
error = %e,
"Zaprite auto-charge failed; falling back to manual-pay renewal"
);
let _ = repo::insert_audit(
&state.db,
"renewal_worker",
None,
"subscription.auto_charge_failed",
Some("subscription"),
Some(&sub.id),
None,
None,
&json!({
"invoice_id": internal_invoice_id,
"provider_invoice_id": handle.provider_invoice_id,
"error": format!("{e:#}"),
}),
)
.await;
crate::webhooks::dispatch(
state,
"subscription.auto_charge_failed",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"invoice_id": internal_invoice_id,
"reason": format!("{e:#}"),
}),
)
.await;
false
}
};
if auto_charged {
// Auto-charge succeeded — Zaprite will fire `order.paid`
// shortly and the webhook handler runs the rest of the
// renewal lifecycle. Fire an operator-visible event so
// the operator's app can render "renewed automatically"
// copy in their notification UI, distinct from "buyer
// needs to pay" copy.
crate::webhooks::dispatch(
state,
"subscription.auto_charge_initiated",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"invoice_id": internal_invoice_id,
"amount_sats": amount_sats,
"listed_currency": sub.listed_currency,
"listed_value": sub.listed_value,
"cycle_number": next_cycle_num,
"cycle_start_at": cycle_start.to_rfc3339(),
"cycle_end_at": cycle_end.to_rfc3339(),
}),
)
.await;
return Ok(());
}
// 10. Manual-pay path. Operator's app gets notified that a
// renewal invoice exists and the buyer needs to pay. The
// operator's webhook receiver renders an email / push /
// in-app notification with `checkout_url` and sends it to
@@ -874,6 +1029,22 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
None => return Ok(()), // not a subscription invoice
};
mark_active_after_settle(&state.db, &sub_id).await?;
// Best-effort: if this was the FIRST cycle of a Zaprite-paid
// recurring subscription AND the buyer saved a payment profile
// at checkout, capture the profile id so the renewal worker can
// auto-charge subsequent cycles. Failures here are logged but
// never block — the sub stays valid; renewals just fall back to
// the manual-pay branch.
if let Err(e) = capture_zaprite_payment_profile(state, &sub_id, invoice).await {
tracing::warn!(
sub_id = %sub_id,
invoice_id = %invoice.id,
error = %e,
"capture_zaprite_payment_profile failed; renewals will fall back to manual pay"
);
}
crate::webhooks::dispatch(
state,
"subscription.renewed",
@@ -886,3 +1057,208 @@ pub async fn on_invoice_settled(state: &AppState, invoice: &Invoice) -> Result<(
.await;
Ok(())
}
/// Best-effort capture of the Zaprite saved-payment-profile after a
/// first-cycle settle. No-ops in any of these cases:
/// - sub already has `zaprite_payment_profile_id` set (idempotent
/// re-delivery of the same settle webhook)
/// - active provider isn't Zaprite (BTCPay subs have no equivalent)
/// - the invoice predates the saved-profile feature (pre-:44
/// Zaprite subs)
/// - buyer paid with Bitcoin/Lightning, or declined the save-card
/// prompt — no profile gets created on Zaprite's side
///
/// When it does fire, we:
/// 1. Fetch the Zaprite order to find the buyer's `contact.id`
/// 2. Fetch the contact to enumerate `paymentProfiles[]`
/// 3. Find the profile whose `sourceOrder.externalUniqId` matches
/// our local invoice id (= the externalUniqId we set at order
/// creation) — that's the profile saved on THIS purchase
/// 4. UPDATE the subscriptions row with id / method / expiresAt
pub async fn capture_zaprite_payment_profile(
state: &AppState,
sub_id: &str,
invoice: &Invoice,
) -> Result<()> {
use crate::payment::ProviderKind;
// Idempotency: already captured?
let existing: Option<String> = sqlx::query_scalar(
"SELECT zaprite_payment_profile_id FROM subscriptions WHERE id = ?",
)
.bind(sub_id)
.fetch_optional(&state.db)
.await
.context("read existing zaprite_payment_profile_id")?
.flatten();
if existing.is_some() {
return Ok(());
}
// Active provider must be Zaprite for any of the rest to be
// meaningful — `as_any` downcast keeps the trait clean.
let provider = match state.payment_provider().await {
Ok(p) => p,
Err(_) => return Ok(()),
};
if provider.kind() != ProviderKind::Zaprite {
return Ok(());
}
let zaprite = match provider
.as_any()
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
{
Some(z) => z,
None => return Ok(()),
};
let client = zaprite.client();
// 1. Fetch the order so we can read its contact.
let order = client
.get_order(&invoice.btcpay_invoice_id)
.await
.context("fetch Zaprite order for profile capture")?;
let contact_id = order
.pointer("/contact/id")
.or_else(|| order.get("contactId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let contact_id = match contact_id {
Some(c) => c,
None => {
// 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.
return Ok(());
}
};
// 2. Fetch the contact and enumerate its payment profiles.
let contact = client
.get_contact(&contact_id)
.await
.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
};
// 3. Find the profile whose sourceOrder.externalUniqId is
// THIS invoice. Zaprite scopes saved profiles to a contact,
// but a contact may have multiple profiles from prior
// purchases (e.g. the buyer subscribed to another product
// too). The sourceOrder pin is how we identify the one
// Zaprite just minted on this purchase.
let matching = profiles.iter().find(|p| {
p.pointer("/sourceOrder/externalUniqId")
.and_then(|v| v.as_str())
.map(|s| s == invoice.id)
.unwrap_or(false)
});
let profile = match matching {
Some(p) => p,
None => {
// Most common reason: buyer paid with Bitcoin / Lightning
// (no autopay-supporting rail) OR declined the save-
// payment-profile prompt on the card form. Both are
// legitimate; renewals fall back to manual pay.
return Ok(());
}
};
let profile_id = match profile.get("id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => return Ok(()),
};
let method = profile.get("method").and_then(|v| v.as_str()).map(|s| s.to_string());
let expires_at = profile
.get("expiresAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 4. Persist.
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE subscriptions \
SET zaprite_contact_id = ?, zaprite_payment_profile_id = ?, \
zaprite_payment_profile_method = ?, \
zaprite_payment_profile_expires_at = ?, \
updated_at = ? \
WHERE id = ?",
)
.bind(&contact_id)
.bind(&profile_id)
.bind(&method)
.bind(&expires_at)
.bind(&now)
.bind(sub_id)
.execute(&state.db)
.await
.context("UPDATE subscriptions with Zaprite profile metadata")?;
tracing::info!(
sub_id = %sub_id,
contact_id = %contact_id,
profile_id = %profile_id,
method = method.as_deref().unwrap_or("?"),
"captured Zaprite saved payment profile for auto-charge on renewal"
);
Ok(())
}
/// Attempt a merchant-initiated charge against the saved Zaprite
/// payment profile on this subscription. Called by the renewal
/// worker *after* it has created the order; this turns the order
/// from "buyer must pay" into "auto-charged, will settle via the
/// usual webhook." Returns:
/// - `Ok(true)` — the charge call succeeded; the buyer is not
/// expected to pay manually. The settle webhook
/// will fire on its own and flip the sub to
/// `active` via `on_invoice_settled`.
/// - `Ok(false)` — sub has no saved profile, or active provider
/// isn't Zaprite. Caller proceeds with manual-pay
/// fallback (`subscription.renewal_pending`).
/// - `Err(_)` — Zaprite returned an error (declined card,
/// expired profile, network blip). Caller treats
/// this as a soft failure: log, audit, and ALSO
/// fall through to manual-pay so the buyer has
/// a path to recover.
async fn try_auto_charge_zaprite(
state: &AppState,
sub: &Subscription,
provider_invoice_id: &str,
) -> Result<bool> {
use crate::payment::ProviderKind;
let profile_id = match sub.zaprite_payment_profile_id.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(false),
};
let provider = state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?;
if provider.kind() != ProviderKind::Zaprite {
return Ok(false);
}
let zaprite = provider
.as_any()
.downcast_ref::<crate::payment::zaprite::ZapriteProvider>()
.ok_or_else(|| anyhow!("provider.kind is Zaprite but downcast failed"))?;
let resp = zaprite
.client()
.charge_order_with_profile(provider_invoice_id, profile_id)
.await
.context("Zaprite charge_order_with_profile")?;
tracing::info!(
sub_id = %sub.id,
order_id = %provider_invoice_id,
profile_id = %profile_id,
order_status = resp.get("status").and_then(|v| v.as_str()).unwrap_or("?"),
"Zaprite auto-charge succeeded; awaiting settle webhook"
);
Ok(true)
}
+102 -4
View File
@@ -180,6 +180,14 @@ table.t {
border-radius:10px; overflow:hidden;
}
.card > table.t { border:0; border-radius:0 0 10px 10px; }
/* Horizontally scrollable wrapper for tables on narrow screens. When the
table is wider than the card, the wrapper scrolls instead of the row
clipping. The wrapper carries the bottom rounding so the table itself
can stay square; otherwise the rounded table corners would clip mid-row
when scrolled. */
.t-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
.card > .t-wrap { border-radius:0 0 10px 10px; }
.card > .t-wrap > table.t { border:0; border-radius:0; }
table.t thead th {
text-align:left; font-size:11px; font-weight:700;
letter-spacing:0.12em; text-transform:uppercase; color:var(--ink-500);
@@ -315,6 +323,29 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
margin-top:22px;
}
/* ---------- Mobile nav (hamburger + off-canvas drawer) ---------- */
/* The hamburger button lives in the topbar and is hidden by default; the
≤720px breakpoint below reveals it and reframes the sidebar as a slide-in
drawer. The backdrop dims the content and provides a tap target for
closing. */
.nav-toggle {
display:none;
align-items:center; justify-content:center;
width:38px; height:38px; padding:0;
background:transparent; color:var(--navy-900);
border:1px solid var(--border-2); border-radius:7px;
cursor:pointer; transition:all 120ms;
}
.nav-toggle:hover { background:var(--cream-200); }
.nav-toggle [data-lucide] { width:20px; height:20px; }
.sidebar-backdrop {
display:none;
position:fixed; inset:0; background:rgba(14,31,51,0.45);
z-index:40; opacity:0; pointer-events:none;
transition:opacity 200ms;
}
.sidebar-backdrop.open { opacity:1; pointer-events:auto; }
@media (max-width: 980px) {
.app { grid-template-columns:1fr; }
.sidebar { position:static; max-height:none; height:auto; }
@@ -324,6 +355,41 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
.topbar { padding:14px 20px; }
}
/* Tablet/phone: collapse the sidebar into a true off-canvas drawer. Above
720px the stacked-sidebar layout from the 980px breakpoint is fine; below
720px the sidebar takes too much vertical space before content, so we
convert it to a slide-in instead. */
@media (max-width: 720px) {
.nav-toggle { display:inline-flex; }
.sidebar-backdrop { display:block; }
.sidebar {
position:fixed; top:0; left:0; bottom:0;
width:min(280px, 80vw); height:100vh; max-height:100vh;
padding:20px 12px;
transform:translateX(-100%); transition:transform 200ms ease;
z-index:50; overflow-y:auto;
}
.sidebar.open { transform:translateX(0); }
.sidebar a.nav { padding:12px 12px; font-size:14.5px; }
.sidebar a.nav [data-lucide] { width:18px; height:18px; }
}
/* Phone tier: tighten chrome, drop stats to a single column, let toolbar
inputs fill the row, bump button tap targets. */
@media (max-width: 640px) {
.stats { grid-template-columns:1fr; }
.content { padding:14px 14px 56px; }
.topbar { padding:12px 14px; gap:10px; }
.topbar h1 { font-size:18px; }
.topbar .who { display:none; }
.toolbar .input, .toolbar .select { min-width:0; width:100%; }
.card .card-head { padding:12px 14px; flex-wrap:wrap; }
.card .card-head .sub { margin-left:0; flex-basis:100%; }
.card .card-body { padding:14px; }
.btn { padding:10px 14px; }
.btn.sm { padding:8px 12px; }
}
/* Featured (launch special) pill toggle — used on the Discount Codes
create + edit forms. Click anywhere on the pill to flip the
underlying hidden checkbox. Off = muted; on = gold accent. Reads as
@@ -422,7 +488,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
<!-- Main app shell (shown after login) -->
<section id="app-view" class="hide">
<div class="app">
<aside class="sidebar">
<aside class="sidebar" id="sidebar">
<div class="brand">
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true" style="width:26px; height:26px">
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"></ellipse>
@@ -495,6 +561,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</aside>
<main class="main">
<header class="topbar">
<button class="nav-toggle" id="nav-toggle" type="button"
aria-label="Open navigation" aria-expanded="false" aria-controls="sidebar">
<i data-lucide="menu"></i>
</button>
<div>
<div class="crumb" id="crumb">Workspace</div>
<h1 id="page-title">Overview</h1>
@@ -506,6 +576,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
</header>
<div class="content" id="route-target"></div>
</main>
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
</div>
</section>
@@ -1129,7 +1200,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
for (const r of rows) tb.appendChild(r)
t.appendChild(tb)
return el('div', { class: 'card' }, [head, t])
return el('div', { class: 'card' }, [head, el('div', { class: 't-wrap' }, t)])
}
function statusBadge(status) {
const map = {
@@ -3827,7 +3898,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
const tb = el('tbody')
rows.forEach((r) => tb.appendChild(r))
t.appendChild(tb)
return t
return el('div', { class: 't-wrap' }, t)
}
function render() {
@@ -6467,8 +6538,35 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
}
}
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
a.addEventListener('click', (e) => {
e.preventDefault()
setRoute(a.getAttribute('data-route'))
// Close the off-canvas drawer on phones after navigating, otherwise
// the sidebar stays parked over the content the operator just opened.
closeSidebarDrawer()
})
})
// Mobile nav drawer. Above 720px the sidebar is a static column and these
// toggles are no-ops (the CSS keeps it visible regardless of `.open`).
const sidebarEl = document.getElementById('sidebar')
const backdropEl = document.getElementById('sidebar-backdrop')
const toggleEl = document.getElementById('nav-toggle')
function openSidebarDrawer() {
sidebarEl.classList.add('open')
backdropEl.classList.add('open')
toggleEl.setAttribute('aria-expanded', 'true')
}
function closeSidebarDrawer() {
sidebarEl.classList.remove('open')
backdropEl.classList.remove('open')
toggleEl.setAttribute('aria-expanded', 'false')
}
toggleEl.addEventListener('click', () => {
if (sidebarEl.classList.contains('open')) closeSidebarDrawer()
else openSidebarDrawer()
})
backdropEl.addEventListener('click', closeSidebarDrawer)
// Tier status (label + usage + caps) — cached after first fetch so
// multiple consumers within a single route render don't all re-hit
+5 -1
View File
@@ -58,6 +58,10 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
'',
'0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
'',
'0.2.0:43 — **BTCPay authorize success page now says "return to Keysat" instead of "return to StartOS".** The success page is the lightweight HTML BTCPay redirects to after the operator clicks Authorize. With the BTCPay connect flow living inside Keysat\'s admin UI (since the :40-era admin-UI redesign), "return to StartOS" was misdirecting the operator — Keysat\'s own tab is what they came from and what they want to return to. One-line copy change in `success_page()` and the GET fallback path in `btcpay_authorize.rs`; no behavior change.',
'',
'0.2.0:42 — **Revert the implicit Patron→Pro entitlement expansion shipped in :41.** Reasoning on revert: the only license affected by the missing-entitlement bug was the master operator\'s own pre-launch self-license, issued under an earlier entitlement scheme. The Patron policy on the master Keysat now lists the correct entitlements, so any fresh Patron license issued today carries them in the signed payload directly. Making `patron` a magic superset at the resolution layer was paying ongoing complexity tax (entitlement-renames have to update a hardcoded list; the gate behavior diverges from what the policy literally says) for a one-shot migration that won\'t recur. Operators with a stuck old-scheme Patron license should re-issue + run "Activate Keysat license" — the new license overwrites `/data/keysat-license.txt` and the daemon picks up the fresh entitlements without a restart. The :41 BTCPay one-click authorize-flow restoration in the admin UI is unchanged.',
@@ -521,7 +525,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:43',
version: '0.2.0:45',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under