Billing lists every invoice and every staff change; signals carry follow-ups
admin_invoices: workspace and enterprise invoices together, by status and month. admin_audit: every change made in sudo and by Stripe, by who and what, paged. Signals now include the next step and its date, so sudo's Reach out can show follow-ups due.
5 files+174−10/5 viewed
| 819 | 819 | /// Its sales stage, if staff gave it one. | |
| 820 | 820 | pub stage: Option<String>, | |
| 821 | 821 | pub owner: Option<String>, | |
| 822 | + | #[serde(default)] | |
| 823 | + | pub next_step: Option<String>, | |
| 824 | + | /// When the next step is due, `YYYY-MM-DD`. | |
| 825 | + | #[serde(default)] | |
| 826 | + | pub next_at: Option<String>, | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | /// `admin_invoices`: every invoice g1t has sent, workspaces' and | |
| 830 | + | /// enterprises', newest first. Returns `Vec<InvoiceSummary>`. | |
| 831 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 832 | + | pub struct AdminInvoicesArgs { | |
| 833 | + | /// `paid`, `open`, `failed`, `overdue` or `void`. | |
| 834 | + | #[serde(default)] | |
| 835 | + | pub status: Option<String>, | |
| 836 | + | /// YYYY-MM, by when it was sent. | |
| 837 | + | #[serde(default)] | |
| 838 | + | pub month: Option<String>, | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 842 | + | #[serde(rename_all = "camelCase")] | |
| 843 | + | pub struct InvoiceSummary { | |
| 844 | + | pub invoice_id: String, | |
| 845 | + | /// `workspace` or `enterprise`. | |
| 846 | + | pub kind: String, | |
| 847 | + | /// The workspace's slug, or the enterprise's account id. | |
| 848 | + | pub account: String, | |
| 849 | + | /// What to call it: the workspace, or the enterprise's name. | |
| 850 | + | pub name: String, | |
| 851 | + | pub reason: String, | |
| 852 | + | pub period: String, | |
| 853 | + | pub amount_micros: i64, | |
| 854 | + | pub status: String, | |
| 855 | + | pub hosted_url: Option<String>, | |
| 856 | + | pub created_at: String, | |
| 857 | + | pub paid_at: Option<String>, | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | /// `admin_audit`: every change made in sudo, and by Stripe, newest first. | |
| 861 | + | /// Returns `Vec<AdminAction>`. | |
| 862 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 863 | + | pub struct AdminAuditArgs { | |
| 864 | + | #[serde(default)] | |
| 865 | + | pub by: Option<String>, | |
| 866 | + | #[serde(default)] | |
| 867 | + | pub action: Option<String>, | |
| 868 | + | /// Only those before this time, for paging. | |
| 869 | + | #[serde(default)] | |
| 870 | + | pub before: Option<String>, | |
| 822 | 871 | } | |
| 823 | 872 | ||
| 824 | 873 | /// `admin_signals`: every workspace worth reaching out to, most urgent |
| 164 | 164 | valueMicros: number; | |
| 165 | 165 | stage: string | null; | |
| 166 | 166 | owner: string | null; | |
| 167 | + | nextStep?: string | null; | |
| 168 | + | /** When the next step is due, YYYY-MM-DD. */ | |
| 169 | + | nextAt?: string | null; | |
| 170 | + | }; | |
| 171 | + | ||
| 172 | + | /** One invoice g1t has sent, a workspace's or an enterprise's. */ | |
| 173 | + | export type InvoiceSummary = { | |
| 174 | + | invoiceId: string; | |
| 175 | + | kind: "workspace" | "enterprise"; | |
| 176 | + | account: string; | |
| 177 | + | name: string; | |
| 178 | + | reason: string; | |
| 179 | + | period: string; | |
| 180 | + | amountMicros: number; | |
| 181 | + | status: string; | |
| 182 | + | hostedUrl: string | null; | |
| 183 | + | createdAt: string; | |
| 184 | + | paidAt: string | null; | |
| 167 | 185 | }; | |
| 168 | 186 | ||
| 169 | 187 | export type SalesStage = "none" | "lead" | "contacted" | "negotiating" | "won" | "lost" | "churn_risk"; | |
| 237 | 255 | addNote(workspace: string, text: string, by: string): Promise<Result<SalesRecord>>; | |
| 238 | 256 | /** A workspace's invoices from g1t, for staff. */ | |
| 239 | 257 | workspaceInvoices(workspace: string): Promise<WorkspaceInvoice[]>; | |
| 258 | + | /** Every invoice g1t has sent, newest first. */ | |
| 259 | + | allInvoices(filter?: { status?: string; month?: string }): Promise<InvoiceSummary[]>; | |
| 260 | + | /** Every change made in sudo and by Stripe, newest first, 100 at a time. */ | |
| 261 | + | audit(filter?: { by?: string; action?: string; before?: string }): Promise<AdminAction[]>; | |
| 240 | 262 | } | |
| 241 | 263 | ||
| 242 | 264 | /** How much a workspace has earned g1t's trust with money. */ |
| 248 | 248 | }), | |
| 249 | 249 | addNote: (workspace, text, by) => call("admin_add_note", { workspace, text, by }), | |
| 250 | 250 | workspaceInvoices: (workspace) => call("admin_workspace_invoices", { workspace }), | |
| 251 | + | allInvoices: (filter = {}) => call("admin_invoices", { status: filter.status ?? null, month: filter.month ?? null }), | |
| 252 | + | audit: (filter = {}) => | |
| 253 | + | call("admin_audit", { by: filter.by ?? null, action: filter.action ?? null, before: filter.before ?? null }), | |
| 251 | 254 | }; | |
| 252 | 255 | } | |
| 253 | 256 |
| 1028 | 1028 | "admin_sales" => reply(&billing.admin_sales(args(body)?).await?), | |
| 1029 | 1029 | "admin_set_sales" => reply(&billing.admin_set_sales(args(body)?).await?), | |
| 1030 | 1030 | "admin_add_note" => reply(&billing.admin_add_note(args(body)?).await?), | |
| 1031 | + | "admin_invoices" => reply(&billing.admin_invoices(args(body)?).await?), | |
| 1032 | + | "admin_audit" => reply(&billing.admin_audit(args(body)?).await?), | |
| 1031 | 1033 | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 1032 | 1034 | "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?), | |
| 1033 | 1035 | "admin_account" => reply(&billing.admin_account(args(body)?).await?), |
| 272 | 272 | let this_month = row.this_month.unwrap_or(0).max(0); | |
| 273 | 273 | let last_month = row.last_month.unwrap_or(0).max(0); | |
| 274 | 274 | let record = self.record_row(&row.workspace).await?; | |
| 275 | − | let (stage, owner) = record.map_or((None, None), |r| (Some(r.stage), r.owner)); | |
| 275 | + | let (stage, owner, next_step, next_at) = | |
| 276 | + | record.map_or((None, None, None, None), |r| (Some(r.stage), r.owner, r.next_step, r.next_at)); | |
| 276 | 277 | let mut push = |kind: SignalKind, detail: String, value: i64| { | |
| 277 | 278 | signals.push(Signal { | |
| 278 | 279 | workspace: row.workspace.clone(), | |
| 281 | 282 | value_micros: value, | |
| 282 | 283 | stage: stage.clone(), | |
| 283 | 284 | owner: owner.clone(), | |
| 285 | + | next_step: next_step.clone(), | |
| 286 | + | next_at: next_at.clone(), | |
| 284 | 287 | }); | |
| 285 | 288 | }; | |
| 286 | 289 | let declined = limit.message.as_deref().is_some_and(|m| m.contains("could not be charged")); | |
| 342 | 345 | Ok(signals) | |
| 343 | 346 | } | |
| 344 | 347 | ||
| 348 | + | /// Every invoice g1t has sent, workspaces' and enterprises'. | |
| 349 | + | pub(crate) async fn admin_invoices(&self, a: g1t_contracts::billing::AdminInvoicesArgs) -> Result<Vec<g1t_contracts::billing::InvoiceSummary>> { | |
| 350 | + | #[derive(Deserialize)] | |
| 351 | + | struct Row { | |
| 352 | + | invoice_id: String, | |
| 353 | + | kind: String, | |
| 354 | + | account: String, | |
| 355 | + | name: String, | |
| 356 | + | reason: String, | |
| 357 | + | period: String, | |
| 358 | + | amount_micros: i64, | |
| 359 | + | status: String, | |
| 360 | + | hosted_url: Option<String>, | |
| 361 | + | created_at: String, | |
| 362 | + | paid_at: Option<String>, | |
| 363 | + | } | |
| 364 | + | let status = a.status.filter(|s| ["paid", "open", "failed", "overdue", "void"].contains(&s.as_str())); | |
| 365 | + | let month = a.month.filter(|m| m.len() == 7 && m.chars().all(|c| c.is_ascii_digit() || c == '-')); | |
| 366 | + | let rows = self | |
| 367 | + | .db | |
| 368 | + | .prepare( | |
| 369 | + | "SELECT * FROM ( | |
| 370 | + | SELECT invoice_id, 'workspace' AS kind, workspace AS account, workspace AS name, reason, period, | |
| 371 | + | amount_micros, status, hosted_url, created_at, paid_at | |
| 372 | + | FROM workspace_invoices | |
| 373 | + | UNION ALL | |
| 374 | + | SELECT i.invoice_id, 'enterprise', i.account_id, COALESCE(b.name, i.account_id), 'enterprise', i.period, | |
| 375 | + | i.amount_micros, i.status, i.hosted_url, i.created_at, i.paid_at | |
| 376 | + | FROM enterprise_invoices i LEFT JOIN billing_accounts b ON b.id = i.account_id | |
| 377 | + | ) | |
| 378 | + | WHERE (?1 IS NULL OR status = ?1) AND (?2 IS NULL OR substr(created_at, 1, 7) = ?2) | |
| 379 | + | ORDER BY created_at DESC LIMIT 200", | |
| 380 | + | ) | |
| 381 | + | .bind(&[crate::optional(status.as_deref()), crate::optional(month.as_deref())])? | |
| 382 | + | .all() | |
| 383 | + | .await? | |
| 384 | + | .results::<Row>()?; | |
| 385 | + | Ok(rows | |
| 386 | + | .into_iter() | |
| 387 | + | .map(|r| g1t_contracts::billing::InvoiceSummary { | |
| 388 | + | invoice_id: r.invoice_id, | |
| 389 | + | kind: r.kind, | |
| 390 | + | account: r.account, | |
| 391 | + | name: r.name, | |
| 392 | + | reason: r.reason, | |
| 393 | + | period: r.period, | |
| 394 | + | amount_micros: r.amount_micros, | |
| 395 | + | status: r.status, | |
| 396 | + | hosted_url: r.hosted_url, | |
| 397 | + | created_at: r.created_at, | |
| 398 | + | paid_at: r.paid_at, | |
| 399 | + | }) | |
| 400 | + | .collect()) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | /// Every change made in sudo and by Stripe, newest first. | |
| 404 | + | pub(crate) async fn admin_audit(&self, a: g1t_contracts::billing::AdminAuditArgs) -> Result<Vec<g1t_contracts::billing::AdminAction>> { | |
| 405 | + | #[derive(Deserialize)] | |
| 406 | + | struct Row { | |
| 407 | + | id: String, | |
| 408 | + | account: String, | |
| 409 | + | action: String, | |
| 410 | + | detail: String, | |
| 411 | + | by: String, | |
| 412 | + | created_at: String, | |
| 413 | + | } | |
| 414 | + | let rows = self | |
| 415 | + | .db | |
| 416 | + | .prepare( | |
| 417 | + | "SELECT id, account, action, detail, by, created_at FROM admin_actions | |
| 418 | + | WHERE (?1 IS NULL OR by = ?1) AND (?2 IS NULL OR action = ?2) AND (?3 IS NULL OR created_at < ?3) | |
| 419 | + | ORDER BY created_at DESC LIMIT 100", | |
| 420 | + | ) | |
| 421 | + | .bind(&[ | |
| 422 | + | crate::optional(a.by.as_deref().map(str::trim).filter(|s| !s.is_empty())), | |
| 423 | + | crate::optional(a.action.as_deref().map(str::trim).filter(|s| !s.is_empty())), | |
| 424 | + | crate::optional(a.before.as_deref()), | |
| 425 | + | ])? | |
| 426 | + | .all() | |
| 427 | + | .await? | |
| 428 | + | .results::<Row>()?; | |
| 429 | + | Ok(rows | |
| 430 | + | .into_iter() | |
| 431 | + | .map(|r| g1t_contracts::billing::AdminAction { | |
| 432 | + | id: r.id, | |
| 433 | + | account: r.account, | |
| 434 | + | action: r.action, | |
| 435 | + | detail: r.detail, | |
| 436 | + | by: r.by, | |
| 437 | + | created_at: r.created_at, | |
| 438 | + | }) | |
| 439 | + | .collect()) | |
| 440 | + | } | |
| 441 | + | ||
| 345 | 442 | /// The business at a glance. | |
| 346 | 443 | pub(crate) async fn admin_overview(&self, _: AdminOverviewArgs) -> Result<Overview> { | |
| 347 | 444 | let now = rfc3339(now_ms()); |