| 1 | //! Cloudflare's subscriptions: what g1t pays each month whatever it uses |
| 2 | //! (Workers Paid, add-ons), read from Cloudflare each day. |
| 3 | //! |
| 4 | //! They are not on the billable-usage bill, so the costs statement and |
| 5 | //! g1t's own spend add them as a fixed cost. Read with the bill's token |
| 6 | //! (`CLOUDFLARE_BILLING_TOKEN`, Account: Billing Read) and kept in |
| 7 | //! `cf_subscriptions`; until a read has worked, `CLOUDFLARE_FIXED_MONTHLY_MICROS` |
| 8 | //! stands in as an estimate. |
| 9 | //! |
| 10 | //! The same read says when the billing cycle starts (`current_period_start`): |
| 11 | //! the day of the month every cycle starts on (`cycle`), which is when the |
| 12 | //! usage bill's included amounts start again. Until it is read, |
| 13 | //! `CLOUDFLARE_BILLING_DAY` says. |
| 14 | |
| 15 | use g1t_contracts::billing::FixedCost; |
| 16 | use g1t_contracts::time::rfc3339; |
| 17 | use g1t_kit::now_ms; |
| 18 | use serde::Deserialize; |
| 19 | use serde_json::Value; |
| 20 | use worker::Result; |
| 21 | |
| 22 | use crate::Billing; |
| 23 | |
| 24 | /// The states of a subscription that is paid for (or will be). |
| 25 | const PAID: [&str; 4] = ["Paid", "Provisioned", "AwaitingPayment", "Trial"]; |
| 26 | |
| 27 | /// Each subscription that costs money, at what it comes to a month. |
| 28 | pub(crate) fn monthly(body: &Value) -> Vec<FixedCost> { |
| 29 | let mut out = Vec::new(); |
| 30 | for s in body["result"].as_array().into_iter().flatten() { |
| 31 | let state = s["state"].as_str().unwrap_or("Paid"); |
| 32 | if !PAID.contains(&state) { |
| 33 | continue; |
| 34 | } |
| 35 | let price = s["price"].as_f64().unwrap_or(0.0); |
| 36 | let months = match s["frequency"].as_str().unwrap_or("monthly") { |
| 37 | "weekly" => 12.0 / 52.0, |
| 38 | "quarterly" => 3.0, |
| 39 | "yearly" => 12.0, |
| 40 | _ => 1.0, |
| 41 | }; |
| 42 | let monthly_micros = (price / months * 1_000_000.0).round() as i64; |
| 43 | if monthly_micros <= 0 { |
| 44 | continue; |
| 45 | } |
| 46 | let name = s["rate_plan"]["public_name"] |
| 47 | .as_str() |
| 48 | .or_else(|| s["product"]["name"].as_str()) |
| 49 | .or_else(|| s["rate_plan"]["id"].as_str()) |
| 50 | .unwrap_or("Subscription") |
| 51 | .to_owned(); |
| 52 | out.push(FixedCost { name, monthly_micros }); |
| 53 | } |
| 54 | out |
| 55 | } |
| 56 | |
| 57 | /// When the current billing cycle started, from the first paid monthly |
| 58 | /// subscription that says (`current_period_start`), as YYYY-MM-DD. |
| 59 | pub(crate) fn period_start(body: &Value) -> Option<String> { |
| 60 | body["result"] |
| 61 | .as_array()? |
| 62 | .iter() |
| 63 | .filter(|s| PAID.contains(&s["state"].as_str().unwrap_or("Paid"))) |
| 64 | .filter(|s| s["frequency"].as_str().unwrap_or("monthly") == "monthly") |
| 65 | .find_map(|s| s["current_period_start"].as_str().filter(|d| crate::cycle::anchor_of(d).is_some())) |
| 66 | .map(|d| d[..10].to_owned()) |
| 67 | } |
| 68 | |
| 69 | /// What the fixed cost is, and where the figure came from. |
| 70 | pub(crate) struct Fixed { |
| 71 | pub monthly_micros: i64, |
| 72 | /// `cloudflare` (read from Cloudflare) or `estimate` (the variable). |
| 73 | pub source: &'static str, |
| 74 | pub read_at: Option<String>, |
| 75 | pub items: Vec<FixedCost>, |
| 76 | } |
| 77 | |
| 78 | impl Billing { |
| 79 | /// Reads the subscriptions and keeps them; an error is returned for the |
| 80 | /// run to log, and the last read (or the estimate) stays. |
| 81 | pub(crate) async fn read_subscriptions(&self, keeper: &crate::keeper::Keeper) -> Result<usize> { |
| 82 | let body = keeper.subscriptions_body().await?; |
| 83 | let items = monthly(&body); |
| 84 | let total: i64 = items.iter().map(|i| i.monthly_micros).sum(); |
| 85 | let cycle_start = period_start(&body); |
| 86 | self.db |
| 87 | .prepare( |
| 88 | "INSERT INTO cf_subscriptions (id, monthly_micros, detail, read_at, cycle_start) VALUES ('current', ?1, ?2, ?3, ?4) |
| 89 | ON CONFLICT (id) DO UPDATE SET monthly_micros = excluded.monthly_micros, detail = excluded.detail, read_at = excluded.read_at, |
| 90 | cycle_start = COALESCE(excluded.cycle_start, cf_subscriptions.cycle_start)", |
| 91 | ) |
| 92 | .bind(&[ |
| 93 | (total as f64).into(), |
| 94 | serde_json::to_string(&items)?.into(), |
| 95 | rfc3339(now_ms()).into(), |
| 96 | cycle_start.map_or(worker::wasm_bindgen::JsValue::NULL, |d| d.into()), |
| 97 | ])? |
| 98 | .run() |
| 99 | .await?; |
| 100 | Ok(items.len()) |
| 101 | } |
| 102 | |
| 103 | /// The day of the month Cloudflare's billing cycle starts on: from the |
| 104 | /// subscriptions as last read, else `CLOUDFLARE_BILLING_DAY`, else the 1st. |
| 105 | pub(crate) async fn cycle_anchor(&self) -> Result<u32> { |
| 106 | #[derive(Deserialize)] |
| 107 | struct Row { |
| 108 | cycle_start: Option<String>, |
| 109 | } |
| 110 | let read = self |
| 111 | .db |
| 112 | .prepare("SELECT cycle_start FROM cf_subscriptions WHERE id = 'current'") |
| 113 | .first::<Row>(None) |
| 114 | .await? |
| 115 | .and_then(|r| r.cycle_start) |
| 116 | .and_then(|d| crate::cycle::anchor_of(&d)); |
| 117 | let configured = self.env.var("CLOUDFLARE_BILLING_DAY").ok().and_then(|v| v.to_string().trim().parse::<u32>().ok()).filter(|d| (1..=31).contains(d)); |
| 118 | Ok(read.or(configured).unwrap_or(crate::cycle::DEFAULT_ANCHOR)) |
| 119 | } |
| 120 | |
| 121 | /// Cloudflare's subscriptions as last read, else the estimate. |
| 122 | pub(crate) async fn fixed_monthly(&self, estimate: i64) -> Result<Fixed> { |
| 123 | #[derive(Deserialize)] |
| 124 | struct Row { |
| 125 | monthly_micros: i64, |
| 126 | detail: String, |
| 127 | read_at: String, |
| 128 | } |
| 129 | let row = self |
| 130 | .db |
| 131 | .prepare("SELECT monthly_micros, detail, read_at FROM cf_subscriptions WHERE id = 'current'") |
| 132 | .first::<Row>(None) |
| 133 | .await?; |
| 134 | Ok(match row { |
| 135 | Some(row) => Fixed { |
| 136 | monthly_micros: row.monthly_micros, |
| 137 | source: "cloudflare", |
| 138 | read_at: Some(row.read_at), |
| 139 | items: serde_json::from_str(&row.detail).unwrap_or_default(), |
| 140 | }, |
| 141 | None => Fixed { monthly_micros: estimate, source: "estimate", read_at: None, items: Vec::new() }, |
| 142 | }) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | #[cfg(test)] |
| 147 | mod tests { |
| 148 | use super::*; |
| 149 | use serde_json::json; |
| 150 | |
| 151 | #[test] |
| 152 | fn paid_subscriptions_come_to_a_month_each() { |
| 153 | let body = json!({ "result": [ |
| 154 | { "state": "Paid", "price": 5.0, "frequency": "monthly", "rate_plan": { "public_name": "Workers Paid" } }, |
| 155 | { "state": "Paid", "price": 240.0, "frequency": "yearly", "product": { "name": "Zone Pro" } }, |
| 156 | { "state": "Cancelled", "price": 20.0, "frequency": "monthly", "rate_plan": { "public_name": "Old" } }, |
| 157 | { "state": "Paid", "price": 0.0, "frequency": "monthly", "rate_plan": { "public_name": "Free" } }, |
| 158 | ] }); |
| 159 | let items = monthly(&body); |
| 160 | assert_eq!( |
| 161 | items, |
| 162 | vec![ |
| 163 | FixedCost { name: "Workers Paid".into(), monthly_micros: 5_000_000 }, |
| 164 | FixedCost { name: "Zone Pro".into(), monthly_micros: 20_000_000 }, |
| 165 | ] |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | #[test] |
| 170 | fn the_cycle_starts_when_the_monthly_subscription_renews() { |
| 171 | let body = json!({ "result": [ |
| 172 | { "state": "Cancelled", "price": 5.0, "frequency": "monthly", "current_period_start": "2026-09-01T00:00:00Z" }, |
| 173 | { "state": "Paid", "price": 240.0, "frequency": "yearly", "current_period_start": "2026-03-15T00:00:00Z" }, |
| 174 | { "state": "Paid", "price": 5.0, "frequency": "monthly", "current_period_start": "2026-09-28T07:12:00Z" }, |
| 175 | ] }); |
| 176 | assert_eq!(period_start(&body).as_deref(), Some("2026-09-28")); |
| 177 | assert_eq!(period_start(&json!({ "result": [{ "state": "Paid", "price": 5.0 }] })), None); |
| 178 | } |
| 179 | |
| 180 | #[test] |
| 181 | fn nothing_answered_is_nothing_paid() { |
| 182 | assert!(monthly(&json!({ "result": [] })).is_empty()); |
| 183 | assert!(monthly(&json!({})).is_empty()); |
| 184 | } |
| 185 | } |