Billing's fifteen minutes: spend alerts first, each step on its own, this month's users read once from an index (billing 0050)
3 files+100−400/3 viewed
| 1 | + | -- This month's users, read every fifteen minutes for autopay and the limit | |
| 2 | + | -- warnings: usage entries from a date on, by kind and time, and the | |
| 3 | + | -- workspace in the index so the read never touches the ledger itself. | |
| 4 | + | CREATE INDEX IF NOT EXISTS ledger_usage_by_time ON ledger (kind, created_at, workspace); |
| 1154 | 1154 | } | |
| 1155 | 1155 | } | |
| 1156 | 1156 | ||
| 1157 | − | #[event(scheduled)] | |
| 1158 | − | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 1159 | − | let Ok(billing) = Billing::from_env(&env) else { | |
| 1160 | − | return; | |
| 1161 | − | }; | |
| 1162 | − | let keeper = keeper::Keeper::from_env(&env); | |
| 1163 | − | if let Err(error) = billing.settle_runs(&keeper).await { | |
| 1157 | + | /// The fifteen-minute steps, each on its own: one failing never skips the | |
| 1158 | + | /// rest. | |
| 1159 | + | async fn tick(billing: &Billing, env: &Env, keeper: &keeper::Keeper) { | |
| 1160 | + | if let Err(error) = billing.settle_runs(keeper).await { | |
| 1164 | 1161 | worker::console_error!("settling runs failed: {error}"); | |
| 1165 | 1162 | } | |
| 1166 | 1163 | if let Err(error) = billing.settle_own_runs().await { | |
| ⋯ | |||
| 1170 | 1167 | match billing.replay_events().await { | |
| 1171 | 1168 | Ok(done) => worker::console_log!("stripe replay: {done}"), | |
| 1172 | 1169 | Err(error) => worker::console_error!("replaying Stripe events failed: {error}"), | |
| 1173 | − | } | |
| 1174 | − | if let Err(error) = billing.autopay().await { | |
| 1175 | − | worker::console_error!("paying at the limit failed: {error}"); | |
| 1176 | 1170 | } | |
| 1171 | + | // This month's users, read once for autopay and the warnings. | |
| 1172 | + | let users = if billing.stripe.is_some() { | |
| 1173 | + | match billing.month_users().await { | |
| 1174 | + | Ok(users) => Some(users), | |
| 1175 | + | Err(error) => { | |
| 1176 | + | worker::console_error!("reading this month's users failed: {error}"); | |
| 1177 | + | None | |
| 1178 | + | } | |
| 1179 | + | } | |
| 1180 | + | } else { | |
| 1181 | + | None | |
| 1182 | + | }; | |
| 1183 | + | if let Some(users) = &users | |
| 1184 | + | && let Err(error) = billing.autopay(users).await { | |
| 1185 | + | worker::console_error!("paying at the limit failed: {error}"); | |
| 1186 | + | } | |
| 1177 | 1187 | // AI credit below a workspace's auto-reload threshold, reloaded (ai.rs). | |
| 1178 | 1188 | match billing.reload_ai_credit().await { | |
| 1179 | 1189 | Ok(0) => {} | |
| ⋯ | |||
| 1191 | 1201 | if let Err(error) = billing.invoice_enterprises().await { | |
| 1192 | 1202 | worker::console_error!("invoicing enterprises failed: {error}"); | |
| 1193 | 1203 | } | |
| 1204 | + | if let Some(users) = &users | |
| 1205 | + | && let Ok(identity) = env.service("IDENTITY") | |
| 1206 | + | && let Err(error) = billing.warn_limits(&identity, users).await { | |
| 1207 | + | worker::console_error!("warning owners failed: {error}"); | |
| 1208 | + | } | |
| 1209 | + | } | |
| 1210 | + | ||
| 1211 | + | /// Every fifteen minutes: runs settled, Stripe replayed, cards charged at | |
| 1212 | + | /// the limit, months closed, owners warned. Once a day, at | |
| 1213 | + | /// [`keeper::DAILY`], in an invocation of its own: the slower checks | |
| 1214 | + | /// against Stripe and Cloudflare, and measuring. | |
| 1215 | + | /// | |
| 1216 | + | /// Each step logs its own failure and the next still runs. The spend | |
| 1217 | + | /// breaker and budget alerts go first: a step that runs out of CPU ends | |
| 1218 | + | /// the invocation, and must never take the alerts with it. | |
| 1219 | + | #[event(scheduled)] | |
| 1220 | + | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 1221 | + | let Ok(billing) = Billing::from_env(&env) else { | |
| 1222 | + | return; | |
| 1223 | + | }; | |
| 1194 | 1224 | // Comped budgets' alerts, and a tripped breaker staff were not told of. | |
| 1195 | 1225 | if let Err(error) = billing.watch_spend().await { | |
| 1196 | 1226 | worker::console_error!("watching g1t's own spend failed: {error}"); | |
| 1197 | 1227 | } | |
| 1198 | − | if let Ok(identity) = env.service("IDENTITY") | |
| 1199 | − | && let Err(error) = billing.warn_limits(&identity).await { | |
| 1200 | − | worker::console_error!("warning owners failed: {error}"); | |
| 1201 | − | } | |
| 1228 | + | let keeper = keeper::Keeper::from_env(&env); | |
| 1229 | + | let daily = event.cron() == keeper::DAILY; | |
| 1230 | + | if !daily { | |
| 1231 | + | tick(&billing, &env, &keeper).await; | |
| 1232 | + | } | |
| 1202 | 1233 | // Once a day: Stripe's endpoint kept listening to billing's events and | |
| 1203 | 1234 | // enabled, and saved cards and plans not read in a while read again. | |
| 1204 | − | if event.cron() == keeper::DAILY { | |
| 1235 | + | if daily { | |
| 1205 | 1236 | match billing.keep_endpoint("billing").await { | |
| 1206 | 1237 | Ok(done) => worker::console_log!("stripe endpoint: {done}"), | |
| 1207 | 1238 | Err(error) => worker::console_error!("keeping Stripe's endpoint failed: {error}"), | |
| ⋯ | |||
| 1213 | 1244 | } | |
| 1214 | 1245 | // Once a day, and at once if the costs were never checked: check every | |
| 1215 | 1246 | // cost against what Cloudflare billed. | |
| 1216 | − | if (event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false)) | |
| 1247 | + | if (daily || billing.never_checked().await.unwrap_or(false)) | |
| 1217 | 1248 | && let Err(error) = billing.reconcile(&keeper).await { | |
| 1218 | 1249 | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 1219 | 1250 | } | |
| 1220 | 1251 | // Once a day: credit from g1t past its expiry stops counting | |
| 1221 | 1252 | // (grants.rs), before the day is reconciled. | |
| 1222 | − | if event.cron() == keeper::DAILY { | |
| 1253 | + | if daily { | |
| 1223 | 1254 | match billing.expire_credits().await { | |
| 1224 | 1255 | Ok(closed) => worker::console_log!("credits expired: {closed}"), | |
| 1225 | 1256 | Err(error) => worker::console_error!("expiring credits failed: {error}"), | |
| ⋯ | |||
| 1228 | 1259 | // Once a day: what Cloudflare charged, reconciled against what g1t | |
| 1229 | 1260 | // counted and charged; prices whose day has come; margin alerts | |
| 1230 | 1261 | // (margin.rs). After the keeper, so its proposals are in. | |
| 1231 | − | if event.cron() == keeper::DAILY { | |
| 1262 | + | if daily { | |
| 1232 | 1263 | match billing.costs_daily(&env, &keeper).await { | |
| 1233 | 1264 | Ok(run) => worker::console_log!("costs: {} lines, {} days, {} proposals, {} alerts", run.lines, run.days, run.proposals, run.alerts), | |
| 1234 | 1265 | Err(error) => worker::console_error!("reconciling costs failed: {error}"), | |
| ⋯ | |||
| 1237 | 1268 | // Once a day: what each workspace's private repositories hold, its git | |
| 1238 | 1269 | // operations, Deployments plans from before the g1t plan set to end, | |
| 1239 | 1270 | // and old reservations cleared. | |
| 1240 | − | if event.cron() == keeper::DAILY { | |
| 1271 | + | if daily { | |
| 1241 | 1272 | if let Err(error) = billing.measure_packages().await { | |
| 1242 | 1273 | worker::console_error!("measuring package storage failed: {error}"); | |
| 1243 | 1274 | } | |
| 37 | 37 | //! Usage counts at what it cost g1t or what it is charged, whichever is | |
| 38 | 38 | //! more. Test-mode payments are not money, so they do not raise trust. | |
| 39 | 39 | ||
| 40 | + | use std::collections::BTreeSet; | |
| 41 | + | ||
| 40 | 42 | use futures_util::future::{try_join, try_join5, try_join_all}; | |
| 41 | 43 | use g1t_contracts::billing::{ | |
| 42 | 44 | BillingAccount, CheckLimitArgs, Limit, LimitArgs, LimitState, NotePendingArgs, PlanKind, SetBudgetArgs, SetSpendLimitArgs, Trust, | |
| ⋯ | |||
| 714 | 716 | Ok(true) | |
| 715 | 717 | } | |
| 716 | 718 | ||
| 719 | + | /// The workspaces with usage on the ledger this month: autopay's and | |
| 720 | + | /// the limit warnings' candidates, read once a tick for both. Served by | |
| 721 | + | /// `ledger_usage_by_time` (migration 0050), not a scan of the ledger. | |
| 722 | + | pub(crate) async fn month_users(&self) -> Result<BTreeSet<String>> { | |
| 723 | + | #[derive(Deserialize)] | |
| 724 | + | struct User { | |
| 725 | + | workspace: String, | |
| 726 | + | } | |
| 727 | + | let month_start = format!("{}-01", &rfc3339(now_ms())[..7]); | |
| 728 | + | Ok(self | |
| 729 | + | .db | |
| 730 | + | .prepare("SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?") | |
| 731 | + | .bind(&[month_start.into()])? | |
| 732 | + | .all() | |
| 733 | + | .await? | |
| 734 | + | .results::<User>()? | |
| 735 | + | .into_iter() | |
| 736 | + | .map(|user| user.workspace) | |
| 737 | + | .collect()) | |
| 738 | + | } | |
| 739 | + | ||
| 717 | 740 | /// Charges the saved card of each workspace nearing its limit, for what | |
| 718 | 741 | /// it owes, so that a workspace that pays never has its work stopped. | |
| 719 | 742 | /// Only with live payments: test-mode payments are not money and lower | |
| 720 | 743 | /// nothing. Not for a workspace's own spend limit, which means stop, nor | |
| 721 | 744 | /// for enterprises, which are invoiced. A charge at the limit always | |
| 722 | 745 | /// goes through, whatever the minimum charge. | |
| 723 | − | pub(crate) async fn autopay(&self) -> Result<()> { | |
| 746 | + | /// | |
| 747 | + | /// `users` are the workspaces with usage this month ([`Self::month_users`]). | |
| 748 | + | pub(crate) async fn autopay(&self, users: &BTreeSet<String>) -> Result<()> { | |
| 724 | 749 | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { | |
| 725 | 750 | return Ok(()); | |
| 726 | 751 | } | |
| ⋯ | |||
| 728 | 753 | struct Candidate { | |
| 729 | 754 | workspace: String, | |
| 730 | 755 | } | |
| 731 | − | let month_start = format!("{}-01", &rfc3339(now_ms())[..7]); | |
| 732 | 756 | // With a card, and not already declined: a declined card waits for | |
| 733 | 757 | // the owners, rather than being tried again every few minutes. | |
| 734 | 758 | let candidates = self | |
| 735 | 759 | .db | |
| 736 | 760 | .prepare( | |
| 737 | − | "SELECT DISTINCT ledger.workspace AS workspace | |
| 738 | − | FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace | |
| 739 | − | LEFT JOIN limits ON limits.workspace = ledger.workspace | |
| 740 | − | WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL | |
| 741 | − | AND limits.autopay_failed_at IS NULL", | |
| 761 | + | "SELECT accounts.workspace AS workspace | |
| 762 | + | FROM accounts LEFT JOIN limits ON limits.workspace = accounts.workspace | |
| 763 | + | WHERE accounts.customer_id IS NOT NULL AND limits.autopay_failed_at IS NULL", | |
| 742 | 764 | ) | |
| 743 | − | .bind(&[month_start.as_str().into()])? | |
| 744 | 765 | .all() | |
| 745 | 766 | .await? | |
| 746 | − | .results::<Candidate>()?; | |
| 767 | + | .results::<Candidate>()? | |
| 768 | + | .into_iter() | |
| 769 | + | .filter(|candidate| users.contains(&candidate.workspace)); | |
| 747 | 770 | for candidate in candidates { | |
| 748 | 771 | let limit = self.limit_of(&candidate.workspace).await?; | |
| 749 | 772 | // Near g1t's ceiling on what is unpaid; the spend limit is the | |
| ⋯ | |||
| 850 | 873 | /// plan's included usage, its spend limit and g1t's ceiling, once each a | |
| 851 | 874 | /// month; when its card was declined; and when a spend spike paused it. | |
| 852 | 875 | /// The same alerts show in the app (`entitlements`). | |
| 853 | − | pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> { | |
| 876 | + | /// | |
| 877 | + | /// `users` are the workspaces with usage this month ([`Self::month_users`]). | |
| 878 | + | pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher, users: &BTreeSet<String>) -> Result<()> { | |
| 854 | 879 | if self.stripe.is_none() { | |
| 855 | 880 | return Ok(()); | |
| 856 | 881 | } | |
| ⋯ | |||
| 860 | 885 | struct Candidate { | |
| 861 | 886 | workspace: String, | |
| 862 | 887 | } | |
| 863 | − | let candidates = self | |
| 864 | − | .db | |
| 865 | − | .prepare( | |
| 866 | − | "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1 | |
| 867 | − | UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL", | |
| 868 | − | ) | |
| 869 | − | .bind(&[format!("{month}-01").into()])? | |
| 870 | − | .all() | |
| 871 | − | .await? | |
| 872 | − | .results::<Candidate>()?; | |
| 888 | + | let mut candidates = users.clone(); | |
| 889 | + | candidates.extend( | |
| 890 | + | self.db | |
| 891 | + | .prepare("SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL") | |
| 892 | + | .all() | |
| 893 | + | .await? | |
| 894 | + | .results::<Candidate>()? | |
| 895 | + | .into_iter() | |
| 896 | + | .map(|candidate| candidate.workspace), | |
| 897 | + | ); | |
| 873 | 898 | #[derive(Deserialize)] | |
| 874 | 899 | struct Told { | |
| 875 | 900 | autopay_failed_at: Option<String>, | |
| ⋯ | |||
| 880 | 905 | meter: String, | |
| 881 | 906 | level: Option<i64>, | |
| 882 | 907 | } | |
| 883 | − | for Candidate { workspace } in candidates { | |
| 908 | + | for workspace in candidates { | |
| 884 | 909 | let told = self | |
| 885 | 910 | .db | |
| 886 | 911 | .prepare("SELECT autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?") | |