max / makenotwork
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 { |