| 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 | use g1t_contracts::billing::FixedCost; |
| 11 | use g1t_contracts::time::rfc3339; |
| 12 | use g1t_kit::now_ms; |
| 13 | use serde::Deserialize; |
| 14 | use serde_json::Value; |
| 15 | use worker::Result; |
| 16 | |
| 17 | use crate::Billing; |
| 18 | |
| 19 | /// The states of a subscription that is paid for (or will be). |
| 20 | const PAID: [&str; 4] = ["Paid", "Provisioned", "AwaitingPayment", "Trial"]; |
| 21 | |
| 22 | /// Each subscription that costs money, at what it comes to a month. |
| 23 | pub(crate) fn monthly(body: &Value) -> Vec<FixedCost> { |
| 24 | let mut out = Vec::new(); |
| 25 | for s in body["result"].as_array().into_iter().flatten() { |
| 26 | let state = s["state"].as_str().unwrap_or("Paid"); |
| 27 | if !PAID.contains(&state) { |
| 28 | continue; |
| 29 | } |
| 30 | let price = s["price"].as_f64().unwrap_or(0.0); |
| 31 | let months = match s["frequency"].as_str().unwrap_or("monthly") { |
| 32 | "weekly" => 12.0 / 52.0, |
| 33 | "quarterly" => 3.0, |
| 34 | "yearly" => 12.0, |
| 35 | _ => 1.0, |
| 36 | }; |
| 37 | let monthly_micros = (price / months * 1_000_000.0).round() as i64; |
| 38 | if monthly_micros <= 0 { |
| 39 | continue; |
| 40 | } |
| 41 | let name = s["rate_plan"]["public_name"] |
| 42 | .as_str() |
| 43 | .or_else(|| s["product"]["name"].as_str()) |
| 44 | .or_else(|| s["rate_plan"]["id"].as_str()) |
| 45 | .unwrap_or("Subscription") |
| 46 | .to_owned(); |
| 47 | out.push(FixedCost { name, monthly_micros }); |
| 48 | } |
| 49 | out |
| 50 | } |
| 51 | |
| 52 | /// What the fixed cost is, and where the figure came from. |
| 53 | pub(crate) struct Fixed { |
| 54 | pub monthly_micros: i64, |
| 55 | /// `cloudflare` (read from Cloudflare) or `estimate` (the variable). |
| 56 | pub source: &'static str, |
| 57 | pub read_at: Option<String>, |
| 58 | pub items: Vec<FixedCost>, |
| 59 | } |
| 60 | |
| 61 | impl Billing { |
| 62 | /// Reads the subscriptions and keeps them; an error is returned for the |
| 63 | /// run to log, and the last read (or the estimate) stays. |
| 64 | pub(crate) async fn read_subscriptions(&self, keeper: &crate::keeper::Keeper) -> Result<usize> { |
| 65 | let body = keeper.subscriptions_body().await?; |
| 66 | let items = monthly(&body); |
| 67 | let total: i64 = items.iter().map(|i| i.monthly_micros).sum(); |
| 68 | self.db |
| 69 | .prepare( |
| 70 | "INSERT INTO cf_subscriptions (id, monthly_micros, detail, read_at) VALUES ('current', ?1, ?2, ?3) |
| 71 | ON CONFLICT (id) DO UPDATE SET monthly_micros = excluded.monthly_micros, detail = excluded.detail, read_at = excluded.read_at", |
| 72 | ) |
| 73 | .bind(&[(total as f64).into(), serde_json::to_string(&items)?.into(), rfc3339(now_ms()).into()])? |
| 74 | .run() |
| 75 | .await?; |
| 76 | Ok(items.len()) |
| 77 | } |
| 78 | |
| 79 | /// Cloudflare's subscriptions as last read, else the estimate. |
| 80 | pub(crate) async fn fixed_monthly(&self, estimate: i64) -> Result<Fixed> { |
| 81 | #[derive(Deserialize)] |
| 82 | struct Row { |
| 83 | monthly_micros: i64, |
| 84 | detail: String, |
| 85 | read_at: String, |
| 86 | } |
| 87 | let row = self |
| 88 | .db |
| 89 | .prepare("SELECT monthly_micros, detail, read_at FROM cf_subscriptions WHERE id = 'current'") |
| 90 | .first::<Row>(None) |
| 91 | .await?; |
| 92 | Ok(match row { |
| 93 | Some(row) => Fixed { |
| 94 | monthly_micros: row.monthly_micros, |
| 95 | source: "cloudflare", |
| 96 | read_at: Some(row.read_at), |
| 97 | items: serde_json::from_str(&row.detail).unwrap_or_default(), |
| 98 | }, |
| 99 | None => Fixed { monthly_micros: estimate, source: "estimate", read_at: None, items: Vec::new() }, |
| 100 | }) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | #[cfg(test)] |
| 105 | mod tests { |
| 106 | use super::*; |
| 107 | use serde_json::json; |
| 108 | |
| 109 | #[test] |
| 110 | fn paid_subscriptions_come_to_a_month_each() { |
| 111 | let body = json!({ "result": [ |
| 112 | { "state": "Paid", "price": 5.0, "frequency": "monthly", "rate_plan": { "public_name": "Workers Paid" } }, |
| 113 | { "state": "Paid", "price": 240.0, "frequency": "yearly", "product": { "name": "Zone Pro" } }, |
| 114 | { "state": "Cancelled", "price": 20.0, "frequency": "monthly", "rate_plan": { "public_name": "Old" } }, |
| 115 | { "state": "Paid", "price": 0.0, "frequency": "monthly", "rate_plan": { "public_name": "Free" } }, |
| 116 | ] }); |
| 117 | let items = monthly(&body); |
| 118 | assert_eq!( |
| 119 | items, |
| 120 | vec![ |
| 121 | FixedCost { name: "Workers Paid".into(), monthly_micros: 5_000_000 }, |
| 122 | FixedCost { name: "Zone Pro".into(), monthly_micros: 20_000_000 }, |
| 123 | ] |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn nothing_answered_is_nothing_paid() { |
| 129 | assert!(monthly(&json!({ "result": [] })).is_empty()); |
| 130 | assert!(monthly(&json!({})).is_empty()); |
| 131 | } |
| 132 | } |