flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/billing/src/accounts.rs

537 lines21,280 bytesCodeBlame
//! Who pays for a workspace, and on what terms.
//!
//! Every workspace is paid for by a billing account. By default that is
//! its own (`ws_<slug>`), on standard terms, and needs no row. g1t staff
//! can change that in sudo.g1t.sh:
//!
//! - **Terms.** Comped (nothing charged, usage still recorded with its
//!   cost; for g1t's own workspaces and partners), or custom (a discount,
//!   a ceiling of its own, or both), optionally until a date.
//! - **Enterprises.** One account paying for several workspaces, as GitHub
//!   Enterprise does: their usage and payments count together against one
//!   limit, on one set of terms.
//! - **Credits**, such as refunds.
//!
//! Every change names who made it and is kept in `admin_actions`.

use g1t_contracts::billing::{
    AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs,
    AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms,
    TermsKind,
};
use g1t_contracts::time::rfc3339;
use g1t_contracts::{FailureCode, Outcome, new_id};
use g1t_kit::now_ms;
use serde::Deserialize;
use worker::Result;
use worker::wasm_bindgen::JsValue;

use crate::{Billing, LedgerRow, optional};

#[derive(Deserialize)]
struct AccountRow {
    id: String,
    kind: String,
    name: String,
    terms_kind: String,
    discount_percent: u32,
    ceiling_micros: Option<i64>,
    note: String,
    terms_until: Option<String>,
    terms_set_by: Option<String>,
    terms_set_at: Option<String>,
    created_at: String,
}

impl AccountRow {
    fn terms(&self) -> Terms {
        let expired = self.terms_until.as_deref().is_some_and(|until| until < rfc3339(now_ms()).as_str());
        if expired {
            return Terms::standard();
        }
        Terms {
            kind: match self.terms_kind.as_str() {
                "comped" => TermsKind::Comped,
                "custom" => TermsKind::Custom,
                _ => TermsKind::Standard,
            },
            discount_percent: self.discount_percent,
            ceiling_micros: self.ceiling_micros,
            note: self.note.clone(),
            until: self.terms_until.clone(),
            set_by: self.terms_set_by.clone(),
            set_at: self.terms_set_at.clone(),
        }
    }
}

#[derive(Deserialize)]
struct Member {
    workspace: String,
}

#[derive(Deserialize)]
struct ActionRow {
    id: String,
    account: String,
    action: String,
    detail: String,
    by: String,
    created_at: String,
}

/// `ws_<slug>`: a workspace's own account.
pub(crate) fn own_account(workspace: &str) -> String {
    format!("ws_{}", workspace.to_lowercase())
}

fn kind_text(kind: TermsKind) -> &'static str {
    match kind {
        TermsKind::Standard => "standard",
        TermsKind::Comped => "comped",
        TermsKind::Custom => "custom",
    }
}

fn describe(terms: &Terms) -> String {
    let mut text = match terms.kind {
        TermsKind::Standard => "standard".to_owned(),
        TermsKind::Comped => "comped".to_owned(),
        TermsKind::Custom => {
            let mut parts = vec![];
            if terms.discount_percent > 0 {
                parts.push(format!("{}% off", terms.discount_percent));
            }
            if let Some(ceiling) = terms.ceiling_micros {
                parts.push(format!("ceiling {}", crate::features::dollars(ceiling)));
            }
            format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") })
        }
    };
    if let Some(until) = &terms.until {
        text.push_str(&format!(" until {}", &until[..until.len().min(10)]));
    }
    if !terms.note.is_empty() {
        text.push_str(&format!(": {}", terms.note));
    }
    text
}

impl Billing {
    async fn account_row(&self, id: &str) -> Result<Option<AccountRow>> {
        self.db
            .prepare("SELECT * FROM billing_accounts WHERE id = ?")
            .bind(&[id.into()])?
            .first::<AccountRow>(None)
            .await
    }

    async fn members(&self, account: &str) -> Result<Vec<String>> {
        Ok(self
            .db
            .prepare("SELECT workspace FROM account_members WHERE account_id = ? ORDER BY workspace")
            .bind(&[account.into()])?
            .all()
            .await?
            .results::<Member>()?
            .into_iter()
            .map(|m| m.workspace)
            .collect())
    }

    fn to_account(&self, row: &AccountRow, workspaces: Vec<String>) -> BillingAccount {
        BillingAccount {
            id: row.id.clone(),
            kind: if row.kind == "enterprise" { AccountKind::Enterprise } else { AccountKind::Workspace },
            name: row.name.clone(),
            terms: row.terms(),
            workspaces,
            created_at: row.created_at.clone(),
        }
    }

    /// The account that pays for a workspace.
    pub(crate) async fn account_of(&self, workspace: &str) -> Result<BillingAccount> {
        let workspace = workspace.to_lowercase();
        #[derive(Deserialize)]
        struct Link {
            account_id: String,
        }
        let linked = self
            .db
            .prepare("SELECT account_id FROM account_members WHERE workspace = ?")
            .bind(&[workspace.as_str().into()])?
            .first::<Link>(None)
            .await?;
        if let Some(link) = linked {
            if let Some(row) = self.account_row(&link.account_id).await? {
                let members = self.members(&row.id).await?;
                return Ok(self.to_account(&row, members));
            }
        }
        let id = own_account(&workspace);
        Ok(match self.account_row(&id).await? {
            Some(row) => self.to_account(&row, vec![workspace]),
            None => BillingAccount {
                id,
                kind: AccountKind::Workspace,
                name: workspace.clone(),
                terms: Terms::standard(),
                workspaces: vec![workspace],
                created_at: String::new(),
            },
        })
    }

    /// The terms a workspace is charged on.
    pub(crate) async fn terms_of(&self, workspace: &str) -> Result<Terms> {
        Ok(self.account_of(workspace).await?.terms)
    }

    /// An account by id, or the account of a workspace by its slug.
    async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
        let id = id.trim().to_lowercase();
        if id.starts_with("ent_") {
            return Ok(match self.account_row(&id).await? {
                Some(row) => {
                    let members = self.members(&row.id).await?;
                    Some(self.to_account(&row, members))
                }
                None => None,
            });
        }
        let slug = id.strip_prefix("ws_").unwrap_or(&id);
        if slug.is_empty() {
            return Ok(None);
        }
        Ok(Some(self.account_of(slug).await?))
    }

    async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
        let now = now_ms();
        self.db
            .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
            .bind(&[
                new_id("adm", now).into(),
                account.into(),
                action.into(),
                detail.into(),
                by.into(),
                rfc3339(now).into(),
            ])?
            .run()
            .await?;
        Ok(())
    }

    /// Where an account stands this month.
    async fn summary(&self, account: BillingAccount) -> Result<AccountSummary> {
        let first = account.workspaces.first().cloned().unwrap_or_else(|| account.name.clone());
        let limit = self.limit_of(&first).await?;
        #[derive(Deserialize)]
        struct Totals {
            charged: Option<i64>,
            cost: Option<i64>,
        }
        #[derive(Deserialize)]
        struct Paid {
            paid: Option<i64>,
        }
        let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
        let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
        if values.is_empty() {
            values.push(JsValue::from(""));
        }
        let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
        let mut with_month = values.clone();
        with_month.push(month_start.as_str().into());
        let totals = self
            .db
            .prepare(format!(
                "SELECT -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
                 WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ?"
            ))
            .bind(&with_month)?
            .first::<Totals>(None)
            .await?;
        let paid = self
            .db
            .prepare(format!("SELECT SUM(amount_micros) AS paid FROM ledger WHERE kind = 'top_up' AND workspace IN ({marks})"))
            .bind(&values)?
            .first::<Paid>(None)
            .await?;
        Ok(AccountSummary {
            account,
            limit,
            charged_micros: totals.as_ref().and_then(|t| t.charged).unwrap_or(0),
            cost_micros: totals.and_then(|t| t.cost).unwrap_or(0),
            paid_micros: paid.and_then(|p| p.paid).unwrap_or(0),
        })
    }

    // --- Staff ------------------------------------------------------------

    pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
        // Every workspace that has used or paid for anything, and every
        // account with terms of its own.
        #[derive(Deserialize)]
        struct Slug {
            workspace: String,
        }
        let mut slugs: Vec<String> = self
            .db
            .prepare(
                "SELECT DISTINCT workspace FROM ledger
                 UNION SELECT workspace FROM accounts
                 UNION SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace'",
            )
            .all()
            .await?
            .results::<Slug>()?
            .into_iter()
            .map(|s| s.workspace)
            .collect();
        if let Some(query) = a.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
            let query = query.to_lowercase();
            slugs.retain(|slug| slug.contains(&query));
        }
        let mut seen = std::collections::HashSet::new();
        let mut summaries = vec![];
        for slug in slugs.into_iter().take(200) {
            let account = self.account_of(&slug).await?;
            if !seen.insert(account.id.clone()) {
                continue;
            }
            summaries.push(self.summary(account).await?);
        }
        // Enterprises with no usage yet.
        #[derive(Deserialize)]
        struct Id {
            id: String,
        }
        let enterprises = self
            .db
            .prepare("SELECT id FROM billing_accounts WHERE kind = 'enterprise'")
            .all()
            .await?
            .results::<Id>()?;
        for Id { id } in enterprises {
            if seen.contains(&id) {
                continue;
            }
            if let Some(account) = self.find_account(&id).await? {
                if a.query.as_deref().is_none_or(|q| account.name.to_lowercase().contains(&q.to_lowercase())) {
                    seen.insert(id);
                    summaries.push(self.summary(account).await?);
                }
            }
        }
        summaries.sort_by(|x, y| y.limit.exposure_micros.cmp(&x.limit.exposure_micros));
        Ok(summaries)
    }

    pub(crate) async fn admin_account(&self, a: AdminAccountArgs) -> Result<Outcome<AccountDetail>> {
        let Some(account) = self.find_account(&a.id).await? else {
            return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
        };
        let mut workspaces = vec![];
        for workspace in &account.workspaces {
            workspaces.push(self.limit_of(workspace).await?);
        }
        let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
        let mut values: Vec<JsValue> = account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect();
        if values.is_empty() {
            values.push(JsValue::from(""));
        }
        let ledger = self
            .db
            .prepare(format!("SELECT * FROM ledger WHERE workspace IN ({marks}) ORDER BY id DESC LIMIT 100"))
            .bind(&values)?
            .all()
            .await?
            .results::<LedgerRow>()?
            .into_iter()
            .map(LedgerEntry::from)
            .collect();
        let audit = self
            .db
            .prepare("SELECT * FROM admin_actions WHERE account = ? ORDER BY created_at DESC LIMIT 50")
            .bind(&[account.id.as_str().into()])?
            .all()
            .await?
            .results::<ActionRow>()?
            .into_iter()
            .map(|row| AdminAction {
                id: row.id,
                account: row.account,
                action: row.action,
                detail: row.detail,
                by: row.by,
                created_at: row.created_at,
            })
            .collect();
        Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
    }

    pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> {
        if a.by.trim().is_empty() {
            return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
        }
        if a.terms.kind != TermsKind::Standard && a.terms.note.trim().is_empty() {
            return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in the note."));
        }
        if a.terms.discount_percent > 100 || a.terms.ceiling_micros.is_some_and(|c| c < 0) {
            return Ok(Outcome::fail(FailureCode::Invalid, "A discount is 0 to 100%, and a ceiling is not negative."));
        }
        let Some(account) = self.find_account(&a.id).await? else {
            return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
        };
        let now = rfc3339(now_ms());
        // A workspace's own account gets a row the first time its terms change.
        self.db
            .prepare(
                "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, ceiling_micros, note,
                   terms_until, terms_set_by, terms_set_at, created_by, created_at)
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9, ?10)
                 ON CONFLICT (id) DO UPDATE SET terms_kind = ?4, discount_percent = ?5, ceiling_micros = ?6,
                   note = ?7, terms_until = ?8, terms_set_by = ?9, terms_set_at = ?10",
            )
            .bind(&[
                account.id.as_str().into(),
                if account.kind == AccountKind::Enterprise { "enterprise" } else { "workspace" }.into(),
                account.name.as_str().into(),
                kind_text(a.terms.kind).into(),
                a.terms.discount_percent.into(),
                a.terms.ceiling_micros.map_or(JsValue::NULL, |c| (c as f64).into()),
                a.terms.note.trim().into(),
                optional(a.terms.until.as_deref()),
                a.by.as_str().into(),
                now.as_str().into(),
            ])?
            .run()
            .await?;
        self.audit(&account.id, "terms", &format!("{} → {}", describe(&account.terms), describe(&a.terms)), &a.by)
            .await?;
        Ok(Outcome::Ok(self.find_account(&account.id).await?.unwrap_or(account)))
    }

    pub(crate) async fn admin_create_enterprise(&self, a: AdminCreateEnterpriseArgs) -> Result<Outcome<BillingAccount>> {
        let name = a.name.trim();
        if name.is_empty() || a.by.trim().is_empty() {
            return Ok(Outcome::fail(FailureCode::Invalid, "An enterprise needs a name, and who is making it."));
        }
        let now = now_ms();
        let id = new_id("ent", now).to_lowercase();
        self.db
            .prepare(
                "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at)
                 VALUES (?, 'enterprise', ?, 'standard', 0, '', ?, ?)",
            )
            .bind(&[id.as_str().into(), name.into(), a.by.as_str().into(), rfc3339(now).into()])?
            .run()
            .await?;
        self.audit(&id, "create", &format!("Enterprise {name}"), &a.by).await?;
        for workspace in &a.workspaces {
            let workspace = workspace.trim().to_lowercase();
            if !workspace.is_empty() {
                self.attach(&workspace, Some(&id), &a.by).await?;
            }
        }
        Ok(match self.find_account(&id).await? {
            Some(account) => Outcome::Ok(account),
            None => Outcome::fail(FailureCode::NotFound, "The enterprise was not saved."),
        })
    }

    async fn attach(&self, workspace: &str, account: Option<&str>, by: &str) -> Result<()> {
        let before = self.account_of(workspace).await?;
        match account {
            Some(account) => {
                self.db
                    .prepare(
                        "INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES (?1, ?2, ?3, ?4)
                         ON CONFLICT (workspace) DO UPDATE SET account_id = ?2, added_by = ?3, added_at = ?4",
                    )
                    .bind(&[workspace.into(), account.into(), by.into(), rfc3339(now_ms()).into()])?
                    .run()
                    .await?;
                self.audit(account, "attach", &format!("{workspace} joined, from {}", before.name), by).await?;
            }
            None => {
                self.db
                    .prepare("DELETE FROM account_members WHERE workspace = ?")
                    .bind(&[workspace.into()])?
                    .run()
                    .await?;
                self.audit(&before.id, "detach", &format!("{workspace} left, back to paying for itself"), by).await?;
            }
        }
        Ok(())
    }

    pub(crate) async fn admin_attach(&self, a: AdminAttachArgs) -> Result<Outcome<BillingAccount>> {
        let workspace = a.workspace.trim().to_lowercase();
        if workspace.is_empty() || a.by.trim().is_empty() {
            return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change."));
        }
        if let Some(account) = &a.account {
            match self.account_row(account).await? {
                Some(row) if row.kind == "enterprise" => {}
                _ => return Ok(Outcome::fail(FailureCode::NotFound, "Workspaces can only join an enterprise.")),
            }
        }
        self.attach(&workspace, a.account.as_deref(), &a.by).await?;
        Ok(Outcome::Ok(self.account_of(&workspace).await?))
    }

    pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
        let workspace = a.workspace.trim().to_lowercase();
        if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
            return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it."));
        }
        if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR {
            return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000."));
        }
        let reference = new_id("crd", now_ms());
        let description = format!("Credit from g1t: {}", a.note.trim());
        self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None)
            .await?;
        let account = self.account_of(&workspace).await?;
        self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by)
            .await?;
        let row = self
            .db
            .prepare("SELECT * FROM ledger WHERE reference = ?")
            .bind(&[reference.as_str().into()])?
            .first::<LedgerRow>(None)
            .await?;
        Ok(match row {
            Some(row) => Outcome::Ok(LedgerEntry::from(row)),
            None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn terms(kind: TermsKind, discount: u32) -> Terms {
        Terms { kind, discount_percent: discount, ..Terms::standard() }
    }

    #[test]
    fn terms_shape_every_charge() {
        assert_eq!(terms(TermsKind::Standard, 0).apply(1_000), 1_000);
        assert_eq!(terms(TermsKind::Comped, 0).apply(1_000), 0);
        assert_eq!(terms(TermsKind::Custom, 25).apply(1_000), 750);
        assert_eq!(terms(TermsKind::Custom, 250).apply(1_000), 0);
    }

    #[test]
    fn terms_read_plainly_in_the_audit_log() {
        let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) };
        assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
        assert_eq!(describe(&Terms::standard()), "standard");
    }
}