forked from coracle/caravel
Fix a few bugs
This commit is contained in:
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
blossom_enabled INTEGER NOT NULL DEFAULT 0,
|
blossom_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
livekit_enabled INTEGER NOT NULL DEFAULT 0,
|
livekit_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
push_enabled INTEGER NOT NULL DEFAULT 1,
|
push_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -114,6 +115,8 @@ CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant_pubkey, cre
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
|
CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relay_created ON relay (created_at);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
|
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
|
||||||
|
|
||||||
-- Dunning scans a tenant's still-open invoices oldest-first to retry payment.
|
-- Dunning scans a tenant's still-open invoices oldest-first to retry payment.
|
||||||
|
|||||||
+28
-41
@@ -352,8 +352,6 @@ impl Billing {
|
|||||||
autopay: bool,
|
autopay: bool,
|
||||||
notify: bool,
|
notify: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut error_message: Option<String> = None;
|
|
||||||
|
|
||||||
// 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a
|
// 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a
|
||||||
// manual QR scan, or an NWC pay that completed but failed to record).
|
// manual QR scan, or an NWC pay that completed but failed to record).
|
||||||
// Checked before any charge so we never bill on top of it.
|
// Checked before any charge so we never bill on top of it.
|
||||||
@@ -385,23 +383,24 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
|
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
|
||||||
if !tenant.nwc_url.is_empty() {
|
if !tenant.nwc_url.is_empty()
|
||||||
match self.attempt_payment_using_nwc(tenant, invoice).await {
|
&& self
|
||||||
Ok(()) => return self.cleanup_pending_payments(invoice).await,
|
.attempt_payment_using_nwc(tenant, invoice)
|
||||||
Err(e) => error_message = Some(format!("{e}")),
|
.await
|
||||||
}
|
.is_ok()
|
||||||
|
{
|
||||||
|
return self.cleanup_pending_payments(invoice).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Payment method on file: charge the tenant's cached Stripe payment
|
// 4. Payment method on file: charge the tenant's cached Stripe payment
|
||||||
// method, kept fresh by sync_stripe_payment_method before collection.
|
// method, kept fresh by sync_stripe_payment_method before collection.
|
||||||
if let Some(payment_method) = &tenant.stripe_payment_method_id {
|
if let Some(payment_method) = &tenant.stripe_payment_method_id
|
||||||
match self
|
&& self
|
||||||
.attempt_payment_using_stripe(tenant, invoice, payment_method)
|
.attempt_payment_using_stripe(tenant, invoice, payment_method)
|
||||||
.await
|
.await
|
||||||
{
|
.is_ok()
|
||||||
Ok(()) => return self.cleanup_pending_payments(invoice).await,
|
{
|
||||||
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
|
return self.cleanup_pending_payments(invoice).await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !notify {
|
if !notify {
|
||||||
@@ -409,10 +408,7 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Manual payment: DM a link to the in-app payment page for this invoice.
|
// 5. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||||
if let Err(e) = self
|
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice).await {
|
||||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
tenant = %tenant.pubkey,
|
tenant = %tenant.pubkey,
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -482,12 +478,7 @@ impl Billing {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attempt_payment_using_dm(
|
async fn attempt_payment_using_dm(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||||
&self,
|
|
||||||
tenant: &Tenant,
|
|
||||||
invoice: &Invoice,
|
|
||||||
error: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
|
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
|
||||||
@@ -507,21 +498,10 @@ impl Billing {
|
|||||||
let invoice_id = &invoice.id;
|
let invoice_id = &invoice.id;
|
||||||
let url_base = &env::get().app_url;
|
let url_base = &env::get().app_url;
|
||||||
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
|
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
|
||||||
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
|
let message = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
|
||||||
let dm_message = match error {
|
|
||||||
Some(error) if !error.is_empty() => {
|
|
||||||
let limit: usize = 240;
|
|
||||||
let summary = error
|
|
||||||
.chars()
|
|
||||||
.take(limit.saturating_sub(3))
|
|
||||||
.collect::<String>();
|
|
||||||
format!("{base}\n\nAuto-payment failed: {summary}")
|
|
||||||
}
|
|
||||||
_ => base,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send via NIP 17
|
// Send via NIP 17
|
||||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
self.robot.send_dm(&tenant.pubkey, &message).await?;
|
||||||
|
|
||||||
// Record the send to avoid spammy notifications.
|
// Record the send to avoid spammy notifications.
|
||||||
command::mark_invoice_notified(invoice_id).await
|
command::mark_invoice_notified(invoice_id).await
|
||||||
@@ -534,7 +514,11 @@ impl Billing {
|
|||||||
/// transaction, so it's excluded here and not needlessly expired.
|
/// transaction, so it's excluded here and not needlessly expired.
|
||||||
async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> {
|
async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> {
|
||||||
for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? {
|
for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? {
|
||||||
if let Err(error) = self.stripe.expire_checkout_session(&checkout.session_id).await {
|
if let Err(error) = self
|
||||||
|
.stripe
|
||||||
|
.expire_checkout_session(&checkout.session_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
invoice = %invoice.id,
|
invoice = %invoice.id,
|
||||||
checkout = %checkout.id,
|
checkout = %checkout.id,
|
||||||
@@ -561,7 +545,8 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
|
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
|
||||||
&& (existing.settled_at.is_none() || now < existing.expires_at)
|
&& existing.settled_at.is_none()
|
||||||
|
&& now < existing.expires_at
|
||||||
{
|
{
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
@@ -713,15 +698,17 @@ impl BillingPeriod {
|
|||||||
|
|
||||||
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
|
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
|
||||||
/// prorating a mid-period charge or credit. The remaining time is rounded to
|
/// prorating a mid-period charge or credit. The remaining time is rounded to
|
||||||
/// the nearest hour so proration tracks whole hours rather than exact seconds.
|
/// the nearest day so proration tracks whole days rather than exact seconds.
|
||||||
|
/// Rounding by the day keeps a relay created within the first half-day of a
|
||||||
|
/// period billed at the full plan amount instead of a few cents short.
|
||||||
fn fraction_remaining(&self, at: i64) -> f64 {
|
fn fraction_remaining(&self, at: i64) -> f64 {
|
||||||
const HOUR: i64 = 60 * 60;
|
const DAY: i64 = 24 * 60 * 60;
|
||||||
|
|
||||||
let len = (self.end - self.start) as f64;
|
let len = (self.end - self.start) as f64;
|
||||||
if len <= 0.0 {
|
if len <= 0.0 {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
let remaining = ((self.end - at) + HOUR / 2) / HOUR * HOUR;
|
let remaining = ((self.end - at) + DAY / 2) / DAY * DAY;
|
||||||
(remaining as f64 / len).clamp(0.0, 1.0)
|
(remaining as f64 / len).clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<
|
|||||||
// --- Relays ---
|
// --- Relays ---
|
||||||
|
|
||||||
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||||
|
let created_at = chrono::Utc::now().timestamp();
|
||||||
let activity = with_tx(async |tx| {
|
let activity = with_tx(async |tx| {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO relay (
|
"INSERT INTO relay (
|
||||||
@@ -153,8 +154,8 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
info_name, info_icon, info_description,
|
info_name, info_icon, info_description,
|
||||||
policy_public_join, policy_strip_signatures,
|
policy_public_join, policy_strip_signatures,
|
||||||
groups_enabled, management_enabled, blossom_enabled,
|
groups_enabled, management_enabled, blossom_enabled,
|
||||||
livekit_enabled, push_enabled
|
livekit_enabled, push_enabled, created_at
|
||||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&relay.id)
|
.bind(&relay.id)
|
||||||
.bind(&relay.tenant_pubkey)
|
.bind(&relay.tenant_pubkey)
|
||||||
@@ -171,6 +172,7 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
.bind(relay.blossom_enabled)
|
.bind(relay.blossom_enabled)
|
||||||
.bind(relay.livekit_enabled)
|
.bind(relay.livekit_enabled)
|
||||||
.bind(relay.push_enabled)
|
.bind(relay.push_enabled)
|
||||||
|
.bind(created_at)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
let snapshot = Snapshot::Relay {
|
let snapshot = Snapshot::Relay {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub struct Relay {
|
|||||||
pub livekit_enabled: i64,
|
pub livekit_enabled: i64,
|
||||||
pub push_enabled: i64,
|
pub push_enabled: i64,
|
||||||
pub synced: i64,
|
pub synced: i64,
|
||||||
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Relay {
|
impl Default for Relay {
|
||||||
@@ -113,6 +114,7 @@ impl Default for Relay {
|
|||||||
livekit_enabled: 0,
|
livekit_enabled: 0,
|
||||||
push_enabled: 1,
|
push_enabled: 1,
|
||||||
synced: 0,
|
synced: 0,
|
||||||
|
created_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -56,9 +56,11 @@ pub fn get_plan(plan_id: &str) -> Result<Plan> {
|
|||||||
// --- Tenants ---
|
// --- Tenants ---
|
||||||
|
|
||||||
pub async fn list_tenants() -> Result<Vec<Tenant>> {
|
pub async fn list_tenants() -> Result<Vec<Tenant>> {
|
||||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
|
Ok(
|
||||||
.fetch_all(pool())
|
sqlx::query_as::<_, Tenant>(&select_tenant("ORDER BY created_at DESC"))
|
||||||
.await?)
|
.fetch_all(pool())
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
||||||
@@ -73,9 +75,11 @@ pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
|||||||
// --- Relays ---
|
// --- Relays ---
|
||||||
|
|
||||||
pub async fn list_relays() -> Result<Vec<Relay>> {
|
pub async fn list_relays() -> Result<Vec<Relay>> {
|
||||||
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
|
Ok(
|
||||||
.fetch_all(pool())
|
sqlx::query_as::<_, Relay>(&select_relay("ORDER BY created_at DESC"))
|
||||||
.await?)
|
.fetch_all(pool())
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
||||||
@@ -87,12 +91,12 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
|
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
|
||||||
Ok(
|
Ok(sqlx::query_as::<_, Relay>(&select_relay(
|
||||||
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
|
"WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
||||||
.bind(tenant_pubkey)
|
))
|
||||||
.fetch_all(pool())
|
.bind(tenant_pubkey)
|
||||||
.await?,
|
.fetch_all(pool())
|
||||||
)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
(id) => getInvoice(id),
|
(id) => getInvoice(id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A resource keeps its last value once its source goes falsy, so deepLinked()
|
||||||
|
// still returns the fetched invoice after the param is cleared. Gate on the
|
||||||
|
// param so clearing it actually closes the dialog (otherwise it can't be).
|
||||||
|
const deepLinkedInvoice = createMemo(() =>
|
||||||
|
searchParams.invoice ? deepLinked() : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
const prompt = createMemo(() =>
|
const prompt = createMemo(() =>
|
||||||
activeBillingPrompt(
|
activeBillingPrompt(
|
||||||
{
|
{
|
||||||
@@ -108,7 +115,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Pay an invoice — from a prompt action or a deep link. */}
|
{/* Pay an invoice — from a prompt action or a deep link. */}
|
||||||
<Show when={payInvoice() ?? deepLinked()}>
|
<Show when={payInvoice() ?? deepLinkedInvoice()}>
|
||||||
{(invoice) => (
|
{(invoice) => (
|
||||||
<PaymentDialog
|
<PaymentDialog
|
||||||
invoice={invoice()}
|
invoice={invoice()}
|
||||||
|
|||||||
Reference in New Issue
Block a user