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:
@@ -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;
|
||||
@@ -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:#}")))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,9 +6538,36 @@ 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
|
||||
// /v1/admin/tier. Callers pass `forceRefresh:true` to bust the cache
|
||||
|
||||
Reference in New Issue
Block a user