pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/requests.rs

434 lines19,461 bytesCodeBlame
1//! Owners asking g1t for more: a higher limit ("Raise my limit"), or help
2//! with usage past what they meant ("Spent more than you meant to?").
3//!
4//! A request goes to sudo with the workspace's history beside it: spend by
5//! month, payments cleared, disputes and declines, how long it has been
6//! here, and its recent pace. Staff approve it (at the amount asked, or
7//! another) or decline it in one click; the owner is told in the app and by
8//! email. g1t answers within one business day.
9//!
10//! An approved limit is a granted ceiling (see `limits`): a floor under the
11//! ceiling trust gives, and the owners' spend limit moves up to it.
12
13use g1t_contracts::billing::{
14 AdminDecideLimitRequestArgs, AdminLimitRequestsArgs, AdminRecordPaymentArgs, EntryKind, LedgerEntry, LimitRequest,
15 LimitRequestReview, LimitRequestsArgs, RequestLimitArgs, WorkspaceHistory, MICROS_PER_DOLLAR,
16};
17use g1t_contracts::time::rfc3339;
18use g1t_contracts::{FailureCode, Outcome, Role, new_id};
19use g1t_kit::now_ms;
20use serde::Deserialize;
21use worker::Result;
22
23use crate::features::dollars;
24use crate::{Billing, LedgerRow, members_only};
25
26/// The most a request may ask for: past it, the conversation is about
27/// custom terms, which staff set in sudo.
28const MAX_REQUEST_MICROS: i64 = 1_000_000 * MICROS_PER_DOLLAR;
29
30#[derive(Deserialize)]
31struct RequestRow {
32 id: String,
33 workspace: String,
34 kind: String,
35 amount_micros: i64,
36 reason: String,
37 expected_monthly_micros: i64,
38 status: String,
39 decided_micros: Option<i64>,
40 decided_by: Option<String>,
41 answer: Option<String>,
42 created_by: String,
43 created_at: String,
44 decided_at: Option<String>,
45}
46
47impl From<RequestRow> for LimitRequest {
48 fn from(row: RequestRow) -> Self {
49 LimitRequest {
50 id: row.id,
51 workspace: row.workspace,
52 kind: row.kind,
53 amount_micros: row.amount_micros,
54 reason: row.reason,
55 expected_monthly_micros: row.expected_monthly_micros,
56 status: row.status,
57 decided_micros: row.decided_micros,
58 decided_by: row.decided_by,
59 answer: row.answer,
60 created_by: row.created_by,
61 created_at: row.created_at,
62 decided_at: row.decided_at,
63 }
64 }
65}
66
67/// Checks a request before it is saved. `Err` says what to fix.
68pub(crate) fn validate(kind: &str, amount: i64, reason: &str, current: Option<i64>) -> std::result::Result<(), String> {
69 if kind != "limit" && kind != "overage" {
70 return Err("A request is for a higher limit, or about usage past what was meant.".to_owned());
71 }
72 let reason = reason.trim();
73 if reason.chars().count() < 10 {
74 return Err("Say in a sentence what it is for, so g1t can answer.".to_owned());
75 }
76 if reason.chars().count() > 2000 {
77 return Err("Keep it under 2,000 characters.".to_owned());
78 }
79 if kind == "limit" {
80 if amount <= 0 || amount > MAX_REQUEST_MICROS {
81 return Err("Ask for a monthly limit between $1 and $1,000,000.".to_owned());
82 }
83 if current.is_some_and(|current| amount <= current) {
84 return Err(format!(
85 "That is within what you can set yourself ({}). Set it under Spend limit; no request is needed.",
86 dollars(current.unwrap_or_default())
87 ));
88 }
89 }
90 Ok(())
91}
92
93/// What the owner is told about a decision.
94pub(crate) fn answer(kind: &str, approved: Option<i64>, asked: i64, note: &str) -> String {
95 let note = note.trim();
96 let mut text = match (kind, approved) {
97 ("overage", Some(_)) => "g1t looked at this month's usage and has answered below.".to_owned(),
98 ("overage", None) => "g1t looked at this month's usage.".to_owned(),
99 (_, Some(amount)) if amount >= asked => format!("Approved: the limit is now {}.", dollars(amount)),
100 (_, Some(amount)) => format!("Approved at {}, rather than the {} asked for.", dollars(amount), dollars(asked)),
101 (_, None) => "Not approved this time.".to_owned(),
102 };
103 if !note.is_empty() {
104 text.push(' ');
105 text.push_str(note);
106 }
107 text
108}
109
110impl Billing {
111 /// `request_limit`: an owner asks.
112 pub(crate) async fn request_limit(&self, a: RequestLimitArgs) -> Result<Outcome<LimitRequest>> {
113 let workspace = a.workspace.to_lowercase();
114 if a.actor.role_in(&workspace) != Some(Role::Owner) {
115 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can ask g1t about the workspace's limit."));
116 }
117 let limit = self.limit_of(&workspace).await?;
118 let current = limit.available_micros.max(limit.raise_once_micros);
119 if let Err(why) = validate(&a.kind, a.amount_micros, &a.reason, if a.kind == "limit" { current } else { None }) {
120 return Ok(Outcome::fail(FailureCode::Invalid, why));
121 }
122 // One open request of each kind at a time: a second replaces nothing.
123 let open = self
124 .db
125 .prepare("SELECT id FROM limit_requests WHERE workspace = ? AND kind = ? AND status = 'open'")
126 .bind(&[workspace.as_str().into(), a.kind.as_str().into()])?
127 .first::<serde_json::Value>(None)
128 .await?;
129 if open.is_some() {
130 return Ok(Outcome::fail(
131 FailureCode::Conflict,
132 "There is already a request waiting for g1t. It is answered within one business day.",
133 ));
134 }
135 let now = now_ms();
136 let id = new_id("lrq", now);
137 self.db
138 .prepare(
139 "INSERT INTO limit_requests (id, workspace, kind, amount_micros, reason, expected_monthly_micros, status, created_by, created_at)
140 VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)",
141 )
142 .bind(&[
143 id.as_str().into(),
144 workspace.as_str().into(),
145 a.kind.as_str().into(),
146 (a.amount_micros.max(0) as f64).into(),
147 a.reason.trim().into(),
148 (a.expected_monthly_micros.max(0) as f64).into(),
149 a.actor.username.as_str().into(),
150 rfc3339(now).into(),
151 ])?
152 .run()
153 .await?;
154 let account = self.account_of(&workspace).await?;
155 let what = if a.kind == "limit" {
156 format!("asked for a {} limit", dollars(a.amount_micros))
157 } else {
158 "asked about usage past what was meant".to_owned()
159 };
160 self.audit(&account.id, "request", &format!("{workspace}: {what}: {}", a.reason.trim()), &a.actor.username).await?;
161 Ok(match self.request(&id).await? {
162 Some(request) => Outcome::Ok(request),
163 None => Outcome::fail(FailureCode::NotFound, "The request was not saved."),
164 })
165 }
166
167 async fn request(&self, id: &str) -> Result<Option<LimitRequest>> {
168 Ok(self
169 .db
170 .prepare("SELECT * FROM limit_requests WHERE id = ?")
171 .bind(&[id.into()])?
172 .first::<RequestRow>(None)
173 .await?
174 .map(LimitRequest::from))
175 }
176
177 /// `limit_requests`: a workspace's requests and their answers.
178 pub(crate) async fn limit_requests(&self, a: LimitRequestsArgs) -> Result<Outcome<Vec<LimitRequest>>> {
179 let workspace = a.workspace.to_lowercase();
180 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
181 return Ok(members_only());
182 }
183 Ok(Outcome::Ok(self.requests_of(&workspace).await?))
184 }
185
186 pub(crate) async fn requests_of(&self, workspace: &str) -> Result<Vec<LimitRequest>> {
187 Ok(self
188 .db
189 .prepare("SELECT * FROM limit_requests WHERE workspace = ? ORDER BY created_at DESC LIMIT 20")
190 .bind(&[workspace.into()])?
191 .all()
192 .await?
193 .results::<RequestRow>()?
194 .into_iter()
195 .map(LimitRequest::from)
196 .collect())
197 }
198
199 /// A workspace's history with g1t, for staff deciding about it.
200 pub(crate) async fn history(&self, workspace: &str) -> Result<WorkspaceHistory> {
201 let limit = self.limit_of(workspace).await?;
202 let months = self.months_for(&[workspace.to_owned()], 6).await?;
203 #[derive(Deserialize)]
204 struct Row {
205 paid: Option<i64>,
206 payments: Option<u32>,
207 disputes: Option<u32>,
208 first_seen: Option<String>,
209 }
210 let settled = rfc3339(now_ms() - crate::limits::SETTLE_DAYS * 24 * 60 * 60 * 1000);
211 let row = self
212 .db
213 .prepare(
214 "SELECT
215 SUM(CASE WHEN kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' AND disputed = 0
216 AND created_at <= ?2 THEN amount_micros END) AS paid,
217 SUM(CASE WHEN kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' THEN 1 ELSE 0 END) AS payments,
218 SUM(CASE WHEN disputed = 1 THEN 1 ELSE 0 END) AS disputes,
219 MIN(created_at) AS first_seen
220 FROM ledger WHERE workspace = ?1",
221 )
222 .bind(&[workspace.into(), settled.into()])?
223 .first::<Row>(None)
224 .await?;
225 #[derive(Deserialize)]
226 struct Count {
227 n: Option<u32>,
228 }
229 let declines = self
230 .db
231 .prepare("SELECT COUNT(*) AS n FROM workspace_invoices WHERE workspace = ? AND status = 'failed'")
232 .bind(&[workspace.into()])?
233 .first::<Count>(None)
234 .await?
235 .and_then(|c| c.n)
236 .unwrap_or(0);
237 let pace = self.pace(workspace).await?;
238 let row = row.unwrap_or(Row { paid: None, payments: None, disputes: None, first_seen: None });
239 Ok(WorkspaceHistory {
240 plan: Some(self.plan_kind(workspace).await?),
241 months,
242 paid_cleared_micros: row.paid.unwrap_or(0),
243 payments: row.payments.unwrap_or(0),
244 disputes: row.disputes.unwrap_or(0),
245 declines,
246 first_seen: row.first_seen,
247 ceiling_micros: limit.ceiling_micros,
248 max_ceiling_micros: limit.max_ceiling_micros,
249 spend_limit_micros: limit.spend_limit_micros,
250 last_hour_micros: pace.last_hour,
251 average_hour_micros: pace.usual_hour,
252 last_day_micros: pace.last_day,
253 })
254 }
255
256 /// `admin_limit_requests`: requests for staff, oldest open first.
257 pub(crate) async fn admin_limit_requests(&self, a: AdminLimitRequestsArgs) -> Result<Vec<LimitRequestReview>> {
258 let status = a.status.unwrap_or_else(|| "open".to_owned());
259 let rows = self
260 .db
261 .prepare(
262 "SELECT * FROM limit_requests WHERE (?1 = 'all' OR status = ?1)
263 ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, CASE WHEN status = 'open' THEN created_at END ASC,
264 created_at DESC
265 LIMIT 100",
266 )
267 .bind(&[status.as_str().into()])?
268 .all()
269 .await?
270 .results::<RequestRow>()?;
271 let mut reviews = vec![];
272 for row in rows {
273 let request = LimitRequest::from(row);
274 let history = self.history(&request.workspace).await?;
275 reviews.push(LimitRequestReview { request, history });
276 }
277 Ok(reviews)
278 }
279
280 /// `admin_decide_limit_request`: approve or decline, and tell the owner.
281 pub(crate) async fn admin_decide_limit_request(&self, a: AdminDecideLimitRequestArgs) -> Result<Outcome<LimitRequest>> {
282 if a.by.trim().is_empty() {
283 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is deciding."));
284 }
285 let approve = match a.decision.as_str() {
286 "approve" => true,
287 "decline" => false,
288 _ => return Ok(Outcome::fail(FailureCode::Invalid, "Approve or decline.")),
289 };
290 let Some(request) = self.request(&a.id).await? else {
291 return Ok(Outcome::fail(FailureCode::NotFound, "No such request."));
292 };
293 if request.status != "open" {
294 return Ok(Outcome::fail(FailureCode::Conflict, format!("That request was {} already.", request.status)));
295 }
296 let amount = if approve && request.kind == "limit" { Some(a.amount_micros.unwrap_or(request.amount_micros)) } else { None };
297 if amount.is_some_and(|m| m <= 0 || m > MAX_REQUEST_MICROS) {
298 return Ok(Outcome::fail(FailureCode::Invalid, "Approve between $1 and $1,000,000."));
299 }
300 if !approve && a.note.trim().is_empty() {
301 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, for the owner, when declining."));
302 }
303 let text = answer(&request.kind, amount.or(approve.then_some(0)), request.amount_micros, &a.note);
304 let now = rfc3339(now_ms());
305 let claimed = self
306 .db
307 .prepare(
308 "UPDATE limit_requests SET status = ?1, decided_micros = ?2, decided_by = ?3, answer = ?4, decided_at = ?5
309 WHERE id = ?6 AND status = 'open' RETURNING id",
310 )
311 .bind(&[
312 (if approve { "approved" } else { "declined" }).into(),
313 amount.map_or(worker::wasm_bindgen::JsValue::NULL, |m| (m as f64).into()),
314 a.by.trim().into(),
315 text.as_str().into(),
316 now.as_str().into(),
317 a.id.as_str().into(),
318 ])?
319 .first::<serde_json::Value>(None)
320 .await?;
321 if claimed.is_none() {
322 return Ok(Outcome::fail(FailureCode::Conflict, "Someone decided it meanwhile."));
323 }
324 let workspace = request.workspace.clone();
325 if let Some(amount) = amount {
326 // The ceiling rises to it, and so does the owners' spend limit.
327 self.db
328 .prepare(
329 "INSERT INTO limits (workspace, granted_ceiling_micros, spend_limit_micros, updated_at) VALUES (?1, ?2, ?2, ?3)
330 ON CONFLICT (workspace) DO UPDATE SET
331 granted_ceiling_micros = MAX(COALESCE(granted_ceiling_micros, 0), ?2),
332 spend_limit_micros = CASE WHEN spend_limit_full = 1 THEN spend_limit_micros
333 ELSE MAX(COALESCE(spend_limit_micros, 0), ?2) END,
334 updated_at = ?3",
335 )
336 .bind(&[workspace.as_str().into(), (amount as f64).into(), now.as_str().into()])?
337 .run()
338 .await?;
339 }
340 let account = self.account_of(&workspace).await?;
341 self.audit(
342 &account.id,
343 "request",
344 &format!("{workspace}: {} request {}: {text}", request.kind, if approve { "approved" } else { "declined" }),
345 a.by.trim(),
346 )
347 .await?;
348 if let Some(identity) = &self.identity {
349 let subject = match (request.kind.as_str(), approve) {
350 ("limit", true) => format!("g1t: {workspace}'s limit was raised"),
351 ("limit", false) => format!("g1t: about {workspace}'s limit"),
352 _ => format!("g1t: about {workspace}'s usage this month"),
353 };
354 crate::limits::notify(identity, &workspace, &subject, &text, "Open billing", &format!("https://g1t.sh/{workspace}/-/billing"))
355 .await;
356 }
357 Ok(match self.request(&a.id).await? {
358 Some(request) => Outcome::Ok(request),
359 None => Outcome::fail(FailureCode::NotFound, "No such request."),
360 })
361 }
362
363 /// `admin_record_payment`: money that reached g1t outside the card
364 /// pages, such as a bank transfer, entered as a payment.
365 pub(crate) async fn admin_record_payment(&self, a: AdminRecordPaymentArgs) -> Result<Outcome<LedgerEntry>> {
366 let workspace = a.workspace.trim().to_lowercase();
367 let reference = a.reference.trim();
368 if workspace.is_empty() || reference.is_empty() || a.by.trim().is_empty() {
369 return Ok(Outcome::fail(FailureCode::Invalid, "A payment needs a workspace, the transfer's reference, and who recorded it."));
370 }
371 if a.amount_micros <= 0 || a.amount_micros > 100_000 * MICROS_PER_DOLLAR {
372 return Ok(Outcome::fail(FailureCode::Invalid, "A payment is more than $0 and at most $100,000."));
373 }
374 let key = format!("bank/{reference}");
375 let seen = self
376 .db
377 .prepare("SELECT id FROM ledger WHERE reference = ?")
378 .bind(&[key.as_str().into()])?
379 .first::<serde_json::Value>(None)
380 .await?;
381 if seen.is_some() {
382 return Ok(Outcome::fail(FailureCode::Conflict, "That transfer is recorded already."));
383 }
384 let note = a.note.trim();
385 let description = if note.is_empty() { "Paid by bank transfer".to_owned() } else { format!("Paid by bank transfer: {note}") };
386 self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &key, None, None, Some(a.by.trim()), None)
387 .await?;
388 let account = self.account_of(&workspace).await?;
389 self.audit(
390 &account.id,
391 "payment",
392 &format!("{} to {workspace} by bank transfer, reference {reference}{}", dollars(a.amount_micros), if note.is_empty() { String::new() } else { format!(": {note}") }),
393 a.by.trim(),
394 )
395 .await?;
396 let row = self
397 .db
398 .prepare("SELECT * FROM ledger WHERE reference = ?")
399 .bind(&[key.as_str().into()])?
400 .first::<LedgerRow>(None)
401 .await?;
402 Ok(match row {
403 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
404 None => Outcome::fail(FailureCode::NotFound, "The payment was not saved."),
405 })
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn a_request_says_what_it_is_for() {
415 assert!(validate("limit", 500_000_000, "We are moving our CI to g1t this month.", Some(200_000_000)).is_ok());
416 assert!(validate("limit", 500_000_000, "more", Some(200_000_000)).unwrap_err().contains("in a sentence"));
417 assert!(validate("other", 1, "We are moving our CI to g1t.", None).is_err());
418 // Within what the owners can set themselves: no request needed.
419 assert!(validate("limit", 150_000_000, "We are moving our CI to g1t.", Some(200_000_000)).unwrap_err().contains("yourself"));
420 assert!(validate("limit", 0, "We are moving our CI to g1t.", None).is_err());
421 // An overage needs no amount.
422 assert!(validate("overage", 0, "An agent looped overnight on #12.", None).is_ok());
423 }
424
425 #[test]
426 fn the_owner_is_told_plainly() {
427 assert_eq!(answer("limit", Some(500_000_000), 500_000_000, ""), "Approved: the limit is now $500.00.");
428 assert_eq!(
429 answer("limit", Some(300_000_000), 500_000_000, "We can go higher after a month of payments."),
430 "Approved at $300.00, rather than the $500.00 asked for. We can go higher after a month of payments."
431 );
432 assert_eq!(answer("limit", None, 500_000_000, "Your card was declined twice."), "Not approved this time. Your card was declined twice.");
433 }
434}