flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/invoices.rs

361 lines15,608 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 = 'deployments' THEN 'Deployments: builds and usage past the plan'
123 WHEN task = 'security' THEN 'Security scans'
124 WHEN task = 'context' THEN 'Search embeddings'
125 WHEN task = 'storage' THEN 'Private repository storage'
126 WHEN task = 'git' THEN 'Git operations'
127 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
128 ELSE 'Agents on g1t''s models' END AS kind,
129 -SUM(amount_micros) AS charged
130 FROM ledger WHERE workspace = ? AND kind = 'usage' AND created_at > ? AND created_at <= ?
131 GROUP BY 1 ORDER BY charged DESC",
132 )
133 .bind(&[workspace.into(), since.as_str().into(), now.as_str().into()])?
134 .all()
135 .await?
136 .results::<Used>()?
137 .into_iter()
138 .map(|u| (u.kind, u.charged.unwrap_or(0)))
139 .collect();
140 let lines = invoice_lines(&used, owed);
141 let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000);
142 for (position, line) in lines.iter().enumerate() {
143 let fields = [
144 ("customer", customer.clone()),
145 ("amount", (line.amount_micros / 10_000).to_string()),
146 ("currency", "usd".to_owned()),
147 ("description", line.description.clone()),
148 ("metadata[workspace]", workspace.to_owned()),
149 ];
150 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?;
151 }
152 let description = match reason {
153 "month" => format!("g1t usage for {workspace}, {period}"),
154 _ => format!("g1t usage for {workspace}, charged as it neared its limit"),
155 };
156 let fields = [
157 ("customer", customer.clone()),
158 ("collection_method", "charge_automatically".to_owned()),
159 ("auto_advance", "false".to_owned()),
160 ("pending_invoice_items_behavior", "include".to_owned()),
161 ("description", description),
162 ("metadata[g1t_workspace]", workspace.to_owned()),
163 ("metadata[reason]", reason.to_owned()),
164 ("metadata[period]", period.to_owned()),
165 ];
166 let draft: StripeInvoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
167 // A retry finds it finalized already; that is fine.
168 let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
169 // Charge the card now; a decline comes back as an error.
170 let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await;
171 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
172 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>();
173 let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" };
174 let mut writes = vec![self
175 .db
176 .prepare(
177 "INSERT OR REPLACE INTO workspace_invoices
178 (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at)
179 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
180 )
181 .bind(&[
182 invoice.id.as_str().into(),
183 workspace.into(),
184 reason.into(),
185 period.into(),
186 (total as f64).into(),
187 status.into(),
188 crate::optional(invoice.hosted_invoice_url.as_deref()),
189 crate::optional(invoice.invoice_pdf.as_deref()),
190 now.as_str().into(),
191 now.as_str().into(),
192 crate::optional((status == "paid").then_some(now.as_str())),
193 ])?];
194 for (position, line) in lines.iter().enumerate() {
195 writes.push(
196 self.db
197 .prepare("INSERT OR REPLACE INTO workspace_invoice_lines (invoice_id, position, description, amount_micros) VALUES (?, ?, ?, ?)")
198 .bind(&[invoice.id.as_str().into(), (position as u32).into(), line.description.as_str().into(), (line.amount_micros as f64).into()])?,
199 );
200 }
201 self.db.batch(writes).await?;
202 if status == "paid" {
203 self.credit_invoice(workspace, &invoice).await?;
204 } else {
205 let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect());
206 self.mark_declined(workspace, &error).await?;
207 }
208 Ok(Ok(WorkspaceInvoice {
209 invoice_id: invoice.id,
210 workspace: workspace.to_owned(),
211 reason: reason.to_owned(),
212 period: period.to_owned(),
213 amount_micros: total,
214 status: status.to_owned(),
215 hosted_url: invoice.hosted_invoice_url,
216 pdf_url: invoice.invoice_pdf,
217 lines,
218 created_at: now,
219 }))
220 }
221
222 /// Enters an invoice's payment once, with the kind of card that paid.
223 async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice) -> Result<bool> {
224 let seen = self
225 .db
226 .prepare("SELECT id FROM ledger WHERE reference = ?")
227 .bind(&[invoice.id.as_str().into()])?
228 .first::<Value>(None)
229 .await?;
230 if seen.is_some() {
231 return Ok(false);
232 }
233 let amount = invoice.amount_paid * 10_000;
234 if amount <= 0 {
235 return Ok(false);
236 }
237 self.enter(workspace, EntryKind::TopUp, amount, &format!("Paid invoice {}", invoice.id), &invoice.id, None, None, None, None)
238 .await?;
239 // Prepaid cards pay, but never raise the limit.
240 if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge) {
241 if let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await {
242 if let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() {
243 self.db
244 .prepare("UPDATE ledger SET funding = ? WHERE reference = ?")
245 .bind(&[funding.into(), invoice.id.as_str().into()])?
246 .run()
247 .await?;
248 }
249 }
250 }
251 Ok(true)
252 }
253
254 pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> {
255 let now = rfc3339(now_ms());
256 self.db
257 .prepare(
258 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
259 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
260 )
261 .bind(&[workspace.into(), now.as_str().into(), error.into()])?
262 .run()
263 .await?;
264 Ok(())
265 }
266
267 /// A workspace invoice paid later, on Stripe's page or by a retry.
268 pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> {
269 #[derive(Deserialize)]
270 struct Row {
271 workspace: String,
272 }
273 let Some(row) = self
274 .db
275 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
276 .bind(&[invoice_id.into()])?
277 .first::<Row>(None)
278 .await?
279 else {
280 return Ok(None);
281 };
282 let Some(stripe) = &self.stripe else { return Ok(None) };
283 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?;
284 self.db
285 .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?")
286 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
287 .run()
288 .await?;
289 let credited = self.credit_invoice(&row.workspace, &invoice).await?;
290 Ok(Some(format!(
291 "invoice {invoice_id} for {} paid{}",
292 row.workspace,
293 if credited { "" } else { " (already credited)" }
294 )))
295 }
296
297 pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> {
298 let rows = self
299 .db
300 .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36")
301 .bind(&[workspace.into()])?
302 .all()
303 .await?
304 .results::<InvoiceRow>()?;
305 let mut invoices = vec![];
306 for row in rows {
307 let lines = self
308 .db
309 .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position")
310 .bind(&[row.invoice_id.as_str().into()])?
311 .all()
312 .await?
313 .results::<LineRow>()?
314 .into_iter()
315 .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros })
316 .collect();
317 invoices.push(WorkspaceInvoice {
318 invoice_id: row.invoice_id,
319 workspace: row.workspace,
320 reason: row.reason,
321 period: row.period,
322 amount_micros: row.amount_micros,
323 status: row.status,
324 hosted_url: row.hosted_url,
325 pdf_url: row.pdf_url,
326 lines,
327 created_at: row.created_at,
328 });
329 }
330 Ok(invoices)
331 }
332
333 /// `invoices`: for the workspace's members.
334 pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> {
335 let workspace = a.workspace.to_lowercase();
336 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
337 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices."));
338 }
339 Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?))
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn an_invoice_adds_up_to_what_is_owed() {
349 let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)];
350 // $10 of credit was paid in advance.
351 let lines = invoice_lines(&used, 40_000_000);
352 assert_eq!(lines.last().unwrap().description, "Paid in advance");
353 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000);
354 // $5 was left unpaid from before.
355 let lines = invoice_lines(&used, 55_000_000);
356 assert_eq!(lines.last().unwrap().description, "Unpaid from earlier");
357 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000);
358 // Exactly what was used.
359 assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
360 }
361}