g1t/services/billing/src/subscriptions.rs

132 lines5,070 bytesCodeBlame

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.

Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing1//! 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
10use g1t_contracts::billing::FixedCost;
11use g1t_contracts::time::rfc3339;
12use g1t_kit::now_ms;
13use serde::Deserialize;
14use serde_json::Value;
15use worker::Result;
16
17use crate::Billing;
18
19/// The states of a subscription that is paid for (or will be).
20const PAID: [&str; 4] = ["Paid", "Provisioned", "AwaitingPayment", "Trial"];
21
22/// Each subscription that costs money, at what it comes to a month.
23pub(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.
53pub(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
61impl 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)]
105mod 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}