g1t/services/billing/src/invoices.rs

360 lines15,663 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Two limits, real invoices, trust that grows by itself, sales signals1//! 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);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look86 // Only a month's close charges no less than the minimum
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put87 // (`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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look89 // goes through, whatever its size, so a new workspace's limit never
90 // strands it.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put91 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 )));
Two limits, real invoices, trust that grows by itself, sales signals97 }
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'
Fast pages, required checks on the branch, self-hosted runners, honest incidents122 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
Two limits, real invoices, trust that grows by itself, sales signals123 WHEN task = 'deployments' THEN 'Deployments: builds and usage past the plan'
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put124 WHEN task = 'security' THEN 'Security scans'
125 WHEN task = 'context' THEN 'Search embeddings'
126 WHEN task = 'storage' THEN 'Private repository storage'
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look127 WHEN task = 'git' THEN 'Git operations'
Two limits, real invoices, trust that grows by itself, sales signals128 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.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept241 if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge)
242 && let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await
243 && let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() {
Two limits, real invoices, trust that grows by itself, sales signals244 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 Ok(true)
251 }
252
253 pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> {
254 let now = rfc3339(now_ms());
255 self.db
256 .prepare(
257 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
258 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
259 )
260 .bind(&[workspace.into(), now.as_str().into(), error.into()])?
261 .run()
262 .await?;
263 Ok(())
264 }
265
266 /// A workspace invoice paid later, on Stripe's page or by a retry.
267 pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> {
268 #[derive(Deserialize)]
269 struct Row {
270 workspace: String,
271 }
272 let Some(row) = self
273 .db
274 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
275 .bind(&[invoice_id.into()])?
276 .first::<Row>(None)
277 .await?
278 else {
279 return Ok(None);
280 };
281 let Some(stripe) = &self.stripe else { return Ok(None) };
282 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?;
283 self.db
284 .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?")
285 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
286 .run()
287 .await?;
288 let credited = self.credit_invoice(&row.workspace, &invoice).await?;
289 Ok(Some(format!(
290 "invoice {invoice_id} for {} paid{}",
291 row.workspace,
292 if credited { "" } else { " (already credited)" }
293 )))
294 }
295
296 pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> {
297 let rows = self
298 .db
299 .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36")
300 .bind(&[workspace.into()])?
301 .all()
302 .await?
303 .results::<InvoiceRow>()?;
304 let mut invoices = vec![];
305 for row in rows {
306 let lines = self
307 .db
308 .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position")
309 .bind(&[row.invoice_id.as_str().into()])?
310 .all()
311 .await?
312 .results::<LineRow>()?
313 .into_iter()
314 .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros })
315 .collect();
316 invoices.push(WorkspaceInvoice {
317 invoice_id: row.invoice_id,
318 workspace: row.workspace,
319 reason: row.reason,
320 period: row.period,
321 amount_micros: row.amount_micros,
322 status: row.status,
323 hosted_url: row.hosted_url,
324 pdf_url: row.pdf_url,
325 lines,
326 created_at: row.created_at,
327 });
328 }
329 Ok(invoices)
330 }
331
332 /// `invoices`: for the workspace's members.
333 pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> {
334 let workspace = a.workspace.to_lowercase();
335 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
336 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices."));
337 }
338 Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?))
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn an_invoice_adds_up_to_what_is_owed() {
348 let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)];
349 // $10 of credit was paid in advance.
350 let lines = invoice_lines(&used, 40_000_000);
351 assert_eq!(lines.last().unwrap().description, "Paid in advance");
352 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000);
353 // $5 was left unpaid from before.
354 let lines = invoice_lines(&used, 55_000_000);
355 assert_eq!(lines.last().unwrap().description, "Unpaid from earlier");
356 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000);
357 // Exactly what was used.
358 assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
359 }
360}