g1t/services/billing/src/requests.rs
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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | ||
| 13 | use g1t_contracts::billing::{ | |
| 14 | AdminDecideLimitRequestArgs, AdminLimitRequestsArgs, AdminRecordPaymentArgs, EntryKind, LedgerEntry, LimitRequest, | |
| 15 | LimitRequestReview, LimitRequestsArgs, RequestLimitArgs, WorkspaceHistory, MICROS_PER_DOLLAR, | |
| 16 | }; | |
| 17 | use g1t_contracts::time::rfc3339; | |
| 18 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 19 | use g1t_kit::now_ms; | |
| 20 | use serde::Deserialize; | |
| 21 | use worker::Result; | |
| 22 | ||
| 23 | use crate::features::dollars; | |
| 24 | use 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. | |
| 28 | const MAX_REQUEST_MICROS: i64 = 1_000_000 * MICROS_PER_DOLLAR; | |
| 29 | ||
| 30 | #[derive(Deserialize)] | |
| 31 | struct 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 | ||
| 47 | impl 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. | |
| 68 | pub(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. | |
| 94 | pub(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 | ||
| 110 | impl 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)] | |
| 410 | mod 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 | } |