g1t/services/billing/src/webhooks.rs

905 lines38,866 bytesCodeBlame
1//! Stripe telling billing what happened, and enterprise invoices.
2//!
3//! Most of billing asks Stripe when it needs to know: a payment page is
4//! confirmed when the person comes back, a plan is checked when its period
5//! ends. That misses whatever happens while no one is looking: a page paid
6//! for and closed, a renewal that failed, a refund, a dispute, an invoice
7//! paid by bank transfer a week later. Stripe sends each as an event to
8//! `https://api.g1t.sh/stripe/webhook`; the API passes the raw body and its
9//! signature here, untouched.
10//!
11//! - The endpoint is registered by billing itself, from sudo, once per
12//! mode, and its signing secret is kept in billing's database. It is never
13//! shown, and nothing can be posted here without it.
14//! - Each event is handled once, by id, and recorded with what was done.
15//! - Every handler is safe alongside the paths that ask Stripe directly:
16//! both claim the same rows.
17//!
18//! Enterprises are invoiced: one Stripe invoice per month (or sooner, from
19//! sudo), with a line per workspace for what it owes, sent to the
20//! enterprise's billing email and paid on Stripe's hosted invoice page.
21//! When it is paid, each workspace is credited its line; when it goes
22//! overdue, their work stops until it is paid.
23
24use g1t_contracts::billing::{
25 AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice,
26 EntryKind, InvoiceLine, StripeEventSummary, StripeStatus, StripeWebhook, StripeWebhookArgs,
27};
28use g1t_contracts::time::rfc3339;
29use g1t_contracts::{FailureCode, Outcome};
30use g1t_kit::now_ms;
31use hmac::{Hmac, Mac};
32use serde::Deserialize;
33use serde_json::Value;
34use sha2::Sha256;
35use worker::Result;
36use worker::wasm_bindgen::JsValue;
37
38use crate::Billing;
39
40/// Where Stripe sends events.
41pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook";
42
43/// The events billing acts on.
44pub(crate) const EVENTS: &[&str] = &[
45 "checkout.session.completed",
46 // A prepayment by bank transfer: the page completes when the transfer
47 // is set up, and this comes when the money arrives.
48 "checkout.session.async_payment_succeeded",
49 "customer.subscription.updated",
50 "customer.subscription.deleted",
51 "invoice.paid",
52 "invoice.payment_failed",
53 "invoice.overdue",
54 "invoice.voided",
55 "charge.refunded",
56 "charge.dispute.created",
57 "charge.dispute.closed",
58 // The saved card, kept on the account row (stripe_sync.rs).
59 "customer.updated",
60 "payment_method.attached",
61 "payment_method.detached",
62 "payment_method.updated",
63 "payment_method.automatically_updated",
64 "setup_intent.succeeded",
65];
66
67/// How old a signed event may be, so a captured one cannot be replayed.
68const TOLERANCE_SECONDS: i64 = 5 * 60;
69
70/// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the
71/// tolerance of `now_seconds`.
72pub(crate) fn verify(payload: &str, header: &str, secret: &str, now_seconds: i64) -> bool {
73 let mut timestamp = None;
74 let mut signatures = vec![];
75 for part in header.split(',') {
76 match part.trim().split_once('=') {
77 Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
78 Some(("v1", value)) => signatures.push(value.to_owned()),
79 _ => {}
80 }
81 }
82 let Some(timestamp) = timestamp else { return false };
83 if (now_seconds - timestamp).abs() > TOLERANCE_SECONDS {
84 return false;
85 }
86 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
87 mac.update(format!("{timestamp}.{payload}").as_bytes());
88 let expected = mac.finalize().into_bytes();
89 signatures.iter().any(|signature| {
90 hex::decode(signature).is_ok_and(|given| {
91 // Constant time: compare every byte whatever the first difference.
92 given.len() == expected.len() && given.iter().zip(expected.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
93 })
94 })
95}
96
97#[derive(Deserialize)]
98pub(crate) struct WebhookRow {
99 pub(crate) endpoint_id: String,
100 secret: String,
101 url: String,
102 pub(crate) events: String,
103 created_by: String,
104 created_at: String,
105}
106
107#[derive(Deserialize)]
108struct EventRow {
109 id: String,
110 r#type: String,
111 outcome: String,
112 received_at: String,
113}
114
115#[derive(Deserialize)]
116struct InvoiceRow {
117 invoice_id: String,
118 period: String,
119 amount_micros: i64,
120 status: String,
121 hosted_url: Option<String>,
122 created_at: String,
123}
124
125#[derive(Deserialize)]
126struct LineRow {
127 workspace: String,
128 amount_micros: i64,
129}
130
131impl Billing {
132 pub(crate) fn mode(&self) -> &'static str {
133 match &self.stripe {
134 None => "off",
135 Some(stripe) if stripe.live() => "live",
136 Some(_) => "test",
137 }
138 }
139
140 pub(crate) async fn webhook_row(&self) -> Result<Option<WebhookRow>> {
141 self.db
142 .prepare("SELECT * FROM stripe_webhooks WHERE mode = ?")
143 .bind(&[self.mode().into()])?
144 .first::<WebhookRow>(None)
145 .await
146 }
147
148 // --- Staff ------------------------------------------------------------
149
150 pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
151 let mut error = None;
152 if a.setup
153 && let Err(e) = self.register_webhook(a.by.as_deref().unwrap_or("sudo")).await {
154 error = Some(e.to_string());
155 }
156 let webhook = self.webhook_row().await?.map(|row| StripeWebhook {
157 url: row.url,
158 endpoint_id: row.endpoint_id,
159 events: row.events.split(',').map(str::to_owned).collect(),
160 created_by: row.created_by,
161 created_at: row.created_at,
162 });
163 let recent_events = self
164 .db
165 .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
166 .all()
167 .await?
168 .results::<EventRow>()?
169 .into_iter()
170 .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
171 .collect();
172 Ok(StripeStatus { mode: self.mode().to_owned(), webhook, recent_events, error })
173 }
174
175 /// Registers billing's endpoint at Stripe for the current mode,
176 /// replacing any it made before, and keeps the new signing secret.
177 async fn register_webhook(&self, by: &str) -> Result<()> {
178 let Some(stripe) = &self.stripe else {
179 return Err(worker::Error::RustError("payments are not set up".into()));
180 };
181 #[derive(Deserialize)]
182 struct Endpoint {
183 id: String,
184 url: String,
185 #[serde(default)]
186 secret: Option<String>,
187 }
188 #[derive(Deserialize)]
189 struct List {
190 data: Vec<Endpoint>,
191 }
192 // Ours from before, whose secret cannot be read again: replaced.
193 let existing: List = stripe.get("/webhook_endpoints?limit=100").await?;
194 for endpoint in existing.data.iter().filter(|e| e.url == WEBHOOK_URL) {
195 let _: Value = stripe.delete(&format!("/webhook_endpoints/{}", endpoint.id)).await?;
196 }
197 let mut fields = vec![
198 ("url", WEBHOOK_URL.to_owned()),
199 ("description", "g1t billing".to_owned()),
200 ("metadata[g1t]", "billing".to_owned()),
201 ];
202 let names: Vec<String> = (0..EVENTS.len()).map(|i| format!("enabled_events[{i}]")).collect();
203 for (name, event) in names.iter().zip(EVENTS) {
204 fields.push((name.as_str(), (*event).to_owned()));
205 }
206 let created: Endpoint = stripe.post("/webhook_endpoints", &fields).await?;
207 let Some(secret) = created.secret else {
208 return Err(worker::Error::RustError("Stripe returned no signing secret".into()));
209 };
210 self.db
211 .prepare(
212 "INSERT INTO stripe_webhooks (mode, endpoint_id, secret, url, events, created_by, created_at)
213 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
214 ON CONFLICT (mode) DO UPDATE SET endpoint_id = ?2, secret = ?3, url = ?4, events = ?5,
215 created_by = ?6, created_at = ?7",
216 )
217 .bind(&[
218 self.mode().into(),
219 created.id.as_str().into(),
220 secret.as_str().into(),
221 created.url.as_str().into(),
222 EVENTS.join(",").into(),
223 by.into(),
224 rfc3339(now_ms()).into(),
225 ])?
226 .run()
227 .await?;
228 self.audit("stripe", "webhook", &format!("Registered {WEBHOOK_URL} ({} mode)", self.mode()), by).await?;
229 Ok(())
230 }
231
232 // --- Events -----------------------------------------------------------
233
234 pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
235 let Some(webhook) = self.webhook_row().await? else {
236 return Ok(Outcome::fail(FailureCode::Conflict, "No webhook is registered for this mode."));
237 };
238 let now_seconds = (now_ms() / 1000) as i64;
239 if !verify(&a.payload, &a.signature, &webhook.secret, now_seconds) {
240 return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
241 }
242 let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
243 if event["id"].as_str().unwrap_or_default().is_empty() {
244 return Ok(Outcome::fail(FailureCode::Invalid, "Not an event."));
245 }
246 Ok(Outcome::Ok(self.process_event(&event).await?))
247 }
248
249 /// Handles a Stripe event once, however often it arrives: by webhook,
250 /// or again from the event list (stripe_sync.rs). False when it was
251 /// seen before.
252 pub(crate) async fn process_event(&self, event: &Value) -> Result<bool> {
253 let id = event["id"].as_str().unwrap_or_default().to_owned();
254 let kind = event["type"].as_str().unwrap_or_default().to_owned();
255 // Once each: the first to record it handles it.
256 let claimed = self
257 .db
258 .prepare("INSERT OR IGNORE INTO stripe_events (id, type, outcome, received_at) VALUES (?, ?, 'handling', ?) RETURNING id")
259 .bind(&[id.as_str().into(), kind.as_str().into(), rfc3339(now_ms()).into()])?
260 .first::<Value>(None)
261 .await?;
262 if claimed.is_none() {
263 return Ok(false);
264 }
265 let outcome = match self.handle(&kind, event).await {
266 Ok(outcome) => outcome,
267 Err(error) => {
268 // Let Stripe send it again: forget it was seen.
269 self.db.prepare("DELETE FROM stripe_events WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
270 return Err(error);
271 }
272 };
273 self.db
274 .prepare("UPDATE stripe_events SET outcome = ? WHERE id = ?")
275 .bind(&[outcome.as_str().into(), id.as_str().into()])?
276 .run()
277 .await?;
278 Ok(true)
279 }
280
281 async fn handle(&self, kind: &str, event: &Value) -> Result<String> {
282 let object = &event["data"]["object"];
283 let text = |key: &str| object[key].as_str().unwrap_or_default().to_owned();
284 // The customer whose saved card may have changed. A detached card
285 // has no customer any more; the event says whose it was.
286 let card_owner = match kind {
287 "customer.updated" => object["id"].as_str(),
288 "payment_method.detached" => event["data"]["previous_attributes"]["customer"].as_str(),
289 _ => object["customer"].as_str(),
290 };
291 Ok(match kind {
292 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => self.settle_checkout(&text("id")).await?,
293 "customer.subscription.updated" | "customer.subscription.deleted" => {
294 self.settle_subscription(&text("id")).await?
295 }
296 "invoice.paid" => {
297 if let Some(subscription) = object["subscription"].as_str() {
298 self.plan_paid(subscription, &text("id"), object["amount_paid"].as_i64().unwrap_or(0)).await?;
299 self.settle_subscription(subscription).await?
300 } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? {
301 done
302 } else {
303 self.enterprise_invoice_paid(&text("id")).await?
304 }
305 }
306 "invoice.payment_failed" => match (object["subscription"].as_str(), object["metadata"]["g1t_workspace"].as_str()) {
307 (Some(subscription), _) => self.settle_subscription(subscription).await?,
308 (None, Some(tagged)) => {
309 // The invoice's own row names the workspace as it is
310 // now; the metadata keeps the slug it was sent under.
311 let workspace = self.workspace_of_invoice(&text("id")).await?.unwrap_or_else(|| tagged.to_owned());
312 let workspace = workspace.as_str();
313 self.mark_declined(workspace, "the card was declined for an invoice").await?;
314 format!("{workspace}: invoice payment failed; work stopped")
315 }
316 _ => "ignored: not g1t's".to_owned(),
317 },
318 "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
319 "invoice.voided" => self.enterprise_invoice_status(&text("id"), "void").await?,
320 "charge.refunded" => self.refunded(object).await?,
321 "charge.dispute.created" => self.disputed(object, true).await?,
322 "charge.dispute.closed" => self.disputed(object, object["status"].as_str() == Some("lost")).await?,
323 "customer.updated"
324 | "payment_method.attached"
325 | "payment_method.detached"
326 | "payment_method.updated"
327 | "payment_method.automatically_updated"
328 | "setup_intent.succeeded" => match card_owner {
329 Some(customer) => self.sync_card_of(customer).await?,
330 None => "ignored: no customer".to_owned(),
331 },
332 _ => "ignored".to_owned(),
333 })
334 }
335
336 /// A payment page done, whether or not the person came back to g1t.
337 async fn settle_checkout(&self, session_id: &str) -> Result<String> {
338 #[derive(Deserialize)]
339 struct Open {
340 workspace: String,
341 created_by: String,
342 feature: Option<String>,
343 }
344 let Some(open) = self
345 .db
346 .prepare("SELECT workspace, created_by, feature FROM checkouts WHERE id = ? AND status = 'open'")
347 .bind(&[session_id.into()])?
348 .first::<Open>(None)
349 .await?
350 else {
351 return Ok("ignored: already settled or not g1t's".to_owned());
352 };
353 // A card check: saved and verified, never charged.
354 if open.feature.as_deref() == Some(crate::cards::CARD_CHECK) {
355 return Ok(match self.settle_card_check(session_id).await? {
356 Ok(done) => done,
357 Err(why) => format!("card check not passed: {why}"),
358 });
359 }
360 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
361 let session = stripe.session(session_id).await?;
362 if session.payment_status != "paid" && open.feature.is_none() {
363 return Ok("ignored: not paid".to_owned());
364 }
365 let claimed = self
366 .db
367 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
368 .bind(&[session_id.into()])?
369 .first::<Value>(None)
370 .await?;
371 if claimed.is_none() {
372 return Ok("ignored: settled meanwhile".to_owned());
373 }
374 match open.feature.as_deref() {
375 None => {
376 let cents = i64::from(session.amount_total.unwrap_or(0));
377 self.enter(
378 &open.workspace,
379 EntryKind::TopUp,
380 cents * 10_000,
381 "Paid in advance",
382 &session.id,
383 None,
384 None,
385 Some(&open.created_by),
386 session.customer.as_deref(),
387 )
388 .await?;
389 Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace))
390 }
391 Some(feature) => {
392 let Some(feature) = g1t_contracts::billing::Feature::parse(feature) else {
393 return Ok("ignored: unknown feature".to_owned());
394 };
395 if let Some(subscription_id) = &session.subscription {
396 let subscription = stripe.subscription(subscription_id).await?;
397 self.record(&open.workspace, feature, &subscription, &open.created_by).await?;
398 }
399 self.db
400 .prepare(
401 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
402 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
403 )
404 .bind(&[open.workspace.as_str().into(), crate::optional(session.customer.as_deref()), rfc3339(now_ms()).into()])?
405 .run()
406 .await?;
407 Ok(format!("{} plan started for {}", feature.title(), open.workspace))
408 }
409 }
410 }
411
412 /// The plan's monthly price, paid: revenue that never goes through the
413 /// ledger, recorded once per invoice for sudo's figures and for trust.
414 async fn plan_paid(&self, subscription_id: &str, invoice_id: &str, amount_cents: i64) -> Result<()> {
415 if amount_cents <= 0 || invoice_id.is_empty() {
416 return Ok(());
417 }
418 self.db
419 .prepare(
420 "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at)
421 SELECT ?1, workspace, ?2, ?3 FROM subscriptions WHERE subscription_id = ?4
422 ON CONFLICT (invoice_id) DO NOTHING",
423 )
424 .bind(&[
425 invoice_id.into(),
426 ((amount_cents * 10_000) as f64).into(),
427 rfc3339(now_ms()).into(),
428 subscription_id.into(),
429 ])?
430 .run()
431 .await?;
432 Ok(())
433 }
434
435 /// A plan that changed at Stripe: renewed, failed, canceled.
436 pub(crate) async fn settle_subscription(&self, subscription_id: &str) -> Result<String> {
437 #[derive(Deserialize)]
438 struct Plan {
439 workspace: String,
440 feature: String,
441 started_by: String,
442 }
443 let Some(plan) = self
444 .db
445 .prepare("SELECT workspace, feature, started_by FROM subscriptions WHERE subscription_id = ?")
446 .bind(&[subscription_id.into()])?
447 .first::<Plan>(None)
448 .await?
449 else {
450 return Ok("ignored: not a g1t plan".to_owned());
451 };
452 let (Some(stripe), Some(feature)) = (&self.stripe, g1t_contracts::billing::Feature::parse(&plan.feature)) else {
453 return Ok("ignored".to_owned());
454 };
455 let subscription = stripe.subscription(subscription_id).await?;
456 self.record(&plan.workspace, feature, &subscription, &plan.started_by).await?;
457 Ok(format!("{} plan for {} is {}", feature.title(), plan.workspace, subscription.status))
458 }
459
460 /// The workspace a Stripe customer belongs to.
461 pub(crate) async fn workspace_of_customer(&self, customer: &str) -> Result<Option<String>> {
462 #[derive(Deserialize)]
463 struct Row {
464 workspace: String,
465 }
466 Ok(self
467 .db
468 .prepare("SELECT workspace FROM accounts WHERE customer_id = ?")
469 .bind(&[customer.into()])?
470 .first::<Row>(None)
471 .await?
472 .map(|row| row.workspace))
473 }
474
475 /// The workspace a workspace invoice was sent to, under its slug now.
476 async fn workspace_of_invoice(&self, invoice_id: &str) -> Result<Option<String>> {
477 #[derive(Deserialize)]
478 struct Row {
479 workspace: String,
480 }
481 Ok(self
482 .db
483 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
484 .bind(&[invoice_id.into()])?
485 .first::<Row>(None)
486 .await?
487 .map(|row| row.workspace))
488 }
489
490 /// Money given back: what was paid is less, by the refund.
491 async fn refunded(&self, charge: &Value) -> Result<String> {
492 let Some(customer) = charge["customer"].as_str() else { return Ok("ignored: no customer".to_owned()) };
493 let Some(workspace) = self.workspace_of_customer(customer).await? else {
494 return Ok("ignored: not a workspace's customer".to_owned());
495 };
496 let refunded = charge["amount_refunded"].as_i64().unwrap_or(0);
497 if refunded <= 0 {
498 return Ok("ignored: nothing refunded".to_owned());
499 }
500 // Each refund total once, so partial refunds add up correctly.
501 let charge_id = charge["id"].as_str().unwrap_or_default();
502 #[derive(Deserialize)]
503 struct Sum {
504 micros: Option<i64>,
505 }
506 let already = self
507 .db
508 .prepare("SELECT -SUM(amount_micros) AS micros FROM ledger WHERE reference LIKE ?")
509 .bind(&[format!("refund/{charge_id}/%").into()])?
510 .first::<Sum>(None)
511 .await?
512 .and_then(|s| s.micros)
513 .unwrap_or(0);
514 let new = refunded * 10_000 - already;
515 if new <= 0 {
516 return Ok("ignored: refund already recorded".to_owned());
517 }
518 self.enter(
519 &workspace,
520 EntryKind::TopUp,
521 -new,
522 "Refunded to the card",
523 &format!("refund/{charge_id}/{refunded}"),
524 None,
525 None,
526 None,
527 None,
528 )
529 .await?;
530 Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new)))
531 }
532
533 /// A disputed payment stops the workspace's work until it is resolved;
534 /// one closed in the workspace's favour lets it go on.
535 async fn disputed(&self, dispute: &Value, stop: bool) -> Result<String> {
536 let Some(stripe) = &self.stripe else { return Ok("ignored".to_owned()) };
537 let Some(charge_id) = dispute["charge"].as_str() else { return Ok("ignored: no charge".to_owned()) };
538 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
539 let Some(workspace) = (match charge["customer"].as_str() {
540 Some(customer) => self.workspace_of_customer(customer).await?,
541 None => None,
542 }) else {
543 return Ok("ignored: not a workspace's customer".to_owned());
544 };
545 let now = rfc3339(now_ms());
546 // The disputed payment never counts toward trust again.
547 for reference in [charge["payment_intent"].as_str(), charge["invoice"].as_str()].into_iter().flatten() {
548 self.db
549 .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?")
550 .bind(&[(if stop { 1 } else { 0 }).into(), workspace.as_str().into(), reference.into()])?
551 .run()
552 .await?;
553 }
554 if stop {
555 self.db
556 .prepare(
557 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
558 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
559 )
560 .bind(&[workspace.as_str().into(), now.as_str().into(), "a payment was disputed with the card's bank".into()])?
561 .run()
562 .await?;
563 } else {
564 self.db
565 .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
566 .bind(&[workspace.as_str().into()])?
567 .run()
568 .await?;
569 }
570 let account = self.account_of(&workspace).await?;
571 let what = if stop { "dispute: work stopped" } else { "dispute closed in the workspace's favour" };
572 self.audit(&account.id, "dispute", &format!("{workspace}: {what}"), "stripe").await?;
573 Ok(format!("{workspace}: {what}"))
574 }
575
576 // --- Enterprise invoices ------------------------------------------------
577
578 pub(crate) async fn admin_enterprise_billing(&self, a: AdminEnterpriseBillingArgs) -> Result<Outcome<BillingAccount>> {
579 let email = a.email.trim().to_lowercase();
580 if !email.contains('@') || email.contains(char::is_whitespace) || a.by.trim().is_empty() {
581 return Ok(Outcome::fail(FailureCode::Invalid, "Give the email the enterprise's invoices go to."));
582 }
583 let Some(stripe) = &self.stripe else {
584 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
585 };
586 #[derive(Deserialize)]
587 struct Row {
588 name: String,
589 kind: String,
590 customer_id: Option<String>,
591 }
592 let Some(row) = self
593 .db
594 .prepare("SELECT name, kind, customer_id FROM billing_accounts WHERE id = ?")
595 .bind(&[a.id.as_str().into()])?
596 .first::<Row>(None)
597 .await?
598 .filter(|row| row.kind == "enterprise")
599 else {
600 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
601 };
602 #[derive(Deserialize)]
603 struct Customer {
604 id: String,
605 }
606 let fields = [
607 ("name", row.name.clone()),
608 ("email", email.clone()),
609 ("metadata[g1t_enterprise]", a.id.clone()),
610 ];
611 let customer: Customer = match &row.customer_id {
612 Some(id) => stripe.post(&format!("/customers/{id}"), &fields).await?,
613 None => stripe.post("/customers", &fields).await?,
614 };
615 self.db
616 .prepare("UPDATE billing_accounts SET billing_email = ?, customer_id = ? WHERE id = ?")
617 .bind(&[email.as_str().into(), customer.id.as_str().into(), a.id.as_str().into()])?
618 .run()
619 .await?;
620 self.audit(&a.id, "billing_email", &format!("Invoices go to {email}"), &a.by).await?;
621 Ok(match self.enterprise(&a.id).await? {
622 Some(account) => Outcome::Ok(account),
623 None => Outcome::fail(FailureCode::NotFound, "No such enterprise."),
624 })
625 }
626
627 pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> {
628 if a.by.trim().is_empty() {
629 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it."));
630 }
631 match self.invoice_enterprise(&a.id, "now", &a.by).await? {
632 Ok(invoice) => Ok(Outcome::Ok(invoice)),
633 Err(why) => Ok(Outcome::fail(FailureCode::Conflict, why)),
634 }
635 }
636
637 /// Invoices each enterprise for the month that closed. Live payments
638 /// only; sudo can send one sooner in test mode.
639 pub(crate) async fn invoice_enterprises(&self) -> Result<()> {
640 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
641 return Ok(());
642 }
643 let closing = crate::limits::previous_month(&rfc3339(now_ms())[..7]);
644 #[derive(Deserialize)]
645 struct Id {
646 id: String,
647 }
648 let due = self
649 .db
650 .prepare(
651 "SELECT id FROM billing_accounts WHERE kind = 'enterprise' AND customer_id IS NOT NULL
652 AND terms_kind <> 'comped'
653 AND NOT EXISTS (SELECT 1 FROM enterprise_invoices i WHERE i.account_id = billing_accounts.id AND i.period = ?)",
654 )
655 .bind(&[closing.as_str().into()])?
656 .all()
657 .await?
658 .results::<Id>()?;
659 for Id { id } in due {
660 if let Err(why) = self.invoice_enterprise(&id, &closing, "month close").await? {
661 worker::console_log!("enterprise {id} not invoiced for {closing}: {why}");
662 }
663 }
664 Ok(())
665 }
666
667 /// One invoice for what each of the enterprise's workspaces owes now.
668 async fn invoice_enterprise(&self, id: &str, period: &str, by: &str) -> Result<std::result::Result<EnterpriseInvoice, String>> {
669 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
670 let Some(account) = self.enterprise(id).await? else { return Ok(Err("No such enterprise.".into())) };
671 #[derive(Deserialize)]
672 struct Customer {
673 customer_id: Option<String>,
674 }
675 let Some(customer) = self
676 .db
677 .prepare("SELECT customer_id FROM billing_accounts WHERE id = ?")
678 .bind(&[id.into()])?
679 .first::<Customer>(None)
680 .await?
681 .and_then(|row| row.customer_id)
682 else {
683 return Ok(Err("Set where the enterprise's invoices go first.".into()));
684 };
685 // What each workspace owes: its charges less what it has paid, and
686 // less what is on invoices still open.
687 let mut lines = vec![];
688 for workspace in &account.workspaces {
689 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
690 #[derive(Deserialize)]
691 struct Sum {
692 micros: Option<i64>,
693 }
694 let invoiced = self
695 .db
696 .prepare(
697 "SELECT SUM(l.amount_micros) AS micros FROM enterprise_invoice_lines l
698 JOIN enterprise_invoices i ON i.invoice_id = l.invoice_id
699 WHERE l.workspace = ? AND i.status IN ('open', 'overdue')",
700 )
701 .bind(&[workspace.as_str().into()])?
702 .first::<Sum>(None)
703 .await?
704 .and_then(|s| s.micros)
705 .unwrap_or(0);
706 let owed = (-balance).max(0) - invoiced;
707 if owed >= 10_000 {
708 lines.push(InvoiceLine { workspace: workspace.clone(), amount_micros: owed });
709 }
710 }
711 if lines.is_empty() {
712 return Ok(Err("Its workspaces owe nothing to invoice.".into()));
713 }
714 for line in &lines {
715 let cents = (line.amount_micros + 9_999) / 10_000;
716 let fields = [
717 ("customer", customer.clone()),
718 ("amount", cents.to_string()),
719 ("currency", "usd".to_owned()),
720 ("description", format!("{}: g1t usage", line.workspace)),
721 ("metadata[workspace]", line.workspace.clone()),
722 ];
723 let _: Value = stripe.post("/invoiceitems", &fields).await?;
724 }
725 let fields = [
726 ("customer", customer.clone()),
727 ("collection_method", "send_invoice".to_owned()),
728 ("days_until_due", "30".to_owned()),
729 ("pending_invoice_items_behavior", "include".to_owned()),
730 ("description", format!("g1t usage for the {} enterprise", account.name)),
731 ("metadata[g1t_enterprise]", id.to_owned()),
732 ("metadata[period]", period.to_owned()),
733 ];
734 #[derive(Deserialize)]
735 struct Invoice {
736 id: String,
737 #[serde(default)]
738 hosted_invoice_url: Option<String>,
739 #[serde(default)]
740 amount_due: i64,
741 }
742 let draft: Invoice = stripe.post("/invoices", &fields).await?;
743 let _: Value = stripe.post(&format!("/invoices/{}/finalize", draft.id), &[]).await?;
744 let sent: Invoice = stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await?;
745 let now = rfc3339(now_ms());
746 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max(sent.amount_due * 10_000);
747 let mut writes = vec![self
748 .db
749 .prepare(
750 "INSERT INTO enterprise_invoices (invoice_id, account_id, period, amount_micros, status, hosted_url, created_by, created_at)
751 VALUES (?, ?, ?, ?, 'open', ?, ?, ?)",
752 )
753 .bind(&[
754 sent.id.as_str().into(),
755 id.into(),
756 period.into(),
757 (total as f64).into(),
758 crate::optional(sent.hosted_invoice_url.as_deref()),
759 by.into(),
760 now.as_str().into(),
761 ])?];
762 for line in &lines {
763 writes.push(
764 self.db
765 .prepare("INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros) VALUES (?, ?, ?)")
766 .bind(&[sent.id.as_str().into(), line.workspace.as_str().into(), (line.amount_micros as f64).into()])?,
767 );
768 }
769 self.db.batch(writes).await?;
770 self.audit(id, "invoice", &format!("Invoice {} for {} sent ({period})", sent.id, crate::features::dollars(total)), by)
771 .await?;
772 Ok(Ok(EnterpriseInvoice {
773 invoice_id: sent.id,
774 hosted_url: sent.hosted_invoice_url,
775 amount_micros: total,
776 status: "open".to_owned(),
777 period: period.to_owned(),
778 lines,
779 created_at: now,
780 }))
781 }
782
783 /// An enterprise invoice paid: each workspace is credited its line, and
784 /// any stop for the invoice is lifted.
785 async fn enterprise_invoice_paid(&self, invoice_id: &str) -> Result<String> {
786 let claimed = self
787 .db
788 .prepare(
789 "UPDATE enterprise_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ? AND status <> 'paid'
790 RETURNING invoice_id",
791 )
792 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
793 .first::<Value>(None)
794 .await?;
795 if claimed.is_none() {
796 return Ok("ignored: not an open enterprise invoice".to_owned());
797 }
798 let lines = self.invoice_lines(invoice_id).await?;
799 for line in &lines {
800 self.enter(
801 &line.workspace,
802 EntryKind::TopUp,
803 line.amount_micros,
804 &format!("Paid on the enterprise's invoice {invoice_id}"),
805 &format!("inv/{invoice_id}/{}", line.workspace),
806 None,
807 None,
808 None,
809 None,
810 )
811 .await?;
812 }
813 Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len()))
814 }
815
816 /// An enterprise invoice that went overdue stops its workspaces' work;
817 /// one voided is simply closed.
818 async fn enterprise_invoice_status(&self, invoice_id: &str, status: &str) -> Result<String> {
819 let updated = self
820 .db
821 .prepare("UPDATE enterprise_invoices SET status = ? WHERE invoice_id = ? AND status <> 'paid' RETURNING invoice_id")
822 .bind(&[status.into(), invoice_id.into()])?
823 .first::<Value>(None)
824 .await?;
825 if updated.is_none() {
826 return Ok("ignored: not an open enterprise invoice".to_owned());
827 }
828 if status == "overdue" {
829 let now = rfc3339(now_ms());
830 for line in self.invoice_lines(invoice_id).await? {
831 self.db
832 .prepare(
833 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
834 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
835 )
836 .bind(&[line.workspace.as_str().into(), now.as_str().into(), format!("the enterprise's invoice {invoice_id} is overdue").into()])?
837 .run()
838 .await?;
839 }
840 }
841 Ok(format!("invoice {invoice_id} is {status}"))
842 }
843
844 async fn invoice_lines(&self, invoice_id: &str) -> Result<Vec<InvoiceLine>> {
845 Ok(self
846 .db
847 .prepare("SELECT workspace, amount_micros FROM enterprise_invoice_lines WHERE invoice_id = ?")
848 .bind(&[invoice_id.into()])?
849 .all()
850 .await?
851 .results::<LineRow>()?
852 .into_iter()
853 .map(|row| InvoiceLine { workspace: row.workspace, amount_micros: row.amount_micros })
854 .collect())
855 }
856
857 /// An enterprise's invoices, newest first.
858 pub(crate) async fn enterprise_invoices(&self, id: &str) -> Result<Vec<EnterpriseInvoice>> {
859 let rows = self
860 .db
861 .prepare("SELECT * FROM enterprise_invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 24")
862 .bind(&[JsValue::from(id)])?
863 .all()
864 .await?
865 .results::<InvoiceRow>()?;
866 let mut invoices = vec![];
867 for row in rows {
868 invoices.push(EnterpriseInvoice {
869 lines: self.invoice_lines(&row.invoice_id).await?,
870 invoice_id: row.invoice_id,
871 hosted_url: row.hosted_url,
872 amount_micros: row.amount_micros,
873 status: row.status,
874 period: row.period,
875 created_at: row.created_at,
876 });
877 }
878 Ok(invoices)
879 }
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 fn sign(payload: &str, secret: &str, t: i64) -> String {
887 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
888 mac.update(format!("{t}.{payload}").as_bytes());
889 format!("t={t},v1={}", hex::encode(mac.finalize().into_bytes()))
890 }
891
892 #[test]
893 fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
894 let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
895 let header = sign(payload, "whsec_test", 1_000_000);
896 assert!(verify(payload, &header, "whsec_test", 1_000_010));
897 assert!(!verify(payload, &header, "whsec_other", 1_000_010));
898 assert!(!verify(&payload.replace("paid", "voided"), &header, "whsec_test", 1_000_010));
899 assert!(!verify(payload, &header, "whsec_test", 1_000_000 + 301));
900 assert!(!verify(payload, "v1=abc", "whsec_test", 1_000_000));
901 // Stripe may sign with more than one secret while one is rolled.
902 let both = format!("{},v1=00ff", sign(payload, "whsec_test", 1_000_000));
903 assert!(verify(payload, &both, "whsec_test", 1_000_000));
904 }
905}