| 1 | use g1t_contracts::billing::InvoiceLine; |
| 2 | use serde::{Deserialize, Serialize}; |
| 3 | use worker::Result; |
| 4 | use crate::Billing; |
| 5 | |
| 6 | pub(crate) const RETIRE: &str = "DELETE FROM enterprise_invoice_attempts WHERE account_id=?1 AND completed=1"; |
| 7 | // The snapshots used to calculate the invoice must still match at acquisition. |
| 8 | // In particular, a caller that read before another invoice committed must not |
| 9 | // invoice the same balance again after that attempt was retired. |
| 10 | pub(crate) const INSERT: &str = "INSERT OR IGNORE INTO enterprise_invoice_attempts (account_id,id,payload,created_ms) |
| 11 | SELECT ?1,?2,?3,?4 WHERE NOT EXISTS ( |
| 12 | SELECT 1 FROM json_each(?5) s |
| 13 | WHERE COALESCE((SELECT balance_micros FROM accounts WHERE workspace=json_extract(s.value,'$.workspace')),0) <> json_extract(s.value,'$.balance') |
| 14 | OR COALESCE((SELECT SUM(l.amount_micros) FROM enterprise_invoice_lines l JOIN enterprise_invoices i ON i.invoice_id=l.invoice_id |
| 15 | WHERE l.workspace=json_extract(s.value,'$.workspace') AND i.status IN ('open','overdue')),0) <> json_extract(s.value,'$.invoiced') |
| 16 | OR NOT EXISTS (SELECT 1 FROM account_members WHERE account_id=?1 AND workspace=json_extract(s.value,'$.workspace')) |
| 17 | )"; |
| 18 | |
| 19 | #[derive(Serialize, Deserialize)] |
| 20 | pub(crate) struct Request { |
| 21 | pub customer: String, |
| 22 | pub name: String, |
| 23 | pub period: String, |
| 24 | pub by: String, |
| 25 | pub lines: Vec<InvoiceLine>, |
| 26 | } |
| 27 | |
| 28 | #[derive(Serialize)] |
| 29 | pub(crate) struct Snapshot { |
| 30 | pub workspace: String, |
| 31 | pub balance: i64, |
| 32 | pub invoiced: i64, |
| 33 | } |
| 34 | |
| 35 | #[derive(Deserialize)] |
| 36 | pub(crate) struct Attempt { |
| 37 | pub id: String, |
| 38 | pub payload: String, |
| 39 | pub created_ms: u64, |
| 40 | pub invoice_id: Option<String>, |
| 41 | } |
| 42 | |
| 43 | impl Billing { |
| 44 | pub(crate) async fn enterprise_attempt(&self, account: &str) -> Result<Option<Attempt>> { |
| 45 | self.db.prepare("SELECT id,payload,created_ms,invoice_id FROM enterprise_invoice_attempts WHERE account_id=?") |
| 46 | .bind(&[account.into()])?.first::<Attempt>(None).await |
| 47 | } |
| 48 | |
| 49 | pub(crate) async fn begin_enterprise_attempt(&self, account: &str, request: Request, snapshots: Vec<Snapshot>) -> Result<Attempt> { |
| 50 | let now = g1t_kit::now_ms(); |
| 51 | let json = |value| serde_json::to_string(value).map_err(|e| worker::Error::RustError(e.to_string())); |
| 52 | let payload = json(&request)?; |
| 53 | let snapshots = serde_json::to_string(&snapshots).map_err(|e| worker::Error::RustError(e.to_string()))?; |
| 54 | self.db.prepare(INSERT).bind(&[ |
| 55 | account.into(), g1t_contracts::new_id("einv", now).into(), payload.into(), (now as f64).into(), snapshots.into(), |
| 56 | ])?.run().await?; |
| 57 | self.enterprise_attempt(account).await?.ok_or_else(|| worker::Error::RustError("Enterprise invoice balances changed; retry".into())) |
| 58 | } |
| 59 | } |