Skip to content

g1t/services/billing/src/invoices.rs

559 lines26,210 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
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging38/// Each line in whole cents, for the card processor. Their sum is what is
39/// owed rounded up to the next cent, never down: cutting each line to a
40/// cent on its own would charge up to a cent less per line than is owed (and
41/// a credit line a cent less of a credit), and leave the rest stranded under
42/// the minimum charge. The cent each needs is given to the lines with the
43/// largest fractions first.
44pub(crate) fn line_cents(lines: &[InvoiceItem]) -> Vec<i64> {
45 const MICROS_PER_CENT: i64 = 10_000;
46 let total: i64 = lines.iter().map(|l| l.amount_micros).sum();
47 let total_cents = total.div_euclid(MICROS_PER_CENT) + i64::from(total.rem_euclid(MICROS_PER_CENT) > 0);
48 let mut cents: Vec<i64> = lines.iter().map(|l| l.amount_micros.div_euclid(MICROS_PER_CENT)).collect();
49 let mut short = total_cents - cents.iter().sum::<i64>();
50 let mut by_fraction: Vec<usize> = (0..lines.len()).collect();
51 by_fraction.sort_by_key(|&i| std::cmp::Reverse(lines[i].amount_micros.rem_euclid(MICROS_PER_CENT)));
52 for i in by_fraction {
53 if short <= 0 || lines[i].amount_micros.rem_euclid(MICROS_PER_CENT) == 0 {
54 break;
55 }
56 cents[i] += 1;
57 short -= 1;
58 }
59 cents
60}
61
62/// Whether paying an invoice failed because the card said no (Stripe's
63/// 402, a `card_error`), rather than because Stripe could not be reached,
64/// was busy or failed itself. Only a decline stops a workspace's work.
65pub(crate) fn is_decline(error: &str) -> bool {
66 error.contains("answered 402") || error.contains("\"card_error\"")
67}
68
Merge Stripe Tax, the card fee on card payments, and one free workspace per person69/// A workspace invoice's draft: charged to the card, taxed by Stripe Tax,
70/// and holding only the lines put on it, never whatever is pending on the
71/// customer.
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging72fn draft_fields(customer: &str, workspace: &str, reason: &str, period: &str, description: String) -> Vec<(&'static str, String)> {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person73 let mut fields = vec![
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging74 ("customer", customer.to_owned()),
75 ("collection_method", "charge_automatically".to_owned()),
76 ("auto_advance", "false".to_owned()),
77 ("pending_invoice_items_behavior", "exclude".to_owned()),
78 ("description", description),
79 ("metadata[g1t_workspace]", workspace.to_owned()),
80 ("metadata[reason]", reason.to_owned()),
81 ("metadata[period]", period.to_owned()),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person82 ];
83 fields.extend(crate::stripe::invoice_tax_fields());
84 fields
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging85}
86
Merge Stripe Tax, the card fee on card payments, and one free workspace per person87/// One line, on the draft `invoice`, at g1t's tax code, excluding tax.
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging88fn item_fields(customer: &str, invoice: &str, workspace: &str, description: &str, cents: i64) -> Vec<(&'static str, String)> {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person89 let mut fields = vec![
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging90 ("customer", customer.to_owned()),
91 ("invoice", invoice.to_owned()),
92 ("amount", cents.to_string()),
93 ("currency", "usd".to_owned()),
94 ("description", description.to_owned()),
95 ("metadata[workspace]", workspace.to_owned()),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person96 ];
97 fields.extend(crate::stripe::item_tax_fields());
98 fields
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging99}
100
Merge Stripe Tax, the card fee on card payments, and one free workspace per person101/// Whether Stripe left an invoice a draft because Stripe Tax could not
102/// place the customer.
103pub(crate) fn needs_tax_location(invoice: &Value) -> bool {
104 invoice["automatic_tax"]["status"].as_str() == Some("requires_location_inputs")
105}
106
Two limits, real invoices, trust that grows by itself, sales signals107#[derive(Deserialize)]
108struct InvoiceRow {
109 invoice_id: String,
110 workspace: String,
111 reason: String,
112 period: String,
113 amount_micros: i64,
114 status: String,
115 hosted_url: Option<String>,
116 pdf_url: Option<String>,
117 created_at: String,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person118 #[serde(default)]
119 fee_micros: i64,
120 #[serde(default)]
121 tax_micros: i64,
Two limits, real invoices, trust that grows by itself, sales signals122}
123
124#[derive(Deserialize)]
125struct LineRow {
126 description: String,
127 amount_micros: i64,
128}
129
130/// A Stripe invoice, as far as billing reads it.
131#[derive(Deserialize)]
132struct StripeInvoice {
133 id: String,
134 #[serde(default)]
135 status: Option<String>,
136 #[serde(default)]
137 hosted_invoice_url: Option<String>,
138 #[serde(default)]
139 invoice_pdf: Option<String>,
140 #[serde(default)]
141 amount_paid: i64,
142 #[serde(default)]
143 charge: Option<String>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person144 #[serde(default)]
145 payment_intent: Option<String>,
146 /// The tax Stripe added, in cents (`tax`, in this API version).
147 #[serde(default)]
148 tax: Option<i64>,
Two limits, real invoices, trust that grows by itself, sales signals149}
150
Merge Stripe Tax, the card fee on card payments, and one free workspace per person151impl StripeInvoice {
152 fn tax_cents(&self) -> i64 {
153 self.tax.unwrap_or(0).max(0)
154 }
155}
156
Two limits, real invoices, trust that grows by itself, sales signals157impl Billing {
158 /// Invoices the workspace for what it owes, charging its card. `Ok(Err)`
159 /// says why not, when there was nothing to do or no card.
160 pub(crate) async fn invoice_workspace(
161 &self,
162 workspace: &str,
163 reason: &str,
164 period: &str,
165 ) -> Result<std::result::Result<WorkspaceInvoice, String>> {
166 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
167 let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) };
168 let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit169 // AI credit left props up the balance but pays only for models: it
170 // is not money for anything else (ai.rs).
171 let owed = self.owed_with(workspace, account.balance_micros).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look172 // 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 put173 // (`MIN_CHARGE_MICROS`), so a payment's fee is never most of it;
174 // 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 look175 // goes through, whatever its size, so a new workspace's limit never
176 // strands it.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put177 if reason == "month" && !crate::limits::worth_charging(owed, self.plans.min_charge_micros) {
178 return Ok(Err(format!(
179 "{} is owed, under the {} minimum charge; it carries over to the next invoice.",
180 crate::features::dollars(owed),
181 crate::features::dollars(self.plans.min_charge_micros)
182 )));
Two limits, real invoices, trust that grows by itself, sales signals183 }
184 // What was used since the last invoice, by kind.
185 #[derive(Deserialize)]
186 struct Last {
187 through_at: Option<String>,
188 }
189 let since = self
190 .db
191 .prepare("SELECT MAX(through_at) AS through_at FROM workspace_invoices WHERE workspace = ? AND status <> 'void'")
192 .bind(&[workspace.into()])?
193 .first::<Last>(None)
194 .await?
195 .and_then(|l| l.through_at)
196 .unwrap_or_default();
197 #[derive(Deserialize)]
198 struct Used {
199 kind: String,
200 charged: Option<i64>,
201 }
202 let now = rfc3339(now_ms());
203 let used: Vec<(String, i64)> = self
204 .db
205 .prepare(
206 "SELECT CASE
207 WHEN task = 'sandbox' THEN 'Sandbox time'
Fast pages, required checks on the branch, self-hosted runners, honest incidents208 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
Two limits, real invoices, trust that grows by itself, sales signals209 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 put210 WHEN task = 'security' THEN 'Security scans'
211 WHEN task = 'context' THEN 'Search embeddings'
212 WHEN task = 'storage' THEN 'Private repository storage'
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look213 WHEN task = 'git' THEN 'Git operations'
Two limits, real invoices, trust that grows by itself, sales signals214 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
215 ELSE 'Agents on g1t''s models' END AS kind,
216 -SUM(amount_micros) AS charged
217 FROM ledger WHERE workspace = ? AND kind = 'usage' AND created_at > ? AND created_at <= ?
218 GROUP BY 1 ORDER BY charged DESC",
219 )
220 .bind(&[workspace.into(), since.as_str().into(), now.as_str().into()])?
221 .all()
222 .await?
223 .results::<Used>()?
224 .into_iter()
225 .map(|u| (u.kind, u.charged.unwrap_or(0)))
226 .collect();
227 let lines = invoice_lines(&used, owed);
Merge Stripe Tax, the card fee on card payments, and one free workspace per person228 // Stripe Tax needs to know where the customer is. Without an
229 // address nothing is charged: the owners are asked for one, and the
230 // charge goes through once it is there.
231 match stripe.customer_placed(&customer).await {
232 Ok(true) => {}
233 Ok(false) => {
234 self.tax_address_needed(workspace).await?;
235 return Ok(Err(crate::tax::address_needed_message(workspace)));
236 }
237 Err(error) => return Ok(Err(crate::stripe::friendly(&error))),
238 }
239 let cents = line_cents(&lines);
240 // Charged to a card: Stripe's fee is its own line. A bank account
241 // set as the way to pay has no card fee.
242 let by_card = match stripe.default_payment_method(&customer).await {
243 Ok(method) => method.is_none_or(|m| m.kind == "card"),
244 Err(_) => true,
245 };
246 let fee_cents = if by_card { self.card_fee_on(cents.iter().sum::<i64>()).await? } else { 0 };
247 let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}/{fee_cents}", owed / 10_000);
Two limits, real invoices, trust that grows by itself, sales signals248 let description = match reason {
249 "month" => format!("g1t usage for {workspace}, {period}"),
250 _ => format!("g1t usage for {workspace}, charged as it neared its limit"),
251 };
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging252 // The draft first, then its lines on it: lines left pending on the
253 // customer by an attempt that failed half way would otherwise be
254 // swept into the next invoice (this one's retry with a different
255 // total, or the plan's renewal) on top of their own new lines.
256 let draft: StripeInvoice =
257 stripe.post_idempotent("/invoices", &draft_fields(&customer, workspace, reason, period, description), &key).await?;
258 for (position, (line, cents)) in lines.iter().zip(&cents).enumerate() {
259 let fields = item_fields(&customer, &draft.id, workspace, &line.description, *cents);
260 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?;
261 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person262 if fee_cents > 0 {
263 let fields = item_fields(&customer, &draft.id, workspace, crate::stripe::CARD_FEE_LINE, fee_cents);
264 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/card_fee")).await?;
265 }
Two limits, real invoices, trust that grows by itself, sales signals266 // A retry finds it finalized already; that is fine.
Merge Stripe Tax, the card fee on card payments, and one free workspace per person267 let finalized = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
268 if let Err(error) = &finalized
269 && crate::stripe::is_tax_location_error(error)
270 {
271 self.tax_address_needed(workspace).await?;
272 return Ok(Err(crate::tax::address_needed_message(workspace)));
273 }
274 if let Ok(left) = stripe.get::<Value>(&format!("/invoices/{}", draft.id)).await
275 && left["status"].as_str() == Some("draft")
276 && needs_tax_location(&left)
277 {
278 self.tax_address_needed(workspace).await?;
279 return Ok(Err(crate::tax::address_needed_message(workspace)));
280 }
281 self.tax_address_given(workspace).await?;
Two limits, real invoices, trust that grows by itself, sales signals282 // Charge the card now; a decline comes back as an error.
283 let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await;
284 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
285 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>();
Merge Stripe Tax, the card fee on card payments, and one free workspace per person286 let fee_micros = fee_cents * 10_000;
287 let tax_micros = invoice.tax_cents() * 10_000;
Two limits, real invoices, trust that grows by itself, sales signals288 let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" };
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging289 // Only the card saying no is a decline, which stops work until it
290 // is paid. Stripe failing to answer, or answering busy, is g1t's
291 // problem and never stops a payer: the invoice stays as it is, and
292 // the next attempt (the same total finds the same invoice) pays it.
293 if status == "failed" && !paid.as_ref().err().is_some_and(|error| is_decline(&error.to_string())) {
294 return Ok(Err("The card processor did not finish the payment; it is tried again.".into()));
295 }
Two limits, real invoices, trust that grows by itself, sales signals296 let mut writes = vec![self
297 .db
298 .prepare(
299 "INSERT OR REPLACE INTO workspace_invoices
Merge Stripe Tax, the card fee on card payments, and one free workspace per person300 (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at,
301 fee_micros, tax_micros)
302 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Two limits, real invoices, trust that grows by itself, sales signals303 )
304 .bind(&[
305 invoice.id.as_str().into(),
306 workspace.into(),
307 reason.into(),
308 period.into(),
309 (total as f64).into(),
310 status.into(),
311 crate::optional(invoice.hosted_invoice_url.as_deref()),
312 crate::optional(invoice.invoice_pdf.as_deref()),
313 now.as_str().into(),
314 now.as_str().into(),
315 crate::optional((status == "paid").then_some(now.as_str())),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person316 (fee_micros as f64).into(),
317 (tax_micros as f64).into(),
Two limits, real invoices, trust that grows by itself, sales signals318 ])?];
319 for (position, line) in lines.iter().enumerate() {
320 writes.push(
321 self.db
322 .prepare("INSERT OR REPLACE INTO workspace_invoice_lines (invoice_id, position, description, amount_micros) VALUES (?, ?, ?, ?)")
323 .bind(&[invoice.id.as_str().into(), (position as u32).into(), line.description.as_str().into(), (line.amount_micros as f64).into()])?,
324 );
325 }
326 self.db.batch(writes).await?;
327 if status == "paid" {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person328 self.credit_invoice(workspace, &invoice, fee_cents).await?;
Two limits, real invoices, trust that grows by itself, sales signals329 } else {
330 let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect());
331 self.mark_declined(workspace, &error).await?;
332 }
333 Ok(Ok(WorkspaceInvoice {
334 invoice_id: invoice.id,
335 workspace: workspace.to_owned(),
336 reason: reason.to_owned(),
337 period: period.to_owned(),
338 amount_micros: total,
339 status: status.to_owned(),
340 hosted_url: invoice.hosted_invoice_url,
341 pdf_url: invoice.invoice_pdf,
342 lines,
343 created_at: now,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person344 fee_micros,
345 tax_micros,
Two limits, real invoices, trust that grows by itself, sales signals346 }))
347 }
348
Merge Stripe Tax, the card fee on card payments, and one free workspace per person349 /// Enters an invoice's payment once, with the kind of card that paid:
350 /// what it paid for usage, never its tax or card fee, which are kept
351 /// apart (`tax_and_fees`).
352 async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice, fee_cents: i64) -> Result<bool> {
Two limits, real invoices, trust that grows by itself, sales signals353 let seen = self
354 .db
355 .prepare("SELECT id FROM ledger WHERE reference = ?")
356 .bind(&[invoice.id.as_str().into()])?
357 .first::<Value>(None)
358 .await?;
359 if seen.is_some() {
360 return Ok(false);
361 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person362 let extras = crate::tax::Extras { tax_cents: invoice.tax_cents(), fee_cents };
363 let amount = (invoice.amount_paid - extras.tax_cents - extras.fee_cents).max(0) * 10_000;
Two limits, real invoices, trust that grows by itself, sales signals364 if amount <= 0 {
365 return Ok(false);
366 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person367 let description = if extras == crate::tax::Extras::default() {
368 format!("Paid invoice {}", invoice.id)
369 } else {
370 format!(
371 "Paid invoice {} (tax {} and card fee {} paid with it)",
372 invoice.id,
373 crate::features::cents(extras.tax_cents * 10_000),
374 crate::features::cents(extras.fee_cents * 10_000)
375 )
376 };
377 self.enter(workspace, EntryKind::TopUp, amount, &description, &invoice.id, None, None, None, None).await?;
378 self.record_extras(workspace, &invoice.id, invoice.payment_intent.as_deref(), extras, None).await?;
Two limits, real invoices, trust that grows by itself, sales signals379 // 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 kept380 if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge)
381 && let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await
382 && let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() {
Two limits, real invoices, trust that grows by itself, sales signals383 self.db
384 .prepare("UPDATE ledger SET funding = ? WHERE reference = ?")
385 .bind(&[funding.into(), invoice.id.as_str().into()])?
386 .run()
387 .await?;
388 }
389 Ok(true)
390 }
391
392 pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> {
393 let now = rfc3339(now_ms());
394 self.db
395 .prepare(
396 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
397 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
398 )
399 .bind(&[workspace.into(), now.as_str().into(), error.into()])?
400 .run()
401 .await?;
402 Ok(())
403 }
404
405 /// A workspace invoice paid later, on Stripe's page or by a retry.
406 pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> {
407 #[derive(Deserialize)]
408 struct Row {
409 workspace: String,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person410 #[serde(default)]
411 fee_micros: i64,
Two limits, real invoices, trust that grows by itself, sales signals412 }
413 let Some(row) = self
414 .db
Merge Stripe Tax, the card fee on card payments, and one free workspace per person415 .prepare("SELECT workspace, fee_micros FROM workspace_invoices WHERE invoice_id = ?")
Two limits, real invoices, trust that grows by itself, sales signals416 .bind(&[invoice_id.into()])?
417 .first::<Row>(None)
418 .await?
419 else {
420 return Ok(None);
421 };
422 let Some(stripe) = &self.stripe else { return Ok(None) };
423 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?;
424 self.db
Merge Stripe Tax, the card fee on card payments, and one free workspace per person425 .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ?, tax_micros = ? WHERE invoice_id = ?")
426 .bind(&[rfc3339(now_ms()).into(), ((invoice.tax_cents() * 10_000) as f64).into(), invoice_id.into()])?
Two limits, real invoices, trust that grows by itself, sales signals427 .run()
428 .await?;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person429 let credited = self.credit_invoice(&row.workspace, &invoice, row.fee_micros / 10_000).await?;
Two limits, real invoices, trust that grows by itself, sales signals430 Ok(Some(format!(
431 "invoice {invoice_id} for {} paid{}",
432 row.workspace,
433 if credited { "" } else { " (already credited)" }
434 )))
435 }
436
437 pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> {
438 let rows = self
439 .db
440 .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36")
441 .bind(&[workspace.into()])?
442 .all()
443 .await?
444 .results::<InvoiceRow>()?;
445 let mut invoices = vec![];
446 for row in rows {
447 let lines = self
448 .db
449 .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position")
450 .bind(&[row.invoice_id.as_str().into()])?
451 .all()
452 .await?
453 .results::<LineRow>()?
454 .into_iter()
455 .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros })
456 .collect();
457 invoices.push(WorkspaceInvoice {
458 invoice_id: row.invoice_id,
459 workspace: row.workspace,
460 reason: row.reason,
461 period: row.period,
462 amount_micros: row.amount_micros,
463 status: row.status,
464 hosted_url: row.hosted_url,
465 pdf_url: row.pdf_url,
466 lines,
467 created_at: row.created_at,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person468 fee_micros: row.fee_micros,
469 tax_micros: row.tax_micros,
Two limits, real invoices, trust that grows by itself, sales signals470 });
471 }
472 Ok(invoices)
473 }
474
475 /// `invoices`: for the workspace's members.
476 pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> {
477 let workspace = a.workspace.to_lowercase();
478 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
479 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices."));
480 }
481 Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?))
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn an_invoice_adds_up_to_what_is_owed() {
491 let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)];
492 // $10 of credit was paid in advance.
493 let lines = invoice_lines(&used, 40_000_000);
494 assert_eq!(lines.last().unwrap().description, "Paid in advance");
495 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000);
496 // $5 was left unpaid from before.
497 let lines = invoice_lines(&used, 55_000_000);
498 assert_eq!(lines.last().unwrap().description, "Unpaid from earlier");
499 assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000);
500 // Exactly what was used.
501 assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
502 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging503
504 #[test]
505 fn an_invoice_holds_only_its_own_lines() {
506 let draft = draft_fields("cus_1", "acme", "month", "2026-09", "g1t usage".to_owned());
507 assert!(draft.contains(&("pending_invoice_items_behavior", "exclude".to_owned())));
Merge Stripe Tax, the card fee on card payments, and one free workspace per person508 // Taxed by Stripe Tax, every line at g1t's tax code, excluding tax.
509 assert!(draft.contains(&("automatic_tax[enabled]", "true".to_owned())));
510 let line = item_fields("cus_1", "in_1", "acme", crate::stripe::CARD_FEE_LINE, 61);
511 assert!(line.contains(&("tax_code", crate::stripe::TAX_CODE.to_owned())));
512 assert!(line.contains(&("tax_behavior", "exclusive".to_owned())));
513 // Left a draft for want of an address: asked for, never charged.
514 assert!(needs_tax_location(&serde_json::json!({ "automatic_tax": { "status": "requires_location_inputs" } })));
515 assert!(!needs_tax_location(&serde_json::json!({ "automatic_tax": { "status": "complete" } })));
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging516 let item = item_fields("cus_1", "in_1", "acme", "Sandbox time", 1_234);
517 assert!(item.contains(&("invoice", "in_1".to_owned())));
518 assert!(item.contains(&("amount", "1234".to_owned())));
519 }
520
521 #[test]
522 fn only_the_card_saying_no_is_a_decline() {
523 let declined = r#"the card processor answered 402: {"error": {"code": "card_declined", "type": "card_error"}}"#;
524 assert!(is_decline(declined));
525 assert!(is_decline(r#"the card processor answered 400: {"error": {"type": "card_error"}}"#));
526 // Stripe down, busy or failing, or the network: tried again, nobody stopped.
527 assert!(!is_decline(r#"the card processor answered 500: {"error": {"type": "api_error"}}"#));
528 assert!(!is_decline(r#"the card processor answered 429: {"error": {"type": "rate_limit_error"}}"#));
529 assert!(!is_decline("Network connection lost."));
530 }
531
532 fn items(micros: &[i64]) -> Vec<InvoiceItem> {
533 micros.iter().map(|&amount_micros| InvoiceItem { description: String::new(), amount_micros }).collect()
534 }
535
536 #[test]
537 fn the_card_is_charged_what_is_owed_rounded_up_to_the_cent_never_down() {
538 // $1.234567 + $2.345678 = $3.580245 owed: 359 cents, where cutting
539 // each line would have charged 357.
540 let cents = line_cents(&items(&[1_234_567, 2_345_678]));
541 assert_eq!(cents.iter().sum::<i64>(), 359);
542 assert_eq!(cents, vec![124, 235]);
543 // Whole cents stay as they are.
544 assert_eq!(line_cents(&items(&[40_000_000, 10_000_000])), vec![4_000, 1_000]);
545 // A credit line keeps its full credit; the total still rounds up.
546 // $50.004 used, $10.0025 paid in advance: $40.0015 owed, 4,001 cents.
547 let cents = line_cents(&items(&[50_004_000, -10_002_500]));
548 assert_eq!(cents.iter().sum::<i64>(), 4_001);
549 // Lines under a cent each add up to the cents they make together.
550 let cents = line_cents(&items(&[4_000, 4_000, 4_000]));
551 assert_eq!(cents.iter().sum::<i64>(), 2);
552 // Every invoice the close makes: never less than owed, never a cent more.
553 for (used, owed) in [(vec![("a".to_owned(), 7_777_777), ("b".to_owned(), 3)], 6_000_001), (vec![("a".to_owned(), 5_000_001)], 5_000_001)] {
554 let lines = invoice_lines(&used, owed);
555 let charged = line_cents(&lines).iter().sum::<i64>() * 10_000;
556 assert!(charged >= owed && charged - owed < 10_000, "{charged} for {owed}");
557 }
558 }
Two limits, real invoices, trust that grows by itself, sales signals559}

This file's history is long; its oldest lines are credited to the oldest commit read.