Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,637 linesCodeBlameRaw
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. Its signing secret is the worker's `STRIPE_WEBHOOK_SECRET`
13//! environment secret, never kept in the database or shown, and nothing
14//! can be posted here without it.
15//! - An event whose `livemode` is not the key's mode is rejected before
16//! claiming its ID: an early live delivery must remain retryable after
17//! cutover, and test events never move live balances.
18//! - Each event is handled once, by id, and recorded with what was done.
19//! - Every handler is safe alongside the paths that ask Stripe directly:
20//! both claim the same rows.
21//!
22//! Enterprises are invoiced: one Stripe invoice per month (or sooner, from
23//! sudo), with a line per workspace for what it owes, sent to the
24//! enterprise's billing email and paid on Stripe's hosted invoice page.
25//! When it is paid, each workspace is credited its line; when it goes
26//! overdue, their work stops until it is paid.
27
28use g1t_contracts::billing::{
29 AdminEnterpriseAddressArgs, AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice,
30 EntryKind, InvoiceLine, StripeEventSummary, StripePortalStatus, StripeStatus, StripeTaxStatus, StripeWebhook, StripeWebhookArgs,
31};
32use g1t_contracts::time::rfc3339;
33use g1t_contracts::{FailureCode, Outcome};
34use g1t_kit::now_ms;
35use hmac::{Hmac, Mac};
36use serde::Deserialize;
37use serde_json::Value;
38use sha2::Sha256;
39use worker::Result;
40use worker::wasm_bindgen::JsValue;
41
42use crate::Billing;
43
44/// Where Stripe sends events.
45pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook";
46
47pub(crate) const ENTERPRISE_STATUS: &str = "UPDATE enterprise_invoices SET status = ? WHERE invoice_id = ? AND status IN ('open', 'overdue') RETURNING invoice_id";
48pub(crate) const ENTERPRISE_OVERDUE_STOP: &str = "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at)
49 SELECT ?1, ?2, ?3, ?2 WHERE EXISTS (SELECT 1 FROM enterprise_invoices WHERE invoice_id = ?4 AND status = 'overdue')
50 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2";
51
52/// The events billing acts on.
53pub(crate) const EVENTS: &[&str] = &[
54 "checkout.session.completed",
55 // A prepayment by bank transfer: the page completes when the transfer
56 // is set up, and this comes when the money arrives.
57 "checkout.session.async_payment_succeeded",
58 "customer.subscription.updated",
59 "customer.subscription.deleted",
60 "invoice.paid",
61 "invoice.payment_failed",
62 "invoice.overdue",
63 "invoice.voided",
64 "charge.refunded",
65 "charge.dispute.created",
66 "charge.dispute.closed",
67 // The saved card, kept on the account row (stripe_sync.rs).
68 "customer.updated",
69 "payment_method.attached",
70 "payment_method.detached",
71 "payment_method.updated",
72 "payment_method.automatically_updated",
73 "setup_intent.succeeded",
74];
75
76/// The subscription an invoice is for: `subscription` at the API version
77/// billing asks in, `parent.subscription_details.subscription` in events
78/// sent at a newer one (a destination keeps the version it was made at).
79pub(crate) fn invoice_subscription(invoice: &Value) -> Option<&str> {
80 invoice["subscription"].as_str().or_else(|| invoice["parent"]["subscription_details"]["subscription"].as_str())
81}
82
83/// Why an event is not for this key, when its `livemode` disagrees with the
84/// key's mode (`live` or `test`): a test event never moves real balances,
85/// nor a live one test balances. None when it agrees, or says nothing.
86pub(crate) fn wrong_mode(livemode: Option<bool>, mode: &str) -> Option<String> {
87 let live = livemode?;
88 let key_live = mode == "live";
89 (live != key_live).then(|| format!("a {} event, and the key is in {mode} mode", if live { "live" } else { "test" }))
90}
91
92/// What earlier refunds of a charge (`?1`, `refund/<charge>/%`) took back,
93/// as a positive amount: from the plan's payments and from the ledger alike.
94pub(crate) const ALREADY_REFUNDED_SQL: &str = "SELECT -(COALESCE((SELECT SUM(amount_micros) FROM plan_payments WHERE invoice_id LIKE ?1), 0)
95 + COALESCE((SELECT SUM(amount_micros) FROM ledger WHERE reference LIKE ?1), 0)) AS micros";
96
97pub(crate) const REFUND_BALANCE: &str = "UPDATE accounts SET balance_micros = balance_micros +
98 (SELECT amount_micros FROM ledger WHERE reference = ?1) WHERE workspace = ?2 AND changes() > 0";
99
100/// Why a workspace's work is stopped while one of its payments is disputed.
101const DISPUTE_STOP: &str = "a payment was disputed with the card's bank";
102
103/// Where a dispute stands, from Stripe's `status`: `lost`, `won` (a
104/// warning closed without a dispute counts as won), else `open`.
105pub(crate) fn dispute_state(status: &str) -> &'static str {
106 match status {
107 "lost" => "lost",
108 "won" | "warning_closed" | "prevented" => "won",
109 _ => "open",
110 }
111}
112
113/// Whether a dispute event moves the dispute from where billing has it
114/// (`known`, none for a dispute not seen before) to `state`: an event that
115/// says what billing knows already does nothing. Once lost, only a win
116/// (Stripe reversing it) moves it again; once detached (a test-mode
117/// dispute), nothing does.
118pub(crate) fn dispute_moves(known: Option<&str>, state: &str) -> bool {
119 match known {
120 None => true,
121 Some(known) if known == state => false,
122 // Closed by a test-mode detach: a test dispute, never acted on.
123 Some("detached") => false,
124 Some("lost") => state == "won",
125 Some(_) => true,
126 }
127}
128
129/// What a dispute won after it was lost gives back: what the loss took
130/// (`taken`), only on that move. Nothing for any other.
131pub(crate) fn restore_on_win(known: Option<&str>, state: &str, taken: i64) -> i64 {
132 if known == Some("lost") && state == "won" { taken.max(0) } else { 0 }
133}
134
135/// Where a lost dispute's reversal is given back: `dispute/<id>/won`,
136/// unique, so it is given back once.
137pub(crate) fn won_reference(dispute_id: &str) -> String {
138 format!("dispute/{dispute_id}/won")
139}
140
141/// What a dispute won after it was lost no longer leaves owed: `?1` the
142/// workspace, `?2` what the loss took. Never below nothing.
143pub(crate) const LOWER_DISPUTE_OWED: &str =
144 "UPDATE limits SET dispute_owed_micros = MAX(0, dispute_owed_micros - ?2) WHERE workspace = ?1";
145
146/// Events billing handles that `has` does not include, in billing's order.
147pub(crate) fn missing_events(has: &[String]) -> Vec<String> {
148 // `*` is every event.
149 if has.iter().any(|event| event == "*") {
150 return Vec::new();
151 }
152 EVENTS.iter().filter(|event| !has.iter().any(|h| h == *event)).map(|event| (*event).to_owned()).collect()
153}
154
155/// How old a signed event may be, so a captured one cannot be replayed.
156const TOLERANCE_SECONDS: i64 = 5 * 60;
157
158/// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the
159/// tolerance of `now_seconds`.
160pub(crate) fn verify(payload: &str, header: &str, secret: &str, now_seconds: i64) -> bool {
161 let mut timestamp = None;
162 let mut signatures = vec![];
163 for part in header.split(',') {
164 match part.trim().split_once('=') {
165 Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
166 Some(("v1", value)) => signatures.push(value.to_owned()),
167 _ => {}
168 }
169 }
170 let Some(timestamp) = timestamp else { return false };
171 if now_seconds.abs_diff(timestamp) > TOLERANCE_SECONDS as u64 {
172 return false;
173 }
174 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
175 mac.update(format!("{timestamp}.{payload}").as_bytes());
176 let expected = mac.finalize().into_bytes();
177 signatures.iter().any(|signature| {
178 hex::decode(signature).is_ok_and(|given| {
179 // Constant time: compare every byte whatever the first difference.
180 given.len() == expected.len() && given.iter().zip(expected.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
181 })
182 })
183}
184
185/// The destination at billing's address, as Stripe lists it.
186#[derive(Deserialize)]
187pub(crate) struct Destination {
188 pub(crate) id: String,
189 url: String,
190 /// `enabled` or `disabled`.
191 pub(crate) status: String,
192 pub(crate) enabled_events: Vec<String>,
193 created: i64,
194}
195
196#[derive(Deserialize)]
197struct EventRow {
198 id: String,
199 r#type: String,
200 outcome: String,
201 received_at: String,
202}
203
204#[derive(Deserialize)]
205struct InvoiceRow {
206 invoice_id: String,
207 period: String,
208 amount_micros: i64,
209 status: String,
210 hosted_url: Option<String>,
211 created_at: String,
212}
213
214#[derive(Deserialize)]
215struct LineRow {
216 workspace: String,
217 amount_micros: i64,
218}
219
220/// The idempotency key of an enterprise's invoice: the same enterprise,
221/// period and amounts are the same invoice however often it is attempted.
222#[cfg(test)]
223pub(crate) fn enterprise_invoice_key(id: &str, period: &str, lines: &[(&str, i64)]) -> String {
224 // FNV-1a over every line, so the key stays short however many there are.
225 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
226 for (workspace, cents) in lines {
227 for byte in format!("{workspace}={cents};").bytes() {
228 hash = (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
229 }
230 }
231 format!("ent-invoice/{id}/{period}/{hash:016x}")
232}
233
234impl Billing {
235 pub(crate) fn mode(&self) -> &'static str {
236 match &self.stripe {
237 None => "off",
238 Some(stripe) if stripe.live() => "live",
239 Some(_) => "test",
240 }
241 }
242
243 /// The destination at billing's address in Stripe, for this key's
244 /// mode: an enabled one first. None when there is none.
245 pub(crate) async fn destination(&self) -> Result<Option<Destination>> {
246 let Some(stripe) = &self.stripe else { return Ok(None) };
247 #[derive(Deserialize)]
248 struct List {
249 data: Vec<Destination>,
250 }
251 let mut ours: Vec<Destination> =
252 stripe.get::<List>("/webhook_endpoints?limit=100").await?.data.into_iter().filter(|d| d.url == WEBHOOK_URL).collect();
253 ours.sort_by_key(|d| d.status != "enabled");
254 Ok(ours.into_iter().next())
255 }
256
257 // --- Staff ------------------------------------------------------------
258
259 pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
260 let mut error = None;
261 if a.fix
262 && let Err(e) = self.keep_endpoint(a.by.as_deref().unwrap_or("sudo")).await
263 {
264 error = Some(e.to_string());
265 }
266 let (webhook, missing_events) = match self.destination().await {
267 Ok(Some(d)) => {
268 let missing = missing_events(&d.enabled_events);
269 let webhook = StripeWebhook {
270 url: d.url,
271 endpoint_id: d.id,
272 status: d.status,
273 events: d.enabled_events,
274 created_at: rfc3339(d.created.max(0) as u64 * 1000),
275 };
276 (Some(webhook), missing)
277 }
278 Ok(None) => (None, Vec::new()),
279 Err(e) => {
280 error = error.or(Some(format!("Stripe could not be read: {e}")));
281 (None, Vec::new())
282 }
283 };
284 let recent_events = self
285 .db
286 .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
287 .all()
288 .await?
289 .results::<EventRow>()?
290 .into_iter()
291 .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
292 .collect();
293 // Stripe Tax and the customer portal, for the go-live checklist:
294 // read as Stripe has them, never made or changed here.
295 let (mut tax, mut portal) = (None, None);
296 if let Some(stripe) = &self.stripe {
297 match stripe.tax_settings().await {
298 Ok(settings) => {
299 tax = Some(StripeTaxStatus {
300 status: settings.status,
301 missing_fields: settings.status_details.pending.and_then(|p| p.missing_fields).unwrap_or_default(),
302 })
303 }
304 Err(e) => error = error.or(Some(format!("Stripe Tax's settings could not be read: {e}"))),
305 }
306 match stripe.our_portal().await {
307 Ok(found) => portal = found.map(|c| StripePortalStatus { configuration_id: c.id, active: c.active }),
308 Err(e) => error = error.or(Some(format!("The customer portal's settings could not be read: {e}"))),
309 }
310 }
311 Ok(StripeStatus {
312 mode: self.mode().to_owned(),
313 secret_set: self.webhook_secret.is_some(),
314 webhook,
315 missing_events,
316 recent_events,
317 error,
318 tax,
319 portal,
320 })
321 }
322
323 // --- Events -----------------------------------------------------------
324
325 pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
326 let Some(secret) = &self.webhook_secret else {
327 return Ok(Outcome::fail(
328 FailureCode::Conflict,
329 "STRIPE_WEBHOOK_SECRET is not set, so billing cannot check that events come from Stripe.",
330 ));
331 };
332 let now_seconds = (now_ms() / 1000) as i64;
333 if !verify(&a.payload, &a.signature, secret, now_seconds) {
334 return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
335 }
336 let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
337 if event["id"].as_str().unwrap_or_default().is_empty() {
338 return Ok(Outcome::fail(FailureCode::Invalid, "Not an event."));
339 }
340 Ok(Outcome::Ok(self.process_event(&event).await?))
341 }
342
343 /// Handles a Stripe event once, however often it arrives: by webhook,
344 /// or again from the event list (stripe_sync.rs). False when it was
345 /// seen before.
346 pub(crate) async fn process_event(&self, event: &Value) -> Result<bool> {
347 // Do not permanently acknowledge an event delivered while the live
348 // webhook and API key are being switched. Recording it as ignored
349 // would prevent both redelivery and event replay after cutover.
350 if let Some(why) = wrong_mode(event["livemode"].as_bool(), self.mode()) {
351 return Err(worker::Error::RustError(format!("Stripe mode mismatch; retry after configuration is corrected: {why}")));
352 }
353 let id = event["id"].as_str().unwrap_or_default().to_owned();
354 let kind = event["type"].as_str().unwrap_or_default().to_owned();
355 // Once each: the first to record it handles it.
356 let claimed = self
357 .db
358 .prepare("INSERT OR IGNORE INTO stripe_events (id, type, outcome, received_at) VALUES (?, ?, 'handling', ?) RETURNING id")
359 .bind(&[id.as_str().into(), kind.as_str().into(), rfc3339(now_ms()).into()])?
360 .first::<Value>(None)
361 .await?;
362 if claimed.is_none() {
363 // A concurrent handler has not acknowledged this event yet.
364 // Returning success here could advance replay past work that
365 // subsequently fails (or acknowledge Stripe's last retry).
366 let pending = self.db
367 .prepare("SELECT id FROM stripe_events WHERE id = ? AND outcome = 'handling'")
368 .bind(&[id.as_str().into()])?
369 .first::<Value>(None).await?;
370 if pending.is_some() {
371 return Err(worker::Error::RustError("Stripe event is still being handled; retry it".into()));
372 }
373 return Ok(false);
374 }
375 let outcome = match self.handle(&kind, event).await {
376 Ok(outcome) => outcome,
377 Err(error) => {
378 // Let Stripe send it again: forget it was seen.
379 self.db.prepare("DELETE FROM stripe_events WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
380 return Err(error);
381 }
382 };
383 self.db
384 .prepare("UPDATE stripe_events SET outcome = ? WHERE id = ?")
385 .bind(&[outcome.as_str().into(), id.as_str().into()])?
386 .run()
387 .await?;
388 Ok(true)
389 }
390
391 async fn handle(&self, kind: &str, event: &Value) -> Result<String> {
392 let object = &event["data"]["object"];
393 let text = |key: &str| object[key].as_str().unwrap_or_default().to_owned();
394 // The customer whose saved card may have changed. A detached card
395 // has no customer any more; the event says whose it was.
396 let card_owner = match kind {
397 "customer.updated" => object["id"].as_str(),
398 "payment_method.detached" => event["data"]["previous_attributes"]["customer"].as_str(),
399 _ => object["customer"].as_str(),
400 };
401 Ok(match kind {
402 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => self.settle_checkout(&text("id")).await?,
403 "customer.subscription.updated" | "customer.subscription.deleted" => {
404 self.settle_subscription(&text("id")).await?
405 }
406 "invoice.paid" => {
407 if let Some(subscription) = invoice_subscription(object) {
408 self.plan_paid(subscription, &text("id")).await?;
409 self.settle_subscription(subscription).await?
410 } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? {
411 done
412 } else {
413 self.enterprise_invoice_paid(&text("id")).await?
414 }
415 }
416 "invoice.payment_failed" => match (invoice_subscription(object), object["metadata"]["g1t_workspace"].as_str()) {
417 (Some(subscription), _) => self.settle_subscription(subscription).await?,
418 (None, Some(tagged)) => self.workspace_invoice_failed(&text("id"), tagged).await?,
419 _ => "ignored: not g1t's".to_owned(),
420 },
421 "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
422 "invoice.voided" => {
423 // A workspace invoice voided at Stripe stops being open here.
424 self.db
425 .prepare("UPDATE workspace_invoices SET status = 'void' WHERE invoice_id = ? AND status IN ('open', 'failed')")
426 .bind(&[text("id").into()])?
427 .run()
428 .await?;
429 self.enterprise_invoice_status(&text("id"), "void").await?
430 }
431 "charge.refunded" => self.refunded(&text("id")).await?,
432 // Created or closed, the dispute is read as it stands now, so
433 // events late or out of order act on where it is.
434 "charge.dispute.created" | "charge.dispute.closed" => self.disputed(&text("id")).await?,
435 "customer.updated"
436 | "payment_method.attached"
437 | "payment_method.detached"
438 | "payment_method.updated"
439 | "payment_method.automatically_updated"
440 | "setup_intent.succeeded" => match card_owner {
441 Some(customer) => self.sync_card_of(customer).await?,
442 None => "ignored: no customer".to_owned(),
443 },
444 _ => "ignored".to_owned(),
445 })
446 }
447
448 /// A payment page done, whether or not the person came back to g1t.
449 pub(crate) async fn settle_checkout(&self, session_id: &str) -> Result<String> {
450 #[derive(Deserialize)]
451 struct Open {
452 workspace: String,
453 created_by: String,
454 feature: Option<String>,
455 #[serde(default)]
456 fee_cents: Option<u32>,
457 }
458 let Some(open) = self
459 .db
460 .prepare("SELECT workspace, created_by, feature, fee_cents FROM checkouts WHERE id = ? AND status = 'open'")
461 .bind(&[session_id.into()])?
462 .first::<Open>(None)
463 .await?
464 else {
465 return Ok("ignored: already settled or not g1t's".to_owned());
466 };
467 // A card check: saved and verified, never charged.
468 if open.feature.as_deref() == Some(crate::cards::CARD_CHECK) {
469 return Ok(match self.settle_card_check(session_id).await? {
470 Ok(done) => done,
471 Err(why) => format!("card check not passed: {why}"),
472 });
473 }
474 // AI credit, from before g1t stopped selling it: a page left open
475 // then is never credited as anything else.
476 if open.feature.as_deref() == Some("ai_credit") {
477 return Ok("ignored: AI credit is no longer sold".to_owned());
478 }
479 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
480 let session = stripe.session(session_id).await?;
481 if session.payment_status != "paid" && open.feature.is_none() {
482 return Ok("ignored: not paid".to_owned());
483 }
484 // What the page paid for first, then the page marked paid: a
485 // failure between the two leaves it open, and settling it again
486 // finds the credit (the ledger's reference) or the plan entered.
487 let mark = self.db.prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'").bind(&[session_id.into()])?;
488 match open.feature.as_deref() {
489 None => {
490 let cents = self.credit_prepayment(&open.workspace, &session, open.fee_cents.unwrap_or(0), &open.created_by).await?;
491 mark.run().await?;
492 if cents == 0 {
493 return Ok("ignored: credited meanwhile".to_owned());
494 }
495 Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace))
496 }
497 Some(feature) => {
498 let Some(feature) = g1t_contracts::billing::Feature::parse(feature) else {
499 return Ok("ignored: unknown feature".to_owned());
500 };
501 if let Some(subscription_id) = &session.subscription {
502 let subscription = stripe.subscription(subscription_id).await?;
503 self.record(&open.workspace, feature, &subscription, &open.created_by).await?;
504 }
505 self.db
506 .prepare(
507 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
508 ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
509 )
510 .bind(&[open.workspace.as_str().into(), crate::optional(session.customer.as_deref()), rfc3339(now_ms()).into()])?
511 .run()
512 .await?;
513 mark.run().await?;
514 Ok(format!("{} plan started for {}", feature.title(), open.workspace))
515 }
516 }
517 }
518
519 /// The plan's monthly price, paid: revenue that never goes through the
520 /// ledger, recorded once per invoice for sudo's figures and for trust.
521 /// Only the plan's own price is revenue: the invoice's tax and card fee
522 /// are kept apart (`tax_and_fees`).
523 ///
524 /// The invoice is read again at billing's API version: the event's copy
525 /// is at the destination's, where `tax` and `payment_intent` may be
526 /// gone.
527 pub(crate) async fn plan_paid(&self, subscription_id: &str, invoice_id: &str) -> Result<()> {
528 let Some(stripe) = &self.stripe else { return Ok(()) };
529 if invoice_id.is_empty() {
530 return Ok(());
531 }
532 let invoice: Value = stripe.get(&format!("/invoices/{invoice_id}")).await?;
533 if invoice["status"].as_str() != Some("paid") {
534 return Ok(());
535 }
536 let invoice = &invoice;
537 let split = crate::stripe::invoice_split(invoice, invoice["amount_paid"].as_i64().unwrap_or(0));
538 let amount_cents = split.net_cents;
539 if amount_cents <= 0 {
540 return Ok(());
541 }
542 #[derive(Deserialize)]
543 struct Owner {
544 workspace: String,
545 }
546 // The plan's subscription, or an enrollment's: its custom fee, or
547 // the first plan invoice of a conversion Stripe has not applied yet.
548 // Either is money received.
549 let Some(owner) = self
550 .db
551 .prepare(
552 "SELECT workspace FROM subscriptions WHERE subscription_id = ?1
553 UNION ALL SELECT workspace FROM complimentary WHERE subscription_id = ?1 LIMIT 1",
554 )
555 .bind(&[subscription_id.into()])?
556 .first::<Owner>(None)
557 .await?
558 else {
559 return Ok(());
560 };
561 let extras = crate::tax::Extras { tax_cents: split.tax_cents, fee_cents: split.fee_cents };
562 self.record_extras(&owner.workspace, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
563 self.db
564 .prepare(
565 "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at)
566 VALUES (?1, ?4, ?2, ?3)
567 ON CONFLICT (invoice_id) DO NOTHING",
568 )
569 .bind(&[
570 invoice_id.into(),
571 ((amount_cents * 10_000) as f64).into(),
572 rfc3339(now_ms()).into(),
573 owner.workspace.as_str().into(),
574 ])?
575 .run()
576 .await?;
577 Ok(())
578 }
579
580 /// A plan that changed at Stripe: renewed, failed, canceled.
581 pub(crate) async fn settle_subscription(&self, subscription_id: &str) -> Result<String> {
582 #[derive(Deserialize)]
583 struct Plan {
584 workspace: String,
585 feature: String,
586 started_by: String,
587 }
588 let plan = self
589 .db
590 .prepare("SELECT workspace, feature, started_by FROM subscriptions WHERE subscription_id = ?")
591 .bind(&[subscription_id.into()])?
592 .first::<Plan>(None)
593 .await?;
594 // A complimentary enrollment's subscription (or one converting to
595 // the plan, which has no plan row until Stripe applies it).
596 let Some(plan) = plan else {
597 return Ok(self.settle_complimentary(subscription_id).await?.unwrap_or_else(|| "ignored: not a g1t plan".to_owned()));
598 };
599 let (Some(stripe), Some(feature)) = (&self.stripe, g1t_contracts::billing::Feature::parse(&plan.feature)) else {
600 return Ok("ignored".to_owned());
601 };
602 let subscription = match stripe.subscription(subscription_id).await {
603 Ok(subscription) => subscription,
604 // A plan Stripe no longer has (another account's, or deleted):
605 // it has ended here.
606 Err(error) if crate::stripe::is_missing(&error) => {
607 self.db
608 .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE subscription_id = ?")
609 .bind(&[rfc3339(now_ms()).into(), subscription_id.into()])?
610 .run()
611 .await?;
612 return Ok(format!("{} plan for {} is gone at Stripe: canceled", feature.title(), plan.workspace));
613 }
614 Err(error) => return Err(error),
615 };
616 self.record(&plan.workspace, feature, &subscription, &plan.started_by).await?;
617 Ok(format!("{} plan for {} is {}", feature.title(), plan.workspace, subscription.status))
618 }
619
620 /// The workspace a Stripe customer belongs to.
621 pub(crate) async fn workspace_of_customer(&self, customer: &str) -> Result<Option<String>> {
622 #[derive(Deserialize)]
623 struct Row {
624 workspace: String,
625 }
626 Ok(self
627 .db
628 .prepare("SELECT workspace FROM accounts WHERE customer_id = ?")
629 .bind(&[customer.into()])?
630 .first::<Row>(None)
631 .await?
632 .map(|row| row.workspace))
633 }
634
635 /// The workspace a workspace invoice was sent to, under its slug now.
636 pub(crate) async fn workspace_of_invoice(&self, invoice_id: &str) -> Result<Option<String>> {
637 #[derive(Deserialize)]
638 struct Row {
639 workspace: String,
640 }
641 Ok(self
642 .db
643 .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?")
644 .bind(&[invoice_id.into()])?
645 .first::<Row>(None)
646 .await?
647 .map(|row| row.workspace))
648 }
649
650 /// Money given back: what was paid is less, by the refund. A refund of
651 /// a plan's invoice takes back the plan's payment (`plan_payments`);
652 /// any other takes back credit on the balance. The charge is read again
653 /// at billing's API version, where it still carries `invoice`.
654 async fn refunded(&self, charge_id: &str) -> Result<String> {
655 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
656 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
657 let charge = &charge;
658 let Some(customer) = charge["customer"].as_str() else { return Ok("ignored: no customer".to_owned()) };
659 let Some(workspace) = self.workspace_of_customer(customer).await? else {
660 return Ok("ignored: not a workspace's customer".to_owned());
661 };
662 let refunded = charge["amount_refunded"].as_i64().unwrap_or(0);
663 if refunded <= 0 {
664 return Ok("ignored: nothing refunded".to_owned());
665 }
666 // Refunds can precede payment webhooks. Establish the original
667 // payment and tax split first instead of treating tax as credit.
668 let mut invoice_extras = None;
669 let plan_invoice = if let Some(id) = charge["invoice"].as_str() {
670 let invoice: Value = stripe.get(&format!("/invoices/{id}")).await?;
671 let split = crate::stripe::invoice_split(&invoice, invoice["amount_paid"].as_i64().unwrap_or(0));
672 invoice_extras = Some(crate::tax::Extras { tax_cents: split.tax_cents, fee_cents: split.fee_cents });
673 if invoice_subscription(&invoice).is_some() {
674 true
675 } else {
676 self.workspace_invoice_paid(id).await?;
677 false
678 }
679 } else {
680 if let Some(intent) = charge["payment_intent"].as_str() {
681 let sessions: Value = stripe.get(&format!("/checkout/sessions?{}", crate::stripe::form(&[
682 ("payment_intent", intent.to_owned()), ("limit", "100".to_owned()),
683 ]))).await?;
684 for session in sessions["data"].as_array().into_iter().flatten() {
685 if let Some(id) = session["id"].as_str() {
686 self.settle_checkout(id).await?;
687 }
688 }
689 }
690 false
691 };
692 // Each refund total once, so partial refunds add up correctly: what
693 // earlier refunds of the charge took back, wherever they were kept.
694 // A plan's refund recorded on the ledger (before plan refunds went
695 // to `plan_payments`) counts as much as one recorded there.
696 #[derive(Deserialize)]
697 struct Sum {
698 micros: Option<i64>,
699 }
700 let already = self
701 .db
702 .prepare(ALREADY_REFUNDED_SQL)
703 .bind(&[format!("refund/{charge_id}/%").into()])?
704 .first::<Sum>(None)
705 .await?
706 .and_then(|s| s.micros)
707 .unwrap_or(0);
708 // A refund gives back the payment's tax and card fee in proportion:
709 // only the rest comes off the balance, which never held them.
710 let (extras, transaction) = self.extras_of(charge["payment_intent"].as_str(), charge["invoice"].as_str()).await?;
711 let extras = invoice_extras.unwrap_or(extras);
712 let paid = charge["amount"].as_i64().unwrap_or(refunded);
713 let (balance_cents, tax_cents, fee_cents) = crate::tax::refund_split(refunded, paid, extras.tax_cents, extras.fee_cents);
714 let (given_tax, given_fee) = self.refunded_extras(charge_id).await?;
715 let new = balance_cents * 10_000 - already;
716 let new_extras = crate::tax::Extras { tax_cents: -(tax_cents - given_tax), fee_cents: -(fee_cents - given_fee) };
717 if new <= 0 && new_extras == crate::tax::Extras::default() {
718 return Ok("ignored: refund already recorded".to_owned());
719 }
720 let reference = format!("refund/{charge_id}/{refunded}");
721 // Compute each delta inside the transaction, not from the earlier
722 // read: distinct partial-refund events can execute concurrently.
723 let now = rfc3339(now_ms());
724 let pattern = format!("refund/{charge_id}/%");
725 let mut writes = vec![self.db.prepare(if plan_invoice {
726 include_str!("refund_plan.sql")
727 } else {
728 include_str!("refund_ledger.sql")
729 }).bind(&[
730 g1t_contracts::new_id("led", now_ms()).into(), workspace.as_str().into(),
731 ((balance_cents * 10_000) as f64).into(), reference.as_str().into(), now.as_str().into(), pattern.as_str().into(),
732 ])?];
733 if !plan_invoice {
734 // changes() is the preceding insert's count: replaying a
735 // cumulative refund never moves the balance a second time.
736 writes.push(self.db.prepare(REFUND_BALANCE).bind(&[reference.as_str().into(), workspace.as_str().into()])?);
737 }
738 for (kind, total) in [("tax", tax_cents), ("card_fee", fee_cents)] {
739 writes.push(self.db.prepare(include_str!("refund_extras.sql")).bind(&[
740 format!("{reference}/{kind}").into(), workspace.as_str().into(), kind.into(),
741 ((total * 10_000) as f64).into(), reference.as_str().into(),
742 crate::optional(charge["payment_intent"].as_str()), now.as_str().into(), pattern.as_str().into(),
743 ])?);
744 }
745 self.db.batch(writes).await?;
746 // An off-session charge's tax was recorded by g1t (auto-reload):
747 // reverse what this refund gave back. Checkout's and invoices' tax
748 // Stripe Tax keeps itself.
749 if let (Some(stripe), Some(transaction)) = (&self.stripe, transaction.as_deref()) {
750 let given_back = new.max(0) / 10_000 - new_extras.tax_cents - new_extras.fee_cents;
751 if given_back > 0
752 && let Err(error) = stripe.reverse_tax(transaction, &reference, given_back).await
753 {
754 worker::console_error!("{workspace}: the tax on refund {reference} was not reversed: {error}");
755 }
756 }
757 Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new.max(0))))
758 }
759
760 /// What earlier refunds of a charge gave back of its tax and card fee,
761 /// in cents, as positive amounts.
762 async fn refunded_extras(&self, charge_id: &str) -> Result<(i64, i64)> {
763 #[derive(Deserialize)]
764 struct Row {
765 kind: String,
766 micros: Option<i64>,
767 }
768 let rows = self
769 .db
770 .prepare("SELECT kind, SUM(amount_micros) AS micros FROM tax_and_fees WHERE reference LIKE ? GROUP BY kind")
771 .bind(&[format!("refund/{charge_id}/%").into()])?
772 .all()
773 .await?
774 .results::<Row>()?;
775 let of = |kind: &str| rows.iter().filter(|r| r.kind == kind).map(|r| -r.micros.unwrap_or(0) / 10_000).sum::<i64>();
776 Ok((of("tax"), of("card_fee")))
777 }
778
779 /// Whether a Stripe invoice is a plan's, its payment recorded in
780 /// `plan_payments`.
781 async fn plan_payment_of(&self, invoice_id: &str) -> Result<bool> {
782 Ok(self
783 .db
784 .prepare("SELECT invoice_id FROM plan_payments WHERE invoice_id = ?")
785 .bind(&[invoice_id.into()])?
786 .first::<Value>(None)
787 .await?
788 .is_some())
789 }
790
791 /// A disputed payment, acted on as Stripe says it stands now (so an
792 /// event late, twice or out of order does the right thing):
793 ///
794 /// - Open: the payment never counts toward trust, and work stops.
795 /// - Won: it counts again, and the stop lifts unless another dispute is
796 /// open or what a lost one took is unpaid. Won after it was lost (the
797 /// bank reversed itself), what was taken back is given back once
798 /// (`dispute/<id>/won`, where the loss was entered) and no longer
799 /// owed.
800 /// - Lost: its credit is taken back once (`dispute/<id>` on the ledger,
801 /// or on `plan_payments` for a plan's invoice), in the share of the
802 /// payment that was not tax or card fee, and work stays stopped until
803 /// that much is paid again (`dispute_owed_micros`). A smaller payment
804 /// in the meantime is credit like any other, but does not lift it.
805 async fn disputed(&self, dispute_id: &str) -> Result<String> {
806 let Some(stripe) = &self.stripe else { return Ok("ignored".to_owned()) };
807 let dispute: Value = stripe.get(&format!("/disputes/{dispute_id}")).await?;
808 let state = dispute_state(dispute["status"].as_str().unwrap_or_default());
809 let Some(charge_id) = dispute["charge"].as_str().or_else(|| dispute["charge"]["id"].as_str()) else {
810 return Ok("ignored: no charge".to_owned());
811 };
812 let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
813 let Some(workspace) = (match charge["customer"].as_str() {
814 Some(customer) => self.workspace_of_customer(customer).await?,
815 None => None,
816 }) else {
817 return Ok("ignored: not a workspace's customer".to_owned());
818 };
819 let now = rfc3339(now_ms());
820 let payment_intent = charge["payment_intent"].as_str();
821 let invoice = charge["invoice"].as_str();
822 // The payment as the ledger knows it: its PaymentIntent, its
823 // invoice, or the prepayment page that took it.
824 let mut references: Vec<String> = [payment_intent, invoice].into_iter().flatten().map(str::to_owned).collect();
825 if let Some(intent) = payment_intent {
826 let pages: Value = stripe.get(&format!("/checkout/sessions?payment_intent={intent}&limit=10")).await?;
827 references.extend(pages["data"].as_array().into_iter().flatten().filter_map(|page| page["id"].as_str().map(str::to_owned)));
828 }
829 // It counts toward trust again only if won.
830 for reference in &references {
831 self.db
832 .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?")
833 .bind(&[(if state == "won" { 0 } else { 1 }).into(), workspace.as_str().into(), reference.as_str().into()])?
834 .run()
835 .await?;
836 }
837 #[derive(Deserialize)]
838 struct Known {
839 status: String,
840 reversed_micros: Option<i64>,
841 }
842 let known_row = self
843 .db
844 .prepare("SELECT status, reversed_micros FROM disputes WHERE dispute_id = ?")
845 .bind(&[dispute_id.into()])?
846 .first::<Known>(None)
847 .await?;
848 let taken = known_row.as_ref().and_then(|row| row.reversed_micros).unwrap_or(0);
849 let known = known_row.map(|row| row.status);
850 // Where it stood already: an event twice, or late, changes nothing.
851 // Work is not stopped again (an owner may have paid since), and
852 // nothing is taken back or owed twice.
853 if !dispute_moves(known.as_deref(), state) {
854 return Ok(format!("ignored: dispute {dispute_id} is {state} already"));
855 }
856 let reference = format!("dispute/{dispute_id}");
857 let mut writes = vec![];
858 // A lost dispute's credit, taken back once: the reversal, the
859 // dispute's row and what is owed go in one batch, so a delivery
860 // alongside (whose reversal fails on the same reference, undoing
861 // its whole batch) never adds what is owed a second time.
862 let mut reversed = 0;
863 if state == "lost" {
864 let (extras, _) = self.extras_of(payment_intent, invoice).await?;
865 let amount = dispute["amount"].as_i64().unwrap_or(0);
866 let paid = charge["amount"].as_i64().unwrap_or(amount);
867 reversed = crate::tax::refund_split(amount, paid, extras.tax_cents, extras.fee_cents).0 * 10_000;
868 let plan = match invoice {
869 Some(invoice) => self.plan_payment_of(invoice).await?,
870 None => false,
871 };
872 if reversed > 0 && plan {
873 // No `ON CONFLICT`: a second insert fails the batch.
874 writes.push(
875 self.db
876 .prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES (?, ?, ?, ?)")
877 .bind(&[reference.as_str().into(), workspace.as_str().into(), ((-reversed) as f64).into(), now.as_str().into()])?,
878 );
879 } else if reversed > 0 {
880 writes.extend(self.entry_statements(
881 &workspace,
882 EntryKind::TopUp,
883 -reversed,
884 "Taken back by the card's bank: a dispute was lost",
885 &reference,
886 None,
887 None,
888 None,
889 )?);
890 }
891 }
892 // Lost, then won: what the loss took back is given back, where it
893 // was taken (the plan's payments or the ledger), in the same batch
894 // as the dispute's row, so a delivery alongside fails on the same
895 // reference and gives nothing twice.
896 let restored = restore_on_win(known.as_deref(), state, taken);
897 if restored > 0 {
898 let back = won_reference(dispute_id);
899 if self.plan_payment_of(&reference).await? {
900 // No `ON CONFLICT`: a second insert fails the batch.
901 writes.push(
902 self.db
903 .prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES (?, ?, ?, ?)")
904 .bind(&[back.as_str().into(), workspace.as_str().into(), (restored as f64).into(), now.as_str().into()])?,
905 );
906 } else {
907 writes.extend(self.entry_statements(
908 &workspace,
909 EntryKind::TopUp,
910 restored,
911 "Given back by the card's bank: a lost dispute was reversed",
912 &back,
913 None,
914 None,
915 None,
916 )?);
917 }
918 }
919 let reversing = !writes.is_empty();
920 writes.push(
921 self.db
922 .prepare(
923 "INSERT INTO disputes (dispute_id, workspace, charge_id, status, reversed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
924 ON CONFLICT (dispute_id) DO UPDATE SET status = ?4, reversed_micros = reversed_micros + ?5, updated_at = ?6",
925 )
926 .bind(&[dispute_id.into(), workspace.as_str().into(), charge_id.into(), state.into(), (reversed as f64).into(), now.as_str().into()])?,
927 );
928 if state == "won" {
929 if restored > 0 {
930 writes.push(self.db.prepare(LOWER_DISPUTE_OWED).bind(&[workspace.as_str().into(), (restored as f64).into()])?);
931 }
932 // Only the stop a dispute made: a declined card stays stopped.
933 writes.push(
934 self.db
935 .prepare(format!("{} AND autopay_error = ?2", crate::CLEAR_STOP))
936 .bind(&[workspace.as_str().into(), DISPUTE_STOP.into()])?,
937 );
938 } else {
939 writes.push(
940 self.db
941 .prepare(
942 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, dispute_owed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?2)
943 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3,
944 dispute_owed_micros = dispute_owed_micros + ?4, updated_at = ?2",
945 )
946 .bind(&[workspace.as_str().into(), now.as_str().into(), DISPUTE_STOP.into(), (reversed as f64).into()])?,
947 );
948 }
949 match self.db.batch(writes).await {
950 Ok(_) => {}
951 // The reversal was entered already: by a delivery alongside,
952 // whose batch recorded the dispute and what is owed with it, or
953 // (before these went in one batch) on its own, its dispute
954 // left unrecorded. Only then is the dispute recorded now, at
955 // the amount entered, once.
956 // Given back already, by a delivery alongside.
957 Err(error) if restored > 0 && crate::is_duplicate(&error) => {
958 return Ok(format!("ignored: dispute {dispute_id} is won already"));
959 }
960 Err(error) if reversing && crate::is_duplicate(&error) => {
961 return self.dispute_reversed_before(&workspace, dispute_id, charge_id, &reference, &now).await;
962 }
963 Err(error) => return Err(error),
964 }
965 let account = self.account_of(&workspace).await?;
966 let what = match state {
967 "won" if restored > 0 => {
968 format!("dispute reversed in the workspace's favour: {} given back", crate::features::dollars(restored))
969 }
970 "won" => "dispute closed in the workspace's favour".to_owned(),
971 "lost" => format!("dispute lost: {} taken back; work stopped until it is paid", crate::features::dollars(reversed)),
972 _ => "dispute: work stopped".to_owned(),
973 };
974 self.audit(&account.id, "dispute", &format!("{workspace}: {what}"), "stripe").await?;
975 Ok(format!("{workspace}: {what}"))
976 }
977
978 /// A lost dispute whose reversal is on the ledger (or the plan's
979 /// payments) already. Recorded as lost, and what it took back owed,
980 /// only by the one delivery that moves the dispute's row to lost.
981 async fn dispute_reversed_before(&self, workspace: &str, dispute_id: &str, charge_id: &str, reference: &str, now: &str) -> Result<String> {
982 #[derive(Deserialize)]
983 struct Sum {
984 micros: Option<i64>,
985 }
986 let reversed = self
987 .db
988 .prepare(
989 "SELECT -(COALESCE((SELECT SUM(amount_micros) FROM ledger WHERE reference = ?1), 0)
990 + COALESCE((SELECT SUM(amount_micros) FROM plan_payments WHERE invoice_id = ?1), 0)) AS micros",
991 )
992 .bind(&[reference.into()])?
993 .first::<Sum>(None)
994 .await?
995 .and_then(|s| s.micros)
996 .unwrap_or(0)
997 .max(0);
998 let moved = self
999 .db
1000 .prepare(
1001 "INSERT INTO disputes (dispute_id, workspace, charge_id, status, reversed_micros, updated_at) VALUES (?1, ?2, ?3, 'lost', ?4, ?5)
1002 ON CONFLICT (dispute_id) DO UPDATE SET status = 'lost', reversed_micros = ?4, updated_at = ?5 WHERE disputes.status <> 'lost'
1003 RETURNING dispute_id",
1004 )
1005 .bind(&[dispute_id.into(), workspace.into(), charge_id.into(), (reversed as f64).into(), now.into()])?
1006 .first::<Value>(None)
1007 .await?
1008 .is_some();
1009 if !moved {
1010 return Ok(format!("ignored: dispute {dispute_id} is lost already"));
1011 }
1012 self.db
1013 .prepare(
1014 "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, dispute_owed_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?2)
1015 ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3,
1016 dispute_owed_micros = dispute_owed_micros + ?4, updated_at = ?2",
1017 )
1018 .bind(&[workspace.into(), now.into(), DISPUTE_STOP.into(), (reversed as f64).into()])?
1019 .run()
1020 .await?;
1021 Ok(format!("{workspace}: dispute lost: {} taken back earlier; work stopped until it is paid", crate::features::dollars(reversed)))
1022 }
1023
1024 // --- Enterprise invoices ------------------------------------------------
1025
1026 pub(crate) async fn admin_enterprise_billing(&self, a: AdminEnterpriseBillingArgs) -> Result<Outcome<BillingAccount>> {
1027 let email = a.email.trim().to_lowercase();
1028 if !email.contains('@') || email.contains(char::is_whitespace) || a.by.trim().is_empty() {
1029 return Ok(Outcome::fail(FailureCode::Invalid, "Give the email the enterprise's invoices go to."));
1030 }
1031 let Some(stripe) = &self.stripe else {
1032 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
1033 };
1034 #[derive(Deserialize)]
1035 struct Row {
1036 name: String,
1037 kind: String,
1038 customer_id: Option<String>,
1039 }
1040 let Some(row) = self
1041 .db
1042 .prepare("SELECT name, kind, customer_id FROM billing_accounts WHERE id = ?")
1043 .bind(&[a.id.as_str().into()])?
1044 .first::<Row>(None)
1045 .await?
1046 .filter(|row| row.kind == "enterprise")
1047 else {
1048 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
1049 };
1050 #[derive(Deserialize)]
1051 struct Customer {
1052 id: String,
1053 }
1054 let fields = [
1055 ("name", row.name.clone()),
1056 ("email", email.clone()),
1057 ("metadata[g1t_enterprise]", a.id.clone()),
1058 ];
1059 let customer: Customer = match &row.customer_id {
1060 Some(id) => stripe.post(&format!("/customers/{id}"), &fields).await?,
1061 None => stripe.post("/customers", &fields).await?,
1062 };
1063 self.db
1064 .prepare("UPDATE billing_accounts SET billing_email = ?, customer_id = ? WHERE id = ?")
1065 .bind(&[email.as_str().into(), customer.id.as_str().into(), a.id.as_str().into()])?
1066 .run()
1067 .await?;
1068 self.audit(&a.id, "billing_email", &format!("Invoices go to {email}"), &a.by).await?;
1069 Ok(match self.enterprise(&a.id).await? {
1070 Some(account) => Outcome::Ok(account),
1071 None => Outcome::fail(FailureCode::NotFound, "No such enterprise."),
1072 })
1073 }
1074
1075 /// `admin_enterprise_address`: the address Stripe Tax works the
1076 /// enterprise's invoices out from, and its tax ID, on its customer.
1077 pub(crate) async fn admin_enterprise_address(&self, a: AdminEnterpriseAddressArgs) -> Result<Outcome<bool>> {
1078 if a.by.trim().is_empty() {
1079 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is changing it."));
1080 }
1081 if let Some(why) = crate::details::address_invalid(&a.address).or_else(|| crate::details::tax_id_invalid(a.tax_id_type.as_deref(), a.tax_id.as_deref())) {
1082 return Ok(Outcome::fail(FailureCode::Invalid, why));
1083 }
1084 let place = serde_json::json!({ "country": a.address.country.trim(), "postal_code": a.address.postal_code.trim(), "state": a.address.state.trim() });
1085 if !crate::stripe::address_places_customer(&place) {
1086 return Ok(Outcome::fail(FailureCode::Invalid, "Give at least the country, and in the US the ZIP code: Stripe Tax needs them."));
1087 }
1088 let Some(stripe) = &self.stripe else {
1089 return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
1090 };
1091 #[derive(Deserialize)]
1092 struct Row {
1093 kind: String,
1094 customer_id: Option<String>,
1095 }
1096 let Some(row) = self
1097 .db
1098 .prepare("SELECT kind, customer_id FROM billing_accounts WHERE id = ?")
1099 .bind(&[a.id.as_str().into()])?
1100 .first::<Row>(None)
1101 .await?
1102 .filter(|row| row.kind == "enterprise")
1103 else {
1104 return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
1105 };
1106 let Some(customer) = row.customer_id else {
1107 return Ok(Outcome::fail(FailureCode::Conflict, "Set where its invoices go first: that makes its Stripe customer."));
1108 };
1109 let fields: Vec<(&str, String)> = vec![
1110 ("address[line1]", a.address.line1.trim().to_owned()),
1111 ("address[line2]", a.address.line2.trim().to_owned()),
1112 ("address[city]", a.address.city.trim().to_owned()),
1113 ("address[state]", a.address.state.trim().to_owned()),
1114 ("address[postal_code]", a.address.postal_code.trim().to_owned()),
1115 ("address[country]", a.address.country.trim().to_uppercase()),
1116 ];
1117 if let Err(error) = stripe.post::<Value>(&format!("/customers/{customer}"), &fields).await {
1118 return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error)));
1119 }
1120 if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref())
1121 && let Err(why) = crate::details::replace_tax_id(stripe, &customer, &a.id, kind, value).await
1122 {
1123 return Ok(Outcome::fail(FailureCode::Invalid, why));
1124 }
1125 self.audit(&a.id, "billing_address", &format!("Billing address set ({})", a.address.country.trim().to_uppercase()), &a.by).await?;
1126 Ok(Outcome::Ok(true))
1127 }
1128
1129 pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> {
1130 if a.by.trim().is_empty() {
1131 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it."));
1132 }
1133 match self.invoice_enterprise(&a.id, "now", &a.by).await {
1134 Ok(Ok(invoice)) => Ok(Outcome::Ok(invoice)),
1135 Ok(Err(why)) => Ok(Outcome::fail(FailureCode::Conflict, why)),
1136 Err(error) => {
1137 worker::console_error!("enterprise invoice {} could not complete: {error}", a.id);
1138 Ok(Outcome::fail(FailureCode::Conflict,
1139 "The invoice has not completed. Retry to resume the same invoice; if this continues, contact support."))
1140 }
1141 }
1142 }
1143
1144 /// Invoices each enterprise for the month that closed. Live payments
1145 /// only; sudo can send one sooner in test mode.
1146 pub(crate) async fn invoice_enterprises(&self) -> Result<()> {
1147 if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
1148 return Ok(());
1149 }
1150 let closing = crate::limits::previous_month(&rfc3339(now_ms())[..7]);
1151 #[derive(Deserialize)]
1152 struct Id {
1153 id: String,
1154 }
1155 let due = self
1156 .db
1157 .prepare(format!(
1158 "SELECT id FROM billing_accounts WHERE kind = 'enterprise' AND customer_id IS NOT NULL
1159 AND NOT {full}
1160 AND NOT EXISTS (SELECT 1 FROM enterprise_invoices i WHERE i.account_id = billing_accounts.id AND i.period = ?)",
1161 full = crate::sales::FULL_DISCOUNT_SQL
1162 ))
1163 .bind(&[closing.as_str().into()])?
1164 .all()
1165 .await?
1166 .results::<Id>()?;
1167 for Id { id } in due {
1168 if let Err(why) = self.invoice_enterprise(&id, &closing, "month close").await? {
1169 worker::console_log!("enterprise {id} not invoiced for {closing}: {why}");
1170 }
1171 }
1172 Ok(())
1173 }
1174
1175 /// One invoice for what each of the enterprise's workspaces owes now.
1176 async fn invoice_enterprise(&self, id: &str, period: &str, by: &str) -> Result<std::result::Result<EnterpriseInvoice, String>> {
1177 if self.stripe.is_none() { return Ok(Err("Payments are not set up.".into())); }
1178 let Some(account) = self.enterprise(id).await? else { return Ok(Err("No such enterprise.".into())) };
1179 self.db.prepare(crate::enterprise_attempt::RETIRE).bind(&[id.into()])?.run().await?;
1180 if let Some(attempt) = self.enterprise_attempt(id).await? {
1181 return self.finish_enterprise_attempt(id, attempt).await;
1182 }
1183 #[derive(Deserialize)]
1184 struct Customer {
1185 customer_id: Option<String>,
1186 }
1187 let Some(customer) = self
1188 .db
1189 .prepare("SELECT customer_id FROM billing_accounts WHERE id = ?")
1190 .bind(&[id.into()])?
1191 .first::<Customer>(None)
1192 .await?
1193 .and_then(|row| row.customer_id)
1194 else {
1195 return Ok(Err("Set where the enterprise's invoices go first.".into()));
1196 };
1197 // What each workspace owes: its charges less what it has paid, and
1198 // less what is on invoices still open.
1199 let mut lines = vec![];
1200 let mut snapshots = vec![];
1201 for workspace in &account.workspaces {
1202 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
1203 #[derive(Deserialize)]
1204 struct Sum {
1205 micros: Option<i64>,
1206 }
1207 let invoiced = self
1208 .db
1209 .prepare(
1210 "SELECT SUM(l.amount_micros) AS micros FROM enterprise_invoice_lines l
1211 JOIN enterprise_invoices i ON i.invoice_id = l.invoice_id
1212 WHERE l.workspace = ? AND i.status IN ('open', 'overdue')",
1213 )
1214 .bind(&[workspace.as_str().into()])?
1215 .first::<Sum>(None)
1216 .await?
1217 .and_then(|s| s.micros)
1218 .unwrap_or(0);
1219 snapshots.push(crate::enterprise_attempt::Snapshot { workspace: workspace.clone(), balance, invoiced });
1220 let owed = (-balance).max(0) - invoiced;
1221 if owed >= 10_000 {
1222 // Store precisely what Stripe bills, including cent rounding.
1223 lines.push(InvoiceLine { workspace: workspace.clone(), amount_micros: ((owed + 9_999) / 10_000) * 10_000 });
1224 }
1225 }
1226 if lines.is_empty() {
1227 return Ok(Err("Its workspaces owe nothing to invoice.".into()));
1228 }
1229 let attempt = self.begin_enterprise_attempt(id, crate::enterprise_attempt::Request {
1230 customer, name: account.name, period: period.to_owned(), by: by.to_owned(), lines,
1231 }, snapshots).await?;
1232 self.finish_enterprise_attempt(id, attempt).await
1233 }
1234
1235 async fn finish_enterprise_attempt(&self, id: &str, attempt: crate::enterprise_attempt::Attempt) -> Result<std::result::Result<EnterpriseInvoice, String>> {
1236 let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
1237 let request: crate::enterprise_attempt::Request = serde_json::from_str(&attempt.payload)
1238 .map_err(|e| worker::Error::RustError(e.to_string()))?;
1239 let customer = request.customer;
1240 let name = request.name;
1241 let period = request.period.as_str();
1242 let by = request.by.as_str();
1243 let lines = request.lines;
1244 // Stripe Tax places the customer by its address; without one the
1245 // invoice could not be finalized, so it is not started.
1246 match stripe.customer_placed(&customer).await {
1247 Ok(true) => {}
1248 Ok(false) => {
1249 return Ok(Err(format!(
1250 "Add {}'s billing address first (Invoices → Billing address): Stripe needs it to work out tax.",
1251 name
1252 )));
1253 }
1254 Err(error) => return Ok(Err(crate::stripe::friendly(&error))),
1255 }
1256 // Invoiced, never by card: no card fee. Tax on top, by Stripe Tax.
1257 let mut fields = vec![
1258 ("customer", customer.clone()),
1259 ("auto_advance", "false".to_owned()),
1260 ("collection_method", "send_invoice".to_owned()),
1261 ("days_until_due", "30".to_owned()),
1262 ("pending_invoice_items_behavior", "exclude".to_owned()),
1263 ("description", format!("g1t usage for the {} enterprise", name)),
1264 ("metadata[g1t_enterprise]", id.to_owned()),
1265 ("metadata[g1t_attempt]", attempt.id.clone()),
1266 ("metadata[period]", period.to_owned()),
1267 ];
1268 fields.extend(crate::stripe::invoice_tax_fields());
1269 #[derive(Deserialize)]
1270 struct Invoice {
1271 id: String,
1272 status: String,
1273 #[serde(default)]
1274 hosted_invoice_url: Option<String>,
1275 #[serde(default)]
1276 amount_due: i64,
1277 /// The tax Stripe added, in cents (`tax`, in this API version).
1278 #[serde(default)]
1279 tax: Option<i64>,
1280 }
1281 // The draft first, keyed on what it bills, and its lines put on it:
1282 // an attempt that failed half way is found again, never billed again
1283 // by an invoice sweeping its pending lines in.
1284 let cents: Vec<i64> = lines.iter().map(|line| (line.amount_micros + 9_999) / 10_000).collect();
1285 let key = attempt.id;
1286 let too_old = !crate::invoice_attempt::safe_to_build(attempt.created_ms, now_ms());
1287 let draft: Invoice = if let Some(invoice_id) = attempt.invoice_id {
1288 stripe.get(&format!("/invoices/{invoice_id}")).await?
1289 } else {
1290 if too_old { return Ok(Err(format!("Invoice attempt {key} needs reconciliation before retrying an expired Stripe idempotency key."))); }
1291 let invoice: Invoice = stripe.post_idempotent("/invoices", &fields, &key).await?;
1292 self.db.prepare("UPDATE enterprise_invoice_attempts SET invoice_id=? WHERE id=?")
1293 .bind(&[invoice.id.as_str().into(), key.as_str().into()])?.run().await?;
1294 invoice
1295 };
1296 if draft.status == "draft" {
1297 if too_old { return Ok(Err(format!("Draft {} needs reconciliation before rebuilding old invoice items.", draft.id))); }
1298 for (line, cents) in lines.iter().zip(&cents) {
1299 let mut fields = vec![
1300 ("customer", customer.clone()),
1301 ("invoice", draft.id.clone()),
1302 ("amount", cents.to_string()),
1303 ("currency", "usd".to_owned()),
1304 ("description", format!("{}: g1t usage", line.workspace)),
1305 ("metadata[workspace]", line.workspace.clone()),
1306 ("metadata[g1t_attempt]", key.clone()),
1307 ];
1308 fields.extend(crate::stripe::item_tax_fields());
1309 let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{}", line.workspace)).await?;
1310 }
1311 let _: Invoice = stripe.post_idempotent(&format!("/invoices/{}/finalize", draft.id), &[], &format!("{key}/finalize")).await?;
1312 }
1313 let current: Invoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
1314 let sent: Invoice = match current.status.as_str() {
1315 "open" => stripe.post_idempotent(&format!("/invoices/{}/send", draft.id), &[], &format!("{key}/send")).await?,
1316 "paid" | "void" | "uncollectible" => current,
1317 _ => return Ok(Err(format!("Invoice {} is not ready to send ({}).", current.id, current.status))),
1318 };
1319 let now = rfc3339(now_ms());
1320 // What the workspaces owe, before tax: the tax is the enterprise's
1321 // to pay on top, never their usage, and is kept apart.
1322 let tax_cents = sent.tax.unwrap_or(0).max(0);
1323 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max((sent.amount_due - tax_cents) * 10_000);
1324 let mut writes = vec![self
1325 .db
1326 .prepare(
1327 "INSERT INTO enterprise_invoices (invoice_id, account_id, period, amount_micros, status, hosted_url, created_by, created_at)
1328 SELECT ?, ?, ?, ?, 'open', ?, ?, ?
1329 WHERE EXISTS (SELECT 1 FROM enterprise_invoice_attempts WHERE id=? AND completed=0)
1330 ON CONFLICT (invoice_id) DO NOTHING",
1331 )
1332 .bind(&[
1333 sent.id.as_str().into(),
1334 id.into(),
1335 period.into(),
1336 (total as f64).into(),
1337 crate::optional(sent.hosted_invoice_url.as_deref()),
1338 by.into(),
1339 now.as_str().into(),
1340 key.as_str().into(),
1341 ])?];
1342 for line in &lines {
1343 writes.push(
1344 self.db
1345 .prepare("INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros)
1346 SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM enterprise_invoice_attempts WHERE id=? AND completed=0)
1347 ON CONFLICT (invoice_id, workspace) DO NOTHING")
1348 .bind(&[sent.id.as_str().into(), line.workspace.as_str().into(), (line.amount_micros as f64).into(), key.as_str().into()])?,
1349 );
1350 }
1351 writes.push(self.db.prepare("UPDATE enterprise_invoice_attempts SET completed=1 WHERE id=?")
1352 .bind(&[key.as_str().into()])?);
1353 self.db.batch(writes).await?;
1354 if sent.status == "paid" {
1355 self.enterprise_invoice_paid(&sent.id).await?;
1356 } else if matches!(sent.status.as_str(), "void" | "uncollectible") {
1357 self.enterprise_invoice_status(&sent.id, &sent.status).await?;
1358 }
1359 self.audit(id, "invoice", &format!("Invoice {} for {} sent ({period})", sent.id, crate::features::dollars(total)), by)
1360 .await?;
1361 Ok(Ok(EnterpriseInvoice {
1362 invoice_id: sent.id,
1363 hosted_url: sent.hosted_invoice_url,
1364 amount_micros: total,
1365 status: sent.status,
1366 period: period.to_owned(),
1367 lines,
1368 created_at: now,
1369 }))
1370 }
1371
1372 /// An enterprise invoice paid: each workspace is credited its line, and
1373 /// any stop for the invoice is lifted.
1374 ///
1375 /// Each line is credited before the invoice is marked paid, once by its
1376 /// ledger reference: a failure part way leaves it open, and the event
1377 /// again credits only the lines not yet credited.
1378 async fn enterprise_invoice_paid(&self, invoice_id: &str) -> Result<String> {
1379 let open = self
1380 .db
1381 .prepare("SELECT invoice_id FROM enterprise_invoices WHERE invoice_id = ? AND status <> 'paid'")
1382 .bind(&[invoice_id.into()])?
1383 .first::<Value>(None)
1384 .await?;
1385 if open.is_none() {
1386 return Ok("ignored: not an open enterprise invoice".to_owned());
1387 }
1388 // Read at billing's API version, before anything is written: the
1389 // event's copy may lack `tax` and `payment_intent`.
1390 let fetched: Option<Value> = match &self.stripe {
1391 Some(stripe) => Some(stripe.get(&format!("/invoices/{invoice_id}")).await?),
1392 None => None,
1393 };
1394 if fetched.as_ref().and_then(|i| i["status"].as_str()) != Some("paid") {
1395 return Ok("ignored: enterprise invoice is not paid".to_owned());
1396 }
1397 let lines = self.invoice_lines(invoice_id).await?;
1398 for line in &lines {
1399 let reference = format!("inv/{invoice_id}/{}", line.workspace);
1400 let seen = self.db.prepare("SELECT id FROM ledger WHERE reference = ?").bind(&[reference.as_str().into()])?.first::<Value>(None).await?;
1401 if seen.is_some() {
1402 continue;
1403 }
1404 let entered = self
1405 .enter(
1406 &line.workspace,
1407 EntryKind::TopUp,
1408 line.amount_micros,
1409 &format!("Paid on the enterprise's invoice {invoice_id}"),
1410 &reference,
1411 None,
1412 None,
1413 None,
1414 )
1415 .await;
1416 match entered {
1417 Ok(()) => {}
1418 Err(error) if crate::is_duplicate(&error) => {}
1419 Err(error) => return Err(error),
1420 }
1421 }
1422 // Its tax is the enterprise's, paid on top of what its workspaces
1423 // owed, and never credited to them.
1424 #[derive(Deserialize)]
1425 struct Account {
1426 account_id: String,
1427 }
1428 if let Some(account) = self
1429 .db
1430 .prepare("SELECT account_id FROM enterprise_invoices WHERE invoice_id = ?")
1431 .bind(&[invoice_id.into()])?
1432 .first::<Account>(None)
1433 .await?
1434 && let Some(invoice) = &fetched
1435 {
1436 let extras = crate::tax::Extras { tax_cents: invoice["tax"].as_i64().unwrap_or(0).max(0), fee_cents: 0 };
1437 self.record_extras(&account.account_id, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?;
1438 }
1439 // Complete only after all retryable accounting work succeeded.
1440 let mut completed = vec![self.db
1441 .prepare("UPDATE enterprise_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ? AND status <> 'paid'")
1442 .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?];
1443 // Clear a concurrent overdue stop in the same transaction that makes
1444 // the invoice terminal. A late stop checks this state atomically.
1445 for line in &lines {
1446 completed.push(self.db.prepare(crate::CLEAR_STOP).bind(&[line.workspace.as_str().into()])?);
1447 }
1448 self.db.batch(completed).await?;
1449 Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len()))
1450 }
1451
1452 /// An enterprise invoice that went overdue stops its workspaces' work;
1453 /// one voided is simply closed.
1454 async fn enterprise_invoice_status(&self, invoice_id: &str, status: &str) -> Result<String> {
1455 let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".into()) };
1456 let invoice: Value = stripe.get(&format!("/invoices/{invoice_id}")).await?;
1457 let status = match invoice["status"].as_str() {
1458 Some("paid") => return self.enterprise_invoice_paid(invoice_id).await,
1459 Some("void") => "void",
1460 Some("open") if status == "overdue"
1461 && invoice["due_date"].as_i64().is_some_and(|due| due < (now_ms() / 1000) as i64) => "overdue",
1462 _ => return Ok("ignored: invoice is no longer in the event's state".into()),
1463 };
1464 let updated = self
1465 .db
1466 .prepare(ENTERPRISE_STATUS)
1467 .bind(&[status.into(), invoice_id.into()])?
1468 .first::<Value>(None)
1469 .await?;
1470 if updated.is_none() {
1471 return Ok("ignored: not an open enterprise invoice".to_owned());
1472 }
1473 if status == "overdue" {
1474 let now = rfc3339(now_ms());
1475 for line in self.invoice_lines(invoice_id).await? {
1476 self.db
1477 .prepare(ENTERPRISE_OVERDUE_STOP)
1478 .bind(&[line.workspace.as_str().into(), now.as_str().into(), format!("the enterprise's invoice {invoice_id} is overdue").into(), invoice_id.into()])?
1479 .run()
1480 .await?;
1481 }
1482 }
1483 Ok(format!("invoice {invoice_id} is {status}"))
1484 }
1485
1486 async fn invoice_lines(&self, invoice_id: &str) -> Result<Vec<InvoiceLine>> {
1487 Ok(self
1488 .db
1489 .prepare("SELECT workspace, amount_micros FROM enterprise_invoice_lines WHERE invoice_id = ?")
1490 .bind(&[invoice_id.into()])?
1491 .all()
1492 .await?
1493 .results::<LineRow>()?
1494 .into_iter()
1495 .map(|row| InvoiceLine { workspace: row.workspace, amount_micros: row.amount_micros })
1496 .collect())
1497 }
1498
1499 /// An enterprise's invoices, newest first.
1500 pub(crate) async fn enterprise_invoices(&self, id: &str) -> Result<Vec<EnterpriseInvoice>> {
1501 let rows = self
1502 .db
1503 .prepare("SELECT * FROM enterprise_invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 24")
1504 .bind(&[JsValue::from(id)])?
1505 .all()
1506 .await?
1507 .results::<InvoiceRow>()?;
1508 let mut invoices = vec![];
1509 for row in rows {
1510 invoices.push(EnterpriseInvoice {
1511 lines: self.invoice_lines(&row.invoice_id).await?,
1512 invoice_id: row.invoice_id,
1513 hosted_url: row.hosted_url,
1514 amount_micros: row.amount_micros,
1515 status: row.status,
1516 period: row.period,
1517 created_at: row.created_at,
1518 });
1519 }
1520 Ok(invoices)
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527
1528 #[test]
1529 fn an_enterprise_invoice_attempted_again_is_the_same_invoice() {
1530 let lines = [("acme", 1_250), ("acme-labs", 99)];
1531 let key = enterprise_invoice_key("ent_1", "2026-09", &lines);
1532 assert_eq!(key, enterprise_invoice_key("ent_1", "2026-09", &lines));
1533 assert!(key.len() < 255 && key.starts_with("ent-invoice/ent_1/2026-09/"));
1534 // Different amounts, even with the same total, are a different invoice.
1535 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-09", &[("acme", 1_300), ("acme-labs", 49)]));
1536 assert_ne!(key, enterprise_invoice_key("ent_1", "2026-10", &lines));
1537 }
1538
1539 #[test]
1540 fn an_event_from_the_other_mode_is_ignored() {
1541 assert!(wrong_mode(Some(false), "live").is_some());
1542 assert!(wrong_mode(Some(true), "test").is_some());
1543 assert!(wrong_mode(Some(true), "live").is_none());
1544 assert!(wrong_mode(Some(false), "test").is_none());
1545 assert!(wrong_mode(None, "live").is_none());
1546 }
1547
1548 #[test]
1549 fn a_dispute_won_after_it_was_lost_gives_back_what_it_took_once() {
1550 // Lost then won: the $46.40 taken back is given back.
1551 assert_eq!(restore_on_win(Some("lost"), "won", 46_400_000), 46_400_000);
1552 // Won from open, or won again (a redelivery): nothing.
1553 assert_eq!(restore_on_win(Some("open"), "won", 0), 0);
1554 assert_eq!(restore_on_win(None, "won", 0), 0);
1555 assert_eq!(restore_on_win(Some("won"), "won", 46_400_000), 0);
1556 assert!(!dispute_moves(Some("won"), "won"));
1557 assert_eq!(restore_on_win(Some("lost"), "lost", 46_400_000), 0);
1558 assert_eq!(restore_on_win(Some("lost"), "won", -5), 0);
1559 // Its own reference: unique, so a delivery alongside fails the
1560 // batch instead of giving it back twice; apart from the loss's.
1561 assert_eq!(won_reference("dp_1"), "dispute/dp_1/won");
1562 assert_ne!(won_reference("dp_1"), "dispute/dp_1");
1563 // What is owed comes down by it, never below nothing, before the
1564 // stop is cleared (which needs nothing owed).
1565 assert!(LOWER_DISPUTE_OWED.contains("MAX(0, dispute_owed_micros - ?2)"));
1566 assert!(crate::CLEAR_STOP.contains("dispute_owed_micros = 0"));
1567 }
1568
1569 #[test]
1570 fn a_dispute_event_twice_or_late_changes_nothing() {
1571 assert!(dispute_moves(None, "open"));
1572 assert!(dispute_moves(None, "lost"));
1573 assert!(dispute_moves(Some("open"), "lost"));
1574 assert!(dispute_moves(Some("open"), "won"));
1575 assert!(dispute_moves(Some("lost"), "won"));
1576 // The same state again: nothing stopped, taken back or owed twice.
1577 for state in ["open", "won", "lost"] {
1578 assert!(!dispute_moves(Some(state), state), "{state}");
1579 }
1580 // A late `created` after the dispute was lost never re-opens it.
1581 assert!(!dispute_moves(Some("lost"), "open"));
1582 }
1583
1584 #[test]
1585 fn a_refund_counts_what_earlier_refunds_took_back_wherever_they_were_kept() {
1586 assert!(ALREADY_REFUNDED_SQL.contains("FROM plan_payments WHERE invoice_id LIKE ?1"));
1587 assert!(ALREADY_REFUNDED_SQL.contains("FROM ledger WHERE reference LIKE ?1"));
1588 assert_eq!(crate::rename::parameters(ALREADY_REFUNDED_SQL), 1);
1589 }
1590
1591 #[test]
1592 fn a_dispute_is_acted_on_as_it_stands_now() {
1593 for open in ["warning_needs_response", "warning_under_review", "needs_response", "under_review"] {
1594 assert_eq!(dispute_state(open), "open");
1595 }
1596 assert_eq!(dispute_state("won"), "won");
1597 assert_eq!(dispute_state("warning_closed"), "won");
1598 assert_eq!(dispute_state("lost"), "lost");
1599 // A lost dispute takes back only the credit: $50 paid as $46.40
1600 // credit, $1.60 card fee and $2 tax.
1601 assert_eq!(crate::tax::refund_split(5_000, 5_000, 200, 160).0, 4_640);
1602 // Lifting a stop for a won dispute leaves a declined card's.
1603 assert!(crate::CLEAR_STOP.contains("dispute_owed_micros = 0") && crate::CLEAR_STOP.contains("status = 'open'"));
1604 assert!(!DISPUTE_STOP.is_empty());
1605 }
1606
1607 fn sign(payload: &str, secret: &str, t: i64) -> String {
1608 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1609 mac.update(format!("{t}.{payload}").as_bytes());
1610 format!("t={t},v1={}", hex::encode(mac.finalize().into_bytes()))
1611 }
1612
1613 #[test]
1614 fn a_destination_misses_the_events_billing_handles_that_it_does_not_send() {
1615 let has: Vec<String> = EVENTS.iter().take(11).map(|e| (*e).to_owned()).collect();
1616 assert_eq!(missing_events(&has), EVENTS[11..].iter().map(|e| (*e).to_owned()).collect::<Vec<_>>());
1617 let all: Vec<String> = EVENTS.iter().map(|e| (*e).to_owned()).collect();
1618 assert!(missing_events(&all).is_empty());
1619 assert!(missing_events(&["*".to_owned()]).is_empty());
1620 }
1621
1622 #[test]
1623 fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
1624 let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
1625 let header = sign(payload, "whsec_test", 1_000_000);
1626 assert!(verify(payload, &header, "whsec_test", 1_000_010));
1627 assert!(!verify(payload, &header, "whsec_other", 1_000_010));
1628 assert!(!verify(&payload.replace("paid", "voided"), &header, "whsec_test", 1_000_010));
1629 assert!(!verify(payload, &header, "whsec_test", 1_000_000 + 301));
1630 assert!(!verify(payload, "v1=abc", "whsec_test", 1_000_000));
1631 assert!(!verify(payload, &format!("t={},v1=00", i64::MIN), "whsec_test", i64::MAX));
1632 assert!(!verify(payload, &format!("t={},v1=00", i64::MAX), "whsec_test", i64::MIN));
1633 // Stripe may sign with more than one secret while one is rolled.
1634 let both = format!("{},v1=00ff", sign(payload, "whsec_test", 1_000_000));
1635 assert!(verify(payload, &both, "whsec_test", 1_000_000));
1636 }
1637}