Skip to content
185 linesCodeBlameRaw
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
15use g1t_contracts::billing::FixedCost;
16use g1t_contracts::time::rfc3339;
17use g1t_kit::now_ms;
18use serde::Deserialize;
19use serde_json::Value;
20use worker::Result;
21
22use crate::Billing;
23
24/// The states of a subscription that is paid for (or will be).
25const PAID: [&str; 4] = ["Paid", "Provisioned", "AwaitingPayment", "Trial"];
26
27/// Each subscription that costs money, at what it comes to a month.
28pub(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.
59pub(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.
70pub(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
78impl 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)]
147mod 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}