diff --git a/Cargo.lock b/Cargo.lock index 524803c21c..cad875daf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,6 +410,7 @@ dependencies = [ "clap 3.2.24", "futures", "serde", + "serde-enum-str", "serde_json", "sqlx", "tokio", @@ -1148,6 +1149,40 @@ dependencies = [ "syn 2.0.31", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "dashmap" version = "5.4.0" @@ -2118,6 +2153,12 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -4217,6 +4258,36 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-attributes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb8ec7724e4e524b2492b510e66957fe1a2c76c26a6975ec80823f2439da685" +dependencies = [ + "darling_core", + "serde-rename-rule", + "syn 1.0.109", +] + +[[package]] +name = "serde-enum-str" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26416dc95fcd46b0e4b12a3758043a229a6914050aaec2e8191949753ed4e9aa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde-attributes", + "syn 1.0.109", +] + +[[package]] +name = "serde-rename-rule" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794e44574226fc701e3be5c651feb7939038fc67fb73f6f4dd5c4ba90fd3be70" + [[package]] name = "serde-transcode" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 1583a7b06a..20629a6c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ rusqlite = { version = "0.29", features = ["bundled-full"] } rustyline = "11.0" schemars = "0.8" serde = { version = "1.0", features = ["derive"] } +serde-enum-str = { version = "0.4.0" } serde_json = { version = "1.0.85", features = ["raw_value"] } serde_yaml = "0.8" serde-transcode = "1.1" diff --git a/crates/billing-integrations/Cargo.toml b/crates/billing-integrations/Cargo.toml index c635ee6e60..1bd496fd96 100644 --- a/crates/billing-integrations/Cargo.toml +++ b/crates/billing-integrations/Cargo.toml @@ -17,6 +17,7 @@ clap = { workspace = true } chrono = { workspace = true } futures = { workspace = true } serde = { workspace = true } +serde-enum-str = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } diff --git a/crates/billing-integrations/src/stripe.rs b/crates/billing-integrations/src/stripe.rs index 0a6c9713c1..e02949d484 100644 --- a/crates/billing-integrations/src/stripe.rs +++ b/crates/billing-integrations/src/stripe.rs @@ -1,8 +1,8 @@ use anyhow::{bail, Context}; -use chrono::{Months, ParseError, Utc}; -use core::fmt; -use futures::{Future, FutureExt, StreamExt, TryStreamExt}; +use chrono::{ParseError, Utc}; +use futures::{FutureExt, StreamExt, TryStreamExt}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_enum_str::{Deserialize_enum_str, Serialize_enum_str}; use sqlx::{postgres::PgPoolOptions, types::chrono::NaiveDate, Pool}; use sqlx::{types::chrono::DateTime, Postgres}; use std::collections::HashMap; @@ -45,32 +45,17 @@ fn parse_date(arg: &str) -> Result { NaiveDate::parse_from_str(arg, "%Y-%m-%d") } -#[derive(Debug)] +#[derive( + Debug, Clone, PartialEq, Eq, Hash, sqlx::Type, Deserialize_enum_str, Serialize_enum_str, +)] +#[sqlx(rename_all = "snake_case")] enum InvoiceType { + #[serde(rename = "usage")] Usage, + #[serde(rename = "manual")] Manual, -} - -impl InvoiceType { - pub fn to_string(&self) -> String { - match self { - InvoiceType::Usage => "usage".to_string(), - InvoiceType::Manual => "manual".to_string(), - } - } - pub fn from_str(str: &str) -> Option { - match str { - "Usage" => Some(Self::Usage), - "Manual" => Some(Self::Manual), - _ => None, - } - } -} - -impl fmt::Display for InvoiceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_string()) - } + #[serde(rename = "current_month")] + CurrentMonth, } #[derive(Serialize, Default, Debug)] @@ -93,41 +78,12 @@ async fn stripe_search( } #[derive(Serialize, Deserialize, Debug, Clone)] -struct ManualBill { - tenant: String, - usd_cents: i32, - description: String, - date_start: NaiveDate, - date_end: NaiveDate, -} - -impl ManualBill { - pub async fn upsert_invoice( - &self, - client: &stripe::Client, - db_client: &Pool, - recreate_finalized: bool, - ) -> anyhow::Result<()> { - upsert_invoice( - client, - db_client, - self.date_start, - self.date_end, - self.tenant.to_owned(), - InvoiceType::Manual, - self.usd_cents as i64, - vec![LineItem { - count: 1.0, - subtotal: self.usd_cents as i64, - rate: self.usd_cents as i64, - description: Some(self.description.to_owned()), - }], - recreate_finalized, - ) - .await?; - - Ok(()) - } +struct Extra { + trial_start: Option>, + trial_credit: i64, + recurring_fee: i64, + task_usage_hours: f64, + processed_data_gb: f64, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -138,47 +94,270 @@ struct LineItem { description: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] -struct Bill { +#[derive(Serialize, Deserialize, Debug, Clone, sqlx::FromRow)] +struct Invoice { subtotal: i64, - line_items: Vec, - billed_month: DateTime, + line_items: sqlx::types::Json>, + date_start: DateTime, + date_end: DateTime, billed_prefix: String, - recurring_fee: i64, - task_usage_hours: f64, - processed_data_gb: f64, + invoice_type: InvoiceType, + extra: Option>>, } -impl Bill { +impl Invoice { + #[tracing::instrument(skip(self, client, db_client), fields(tenant=self.billed_prefix, invoice_type=format!("{:?}",self.invoice_type), subtotal=format!("${:.2}", self.subtotal as f64 / 100.0)))] pub async fn upsert_invoice( &self, client: &stripe::Client, db_client: &Pool, recreate_finalized: bool, ) -> anyhow::Result<()> { - if !(self.recurring_fee > 0 - || self.processed_data_gb > 0.0 - || self.task_usage_hours > 0.0 - || self.subtotal > 0) - { - tracing::debug!("Skipping tenant with no usage"); - return Ok(()); + match (&self.invoice_type, &self.extra) { + (InvoiceType::CurrentMonth, _) => { + bail!("Should not create Stripe invoices for dynamic current_month invoices") + } + (InvoiceType::Usage, Some(extra)) => { + let unwrapped_extra = extra.clone().0.expect( + "This is just a sqlx quirk, if the outer Option is Some then this will be Some", + ); + if !(unwrapped_extra.recurring_fee > 0 + || unwrapped_extra.processed_data_gb > 0.0 + || unwrapped_extra.task_usage_hours > 0.0 + || self.subtotal > 0) + { + tracing::debug!("Skipping invoice with no usage"); + return Ok(()); + } + } + (InvoiceType::Usage, None) => { + bail!("Invoice should have extra") + } + _ => {} + }; + + // An invoice should be generated in Stripe if the tenant is on a paid plan, which means: + // * The tenant has a free trial start date + if let InvoiceType::Usage = self.invoice_type { + if let None = get_tenant_trial_date(&db_client, self.billed_prefix.to_owned()).await? { + tracing::info!("Skipping usage invoice for tenant in free tier"); + return Ok(()); + } } - upsert_invoice( + + // Anything before 12:00:00 renders as the previous day in Stripe + let date_start_secs = self + .date_start + .date_naive() + .and_hms_opt(12, 0, 0) + .expect("Error manipulating date") + .and_local_timezone(Utc) + .single() + .expect("Error manipulating date") + .timestamp(); + let date_end_secs = self + .date_end + .date_naive() + .and_hms_opt(12, 0, 0) + .expect("Error manipulating date") + .and_local_timezone(Utc) + .single() + .expect("Error manipulating date") + .timestamp(); + + let timestamp_now = Utc::now().timestamp(); + + let date_start_human = self.date_start.format("%B %d %Y").to_string(); + let date_end_human = self.date_end.format("%B %d %Y").to_string(); + + let date_start_repr = self.date_start.format("%F").to_string(); + let date_end_repr = self.date_end.format("%F").to_string(); + + let invoice_type_str = self.invoice_type.to_string(); + + let customer = + get_or_create_customer_for_tenant(client, db_client, self.billed_prefix.to_owned()) + .await?; + let customer_id = customer.id.to_string(); + + let invoice_search = stripe_search::( client, - db_client, - self.billed_month.date_naive(), - self.billed_month - .date_naive() - .checked_add_months(Months::new(1)) - .expect("Only fails when adding > max 32-bit int months"), - self.billed_prefix.to_owned(), - InvoiceType::Usage, - self.subtotal, - self.line_items.clone(), - recreate_finalized, + "invoices", + SearchParams { + query: format!( + r#" + -status:"deleted" AND + customer:"{customer_id}" AND + metadata["{INVOICE_TYPE_KEY}"]:"{invoice_type_str}" AND + metadata["{BILLING_PERIOD_START_KEY}"]:"{date_start_repr}" AND + metadata["{BILLING_PERIOD_END_KEY}"]:"{date_end_repr}" + "# + ), + ..Default::default() + }, ) - .await?; + .await + .context("Searching for an invoice")?; + + let maybe_invoice = if let Some(invoice) = invoice_search.data.into_iter().next() { + match invoice.status { + Some(state @ (stripe::InvoiceStatus::Open | stripe::InvoiceStatus::Draft)) + if recreate_finalized => + { + tracing::warn!( + "Found invoice {id} in state {state} deleting and recreating", + id = invoice.id.to_string(), + state = state + ); + stripe::Invoice::delete(client, &invoice.id).await?; + None + } + Some(stripe::InvoiceStatus::Draft) => { + tracing::debug!( + "Updating existing invoice {id}", + id = invoice.id.to_string() + ); + Some(invoice) + } + Some(stripe::InvoiceStatus::Open) => { + bail!("Found finalized invoice {id}. Pass --recreate-finalized to delete and recreate this invoice.", id = invoice.id.to_string()) + } + Some(status) => { + bail!( + "Found invoice {id} in unsupported state {status}, skipping.", + id = invoice.id.to_string(), + status = status + ); + } + None => { + bail!( + "Unexpected missing status from invoice {id}", + id = invoice.id.to_string() + ); + } + } + } else { + None + }; + + let invoice = match maybe_invoice.clone() { + Some(inv) => inv, + None => { + let invoice = stripe::Invoice::create( + client, + stripe::CreateInvoice { + customer: Some(customer.id.to_owned()), + // Stripe timestamps are measured in _seconds_ since epoch + // Due date must be in the future + due_date: if date_end_secs > timestamp_now { Some(date_end_secs) } else {Some(timestamp_now + 10)}, + description: Some( + format!( + "Your Flow bill for the billing preiod between {date_start_human} - {date_end_human}" + ) + .as_str(), + ), + collection_method: Some(stripe::CollectionMethod::SendInvoice), + auto_advance: Some(false), + custom_fields: Some(vec![ + stripe::CreateInvoiceCustomFields { + name: "Billing Period Start".to_string(), + value: date_start_human.to_owned(), + }, + stripe::CreateInvoiceCustomFields { + name: "Billing Period End".to_string(), + value: date_end_human.to_owned(), + }, + stripe::CreateInvoiceCustomFields { + name: "Tenant".to_string(), + value: self.billed_prefix.to_owned(), + }, + ]), + metadata: Some(HashMap::from([ + (TENANT_METADATA_KEY.to_string(), self.billed_prefix.to_owned()), + (INVOICE_TYPE_KEY.to_string(), invoice_type_str.to_owned()), + (BILLING_PERIOD_START_KEY.to_string(), date_start_repr), + (BILLING_PERIOD_END_KEY.to_string(), date_end_repr) + ])), + ..Default::default() + }, + ) + .await.context("Creating a new invoice")?; + + tracing::debug!("Created a new invoice {id}", id = invoice.id); + + invoice + } + }; + + // Clear out line items from invoice, if there are any + for item in stripe::InvoiceItem::list( + client, + &stripe::ListInvoiceItems { + invoice: Some(invoice.id.to_owned()), + ..Default::default() + }, + ) + .await? + .data + .into_iter() + { + tracing::debug!( + "Delete invoice line item: '{desc}'", + desc = item.description.to_owned().unwrap_or_default() + ); + stripe::InvoiceItem::delete(client, &item.id).await?; + } + + let mut diff: f64 = 0.0; + + for item in self.line_items.iter() { + let description = item + .description + .clone() + .ok_or(anyhow::anyhow!("Missing line item description. Skipping"))?; + tracing::debug!("Created new invoice line item: '{description}'"); + diff = diff + ((item.count.ceil() - item.count) * item.rate as f64); + stripe::InvoiceItem::create( + client, + stripe::CreateInvoiceItem { + quantity: Some(item.count.ceil() as u64), + unit_amount: Some(item.rate), + currency: Some(stripe::Currency::USD), + description: Some(description.as_str()), + invoice: Some(invoice.id.to_owned()), + period: Some(stripe::Period { + start: Some(date_start_secs), + end: Some(date_end_secs), + }), + ..stripe::CreateInvoiceItem::new(customer.id.to_owned()) + }, + ) + .await?; + } + + if diff > 0.0 { + tracing::warn!("Invoice line items use fractional quantities, which Stripe does not allow. Rounding up resulted in a difference of ${difference:.2}", difference = diff.ceil()/100.0); + } + + // Let's double-check that the invoice total matches the desired total + let check_invoice = stripe::Invoice::retrieve(client, &invoice.id, &[]).await?; + + if !check_invoice + .amount_due + .eq(&Some(self.subtotal + (diff.ceil() as i64))) + { + bail!( + "The correct bill is ${our_bill:.2}, but the invoice's total is ${their_bill:.2}", + our_bill = self.subtotal as f64 / 100.0, + their_bill = check_invoice.amount_due.unwrap_or(0) as f64 / 100.0 + ) + } + + if maybe_invoice.is_some() { + tracing::info!("Updated existing invoice"); + } else { + tracing::info!("Published new invoice") + } Ok(()) } @@ -195,39 +374,78 @@ pub async fn do_publish_invoices(cmd: &PublishInvoice) -> anyhow::Result<()> { tracing::info!("Fetching billing data for {month_human_repr}"); - let billing_historicals: Vec<_> = if cmd.tenants.len() > 0 { - sqlx::query!( + let invoices: Vec = if cmd.tenants.len() > 0 { + sqlx::query_as!( + Invoice, r#" - select report as "report!: sqlx::types::Json" - from billing_historicals - where billed_month = date_trunc('day', $1::date) - and tenant = any($2) + select + date_start as "date_start!", + date_end as "date_end!", + billed_prefix as "billed_prefix!", + invoice_type as "invoice_type!: InvoiceType", + line_items as "line_items!: sqlx::types::Json>", + subtotal::bigint as "subtotal!", + extra as "extra: sqlx::types::Json>" + from invoices_ext + where (( + date_start >= date_trunc('day', $1::date) + and date_end <= date_trunc('day', ($1::date)) + interval '1 month' - interval '1 day' + and invoice_type = 'usage' + ) or ( + invoice_type = 'manual' + )) + and billed_prefix = any($2) "#, cmd.month, &cmd.tenants[..] ) .fetch_all(&db_pool) .await? - .into_iter() - .map(|response| response.report) - .collect() } else { - sqlx::query!( + sqlx::query_as!( + Invoice, r#" - select report as "report!: sqlx::types::Json" - from billing_historicals - where billed_month = date_trunc('day', $1::date) + select + date_start as "date_start!", + date_end as "date_end!", + billed_prefix as "billed_prefix!", + invoice_type as "invoice_type!: InvoiceType", + line_items as "line_items!: sqlx::types::Json>", + subtotal::bigint as "subtotal!", + extra as "extra: sqlx::types::Json>" + from invoices_ext + where ( + date_start >= date_trunc('day', $1::date) + and date_end <= date_trunc('day', ($1::date)) + interval '1 month' - interval '1 day' + and invoice_type = 'usage' + ) or ( + invoice_type = 'manual' + ) "#, cmd.month ) .fetch_all(&db_pool) .await? - .into_iter() - .map(|response| response.report) - .collect() }; - let billing_historicals_futures: Vec<_> = billing_historicals + let mut invoice_type_counter: HashMap = HashMap::new(); + invoices.iter().for_each(|invoice| { + *invoice_type_counter + .entry(invoice.invoice_type.clone()) + .or_default() += 1; + }); + + tracing::info!( + "Processing {usage} usage-based bills, and {manual} manually-entered bills.", + usage = invoice_type_counter + .remove(&InvoiceType::Usage) + .unwrap_or_default(), + manual = invoice_type_counter + .remove(&InvoiceType::Manual) + .unwrap_or_default(), + ); + + let invoice_futures: Vec<_> = invoices .iter() .map(|response| { let client = stripe_client.clone(); @@ -249,80 +467,39 @@ pub async fn do_publish_invoices(cmd: &PublishInvoice) -> anyhow::Result<()> { }) .collect(); - let manual_bills: Vec = if cmd.tenants.len() > 0 { - sqlx::query_as!( - ManualBill, - r#" - select tenant, usd_cents, description, date_start, date_end - from manual_bills - where date_start >= date_trunc('day', $1::date) - and tenant = any($2) - "#, - cmd.month, - &cmd.tenants[..] - ) - .fetch_all(&db_pool) - .await? - } else { - sqlx::query_as!( - ManualBill, - r#" - select tenant, usd_cents, description, date_start, date_end - from manual_bills - where date_start >= date_trunc('day', $1::date) - "#, - cmd.month - ) - .fetch_all(&db_pool) - .await? - }; - - let manual_futures: Vec<_> = manual_bills - .iter() - .map(|response| { - let client = stripe_client.clone(); - let db_pool = db_pool.clone(); - async move { - let res = response - .upsert_invoice(&client, &db_pool, cmd.recreate_finalized) - .await; - if let Err(error) = res { - let formatted = format!( - "Error publishing invoice for {tenant}", - tenant = response.tenant, - ); - bail!("{}\n{err:?}", formatted, err = error); - } + futures::stream::iter(invoice_futures) + // Let's run 10 `upsert_invoice()`s at a time + .buffer_unordered(10) + .or_else(|err| async move { + if !cmd.fail_fast { + tracing::error!("{}", err.to_string()); Ok(()) + } else { + Err(err) } - .boxed() }) - .collect(); - - tracing::info!( - "Processing {usage} usage-based bills, and {manual} manually-entered bills.", - usage = billing_historicals.len(), - manual = manual_bills.len() - ); + // Collects into Result<(), anyhow::Error> because a stream of ()s can be collected into a single () + .try_collect() + .await +} - futures::stream::iter( - manual_futures - .into_iter() - .chain(billing_historicals_futures.into_iter()), +#[tracing::instrument(skip(db_client))] +async fn get_tenant_trial_date( + db_client: &Pool, + tenant: String, +) -> anyhow::Result> { + let query_result = sqlx::query!( + r#" + select tenants.trial_start + from tenants + where tenants.tenant = $1 + "#, + tenant ) - // Let's run 10 `upsert_invoice()`s at a time - .buffer_unordered(10) - .or_else(|err| async move { - if !cmd.fail_fast { - tracing::error!("{}", err.to_string()); - Ok(()) - } else { - Err(err) - } - }) - // Collects into Result<(), anyhow::Error> because a stream of ()s can be collected into a single () - .try_collect() - .await + .fetch_one(db_client) + .await?; + + Ok(query_result.trial_start) } #[tracing::instrument(skip_all)] @@ -404,224 +581,3 @@ async fn get_or_create_customer_for_tenant( } Ok(customer) } - -#[tracing::instrument(skip(client, db_client, subtotal, items), fields(subtotal=format!("${:.2}", subtotal as f64 / 100.0)))] -async fn upsert_invoice( - client: &stripe::Client, - db_client: &Pool, - date_start: NaiveDate, - date_end: NaiveDate, - tenant: String, - invoice_type: InvoiceType, - subtotal: i64, - items: Vec, - recreate_finalized: bool, -) -> anyhow::Result { - // Anything before 12:00:00 renders as the previous day in Stripe - let date_start_secs = date_start - .and_hms_opt(12, 0, 0) - .expect("Error manipulating date") - .and_local_timezone(Utc) - .single() - .expect("Error manipulating date") - .timestamp(); - let date_end_secs = date_end - .and_hms_opt(12, 0, 0) - .expect("Error manipulating date") - .and_local_timezone(Utc) - .single() - .expect("Error manipulating date") - .timestamp(); - - let timestamp_now = Utc::now().timestamp(); - - tracing::debug!(date_start_secs, date_end_secs, "Debug"); - - let date_start_human = date_start.format("%B %d %Y").to_string(); - let date_end_human = date_end.format("%B %d %Y").to_string(); - - let date_start_repr = date_start.format("%F").to_string(); - let date_end_repr = date_end.format("%F").to_string(); - - let invoice_type_str = invoice_type.to_string(); - - let customer = get_or_create_customer_for_tenant(client, db_client, tenant.to_owned()).await?; - let customer_id = customer.id.to_string(); - - let invoice_search = stripe_search::( - client, - "invoices", - SearchParams { - query: format!( - r#" - -status:"deleted" AND - customer:"{customer_id}" AND - metadata["{INVOICE_TYPE_KEY}"]:"{invoice_type_str}" AND - metadata["{BILLING_PERIOD_START_KEY}"]:"{date_start_repr}" AND - metadata["{BILLING_PERIOD_END_KEY}"]:"{date_end_repr}" - "# - ), - ..Default::default() - }, - ) - .await - .context("Searching for an invoice")?; - - let maybe_invoice = if let Some(invoice) = invoice_search.data.into_iter().next() { - match invoice.status { - Some(state @ (stripe::InvoiceStatus::Open | stripe::InvoiceStatus::Draft)) - if recreate_finalized => - { - tracing::warn!( - "Found invoice {id} in state {state} deleting and recreating", - id = invoice.id.to_string(), - state = state - ); - stripe::Invoice::delete(client, &invoice.id).await?; - None - } - Some(stripe::InvoiceStatus::Draft) => { - tracing::debug!( - "Updating existing invoice {id}", - id = invoice.id.to_string() - ); - Some(invoice) - } - Some(stripe::InvoiceStatus::Open) => { - bail!("Found finalized invoice {id}. Pass --recreate-finalized to delete and recreate this invoice.", id = invoice.id.to_string()) - } - Some(status) => { - bail!( - "Found invoice {id} in unsupported state {status}, skipping.", - id = invoice.id.to_string(), - status = status - ); - } - None => { - bail!( - "Unexpected missing status from invoice {id}", - id = invoice.id.to_string() - ); - } - } - } else { - None - }; - - let invoice = match maybe_invoice { - Some(inv) => inv, - None => { - let invoice = stripe::Invoice::create( - client, - stripe::CreateInvoice { - customer: Some(customer.id.to_owned()), - // Stripe timestamps are measured in _seconds_ since epoch - // Due date must be in the future - due_date: if date_end_secs > timestamp_now { Some(date_end_secs) } else {Some(timestamp_now + 10)}, - description: Some( - format!( - "Your Flow bill for the billing preiod between {date_start_human} - {date_end_human}" - ) - .as_str(), - ), - collection_method: Some(stripe::CollectionMethod::SendInvoice), - auto_advance: Some(false), - custom_fields: Some(vec![ - stripe::CreateInvoiceCustomFields { - name: "Billing Period Start".to_string(), - value: date_start_human.to_owned(), - }, - stripe::CreateInvoiceCustomFields { - name: "Billing Period End".to_string(), - value: date_end_human.to_owned(), - }, - stripe::CreateInvoiceCustomFields { - name: "Tenant".to_string(), - value: tenant.to_owned(), - }, - ]), - metadata: Some(HashMap::from([ - (TENANT_METADATA_KEY.to_string(), tenant.to_owned()), - (INVOICE_TYPE_KEY.to_string(), invoice_type_str.to_owned()), - (BILLING_PERIOD_START_KEY.to_string(), date_start_repr), - (BILLING_PERIOD_END_KEY.to_string(), date_end_repr) - ])), - ..Default::default() - }, - ) - .await.context("Creating a new invoice")?; - - tracing::debug!("Created a new invoice {id}", id = invoice.id); - - invoice - } - }; - - // Clear out line items from invoice, if there are any - for item in stripe::InvoiceItem::list( - client, - &stripe::ListInvoiceItems { - invoice: Some(invoice.id.to_owned()), - ..Default::default() - }, - ) - .await? - .data - .into_iter() - { - tracing::debug!( - "Delete invoice line item: '{desc}'", - desc = item.description.to_owned().unwrap_or_default() - ); - stripe::InvoiceItem::delete(client, &item.id).await?; - } - - let mut diff: f64 = 0.0; - - for item in items.iter() { - let description = item - .description - .clone() - .ok_or(anyhow::anyhow!("Missing line item description. Skipping"))?; - tracing::debug!("Created new invoice line item: '{description}'"); - diff = diff + ((item.count.ceil() - item.count) * item.rate as f64); - stripe::InvoiceItem::create( - client, - stripe::CreateInvoiceItem { - quantity: Some(item.count.ceil() as u64), - unit_amount: Some(item.rate), - currency: Some(stripe::Currency::USD), - description: Some(description.as_str()), - invoice: Some(invoice.id.to_owned()), - period: Some(stripe::Period { - start: Some(date_start_secs), - end: Some(date_end_secs), - }), - ..stripe::CreateInvoiceItem::new(customer.id.to_owned()) - }, - ) - .await?; - } - - if diff > 0.0 { - tracing::warn!("Invoice line items use fractional quantities, which Stripe does not allow. Rounding up resulted in a difference of ${difference:.2}", difference = diff.ceil()/100.0); - } - - // Let's double-check that the invoice total matches the desired total - let check_invoice = stripe::Invoice::retrieve(client, &invoice.id, &[]).await?; - - if !check_invoice - .amount_due - .eq(&Some(subtotal + (diff.ceil() as i64))) - { - bail!( - "The correct bill is ${our_bill:.2}, but the invoice's total is ${their_bill:.2}", - our_bill = subtotal as f64 / 100.0, - their_bill = check_invoice.amount_due.unwrap_or(0) as f64 / 100.0 - ) - } - - tracing::info!("Published invoice"); - - Ok(invoice) -}