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.

67 linesCodeBlameRaw
1//! Durable invoice requests. Stripe's idempotency cache is not a database:
2//! freeze the request locally, and stop ambiguous old attempts for review.
3
4use serde::{Deserialize, Serialize};
5use worker::Result;
6use crate::Billing;
7
8pub(crate) const INSERT: &str = "INSERT OR IGNORE INTO invoice_attempts (id, workspace, payload, created_ms)
9 SELECT ?1, ?2, ?3, ?4 FROM accounts WHERE workspace = ?2 AND balance_micros = ?5";
10pub(crate) const RETIRE: &str = "DELETE FROM invoice_attempts WHERE workspace = ?1 AND invoice_id IN
11 (SELECT invoice_id FROM workspace_invoices WHERE workspace = ?1 AND status IN ('paid', 'void'))";
12
13#[derive(Serialize, Deserialize)]
14pub(crate) struct Request {
15 pub customer: String,
16 pub workspace: String,
17 pub reason: String,
18 pub period: String,
19 pub through_at: String,
20 pub lines: Vec<g1t_contracts::billing::InvoiceItem>,
21}
22
23#[derive(Deserialize)]
24pub(crate) struct Attempt {
25 pub id: String,
26 pub payload: String,
27 pub created_ms: u64,
28 pub invoice_id: Option<String>,
29}
30
31// Leave an hour before Stripe may prune the idempotency key. An unfinished
32// older draft needs reconciliation, never a blind second charge or line.
33pub(crate) fn safe_to_build(created: u64, now: u64) -> bool {
34 now.saturating_sub(created) < 23 * 60 * 60 * 1000
35}
36
37impl Billing {
38 pub(crate) async fn pending_invoice_attempt(&self, workspace: &str) -> Result<Option<Attempt>> {
39 self.db.prepare(RETIRE).bind(&[workspace.into()])?.run().await?;
40 self.db.prepare("SELECT id, payload, created_ms, invoice_id FROM invoice_attempts WHERE workspace = ?")
41 .bind(&[workspace.into()])?.first::<Attempt>(None).await
42 }
43
44 pub(crate) async fn begin_invoice_attempt(&self, workspace: &str, balance: i64, request: Request) -> Result<Attempt> {
45 let now = g1t_kit::now_ms();
46 let payload = serde_json::to_string(&request).map_err(|e| worker::Error::RustError(e.to_string()))?;
47 self.db.prepare(INSERT).bind(&[
48 g1t_contracts::new_id("invoice", now).into(), workspace.into(), payload.into(), (now as f64).into(), (balance as f64).into(),
49 ])?.run().await?;
50 // Do not retire here: another caller may just have completed this
51 // same attempt. Return it, rather than starting another invoice.
52 self.db.prepare("SELECT id, payload, created_ms, invoice_id FROM invoice_attempts WHERE workspace = ?")
53 .bind(&[workspace.into()])?.first::<Attempt>(None).await?
54 .ok_or_else(|| worker::Error::RustError("Invoice attempt changed; retry".into()))
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61 #[test]
62 fn expired_idempotency_keys_are_never_reused_to_build_an_invoice() {
63 assert!(safe_to_build(1_000, 1_000 + 23 * 60 * 60 * 1000 - 1));
64 assert!(!safe_to_build(1_000, 1_000 + 23 * 60 * 60 * 1000));
65 assert!(!safe_to_build(0, u64::MAX));
66 }
67}