| 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 | |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | use worker::Result; |
| 6 | use crate::Billing; |
| 7 | |
| 8 | pub(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"; |
| 10 | pub(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)] |
| 14 | pub(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)] |
| 24 | pub(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. |
| 33 | pub(crate) fn safe_to_build(created: u64, now: u64) -> bool { |
| 34 | now.saturating_sub(created) < 23 * 60 * 60 * 1000 |
| 35 | } |
| 36 | |
| 37 | impl 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)] |
| 59 | mod 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 | } |