Simplify subscription syncing
This commit is contained in:
+117
-156
@@ -4,10 +4,10 @@ use std::collections::BTreeMap;
|
|||||||
use crate::bitcoin;
|
use crate::bitcoin;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::env::Env;
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
use crate::models::{Activity, Tenant, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::stripe::{Stripe, StripeInvoice};
|
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
|
||||||
use crate::wallet::Wallet;
|
use crate::wallet::Wallet;
|
||||||
|
|
||||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
|
||||||
@@ -34,10 +34,7 @@ pub struct Billing {
|
|||||||
impl Billing {
|
impl Billing {
|
||||||
pub fn new(query: Query, command: Command, robot: Robot, env: &Env) -> Self {
|
pub fn new(query: Query, command: Command, robot: Robot, env: &Env) -> Self {
|
||||||
Self {
|
Self {
|
||||||
stripe: Stripe::new(
|
stripe: Stripe::new(env),
|
||||||
env.stripe_secret_key.clone(),
|
|
||||||
env.stripe_webhook_secret.clone(),
|
|
||||||
),
|
|
||||||
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
|
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
|
||||||
env: env.clone(),
|
env: env.clone(),
|
||||||
query,
|
query,
|
||||||
@@ -49,7 +46,7 @@ impl Billing {
|
|||||||
pub async fn start(self) {
|
pub async fn start(self) {
|
||||||
let mut rx = self.command.notify.subscribe();
|
let mut rx = self.command.notify.subscribe();
|
||||||
|
|
||||||
if let Err(error) = self.reconcile_relay_subscriptions("startup").await {
|
if let Err(error) = self.reconcile_subscriptions("startup").await {
|
||||||
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
|
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +60,7 @@ impl Billing {
|
|||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
tracing::warn!(missed = n, "billing lagged");
|
tracing::warn!(missed = n, "billing lagged");
|
||||||
|
|
||||||
if let Err(error) = self.reconcile_relay_subscriptions("lagged").await {
|
if let Err(error) = self.reconcile_subscriptions("lagged").await {
|
||||||
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
|
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +69,7 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reconcile_relay_subscriptions(&self, source: &str) -> Result<()> {
|
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
|
||||||
let tenants = self.query.list_tenants().await?;
|
let tenants = self.query.list_tenants().await?;
|
||||||
|
|
||||||
if tenants.is_empty() {
|
if tenants.is_empty() {
|
||||||
@@ -86,7 +83,7 @@ impl Billing {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for tenant in tenants {
|
for tenant in tenants {
|
||||||
if let Err(error) = self.sync_tenant_subscription(&tenant.pubkey).await {
|
if let Err(error) = self.sync_tenant(&tenant.pubkey).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
source,
|
source,
|
||||||
tenant = %tenant.pubkey,
|
tenant = %tenant.pubkey,
|
||||||
@@ -110,10 +107,8 @@ impl Billing {
|
|||||||
| "complete_relay_sync"
|
| "complete_relay_sync"
|
||||||
);
|
);
|
||||||
|
|
||||||
if needs_billing_sync
|
if needs_billing_sync {
|
||||||
&& let Some(relay) = self.query.get_relay(&activity.resource_id).await?
|
self.sync_tenant(&activity.tenant).await?;
|
||||||
{
|
|
||||||
self.sync_tenant_subscription(&relay.tenant).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -125,135 +120,125 @@ impl Billing {
|
|||||||
/// Stripe forbids two subscription items on the same subscription from sharing a
|
/// Stripe forbids two subscription items on the same subscription from sharing a
|
||||||
/// price, so billing is modeled as one subscription item per plan (price) with
|
/// price, so billing is modeled as one subscription item per plan (price) with
|
||||||
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
|
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
|
||||||
async fn sync_tenant_subscription(&self, tenant_pubkey: &str) -> Result<()> {
|
async fn sync_tenant(&self, tenant_pubkey: &str) -> Result<()> {
|
||||||
let Some(mut tenant) = self.query.get_tenant(tenant_pubkey).await? else {
|
let Some(tenant) = self.query.get_tenant(tenant_pubkey).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let relays = self.query.list_relays_for_tenant(tenant_pubkey).await?;
|
let quantity_by_price_id = self.get_quantity_by_price_id(&tenant).await?;
|
||||||
|
|
||||||
// Desired billed state: price id -> quantity.
|
// If we've got no subscription items, we can cancel and clear the tenant's subscription
|
||||||
let mut desired: BTreeMap<String, i64> = BTreeMap::new();
|
if quantity_by_price_id.is_empty() {
|
||||||
for relay in &relays {
|
self.ensure_subscription_is_inactive(&tenant).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = self
|
||||||
|
.ensure_subscription_is_active(&tenant, &quantity_by_price_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.ensure_subscription_items(subscription, quantity_by_price_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --Stripe helpers--
|
||||||
|
|
||||||
|
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
|
||||||
|
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
|
||||||
|
let mut quantity_by_price_id = BTreeMap::new();
|
||||||
|
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? {
|
||||||
if relay.status != RELAY_STATUS_ACTIVE {
|
if relay.status != RELAY_STATUS_ACTIVE {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(plan) = self.query.get_plan(&relay.plan) else {
|
let Some(price_id) = self.query.get_plan(&relay.plan).and_then(|p| p.stripe_price_id) else {
|
||||||
tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on unknown plan; not billed");
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(price_id) = plan.stripe_price_id else {
|
*quantity_by_price_id.entry(price_id).or_insert(0) += 1;
|
||||||
continue; // free plan: nothing to bill
|
|
||||||
};
|
|
||||||
if price_id.trim().is_empty() {
|
|
||||||
tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on a paid plan with no configured Stripe price id; not billed");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
*desired.entry(price_id).or_insert(0) += 1;
|
|
||||||
}
|
}
|
||||||
|
Ok(quantity_by_price_id)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the live subscription, dropping a stale reference to one that no
|
/// Fetch the tenant's current subscription from Stripe, if it has one
|
||||||
// longer exists or has been canceled.
|
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> {
|
||||||
let subscription = match tenant.stripe_subscription_id.as_deref() {
|
let subscription = match &tenant.stripe_subscription_id {
|
||||||
Some(subscription_id) => match self.stripe.get_subscription(subscription_id).await? {
|
Some(id) => self.stripe.get_subscription(id).await?,
|
||||||
Some(sub) if !matches!(sub.status.as_str(), "canceled" | "incomplete_expired") => {
|
|
||||||
Some(sub)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.command
|
|
||||||
.clear_tenant_subscription(tenant_pubkey)
|
|
||||||
.await?;
|
|
||||||
tenant.stripe_subscription_id = None;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// No relays to bill: tear everything down.
|
// If it's canceled, clear the subscription id and return nothing for simplicity
|
||||||
if desired.is_empty() {
|
if subscription
|
||||||
if let Some(ref subscription_id) = tenant.stripe_subscription_id {
|
.as_ref()
|
||||||
self.stripe.cancel_subscription(subscription_id).await?;
|
.is_some_and(|s| matches!(s.status.as_str(), "canceled" | "incomplete_expired"))
|
||||||
self.command
|
{
|
||||||
.clear_tenant_subscription(tenant_pubkey)
|
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
|
||||||
.await?;
|
return Ok(None);
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bring the subscription's items in line with `desired`.
|
Ok(subscription)
|
||||||
let mut downgraded = false;
|
}
|
||||||
|
|
||||||
match subscription {
|
/// Make sure the tenant has an active subscription, creating one with the desired
|
||||||
None => {
|
/// items if it doesn't (Stripe rejects an itemless subscription).
|
||||||
let sub = self
|
async fn ensure_subscription_is_active(
|
||||||
.stripe
|
&self,
|
||||||
.create_subscription(&tenant.stripe_customer_id, &desired)
|
tenant: &Tenant,
|
||||||
.await?;
|
quantity_by_price_id: &BTreeMap<String, i64>,
|
||||||
self.command
|
) -> Result<StripeSubscription> {
|
||||||
.set_tenant_subscription(tenant_pubkey, &sub.id)
|
if let Some(sub) = self.get_subscription(tenant).await? {
|
||||||
.await?;
|
return Ok(sub);
|
||||||
tenant.stripe_subscription_id = Some(sub.id);
|
|
||||||
}
|
|
||||||
Some(sub) => {
|
|
||||||
let subscription_id = sub.id;
|
|
||||||
|
|
||||||
// price id -> (item id, quantity) for items currently on the subscription.
|
|
||||||
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
|
|
||||||
for item in sub.items {
|
|
||||||
current.insert(item.price.id, (item.id, item.quantity));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (price_id, &quantity) in &desired {
|
|
||||||
if let Some((item_id, current_quantity)) = current.remove(price_id) {
|
|
||||||
if current_quantity != quantity {
|
|
||||||
if quantity < current_quantity {
|
|
||||||
downgraded = true;
|
|
||||||
}
|
|
||||||
self.stripe
|
|
||||||
.set_subscription_item_quantity(&item_id, quantity)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.stripe
|
|
||||||
.create_subscription_item(&subscription_id, price_id, quantity)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Items for plans no relay is on anymore.
|
|
||||||
for (_, (item_id, _)) in current {
|
|
||||||
downgraded = true;
|
|
||||||
self.stripe.delete_subscription_item(&item_id).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if downgraded {
|
let sub = self
|
||||||
self.validate_downgrade_proration(&tenant, "tenant-subscription-sync")
|
.stripe
|
||||||
.await;
|
.create_subscription(&tenant.stripe_customer_id, quantity_by_price_id)
|
||||||
|
.await?;
|
||||||
|
self.command.set_tenant_subscription(&tenant.pubkey, &sub.id).await?;
|
||||||
|
Ok(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the tenant has a subscription, cancel and clear it
|
||||||
|
async fn ensure_subscription_is_inactive(&self, tenant: &Tenant) -> Result<()> {
|
||||||
|
if let Some(s) = self.get_subscription(tenant).await? {
|
||||||
|
self.stripe.cancel_subscription(&s.id).await?;
|
||||||
|
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn existing_invoice_nwc_payment_outcome(
|
/// Sync desired quantity_by_price_id with stripe
|
||||||
|
async fn ensure_subscription_items(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
subscription: StripeSubscription,
|
||||||
) -> Result<Option<NwcInvoicePaymentOutcome>> {
|
quantity_by_price_id: BTreeMap<String, i64>,
|
||||||
let state = self.query.get_invoice_nwc_payment_state(invoice_id).await?;
|
) -> Result<()> {
|
||||||
match state.as_deref() {
|
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
|
||||||
Some("paid") => Ok(Some(NwcInvoicePaymentOutcome::Paid)),
|
for item in subscription.items {
|
||||||
Some("pending") => Ok(Some(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
current.insert(item.price.id, (item.id, item.quantity));
|
||||||
"invoice {invoice_id} has a pending NWC reconciliation; refusing to create a new Lightning charge"
|
|
||||||
)))),
|
|
||||||
Some(other) => Err(anyhow!(
|
|
||||||
"unknown invoice_nwc_payment state '{other}' for invoice {invoice_id}"
|
|
||||||
)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (price_id, &quantity) in &quantity_by_price_id {
|
||||||
|
if let Some((item_id, current_quantity)) = current.remove(price_id) {
|
||||||
|
if current_quantity != quantity {
|
||||||
|
self.stripe
|
||||||
|
.set_subscription_item_quantity(&item_id, quantity)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.stripe
|
||||||
|
.create_subscription_item(&subscription.id, price_id, quantity)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, (item_id, _)) in current {
|
||||||
|
self.stripe.delete_subscription_item(&item_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --Stripe Webhooks--
|
||||||
|
|
||||||
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
|
pub async fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()> {
|
||||||
let event = self.stripe.get_webhook_event(payload, signature)?;
|
let event = self.stripe.get_webhook_event(payload, signature)?;
|
||||||
let obj = &event.data.object;
|
let obj = &event.data.object;
|
||||||
@@ -520,47 +505,6 @@ impl Billing {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
|
||||||
match self
|
|
||||||
.stripe
|
|
||||||
.preview_invoice(
|
|
||||||
&tenant.stripe_customer_id,
|
|
||||||
tenant.stripe_subscription_id.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(upcoming) => {
|
|
||||||
let proration_lines = upcoming.lines.iter().filter(|line| line.proration).count();
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
tenant_pubkey = %tenant.pubkey,
|
|
||||||
stripe_customer_id = %tenant.stripe_customer_id,
|
|
||||||
context,
|
|
||||||
proration_lines,
|
|
||||||
amount_due = upcoming.amount_due,
|
|
||||||
currency = %upcoming.currency,
|
|
||||||
"validated Stripe proration preview for downgrade"
|
|
||||||
);
|
|
||||||
|
|
||||||
if proration_lines == 0 {
|
|
||||||
tracing::warn!(
|
|
||||||
tenant_pubkey = %tenant.pubkey,
|
|
||||||
context,
|
|
||||||
"downgrade proration preview has no proration lines; verify in Stripe dashboard"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(
|
|
||||||
error = %error,
|
|
||||||
tenant_pubkey = %tenant.pubkey,
|
|
||||||
context,
|
|
||||||
"failed to fetch downgrade proration preview"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Public API helpers ---
|
// --- Public API helpers ---
|
||||||
|
|
||||||
/// Returns `Ok(None)` if Stripe has no such invoice; the route turns that into a 404.
|
/// Returns `Ok(None)` if Stripe has no such invoice; the route turns that into a 404.
|
||||||
@@ -888,6 +832,23 @@ impl Billing {
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn existing_invoice_nwc_payment_outcome(
|
||||||
|
&self,
|
||||||
|
invoice_id: &str,
|
||||||
|
) -> Result<Option<NwcInvoicePaymentOutcome>> {
|
||||||
|
let state = self.query.get_invoice_nwc_payment_state(invoice_id).await?;
|
||||||
|
match state.as_deref() {
|
||||||
|
Some("paid") => Ok(Some(NwcInvoicePaymentOutcome::Paid)),
|
||||||
|
Some("pending") => Ok(Some(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
||||||
|
"invoice {invoice_id} has a pending NWC reconciliation; refusing to create a new Lightning charge"
|
||||||
|
)))),
|
||||||
|
Some(other) => Err(anyhow!(
|
||||||
|
"unknown invoice_nwc_payment state '{other}' for invoice {invoice_id}"
|
||||||
|
)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||||
|
|||||||
+12
-38
@@ -9,6 +9,8 @@ use hmac::{Hmac, Mac};
|
|||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
const STRIPE_API: &str = "https://api.stripe.com/v1";
|
||||||
|
|
||||||
// Webhooks
|
// Webhooks
|
||||||
@@ -57,22 +59,6 @@ pub struct StripeInvoice {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub amount_due: i64,
|
pub amount_due: i64,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
#[serde(deserialize_with = "deserialize_list")]
|
|
||||||
pub lines: Vec<StripeInvoiceLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct StripeInvoicePreview {
|
|
||||||
pub amount_due: i64,
|
|
||||||
pub currency: String,
|
|
||||||
#[serde(deserialize_with = "deserialize_list")]
|
|
||||||
pub lines: Vec<StripeInvoiceLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
|
||||||
pub struct StripeInvoiceLine {
|
|
||||||
#[serde(default)]
|
|
||||||
pub proration: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -96,16 +82,14 @@ fn default_quantity() -> i64 {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Stripe {
|
pub struct Stripe {
|
||||||
pub(crate) secret_key: String,
|
env: Env,
|
||||||
pub(crate) webhook_secret: String,
|
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stripe {
|
impl Stripe {
|
||||||
pub fn new(secret_key: String, webhook_secret: String) -> Self {
|
pub fn new(env: &Env) -> Self {
|
||||||
Self {
|
Self {
|
||||||
secret_key,
|
env: env.clone(),
|
||||||
webhook_secret,
|
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,23 +99,23 @@ impl Stripe {
|
|||||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
self.http
|
self.http
|
||||||
.get(format!("{STRIPE_API}{path}"))
|
.get(format!("{STRIPE_API}{path}"))
|
||||||
.bearer_auth(&self.secret_key)
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
self.http
|
self.http
|
||||||
.post(format!("{STRIPE_API}{path}"))
|
.post(format!("{STRIPE_API}{path}"))
|
||||||
.bearer_auth(&self.secret_key)
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
|
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
|
||||||
self.http
|
self.http
|
||||||
.delete(format!("{STRIPE_API}{path}"))
|
.delete(format!("{STRIPE_API}{path}"))
|
||||||
.bearer_auth(&self.secret_key)
|
.bearer_auth(&self.env.stripe_secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn idempotency_key(&self, parts: &[&str]) -> String {
|
fn idempotency_key(&self, parts: &[&str]) -> String {
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(self.secret_key.as_bytes())
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes())
|
||||||
.expect("HMAC accepts any key length");
|
.expect("HMAC accepts any key length");
|
||||||
for (i, part) in parts.iter().enumerate() {
|
for (i, part) in parts.iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -175,6 +159,8 @@ impl Stripe {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stripe requires at least one item to create a subscription, so the desired
|
||||||
|
/// items are sent inline here; [`crate::billing`] reconciles from there.
|
||||||
pub async fn create_subscription(
|
pub async fn create_subscription(
|
||||||
&self,
|
&self,
|
||||||
customer_id: &str,
|
customer_id: &str,
|
||||||
@@ -296,18 +282,6 @@ impl Stripe {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preview_invoice(
|
|
||||||
&self,
|
|
||||||
customer_id: &str,
|
|
||||||
subscription_id: Option<&str>,
|
|
||||||
) -> Result<StripeInvoicePreview> {
|
|
||||||
let mut req = self.get("/invoices/upcoming").query(&[("customer", customer_id)]);
|
|
||||||
if let Some(subscription_id) = subscription_id {
|
|
||||||
req = req.query(&[("subscription", subscription_id)]);
|
|
||||||
}
|
|
||||||
Ok(req.send_ok().await?.json().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Payment methods ---
|
// --- Payment methods ---
|
||||||
|
|
||||||
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
|
||||||
@@ -357,7 +331,7 @@ impl Stripe {
|
|||||||
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
|
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
|
||||||
|
|
||||||
let signed_payload = format!("{timestamp}.{payload}");
|
let signed_payload = format!("{timestamp}.{payload}");
|
||||||
let mut mac = Hmac::<Sha256>::new_from_slice(self.webhook_secret.as_bytes())
|
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
|
||||||
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
|
||||||
mac.update(signed_payload.as_bytes());
|
mac.update(signed_payload.as_bytes());
|
||||||
let expected = hex::encode(mac.finalize().into_bytes());
|
let expected = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|||||||
Reference in New Issue
Block a user