Skip to main content

max / makenotwork

payments: close refund crash-window, seal enforcement_mode enum, license-key idempotency Drive Payments to A (ultra-fuzz Run 7). Pay-S1: split claim from completion in pending_refunds (mig 154 adds completed_at). The refund work marks completed_at only on success; the stale sweep now filters completed_at IS NULL, so a claimed-but-crashed row surfaces for human reconciliation instead of being silently dropped or auto-retried (double-refund risk). Crash-window regression test added. enforcement_mode -> sealed SyncEnforcementMode enum (mirrors SyncBillingStatus): DbSyncAppBilling, the DB writers, blob-confirm, and the warning scan carry the enum; monthly_price_cents now matches exhaustively, so the unknown-mode -> floor fail-open (Pay-S2) is gone by construction. validate_knobs is the single parse point. Dead is_empty draft-sentinel removed (column is NOT NULL DEFAULT 'bulk'). Pay-M1: create_license_key branches on the violated constraint — a transaction_id collision returns the existing key (idempotent finalize re-run); only a key_code collision regenerates and retries. Pay-M2: try_create_activation reads max_activations from the FOR UPDATE-locked row rather than a caller-supplied arg, closing the TOCTOU. Pay-M3: cart subtotal widened to i64 so a large cart can't overflow. create_tip guards amount_cents > 0; Cents::new documented as intentionally unchecked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 21:47 UTC
Commit: d69642cee9bd2b2268e86d71d8c45e214c6c83bf
Parent: e2b6070
22 files changed, +316 insertions, -149 deletions
@@ -1,22 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "SELECT 1 AS \"one!\" FROM license_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE",
4 - "describe": {
5 - "columns": [
6 - {
7 - "ordinal": 0,
8 - "name": "one!",
9 - "type_info": "Int4"
10 - }
11 - ],
12 - "parameters": {
13 - "Left": [
14 - "Uuid"
15 - ]
16 - },
17 - "nullable": [
18 - null
19 - ]
20 - },
21 - "hash": "698765b93a419364d535ce8d01b263b24dbb4b714d17f6b45d1aa7162ee44458"
22 - }
@@ -0,0 +1,14 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "UPDATE pending_refunds SET completed_at = NOW() WHERE id = $1",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid"
9 + ]
10 + },
11 + "nullable": []
12 + },
13 + "hash": "8d30cfccbacb09236d4d9753c5f607fdd085af2cbaed48008a9f4f8967b7f909"
14 + }
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT max_activations FROM license_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "max_activations",
9 + "type_info": "Int4"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Uuid"
15 + ]
16 + },
17 + "nullable": [
18 + true
19 + ]
20 + },
21 + "hash": "b7dd8dd51aea41b8f2f91e2b1d01702c627fc20f7507fefe5a175b1f2e7a7813"
22 + }
@@ -0,0 +1,18 @@
1 + -- Pay-S1: close the pending-refund crash window.
2 + --
3 + -- Before this, `claim_pending_refund` set `matched_at = NOW()` as a single marker
4 + -- meaning both "claimed" and "processed". A SIGKILL/OOM/panic between the claim and
5 + -- the graceful unclaim stranded the row: `get_stale_refunds` only saw
6 + -- `matched_at IS NULL` rows, and the original `charge.refunded` event was already
7 + -- marked processed, so the owed refund was silently dropped.
8 + --
9 + -- Split the two states: `matched_at` marks the claim, a new `completed_at` marks the
10 + -- refund work actually finishing. The stale-refund sweep now surfaces any row that
11 + -- was claimed but never completed (matched-but-stuck) for human escalation rather
12 + -- than auto-retrying money movement (which could double-refund).
13 + ALTER TABLE pending_refunds ADD COLUMN completed_at TIMESTAMPTZ;
14 +
15 + -- Existing matched rows were processed synchronously under the old design (the
16 + -- process did not crash mid-refund, or they'd not be matched), so treat them as
17 + -- completed to avoid escalating historical rows.
18 + UPDATE pending_refunds SET completed_at = matched_at WHERE matched_at IS NOT NULL;
@@ -212,9 +212,7 @@ impl_str_enum!(SubscriptionStatus {
212 212 /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
213 213 /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
214 214 /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
215 - /// CHECK set. (The sibling `enforcement_mode` column stays a string: the pricing layer
216 - /// and the DB use two vocabularies — `bulk` vs `app_wide` — whose reconciliation is a
217 - /// separate, deliberately deferred refactor.)
215 + /// CHECK set.
218 216 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219 217 pub enum SyncBillingStatus {
220 218 #[serde(rename = "draft")]
@@ -234,6 +232,25 @@ impl_str_enum!(SyncBillingStatus {
234 232 Canceled => "canceled",
235 233 });
236 234
235 + /// How a SyncKit developer app's storage billing is enforced (the
236 + /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')`
237 + /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to
238 + /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an
239 + /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical
240 + /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live.
241 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242 + pub enum SyncEnforcementMode {
243 + #[serde(rename = "per_key")]
244 + PerKey,
245 + #[serde(rename = "bulk")]
246 + Bulk,
247 + }
248 +
249 + impl_str_enum!(SyncEnforcementMode {
250 + PerKey => "per_key",
251 + Bulk => "bulk",
252 + });
253 +
237 254 // ── Git repository visibility ──
238 255
239 256 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -45,8 +45,27 @@ pub async fn create_license_key(
45 45 match first {
46 46 Ok(key) => Ok(key),
47 47 Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => {
48 + // Distinguish which unique index fired. A `transaction_id` collision
49 + // (mig 151's partial unique index) means this purchase already minted
50 + // its one key — a duplicate finalize from crash-recovery redelivery.
51 + // Return the existing key as idempotent success; retrying with a fresh
52 + // code would only collide on the same index again (Pay-M1). A `key_code`
53 + // collision is a random clash — regenerate and retry once.
54 + let constraint = e.constraint().map(str::to_string);
55 + if let (Some("license_keys_transaction_id_key"), Some(tx_id)) =
56 + (constraint.as_deref(), transaction_id)
57 + {
58 + return get_license_key_by_transaction_id(pool, tx_id)
59 + .await?
60 + .ok_or_else(|| {
61 + crate::error::AppError::Internal(anyhow::anyhow!(
62 + "license_keys transaction_id unique violation but no existing \
63 + row found for {tx_id:?}"
64 + ))
65 + });
66 + }
48 67 let retry_code = crate::helpers::generate_key_code();
49 - tracing::warn!(item_id = %item_id, "license key 23505 collision; retrying once");
68 + tracing::warn!(item_id = %item_id, "license key key_code 23505 collision; retrying once");
50 69 let key = sqlx::query_as!(
51 70 DbLicenseKey,
52 71 r#"
@@ -281,24 +300,25 @@ pub async fn try_create_activation(
281 300 license_key_id: LicenseKeyId,
282 301 machine_id: &str,
283 302 label: Option<&str>,
284 - max_activations: Option<i32>,
285 303 ) -> Result<Option<DbLicenseActivation>> {
286 304 let mut tx = pool.begin().await?;
287 305
288 - // Lock the license key row to serialize concurrent activations, and re-check
289 - // revocation INSIDE the lock (MINOR TOCTOU, Run #23): a charge.refunded that
290 - // revokes the key between the caller's pre-check and here must block the
291 - // activation. No eligible (non-revoked) row => no activation.
292 - let locked = sqlx::query_scalar!(
293 - r#"SELECT 1 AS "one!" FROM license_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE"#,
306 + // Lock the license key row to serialize concurrent activations, re-check
307 + // revocation, AND read `max_activations` from the locked row — not from a
308 + // caller-supplied argument (Pay-M2). The caller's value was read before the
309 + // lock; if an admin lowered the cap in between, enforcing the stale arg would
310 + // let an extra machine activate. Reading the column here makes the limit the
311 + // authoritative one. No eligible (non-revoked) row => no activation.
312 + let locked: Option<Option<i32>> = sqlx::query_scalar!(
313 + r#"SELECT max_activations FROM license_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE"#,
294 314 license_key_id as LicenseKeyId,
295 315 )
296 316 .fetch_optional(&mut *tx)
297 317 .await?;
298 - if locked.is_none() {
318 + let Some(max_activations) = locked else {
299 319 tx.rollback().await?;
300 320 return Ok(None);
301 - }
321 + };
302 322
303 323 // Check if this machine already has an activation (re-activation is always OK)
304 324 let existing: Option<DbLicenseActivation> = sqlx::query_as!(
@@ -137,8 +137,10 @@ pub struct DbSyncAppBilling {
137 137 /// Storage cap in GB. Set in bulk mode; NULL in per_key mode (capacity is
138 138 /// derived from `key_cap × gb_per_key`) and in draft.
139 139 pub storage_gb_cap: Option<i32>,
140 - /// `'per_key'` | `'bulk'`. Drives both pricing and degradation behavior.
141 - pub enforcement_mode: String,
140 + /// `PerKey` | `Bulk`. Drives both pricing and degradation behavior. The DB
141 + /// column is `NOT NULL CHECK (enforcement_mode IN ('per_key','bulk'))`, so it
142 + /// always decodes to one of the two variants.
143 + pub enforcement_mode: super::super::SyncEnforcementMode,
142 144 /// Max active keys. Set in per_key mode; NULL in bulk mode.
143 145 pub key_cap: Option<i32>,
144 146 /// GB allotment per active key (per_key mode only). Total storage
@@ -46,8 +46,11 @@ pub struct PendingRefund {
46 46
47 47 /// Claim a pending refund matching a payment intent ID.
48 48 ///
49 - /// Atomically marks it as matched (so it is only processed once).
50 - /// Returns `None` if no unmatched pending refund exists.
49 + /// Atomically marks it as matched (so it is only claimed once) but NOT completed —
50 + /// completion is recorded separately by [`mark_refund_completed`] only after the
51 + /// fallible refund work succeeds. A claim that is never completed (process killed
52 + /// mid-refund) leaves `completed_at IS NULL`, so the stale-refund sweep surfaces it
53 + /// for human escalation (PAY-S1). Returns `None` if no unmatched pending refund exists.
51 54 pub async fn claim_pending_refund(
52 55 pool: &PgPool,
53 56 payment_intent_id: &str,
@@ -73,17 +76,26 @@ pub async fn claim_pending_refund(
73 76 Ok(row)
74 77 }
75 78
76 - /// Release a claimed pending refund back to the queue (`matched_at` → NULL) after
77 - /// its processing failed.
79 + /// Record that a claimed pending refund's processing finished successfully.
78 80 ///
79 - /// `claim_pending_refund` marks the row matched BEFORE the fallible refund work
80 - /// runs. If that work errors, the claim must be released or the row is lost
81 - /// forever: `get_stale_refunds` only sees `matched_at IS NULL` rows, and the
82 - /// original `charge.refunded` event was already marked processed, so Stripe will
83 - /// not redeliver it (PAY-S1). Releasing the claim re-enters the row into the
84 - /// stale-refund sweep (human escalation) and lets a later delivery re-claim and
85 - /// retry — safe because the refund handler is atomic, so a failed attempt
86 - /// committed nothing. Idempotent.
81 + /// Sets `completed_at`; only after this is the row considered fully handled. A
82 + /// claimed row without a `completed_at` (the process died between claim and this
83 + /// call) is surfaced by [`get_stale_refunds`] for manual reconciliation instead of
84 + /// being auto-retried — re-issuing a refund that may already have reached Stripe
85 + /// could double-refund (PAY-S1).
86 + pub async fn mark_refund_completed(pool: &PgPool, id: uuid::Uuid) -> Result<()> {
87 + sqlx::query!("UPDATE pending_refunds SET completed_at = NOW() WHERE id = $1", id)
88 + .execute(pool)
89 + .await?;
90 + Ok(())
91 + }
92 +
93 + /// Release a claimed pending refund back to the queue (`matched_at` → NULL) after a
94 + /// *graceful* processing failure (a transient error where the handler committed
95 + /// nothing — it is atomic). Releasing re-opens the row so a later webhook delivery
96 + /// can re-claim and retry. A non-graceful failure (process killed) cannot reach
97 + /// here; that row stays matched-but-incomplete and is escalated by the sweep
98 + /// instead (PAY-S1). Idempotent.
87 99 pub async fn unclaim_pending_refund(pool: &PgPool, id: uuid::Uuid) -> Result<()> {
88 100 sqlx::query!("UPDATE pending_refunds SET matched_at = NULL WHERE id = $1", id)
89 101 .execute(pool)
@@ -107,8 +119,12 @@ pub struct StaleRefund {
107 119 /// letting one unbounded query stall the tick (PERF-S2, Run #23).
108 120 pub const STALE_REFUND_BATCH: i64 = 100;
109 121
110 - /// Get up to [`STALE_REFUND_BATCH`] pending refunds older than `age` that have
111 - /// not been matched or escalated, oldest first.
122 + /// Get up to [`STALE_REFUND_BATCH`] pending refunds older than `age` that still
123 + /// need attention and have not been escalated, oldest first. "Need attention" means
124 + /// the refund work never completed: either the row was never matched to a payment
125 + /// (`matched_at IS NULL`), or it was claimed but the process died before recording
126 + /// completion (`matched_at IS NOT NULL AND completed_at IS NULL`) — the crash-window
127 + /// case (PAY-S1). Both surface here for human reconciliation.
112 128 pub async fn get_stale_refunds(
113 129 pool: &PgPool,
114 130 age: chrono::Duration,
@@ -121,7 +137,7 @@ pub async fn get_stale_refunds(
121 137 r#"
122 138 SELECT id, payment_intent_id, amount, amount_refunded, created_at
123 139 FROM pending_refunds
124 - WHERE matched_at IS NULL
140 + WHERE completed_at IS NULL
125 141 AND escalated_at IS NULL
126 142 AND created_at < $1
127 143 ORDER BY created_at
@@ -165,7 +165,7 @@ pub async fn confirm_developer_blob(
165 165 size_bytes: i64,
166 166 s3_key: &str,
167 167 key: &str,
168 - enforcement_mode: &str,
168 + enforcement_mode: crate::db::SyncEnforcementMode,
169 169 storage_gb_cap: Option<i32>,
170 170 key_cap: Option<i32>,
171 171 gb_per_key: Option<i32>,
@@ -196,7 +196,7 @@ pub async fn confirm_developer_blob(
196 196 }
197 197
198 198 match enforcement_mode {
199 - "bulk" => {
199 + crate::db::SyncEnforcementMode::Bulk => {
200 200 if let Some(gb) = storage_gb_cap {
201 201 let limit = crate::synckit_billing::storage_cap_bytes(gb as u32);
202 202 if app_used.saturating_add(size_bytes) > limit {
@@ -209,7 +209,7 @@ pub async fn confirm_developer_blob(
209 209 }
210 210 }
211 211 }
212 - "per_key" => {
212 + crate::db::SyncEnforcementMode::PerKey => {
213 213 if let (Some(kc), Some(g)) = (key_cap, gb_per_key) {
214 214 let per_key_limit = crate::synckit_billing::storage_cap_bytes(g as u32);
215 215 let app_limit =
@@ -242,7 +242,6 @@ pub async fn confirm_developer_blob(
242 242 }
243 243 }
244 244 }
245 - _ => {}
246 245 }
247 246
248 247 insert_blob_tx(&mut tx, app_id, user_id, hash, size_bytes, s3_key, key).await?;
@@ -15,7 +15,7 @@
15 15 //! ('draft','active','suspended_unpaid','canceled')
16 16 //! storage_gb_cap INT
17 17 //! egress_multiple NUMERIC(6,2)
18 - //! enforcement_mode TEXT NOT NULL CHECK IN ('per_key','app_wide')
18 + //! enforcement_mode TEXT NOT NULL CHECK IN ('per_key','bulk')
19 19 //! key_cap INT
20 20 //! current_period_start TIMESTAMPTZ
21 21 //! current_period_end TIMESTAMPTZ
@@ -82,7 +82,7 @@ pub async fn set_stripe_customer(
82 82 pub async fn activate_billing(
83 83 pool: &PgPool,
84 84 app_id: SyncAppId,
85 - enforcement_mode: &str,
85 + enforcement_mode: super::SyncEnforcementMode,
86 86 storage_gb_cap: Option<i32>,
87 87 key_cap: Option<i32>,
88 88 gb_per_key: Option<i32>,
@@ -122,7 +122,7 @@ pub async fn activate_billing(
122 122 pub async fn update_knobs(
123 123 pool: &PgPool,
124 124 app_id: SyncAppId,
125 - enforcement_mode: &str,
125 + enforcement_mode: super::SyncEnforcementMode,
126 126 storage_gb_cap: Option<i32>,
127 127 key_cap: Option<i32>,
128 128 gb_per_key: Option<i32>,
@@ -634,7 +634,7 @@ pub async fn get_apps_needing_warning(pool: &PgPool, limit: i64) -> Result<Vec<W
634 634 creator_id: UserId,
635 635 creator_email: String,
636 636 app_name: String,
637 - enforcement_mode: String,
637 + enforcement_mode: super::SyncEnforcementMode,
638 638 storage_gb_cap: Option<i32>,
639 639 key_cap: Option<i32>,
640 640 gb_per_key: Option<i32>,
@@ -671,8 +671,8 @@ pub async fn get_apps_needing_warning(pool: &PgPool, limit: i64) -> Result<Vec<W
671 671 let mut out = Vec::new();
672 672 let mut per_key_apps: Vec<(SyncAppId, UserId, String, String, i32)> = Vec::new();
673 673 for r in rows {
674 - match r.enforcement_mode.as_str() {
675 - "bulk" => {
674 + match r.enforcement_mode {
675 + super::SyncEnforcementMode::Bulk => {
676 676 let Some(gb) = r.storage_gb_cap else { continue };
677 677 let limit = crate::synckit_billing::storage_cap_bytes(gb as u32);
678 678 if let Some(pct) =
@@ -691,13 +691,12 @@ pub async fn get_apps_needing_warning(pool: &PgPool, limit: i64) -> Result<Vec<W
691 691 });
692 692 }
693 693 }
694 - "per_key" => {
694 + super::SyncEnforcementMode::PerKey => {
695 695 // Defer to a second query that fans out per (app, key); the
696 696 // per-key counter — not the app aggregate — is what we warn on.
697 697 let (Some(_), Some(g)) = (r.key_cap, r.gb_per_key) else { continue };
698 698 per_key_apps.push((r.app_id, r.creator_id, r.creator_email, r.app_name, g));
699 699 }
700 - _ => {}
701 700 }
702 701 }
703 702
@@ -18,6 +18,12 @@ pub async fn create_tip(
18 18 message: Option<&str>,
19 19 stripe_checkout_session_id: &str,
20 20 ) -> Result<DbTip> {
21 + // Defense in depth: the route handler enforces a $1 minimum and the column
22 + // carries CHECK (amount_cents > 0), but guard here too so any future caller
23 + // gets a clean validation error instead of a raw constraint violation.
24 + if amount_cents <= 0 {
25 + return Err(crate::error::AppError::validation("Tip amount must be positive"));
26 + }
21 27 let tip = sqlx::query_as!(
22 28 DbTip,
23 29 r#"
@@ -244,6 +244,11 @@ pub struct Cents(i64);
244 244 impl Cents {
245 245 pub const ZERO: Self = Self(0);
246 246
247 + /// Wrap a raw cents value. Intentionally unchecked: `Cents` is a general money
248 + /// quantity that legitimately holds zero (free items, empty balances) and
249 + /// negative values (refund deltas, split adjustments). Positivity is a
250 + /// *domain* constraint, so it's enforced at the domain boundary that needs it
251 + /// (e.g. `PriceCents::new`, `create_tip`) rather than blanket-rejected here.
247 252 pub fn new(cents: i64) -> Self {
248 253 Self(cents)
249 254 }
@@ -169,7 +169,6 @@ pub(super) async fn validate_key(
169 169 key.id,
170 170 &req.machine_id,
171 171 req.label.as_deref(),
172 - key.max_activations,
173 172 ).await?;
174 173
175 174 if activation.is_none() {
@@ -590,7 +589,6 @@ pub(super) async fn license_verify(
590 589 key.id,
591 590 &req.machine_fingerprint,
592 591 None,
593 - key.max_activations,
594 592 )
595 593 .await?;
596 594 if reactivated.is_none() {
@@ -609,7 +607,6 @@ pub(super) async fn license_verify(
609 607 key.id,
610 608 &req.machine_fingerprint,
611 609 None,
612 - key.max_activations,
613 610 )
614 611 .await?;
615 612 if activation.is_none() {
@@ -162,7 +162,8 @@ pub(super) async fn cart_page(
162 162 let seller_groups: Vec<CartSellerGroup> = groups
163 163 .into_iter()
164 164 .map(|(seller_id_str, items)| {
165 - let subtotal_cents: i32 = items.iter().map(|i| i.effective_price_cents()).sum();
165 + let subtotal_cents: i64 =
166 + items.iter().map(|i| i64::from(i.effective_price_cents())).sum();
166 167 let item_count = items.len();
167 168 // Savings: buying N items in one session saves (N-1) * $0.30
168 169 let savings_cents = if item_count > 1 { (item_count as i32 - 1) * 30 } else { 0 };
@@ -232,23 +232,35 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s
232 232
233 233 // requeue_if_unmatched = false: this row is already claimed, so an unmatched
234 234 // result must release the claim (below), not insert a duplicate pending row.
235 - if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data, false).await {
236 - tracing::error!(
237 - error = ?e, pending_refund_id = %pending.id,
238 - "failed to process pending refund after payment completion — releasing claim"
239 - );
240 - // The claim was taken before this fallible work. `handle_charge_refunded`
241 - // is atomic, so on error nothing committed; release the claim so the
242 - // stale-refund sweep escalates it and a later delivery can retry. Without
243 - // this the row stays matched forever and the refund is silently lost
244 - // (PAY-S1) — the sweep filters `matched_at IS NULL` and the original
245 - // refund event was already marked processed, so Stripe won't redeliver.
246 - if let Err(e2) = db::pending_refunds::unclaim_pending_refund(&state.db, pending.id).await {
235 + match super::billing::handle_charge_refunded(state, &refund_data, false).await {
236 + Ok(()) => {
237 + // Record completion only after the refund work succeeded. If the process
238 + // dies between the claim and this point, the row stays matched-but-
239 + // incomplete and the stale-refund sweep escalates it for manual
240 + // reconciliation (PAY-S1) instead of silently dropping the refund.
241 + if let Err(e) = db::pending_refunds::mark_refund_completed(&state.db, pending.id).await {
242 + tracing::error!(
243 + error = ?e, pending_refund_id = %pending.id,
244 + "processed pending refund but failed to mark it completed — \
245 + the sweep will escalate it for manual confirmation"
246 + );
247 + }
248 + }
249 + Err(e) => {
247 250 tracing::error!(
248 - error = ?e2, pending_refund_id = %pending.id,
249 - "failed to release pending refund claim after a processing failure — \
250 - refund needs manual intervention"
251 + error = ?e, pending_refund_id = %pending.id,
252 + "failed to process pending refund after payment completion — releasing claim"
251 253 );
254 + // `handle_charge_refunded` is atomic, so on a graceful error nothing
255 + // committed; release the claim so a later delivery can re-claim and
256 + // retry, and the sweep escalates it in the meantime.
257 + if let Err(e2) = db::pending_refunds::unclaim_pending_refund(&state.db, pending.id).await {
258 + tracing::error!(
259 + error = ?e2, pending_refund_id = %pending.id,
260 + "failed to release pending refund claim after a processing failure — \
261 + refund needs manual intervention"
262 + );
263 + }
252 264 }
253 265 }
254 266 }
@@ -99,7 +99,7 @@ pub(super) async fn activate(
99 99 ) -> Result<impl IntoResponse> {
100 100 user.check_not_sandbox()?;
101 101 user.check_not_suspended()?;
102 - validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
102 + let mode = validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
103 103
104 104 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
105 105 .await?
@@ -120,7 +120,7 @@ pub(super) async fn activate(
120 120 })?;
121 121
122 122 let price_cents = monthly_price_cents(
123 - &req.enforcement_mode,
123 + mode,
124 124 req.storage_gb_cap,
125 125 req.key_cap,
126 126 req.gb_per_key,
@@ -142,7 +142,7 @@ pub(super) async fn activate(
142 142 db::synckit_billing::activate_billing(
143 143 &state.db,
144 144 app_id,
145 - &req.enforcement_mode,
145 + mode,
146 146 req.storage_gb_cap.map(|v| v as i32),
147 147 req.key_cap.map(|v| v as i32),
148 148 req.gb_per_key.map(|v| v as i32),
@@ -171,7 +171,7 @@ pub(super) async fn patch(
171 171 ) -> Result<impl IntoResponse> {
172 172 user.check_not_sandbox()?;
173 173 user.check_not_suspended()?;
174 - validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
174 + let mode = validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
175 175
176 176 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
177 177 .await?
@@ -192,7 +192,7 @@ pub(super) async fn patch(
192 192 })?;
193 193
194 194 let new_price = monthly_price_cents(
195 - &req.enforcement_mode,
195 + mode,
196 196 req.storage_gb_cap,
197 197 req.key_cap,
198 198 req.gb_per_key,
@@ -209,7 +209,7 @@ pub(super) async fn patch(
209 209 db::synckit_billing::update_knobs(
210 210 &state.db,
211 211 app_id,
212 - &req.enforcement_mode,
212 + mode,
213 213 req.storage_gb_cap.map(|v| v as i32),
214 214 req.key_cap.map(|v| v as i32),
215 215 req.gb_per_key.map(|v| v as i32),
@@ -273,13 +273,14 @@ pub(super) async fn get(
273 273 return Err(AppError::Forbidden);
274 274 }
275 275
276 - let knobs_set = match app.enforcement_mode.as_str() {
277 - "bulk" => app.storage_gb_cap.is_some(),
278 - "per_key" => app.key_cap.is_some() && app.gb_per_key.is_some(),
279 - _ => false,
276 + let knobs_set = match app.enforcement_mode {
277 + crate::db::SyncEnforcementMode::Bulk => app.storage_gb_cap.is_some(),
278 + crate::db::SyncEnforcementMode::PerKey => {
279 + app.key_cap.is_some() && app.gb_per_key.is_some()
280 + }
280 281 };
281 282 let monthly_price_cents = knobs_set.then(|| monthly_price_cents(
282 - &app.enforcement_mode,
283 + app.enforcement_mode,
283 284 app.storage_gb_cap.map(|v| v as u32),
284 285 app.key_cap.map(|v| v as u32),
285 286 app.gb_per_key.map(|v| v as u32),
@@ -289,7 +290,7 @@ pub(super) async fn get(
289 290 app_id,
290 291 billing_status: app.billing_status.to_string(),
291 292 is_internal: app.is_internal,
292 - enforcement_mode: app.enforcement_mode,
293 + enforcement_mode: app.enforcement_mode.to_string(),
293 294 storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
294 295 key_cap: app.key_cap.map(|v| v as u32),
295 296 gb_per_key: app.gb_per_key.map(|v| v as u32),
@@ -361,12 +362,17 @@ fn synckit_return_url(
361 362 }
362 363
363 364
365 + /// Validate the knob set for a billing request and parse the mode into the sealed
366 + /// [`SyncEnforcementMode`]. This is the single parse point for untrusted
367 + /// `enforcement_mode` input: downstream pricing and persistence take the enum, so
368 + /// an invalid mode is rejected here rather than silently mispriced (Pay-S2).
364 369 fn validate_knobs(
365 370 enforcement_mode: &str,
366 371 storage_gb_cap: Option<u32>,
367 372 key_cap: Option<u32>,
368 373 gb_per_key: Option<u32>,
369 - ) -> Result<()> {
374 + ) -> Result<crate::db::SyncEnforcementMode> {
375 + use crate::db::SyncEnforcementMode;
370 376 // Upper bound on the priced storage so a developer can't provision an
371 377 // absurd Stripe subscription. Bulk: `storage_gb_cap`. Per-key: the
372 378 // `key_cap × gb_per_key` product (the value the price is computed from).
@@ -384,6 +390,7 @@ fn validate_knobs(
384 390 "key_cap and gb_per_key must be omitted when enforcement_mode = bulk".to_string(),
385 391 ));
386 392 }
393 + Ok(SyncEnforcementMode::Bulk)
387 394 }
388 395 "per_key" => {
389 396 let k = match key_cap {
@@ -409,14 +416,14 @@ fn validate_knobs(
409 416 "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(),
410 417 ));
411 418 }
419 + Ok(SyncEnforcementMode::PerKey)
412 420 }
413 421 other => {
414 - return Err(AppError::BadRequest(format!(
422 + Err(AppError::BadRequest(format!(
415 423 "enforcement_mode must be 'bulk' or 'per_key', got {other:?}"
416 - )));
424 + )))
417 425 }
418 426 }
419 - Ok(())
420 427 }
421 428
422 429 #[cfg(test)]
@@ -187,7 +187,7 @@ pub(super) async fn blob_confirm_upload(
187 187 db::synckit::confirm_developer_blob(
188 188 &state.db, sync_user.app_id, sync_user.user_id,
189 189 &req.hash, actual_size, &s3_key, &sync_user.key,
190 - &billing.enforcement_mode, billing.storage_gb_cap, billing.key_cap, billing.gb_per_key,
190 + billing.enforcement_mode, billing.storage_gb_cap, billing.key_cap, billing.gb_per_key,
191 191 )
192 192 .await?
193 193 };
@@ -135,7 +135,8 @@ pub(super) async fn escalate_stale_refunds(state: &AppState) {
135 135 amount = refund.amount.as_i64(),
136 136 amount_refunded = refund.amount_refunded.as_i64(),
137 137 created_at = %refund.created_at,
138 - "STALE PENDING REFUND: unmatched for >24h, needs manual investigation"
138 + "STALE PENDING REFUND: not completed within >24h (unmatched, or claimed but \
139 + the refund never finished), needs manual investigation"
139 140 );
140 141
141 142 if let Some(ref to) = alert_email {
@@ -145,10 +146,12 @@ pub(super) async fn escalate_stale_refunds(state: &AppState) {
145 146 );
146 147 let body = format!(
147 148 "A charge.refunded webhook for payment intent {} has been pending for >24 hours \
148 - with no matching completed transaction.\n\n\
149 + without its refund completing.\n\n\
149 150 Amount: {}c\nAmount refunded: {}c\nReceived: {}\n\n\
150 - This likely means the checkout.session.completed webhook was lost. \
151 - Check the Stripe dashboard and reconcile manually.",
151 + Either the checkout.session.completed webhook was lost (never matched), or the \
152 + refund was claimed but the process died before it finished. \
153 + Check the Stripe dashboard for whether the refund was actually issued and \
154 + reconcile manually.",
152 155 refund.payment_intent_id,
153 156 refund.amount,
154 157 refund.amount_refunded,
@@ -46,24 +46,27 @@ pub const WARNING_THRESHOLDS_PCT: &[i16] = &[75, 90, 100];
46 46 /// Floors at `BASE_FLOOR_CENTS` so we never invoice below the Stripe-fee
47 47 /// break-even amount.
48 48 pub fn monthly_price_cents(
49 - enforcement_mode: &str,
49 + enforcement_mode: crate::db::SyncEnforcementMode,
50 50 storage_gb_cap: Option<u32>,
51 51 key_cap: Option<u32>,
52 52 gb_per_key: Option<u32>,
53 53 ) -> i64 {
54 + use crate::db::SyncEnforcementMode::{Bulk, PerKey};
54 55 // Pure integer-cents arithmetic. The rate is a whole number of cents and
55 56 // the caps are whole GB, so there is no fractional money to round; the old
56 57 // `(gb as f64 * 3.0).ceil()` was an unnecessary trip through f64. Saturating
57 58 // multiplies keep absurd admin-set caps from overflowing i64 instead of
58 - // wrapping to a negative invoice.
59 + // wrapping to a negative invoice. The match is exhaustive over the sealed
60 + // enum — there is no unknown-mode arm that could silently price at the floor
61 + // (Pay-S2); an invalid mode can't reach here because the column is
62 + // CHECK-constrained and the type is parsed at the API boundary.
59 63 let gb: i64 = match enforcement_mode {
60 - "bulk" => i64::from(storage_gb_cap.unwrap_or(0)),
61 - "per_key" => {
64 + Bulk => i64::from(storage_gb_cap.unwrap_or(0)),
65 + PerKey => {
62 66 let k = i64::from(key_cap.unwrap_or(0));
63 67 let g = i64::from(gb_per_key.unwrap_or(0));
64 68 k.saturating_mul(g)
65 69 }
66 - _ => 0,
67 70 };
68 71 let raw = gb.saturating_mul(STORAGE_RATE_CENTS_PER_GB);
69 72 raw.max(BASE_FLOOR_CENTS)
@@ -77,55 +80,54 @@ pub fn storage_cap_bytes(storage_gb: u32) -> i64 {
77 80 #[cfg(test)]
78 81 mod tests {
79 82 use super::*;
83 + use crate::db::SyncEnforcementMode::{Bulk, PerKey};
80 84
81 85 #[test]
82 86 fn bulk_mode_pricing() {
83 87 // 100 GB bulk → 100 × 3 = 300 cents.
84 - assert_eq!(monthly_price_cents("bulk", Some(100), None, None), 300);
88 + assert_eq!(monthly_price_cents(Bulk, Some(100), None, None), 300);
85 89 // 1000 GB → $30.
86 - assert_eq!(monthly_price_cents("bulk", Some(1000), None, None), 3000);
90 + assert_eq!(monthly_price_cents(Bulk, Some(1000), None, None), 3000);
87 91 }
88 92
89 93 #[test]
90 94 fn per_key_mode_pricing() {
91 95 // 50 keys × 2 GB = 100 GB equivalent → 300 cents. Matches 100 GB bulk.
92 - assert_eq!(monthly_price_cents("per_key", None, Some(50), Some(2)), 300);
96 + assert_eq!(monthly_price_cents(PerKey, None, Some(50), Some(2)), 300);
93 97 // 1000 keys × 1 GB → $30.
94 - assert_eq!(monthly_price_cents("per_key", None, Some(1000), Some(1)), 3000);
98 + assert_eq!(monthly_price_cents(PerKey, None, Some(1000), Some(1)), 3000);
95 99 }
96 100
97 101 #[test]
98 102 fn floor_kicks_in_for_small_accounts() {
99 103 // 1 GB bulk → 3¢ raw, floored to 31¢.
100 - assert_eq!(monthly_price_cents("bulk", Some(1), None, None), 31);
104 + assert_eq!(monthly_price_cents(Bulk, Some(1), None, None), 31);
101 105 // 10 GB → 30¢, also floored to 31¢ (one cent short).
102 - assert_eq!(monthly_price_cents("bulk", Some(10), None, None), 31);
106 + assert_eq!(monthly_price_cents(Bulk, Some(10), None, None), 31);
103 107 // 11 GB → 33¢, above floor.
104 - assert_eq!(monthly_price_cents("bulk", Some(11), None, None), 33);
108 + assert_eq!(monthly_price_cents(Bulk, Some(11), None, None), 33);
105 109 // 1 key × 1 GB → 3¢ raw, floored.
106 - assert_eq!(monthly_price_cents("per_key", None, Some(1), Some(1)), 31);
110 + assert_eq!(monthly_price_cents(PerKey, None, Some(1), Some(1)), 31);
107 111 }
108 112
109 113 #[test]
110 114 fn heavy_workload_pricing() {
111 115 // 10 TB bulk → 10240 × 3 = 30720¢ = $307.20.
112 - assert_eq!(monthly_price_cents("bulk", Some(10_240), None, None), 30_720);
116 + assert_eq!(monthly_price_cents(Bulk, Some(10_240), None, None), 30_720);
113 117 // 10k keys × 1 GB → same.
114 - assert_eq!(monthly_price_cents("per_key", None, Some(10_000), Some(1)), 30_000);
118 + assert_eq!(monthly_price_cents(PerKey, None, Some(10_000), Some(1)), 30_000);
115 119 }
116 120
117 121 #[test]
118 122 fn missing_knobs_drop_to_floor() {
119 123 // Mode is set but no knobs provided — should hit the floor.
120 - assert_eq!(monthly_price_cents("bulk", None, None, None), BASE_FLOOR_CENTS);
121 - assert_eq!(monthly_price_cents("per_key", None, None, None), BASE_FLOOR_CENTS);
124 + assert_eq!(monthly_price_cents(Bulk, None, None, None), BASE_FLOOR_CENTS);
125 + assert_eq!(monthly_price_cents(PerKey, None, None, None), BASE_FLOOR_CENTS);
122 126 }
123 127
124 - #[test]
125 - fn unknown_mode_drops_to_floor() {
126 - // Defensive: an unrecognized mode shouldn't blow up; it lands at the floor.
127 - assert_eq!(monthly_price_cents("unknown", Some(100), None, None), BASE_FLOOR_CENTS);
128 - }
128 + // (The former `unknown_mode_drops_to_floor` test is gone: `enforcement_mode`
129 + // is now a sealed enum, so an unrecognized mode is unrepresentable — the
130 + // Pay-S2 fail-open it guarded against can no longer be written.)
129 131
130 132 #[test]
131 133 fn floor_amount_covers_stripe_fee() {
@@ -146,7 +148,7 @@ mod tests {
146 148 #[test]
147 149 fn pricing_at_u32_max_does_not_panic() {
148 150 // u32::MAX GB × 3¢ ≈ 1.3e10 cents, fits in i64. The cast must not panic.
149 - let p = monthly_price_cents("bulk", Some(u32::MAX), None, None);
151 + let p = monthly_price_cents(Bulk, Some(u32::MAX), None, None);
150 152 assert!(p > 0, "huge price should be positive, got {p}");
151 153 }
152 154
@@ -154,7 +156,7 @@ mod tests {
154 156 fn per_key_pricing_at_u32_max_saturates_cleanly() {
155 157 // u32::MAX × u32::MAX overflows f64 precision but Rust's f64-as-i64 cast
156 158 // saturates at i64::MAX rather than UB. Must not panic.
157 - let p = monthly_price_cents("per_key", None, Some(u32::MAX), Some(u32::MAX));
159 + let p = monthly_price_cents(PerKey, None, Some(u32::MAX), Some(u32::MAX));
158 160 assert!(p > 0, "saturated price should still be positive, got {p}");
159 161 }
160 162
@@ -170,13 +172,13 @@ mod tests {
170 172 fn bulk_with_zero_gb_drops_to_floor() {
171 173 // Defensive: validate_knobs rejects gb=0 at the route layer, but the
172 174 // pure function should still produce the floor rather than 0.
173 - assert_eq!(monthly_price_cents("bulk", Some(0), None, None), BASE_FLOOR_CENTS);
175 + assert_eq!(monthly_price_cents(Bulk, Some(0), None, None), BASE_FLOOR_CENTS);
174 176 }
175 177
176 178 #[test]
177 179 fn per_key_one_dimension_zero_drops_to_floor() {
178 180 // If only one of key_cap/gb_per_key is 0, the product is 0 → floor.
179 - assert_eq!(monthly_price_cents("per_key", None, Some(0), Some(10)), BASE_FLOOR_CENTS);
180 - assert_eq!(monthly_price_cents("per_key", None, Some(10), Some(0)), BASE_FLOOR_CENTS);
181 + assert_eq!(monthly_price_cents(PerKey, None, Some(0), Some(10)), BASE_FLOOR_CENTS);
182 + assert_eq!(monthly_price_cents(PerKey, None, Some(10), Some(0)), BASE_FLOOR_CENTS);
181 183 }
182 184 }
@@ -105,7 +105,9 @@ pub struct CartSellerGroup {
105 105 pub seller_id: String,
106 106 pub stripe_ready: bool,
107 107 pub items: Vec<crate::db::cart::CartItem>,
108 - pub subtotal_cents: i32,
108 + /// i64 so a large cart can't overflow when per-item cents are summed (Pay-M3);
109 + /// the per-item price is i32 but the running total is widened.
110 + pub subtotal_cents: i64,
109 111 pub item_count: usize,
110 112 /// How much the creator saves vs. individual purchases ($0.30 per extra item).
111 113 pub savings_cents: i32,
@@ -113,7 +115,7 @@ pub struct CartSellerGroup {
113 115
114 116 impl CartSellerGroup {
115 117 pub fn subtotal_display(&self) -> String {
116 - crate::formatting::format_revenue(self.subtotal_cents as i64)
118 + crate::formatting::format_revenue(self.subtotal_cents)
117 119 }
118 120
119 121 pub fn savings_display(&self) -> String {