forked from coracle/caravel
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 072031d0c3 | |||
| ce595c8bc5 |
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS tenant (
|
||||
nwc_url TEXT NOT NULL DEFAULT '',
|
||||
nwc_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
stripe_customer_id TEXT NOT NULL DEFAULT '',
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
stripe_subscription_id TEXT,
|
||||
past_due_at INTEGER
|
||||
);
|
||||
|
||||
+42
-24
@@ -370,32 +370,50 @@ async fn get_identity(
|
||||
let pubkey = state.api.extract_auth_pubkey(&headers)?;
|
||||
let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
|
||||
|
||||
// Only create if tenant doesn't exist yet
|
||||
if let Ok(None) = state.api.query.get_tenant(&pubkey).await {
|
||||
// TODO: Call Stripe API to create a new customer
|
||||
let stripe_customer_id = String::new();
|
||||
// Ensure tenant exists.
|
||||
match state.api.query.get_tenant(&pubkey).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"stripe-customer-create-failed",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let tenant = Tenant {
|
||||
pubkey: pubkey.clone(),
|
||||
nwc_url: String::new(),
|
||||
nwc_error: None,
|
||||
created_at: now_ts(),
|
||||
stripe_customer_id,
|
||||
stripe_subscription_id: None,
|
||||
past_due_at: None,
|
||||
};
|
||||
let tenant = Tenant {
|
||||
pubkey: pubkey.clone(),
|
||||
nwc_url: String::new(),
|
||||
nwc_error: None,
|
||||
created_at: now_ts(),
|
||||
stripe_customer_id,
|
||||
stripe_subscription_id: None,
|
||||
past_due_at: None,
|
||||
};
|
||||
|
||||
match state.api.command.create_tenant(&tenant).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
match state.api.command.create_tenant(&tenant).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {}
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
|
||||
|
||||
@@ -56,6 +56,9 @@ impl Billing {
|
||||
pub fn new(query: Query, command: Command, robot: Robot) -> Self {
|
||||
let nwc_url = std::env::var("NWC_URL").unwrap_or_default();
|
||||
let stripe_secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default();
|
||||
if stripe_secret_key.trim().is_empty() {
|
||||
panic!("missing STRIPE_SECRET_KEY environment variable");
|
||||
}
|
||||
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
|
||||
let btc_quote_api_base =
|
||||
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
|
||||
@@ -472,6 +475,33 @@ impl Billing {
|
||||
Ok((invoice, tenant))
|
||||
}
|
||||
|
||||
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
|
||||
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
|
||||
let display_name = format!("Caravel tenant {short_pubkey}");
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{STRIPE_API}/customers"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.form(&[
|
||||
("name", display_name.as_str()),
|
||||
("metadata[tenant_pubkey]", tenant_pubkey),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = resp.error_for_status()?.json().await?;
|
||||
let customer_id = body["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing customer id"))?;
|
||||
|
||||
if !customer_id.starts_with("cus_") {
|
||||
return Err(anyhow!("unexpected customer id format"));
|
||||
}
|
||||
|
||||
Ok(customer_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.http
|
||||
@@ -872,3 +902,109 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
fn set_stripe_secret_key(value: Option<&str>) {
|
||||
match value {
|
||||
Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) },
|
||||
None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") },
|
||||
}
|
||||
}
|
||||
|
||||
struct StripeSecretKeyGuard {
|
||||
previous: Option<String>,
|
||||
}
|
||||
|
||||
impl StripeSecretKeyGuard {
|
||||
fn set(value: Option<&str>) -> Self {
|
||||
let previous = std::env::var("STRIPE_SECRET_KEY").ok();
|
||||
set_stripe_secret_key(value);
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StripeSecretKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
set_stripe_secret_key(self.previous.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.expect("connect sqlite memory db");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("run migrations");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_panics_without_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(None);
|
||||
|
||||
let pool = test_pool().await;
|
||||
let query = Query::new(pool.clone());
|
||||
let command = Command::new(pool);
|
||||
let robot = Robot::test_stub();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
Billing::new(query, command, robot)
|
||||
}));
|
||||
|
||||
let panic_payload = match result {
|
||||
Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
|
||||
(*msg).to_string()
|
||||
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
assert!(
|
||||
panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"),
|
||||
"unexpected panic: {panic_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_new_accepts_non_empty_stripe_secret_key() {
|
||||
let _lock = env_lock().lock().expect("acquire env lock");
|
||||
let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
|
||||
|
||||
let pool = test_pool().await;
|
||||
let billing = Billing::new(
|
||||
Query::new(pool.clone()),
|
||||
Command::new(pool),
|
||||
Robot::test_stub(),
|
||||
);
|
||||
|
||||
assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
|
||||
}
|
||||
}
|
||||
|
||||
+59
-1
@@ -66,6 +66,10 @@ impl Command {
|
||||
}
|
||||
|
||||
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||
if tenant.stripe_customer_id.trim().is_empty() {
|
||||
anyhow::bail!("stripe_customer_id is required");
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
@@ -205,7 +209,8 @@ impl Command {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay_id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
@@ -327,3 +332,56 @@ impl Command {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
|
||||
.expect("valid sqlite memory url")
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.expect("connect sqlite memory db");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("run migrations");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_tenant_rejects_empty_stripe_customer_id() {
|
||||
let pool = test_pool().await;
|
||||
let command = Command::new(pool);
|
||||
|
||||
let tenant = Tenant {
|
||||
pubkey: "tenant_pubkey".to_string(),
|
||||
nwc_url: String::new(),
|
||||
nwc_error: None,
|
||||
created_at: 0,
|
||||
stripe_customer_id: " ".to_string(),
|
||||
stripe_subscription_id: None,
|
||||
past_due_at: None,
|
||||
};
|
||||
|
||||
let err = command
|
||||
.create_tenant(&tenant)
|
||||
.await
|
||||
.expect_err("empty customer id must be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("stripe_customer_id is required"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,3 +254,23 @@ async fn set_cached(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Robot {
|
||||
pub fn test_stub() -> Self {
|
||||
let keys = Keys::generate();
|
||||
let client = Client::new(keys);
|
||||
|
||||
Self {
|
||||
secret: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
picture: String::new(),
|
||||
outbox_client: client.clone(),
|
||||
indexer_client: client.clone(),
|
||||
messaging_client: client,
|
||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
|
||||
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
|
||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
|
||||
type PaymentInvoice = {
|
||||
id: string
|
||||
@@ -21,20 +22,34 @@ type PaymentDialogProps = {
|
||||
export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const [bolt11, setBolt11] = createSignal("")
|
||||
const [qrDataUrl, setQrDataUrl] = createSignal("")
|
||||
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
|
||||
const [bolt11Error, setBolt11Error] = createSignal("")
|
||||
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
|
||||
const [payError, setPayError] = createSignal("")
|
||||
const [showSetup, setShowSetup] = createSignal(false)
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
|
||||
createEffect(async () => {
|
||||
if (!props.open || !props.invoice.id) return
|
||||
async function loadBolt11() {
|
||||
if (!props.invoice.id) return
|
||||
setBolt11Status("loading")
|
||||
setBolt11Error("")
|
||||
setBolt11("")
|
||||
setQrDataUrl("")
|
||||
|
||||
try {
|
||||
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
|
||||
setBolt11(invoice)
|
||||
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
|
||||
} catch {
|
||||
// bolt11 generation may fail
|
||||
setBolt11Status("ready")
|
||||
} catch (e) {
|
||||
setBolt11Status("error")
|
||||
setBolt11Error(e instanceof Error ? e.message : "Failed to generate Lightning invoice")
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open || !props.invoice.id) return
|
||||
void loadBolt11()
|
||||
})
|
||||
|
||||
function copyBolt11() {
|
||||
@@ -62,6 +77,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
function handleClose() {
|
||||
setPayStatus("idle")
|
||||
setPayError("")
|
||||
setBolt11Status("idle")
|
||||
setBolt11Error("")
|
||||
setBolt11("")
|
||||
setQrDataUrl("")
|
||||
setShowSetup(false)
|
||||
@@ -104,33 +121,46 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
when={payStatus() === "success"}
|
||||
fallback={
|
||||
<div class="w-full space-y-3">
|
||||
<Show
|
||||
when={qrDataUrl()}
|
||||
fallback={<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>}
|
||||
>
|
||||
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
|
||||
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
|
||||
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
|
||||
</Show>
|
||||
<Show when={bolt11()}>
|
||||
<div class="flex rounded-lg border border-gray-300">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={bolt11()}
|
||||
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
|
||||
/>
|
||||
<Show when={bolt11Status() === "error"}>
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
|
||||
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
|
||||
onClick={copyBolt11}
|
||||
title="Copy invoice"
|
||||
onClick={() => void loadBolt11()}
|
||||
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={bolt11Status() === "ready"}>
|
||||
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
|
||||
<Show when={bolt11()}>
|
||||
<div class="flex rounded-lg border border-gray-300">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={bolt11()}
|
||||
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
|
||||
onClick={copyBolt11}
|
||||
title="Copy invoice"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -188,7 +218,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkPayment}
|
||||
disabled={payStatus() === "loading"}
|
||||
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
|
||||
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
|
||||
|
||||
Reference in New Issue
Block a user