g1t/services/billing/src/invoices.rs

362 lines15,691 bytesCodeBlame
1//! A workspace's invoices from g1t.
2//!
3//! Every time g1t charges a workspace's card, it is a real Stripe invoice:
4//! when each month closes, and when the workspace nears its limit mid-month
5//! (a threshold invoice, as Cloudflare and Fly do). Each is itemised by
6//! what was used since the last one, with any credit paid in advance taken
7//! off and anything left unpaid from before added, so its total is exactly
8//! what is owed. Stripe charges the card, emails the receipt, and keeps
9//! the invoice and its PDF in the workspace's billing page.
10
11use g1t_contracts::billing::{EntryKind, InvoiceItem, InvoicesArgs, WorkspaceInvoice};
12use g1t_contracts::time::rfc3339;
13use g1t_contracts::{FailureCode, Outcome};
14use g1t_kit::now_ms;
15use serde::Deserialize;
16use serde_json::Value;
17use worker::Result;
18
19use crate::Billing;
20
21/// The invoice's lines: what was used since the last one, by kind, then
22/// whatever makes the total what is owed.
23pub(crate) fn invoice_lines(used: &[(String, i64)], owed: i64) -> Vec<InvoiceItem> {
24 let mut lines: Vec<InvoiceItem> = used
25 .iter()
26 .filter(|(_, amount)| *amount > 0)
27 .map(|(kind, amount)| InvoiceItem { description: kind.clone(), amount_micros: *amount })
28 .collect();
29 let difference = owed - lines.iter().map(|l| l.amount_micros).sum::<i64>();
30 if difference < 0 {
31 lines.push(InvoiceItem { description: "Paid in advance".to_owned(), amount_micros: difference });
32 } else if difference > 0 {
33 lines.push(InvoiceItem { description: "Unpaid from earlier".to_owned(), amount_micros: difference });
34 }
35 lines
36}
37
38#[derive(Deserialize)]
39struct InvoiceRow {
40 invoice_id: String,
41 workspace: String,
42 reason: String,
43 period: String,
44 amount_micros: i64,
45 status: String,
46 hosted_url: Option<String>,
47 pdf_url: Option<String>,
48 created_at: String,
49}
50
51#[derive(Deserialize)]
52struct LineRow {
53 description: String,
54 amount_micros: i64,
55}
56
57/// A Stripe invoice, as far as billing reads it.
58#[derive(Deserialize)]
59struct StripeInvoice {
60 id: String,
61 #[serde(default)]
62 status: Option<String>,
63 #[serde(default)]
64 hosted_invoice_url: Option<String>,
65 #[serde(default)]
66 invoice_pdf: Option<String>,
67 #[serde(default)]
68 amount_paid: i64,
69 #[serde(default)]
70 charge: Option<String>,
71}
72
73impl Billing {
74 /// Invoices the workspace for what it owes, charging its card. `Ok(Err)`
75 /// says why not, when there was nothing to do or no card.
76 pub(crate) async fn invoice_workspace(
77 &self,
78 workspace: &str,
79 reason: &str,
80 period: &str,
81 ) -> Result<std::result::Result<WorkspaceInvoice, String>> {
82 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
83 let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) };
84 let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) };
85 let owed = (-account.balance_micros).max(0);
86 // Only a month's close charges no less than the minimum
87 // (`MIN_CHARGE_MICROS`), so a payment's fee is never most of it;
88 // less carries over. A charge because a limit was reached always
89 // goes through, whatever its size, so a new workspace's limit never
90 // strands it.
91 if reason == "month" && !crate::limits::worth_charging(owed, self.plans.min_charge_micros) {
92 return Ok(Err(format!(
93 "{} is owed, under the {} minimum charge; it carries over to the next invoice.",
94 crate::features::dollars(owed),
95 crate::features::dollars(self.plans.min_charge_micros)
96 )));
97 }
98 // What was used since the last invoice, by kind.
99 #[derive(Deserialize)]
100 struct Last {
101 through_at: Option<String>,
102 }
103 let since = self
104 .db
105 .prepare("SELECT MAX(through_at) AS through_at FROM workspace_invoices WHERE workspace = ? AND status <> 'void'")
106 .bind(&[workspace.into()])?
107 .first::<Last>(None)
108 .await?
109 .and_then(|l| l.through_at)
110 .unwrap_or_default();
111 #[derive(Deserialize)]
112 struct Used {
113 kind: String,
114 charged: Option<i64>,
115 }
116 let now = rfc3339(now_ms());
117 let used: Vec<(String, i64)> = self
118 .db
119 .prepare(
120 "SELECT CASE
121 WHEN task = 'sandbox' THEN 'Sandbox time'
122 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
123 WHEN task = 'deployments' THEN 'Deployments: builds and usage past the plan'
124 WHEN task = 'security' THEN 'Security scans'
125 WHEN task = 'context' THEN 'Search embeddings'
126 WHEN task = 'storage' THEN 'Private repository storage'
127 WHEN task = 'git' THEN 'Git operations'
128 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
129 ELSE 'Agents on g1t''s models' END AS kind,
130 -SUM(amount_micros) AS charged
131 FROM ledger WHERE workspace = ? AND kind = 'usage' AND created_at > ? AND created_at <= ?
132 GROUP BY 1 ORDER BY charged DESC",
133 )
134 .bind(&[workspace.into(), since.as_str().into(), now.as_str().into()])?
135 .all()
136 .await?
137 .results::<Used>()?
138 .into_iter()
139 .map(|u| (u.kind, u.charged.unwrap_or(0)))
140 .collect();
141 let lines = invoice_lines(&used, owed);
142 let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000);
143 for (position, line) in lines.iter().enumerate() {
144 let fields = [
145 ("customer", customer.clone()),
146 ("amount", (line.amount_micros / 10_000).to_string()),
147 ("currency", "usd".to_owned()),
148 ("description", line.description.clone()),
149 ("metadata[workspace]", workspace.to_owned()),
150 ];
151 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?;
152 }
153 let description = match reason {
154 "month" => format!("g1t usage for {workspace}, {period}"),
155 _ => format!("g1t usage for {workspace}, charged as it neared its limit"),
156 };
157 let fields = [
158 ("customer", customer.clone()),
159 ("collection_method", "charge_automatically".to_owned()),
160 ("auto_advance", "false".to_owned()),
161 ("pending_invoice_items_behavior", "include".to_owned()),
162 ("description", description),
163 ("metadata[g1t_workspace]", workspace.to_owned()),
164 ("metadata[reason]", reason.to_owned()),
165 ("metadata[period]", period.to_owned()),
166 ];
167 let draft: StripeInvoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
168 // A retry finds it finalized already; that is fine.
169 let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
170 // Charge the card now; a decline comes back as an error.
171 let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await;
172 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
173 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>();
174 let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" };
175 let mut writes = vec![self
176 .db
177 .prepare(
178 "INSERT OR REPLACE INTO workspace_invoices
179 (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at)
180 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
181 )
182 .bind(&[
183 invoice.id.as_str().into(),
184 workspace.into(),
185 reason.into(),
186 period.into(),
187 (total as f64).into(),
188 status.into(),
189 crate::optional(invoice.hosted_invoice_url.as_deref()),
190 crate::optional(invoice.invoice_pdf.as_deref()),
191 now.as_str().into(),
192 now.as_str().into(),
193 crate::optional((status == "paid").then_some(now.as_str())),
194 ])?];
195 for (position, line) in lines.iter().enumerate() {
196 writes.push(
197 self.db
198 .prepare("INSERT OR REPLACE INTO workspace_invoice_lines (invoice_id, position, description, amount_micros) VALUES (?, ?, ?, ?)")
199 .bind(&[invoice.id.as_str().into(), (position as u32).into(), line.description.as_str().into(), (line.amount_micros as f64).into()])?,
200 );
201 }
202 self.db.batch(writes).await?;
203 if status == "paid" {
204 self.credit_invoice(workspace, &invoice).await?;
205 } else {
206 let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect());
207 self.mark_declined(workspace, &error).await?;
208 }
209 Ok(Ok(WorkspaceInvoice {
210 invoice_id: invoice.id,
211 workspace: workspace.to_owned(),
212 reason: reason.to_owned(),
213 period: period.to_owned(),
214 amount_micros: total,
215 status: status.to_owned(),
216 hosted_url: invoice.hosted_invoice_url,
217 pdf_url: invoice.invoice_pdf,
218 lines,
219 created_at: now,
220 }))
221 }
222
223 /// Enters an invoice's payment once, with the kind of card that paid.
224 async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice) -> Result<bool> {
225 let seen = self
226 .db
227 .prepare("SELECT id FROM ledger WHERE reference = ?")
228 .bind(&[invoice.id.as_str().into()])?
229 .first::<Value>(None)
230 .await?;
231 if seen.is_some() {
232 return Ok(false);
233 }
234 let amount = invoice.amount_paid * 10_000;
235 if amount <= 0 {
236 return Ok(false);
237 }
238 self.enter(workspace, EntryKind::TopUp, amount, &format!("Paid invoice {}", invoice.id), &invoice.id, None, None, None, None)
239 .await?;
240 // Prepaid cards pay, but never raise the limit.
241 if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge) {
242 if let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await {
243 if let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() {
244 self.db
245 .prepare("UPDATE ledger SET funding = ? WHERE reference = ?")
246 .bind(&[funding.into(), invoice.id.as_str().into()])?
247 .run()
248 .await?;
249 }
250 }
251 }
252 Ok(true)
253 }
254
255 pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> {
256 let now = rfc3339(now_ms());
257 self.db
258 .prepare(
259 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
260 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
261 )
262 .bind(&[workspace.into(), now.as_str().into(), error.into()])?
263 .run()
264 .await?;
265 Ok(())
266 }
267
268 /// A workspace invoice paid later, on Stripe's page or by a retry.
269 pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> {
270 #[derive(Deserialize)]
271 struct Row {
272 workspace: String,
273 }
274 let Some(row) = self
275 .db
276 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
277 .bind(&[invoice_id.into()])?
278 .first::<Row>(None)
279 .await?
280 else {
281 return Ok(None);
282 };
283 let Some(stripe) = &self.stripe else { return Ok(None) };
284 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?;
285 self.db
286 .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?")
287 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
288 .run()
289 .await?;
290 let credited = self.credit_invoice(&row.workspace, &invoice).await?;
291 Ok(Some(format!(
292 "invoice {invoice_id} for {} paid{}",
293 row.workspace,
294 if credited { "" } else { " (already credited)" }
295 )))
296 }
297
298 pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> {
299 let rows = self
300 .db
301 .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36")
302 .bind(&[workspace.into()])?
303 .all()
304 .await?
305 .results::<InvoiceRow>()?;
306 let mut invoices = vec![];
307 for row in rows {
308 let lines = self
309 .db
310 .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position")
311 .bind(&[row.invoice_id.as_str().into()])?
312 .all()
313 .await?
314 .results::<LineRow>()?
315 .into_iter()
316 .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros })
317 .collect();
318 invoices.push(WorkspaceInvoice {
319 invoice_id: row.invoice_id,
320 workspace: row.workspace,
321 reason: row.reason,
322 period: row.period,
323 amount_micros: row.amount_micros,
324 status: row.status,
325 hosted_url: row.hosted_url,
326 pdf_url: row.pdf_url,
327 lines,
328 created_at: row.created_at,
329 });
330 }
331 Ok(invoices)
332 }
333
334 /// `invoices`: for the workspace's members.
335 pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> {
336 let workspace = a.workspace.to_lowercase();
337 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
338 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices."));
339 }
340 Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?))
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn an_invoice_adds_up_to_what_is_owed() {
350 let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)];
351 // $10 of credit was paid in advance.
352 let lines = invoice_lines(&used, 40_000_000);
353 assert_eq!(lines.last().unwrap().description, "Paid in advance");
354 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000);
355 // $5 was left unpaid from before.
356 let lines = invoice_lines(&used, 55_000_000);
357 assert_eq!(lines.last().unwrap().description, "Unpaid from earlier");
358 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000);
359 // Exactly what was used.
360 assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
361 }
362}