max / makenotwork
66 files changed,
+2554 insertions,
-654 deletions
| @@ -0,0 +1,290 @@ | |||
| 1 | + | # Server Code Flaws (Fuzz Audit 2026-04-24) | |
| 2 | + | ||
| 3 | + | Automated adversarial code review of the MNW server. Each flaw confirmed by reading the source. | |
| 4 | + | ||
| 5 | + | All flaws fixed (except #6, false positive). 17 of 18 addressed. | |
| 6 | + | ||
| 7 | + | --- | |
| 8 | + | ||
| 9 | + | ## Critical | |
| 10 | + | ||
| 11 | + | ### 1. confirm_upload accepts arbitrary S3 key -- FIXED | |
| 12 | + | ||
| 13 | + | **Location:** `src/routes/storage/uploads.rs`, `versions.rs`, `media.rs`, `content_insertions.rs` | |
| 14 | + | ||
| 15 | + | `confirm_upload` takes an `s3_key` from the client and only checks that the object exists in S3 and that the user owns the `item_id`. It never validates the key matches what `presign_upload` generated. A user can pass another user's S3 key (discoverable from public CDN URLs) to attach someone else's file to their own item. | |
| 16 | + | ||
| 17 | + | **Fix:** Added `user_id/item_id` prefix validation to all four confirm endpoints. | |
| 18 | + | ||
| 19 | + | --- | |
| 20 | + | ||
| 21 | + | ## Serious | |
| 22 | + | ||
| 23 | + | ### 2. Unicode homograph attack on usernames and slugs -- FIXED | |
| 24 | + | ||
| 25 | + | **Location:** `src/validation/users.rs:88`, `src/validation/mod.rs:84,133`, `src/validation/items.rs:72`, `src/routes/auth.rs:316` | |
| 26 | + | ||
| 27 | + | `char::is_alphanumeric()` accepts Unicode letters (Cyrillic, Greek, etc.), not just ASCII. A user can register `alicе` (Cyrillic е) to impersonate `alice`. Same for project slugs — visually identical URLs. | |
| 28 | + | ||
| 29 | + | **Fix:** Changed to `is_ascii_alphanumeric()` in all 5 call sites. | |
| 30 | + | ||
| 31 | + | ### 3. Bundle refund does not revoke child item access -- FIXED | |
| 32 | + | ||
| 33 | + | **Location:** `src/routes/stripe/webhook/billing.rs`, `src/routes/stripe/checkout/item.rs`, `src/db/transactions.rs` | |
| 34 | + | ||
| 35 | + | When a bundle is refunded, the parent transaction is marked `refunded`, but child items were granted as separate `$0 completed` transactions via `claim_free_item`. These have no link to the parent and remain `completed`. | |
| 36 | + | ||
| 37 | + | **Fix:** Added `parent_transaction_id` column (migration 061), passed it through `ClaimParams`/`grant_bundle_items`, and added `revoke_child_transactions` call in the refund webhook handler. | |
| 38 | + | ||
| 39 | + | ### 4. i32 overflow in tip amount calculation -- FIXED | |
| 40 | + | ||
| 41 | + | **Location:** `src/routes/stripe/checkout/tips.rs:51` | |
| 42 | + | ||
| 43 | + | `amount_dollars * 100` can overflow `i32` in release builds, creating a checkout with a wrong amount. No maximum tip amount enforced server-side. | |
| 44 | + | ||
| 45 | + | **Fix:** Added $1 min / $10,000 max bounds check before multiplication. | |
| 46 | + | ||
| 47 | + | ### 5. Project member split race condition (TOCTOU) -- FIXED | |
| 48 | + | ||
| 49 | + | **Location:** `src/db/project_members.rs:25-31, 110-143` | |
| 50 | + | ||
| 51 | + | `add_project_member` reads the current total split then inserts. Two concurrent requests can both pass the `<= 100%` check and commit, totaling >100%. | |
| 52 | + | ||
| 53 | + | **Fix:** Wrapped both functions in transactions with `SELECT ... FOR UPDATE` locking. | |
| 54 | + | ||
| 55 | + | --- | |
| 56 | + | ||
| 57 | + | ## Moderate | |
| 58 | + | ||
| 59 | + | ### 6. 30-second session revocation window -- FALSE POSITIVE | |
| 60 | + | ||
| 61 | + | **Location:** `src/auth.rs:109-147` | |
| 62 | + | ||
| 63 | + | Session validation is cached for 30 seconds. However, all revocation paths (revoke_session, revoke_other_sessions, logout, password change) already evict from `session_cache`. No code path deletes sessions without clearing the cache. | |
| 64 | + | ||
| 65 | + | ### 7. SSE connection exhaustion -- FIXED | |
| 66 | + | ||
| 67 | + | **Location:** `src/routes/synckit/subscribe.rs` | |
| 68 | + | ||
| 69 | + | Rate limiting throttles new connection attempts but not concurrent open connections. A malicious user can hold many SSE connections open. | |
| 70 | + | ||
| 71 | + | **Fix:** Added per-user atomic connection counter in `AppState.sse_connections` with `SseConnectionGuard` drop guard. Max 10 concurrent SSE connections per user. | |
| 72 | + | ||
| 73 | + | ### 8. Backup codes not created in a transaction -- FIXED | |
| 74 | + | ||
| 75 | + | **Location:** `src/db/totp.rs:73-96` | |
| 76 | + | ||
| 77 | + | DELETE of old backup codes and INSERT loop for new ones are not in a transaction. A crash between them leaves the user with zero backup codes. | |
| 78 | + | ||
| 79 | + | **Fix:** Wrapped in `pool.begin()` / `tx.commit()`. | |
| 80 | + | ||
| 81 | + | ### 9. Storage increment runs after DB writes -- FIXED | |
| 82 | + | ||
| 83 | + | **Location:** `src/routes/storage/uploads.rs`, `versions.rs`, `media.rs`, `content_insertions.rs` | |
| 84 | + | ||
| 85 | + | `try_increment_storage` runs after the item's `s3_key` is written. If the increment fails (quota exceeded), the item references a file that doesn't count toward quota. | |
| 86 | + | ||
| 87 | + | **Fix:** Moved `try_increment_storage` before DB writes in all four confirm endpoints. | |
| 88 | + | ||
| 89 | + | --- | |
| 90 | + | ||
| 91 | + | ## Minor | |
| 92 | + | ||
| 93 | + | ### 10. constant_time_compare leaks string length -- FIXED | |
| 94 | + | ||
| 95 | + | **Location:** `src/helpers.rs:56-58` | |
| 96 | + | ||
| 97 | + | Early return on length mismatch. Low impact since token lengths are fixed, but technically not constant-time. | |
| 98 | + | ||
| 99 | + | **Fix:** Hash both inputs with SHA-256 before XOR comparison, eliminating the length leak. | |
| 100 | + | ||
| 101 | + | ### 11. Login timing leaks user existence -- FIXED | |
| 102 | + | ||
| 103 | + | **Location:** `src/routes/auth.rs:82-104` | |
| 104 | + | ||
| 105 | + | "User not found" returns immediately without Argon2 work (~100ms faster). Rate limiter partially masks this. | |
| 106 | + | ||
| 107 | + | **Fix:** Added dummy Argon2 verification (against a pre-computed hash) when user is not found. | |
| 108 | + | ||
| 109 | + | ### 12. Lockout race allows ~9 attempts instead of 5 -- FIXED | |
| 110 | + | ||
| 111 | + | **Location:** `src/routes/auth.rs`, `src/routes/oauth.rs`, `src/routes/synckit/auth.rs`, `src/db/auth.rs` | |
| 112 | + | ||
| 113 | + | Concurrent burst requests could each pass the lockout check before either incremented. | |
| 114 | + | ||
| 115 | + | **Fix:** Combined increment + conditional lock into a single atomic SQL UPDATE. The `locked_until` is set in the same statement that increments `failed_login_attempts`, so PostgreSQL row-level locking serializes concurrent callers. Removed the separate `lock_account` function. Updated all three call sites (login, OAuth, SyncKit). | |
| 116 | + | ||
| 117 | + | ### 13. Tip refunds not handled -- FIXED | |
| 118 | + | ||
| 119 | + | **Location:** `src/routes/stripe/webhook/billing.rs`, `src/db/tips.rs` | |
| 120 | + | ||
| 121 | + | `charge.refunded` webhook looks up `transactions` only; tips in their separate table are not updated. | |
| 122 | + | ||
| 123 | + | **Fix:** Added `refund_tip_by_payment_intent` in `db/tips.rs` and fallback lookup in the refund webhook handler. | |
| 124 | + | ||
| 125 | + | ### 14. OTA version ordering uses pub_date not semver -- FIXED | |
| 126 | + | ||
| 127 | + | **Location:** `src/db/ota.rs:84` | |
| 128 | + | ||
| 129 | + | Publishing a backport (lower version, newer date) makes it the "latest," hiding the actual newest version. | |
| 130 | + | ||
| 131 | + | **Fix:** Changed ORDER BY to use `(string_to_array(split_part(version, '-', 1), '.'))::int[] DESC` for semver sorting, with pub_date as tiebreaker. | |
| 132 | + | ||
| 133 | + | ### 15. Missing ::BIGINT casts on SUM in analytics.rs -- FIXED | |
| 134 | + | ||
| 135 | + | **Location:** `src/db/analytics.rs`, `src/db/transactions.rs` | |
| 136 | + | ||
| 137 | + | Works now due to `SUM(INT)->BIGINT` promotion, but inconsistent with casts used elsewhere. | |
| 138 | + | ||
| 139 | + | **Fix:** Added `::BIGINT` casts to all 19 SUM queries across both files. | |
| 140 | + | ||
| 141 | + | ### 16. N+1 query loops -- FIXED | |
| 142 | + | ||
| 143 | + | - `src/db/totp.rs` — 10 INSERTs for backup codes | |
| 144 | + | - `src/db/project_members.rs` — split INSERTs | |
| 145 | + | - `src/db/license_keys.rs` — deactivation loop | |
| 146 | + | ||
| 147 | + | **Fix:** Batched with UNNEST (totp, splits) and ANY (license deactivations). | |
| 148 | + | ||
| 149 | + | ### 17. Unbounded admin queries -- PARTIALLY FIXED | |
| 150 | + | ||
| 151 | + | - `src/db/users.rs:363-376` — `get_pending_appeals` | |
| 152 | + | - `src/db/projects.rs:252-259` — `get_projects_without_mt_community` | |
| 153 | + | - `src/db/users.rs:449-457` — `get_all_user_emails` (intentionally unbounded for bulk notifications) | |
| 154 | + | ||
| 155 | + | **Fix:** Added LIMIT 500 to get_pending_appeals and get_projects_without_mt_community. Left get_all_user_emails unbounded per its documented purpose (shutdown notices). | |
| 156 | + | ||
| 157 | + | ### 18. DB functions lack ownership checks -- FIXED | |
| 158 | + | ||
| 159 | + | `db/projects.rs` and `db/items.rs` mutation functions took only an entity ID with no ownership filter. | |
| 160 | + | ||
| 161 | + | **Fix:** Added `user_id` parameter to all high-risk mutation functions: | |
| 162 | + | - **Projects (5 functions):** `update_project`, `set_project_category`, `delete_project`, `update_project_image_url`, `update_project_pricing` — added `AND user_id = $N` to WHERE. | |
| 163 | + | - **Items (10 functions):** `update_item`, `delete_item`, `update_item_text`, `update_item_license_settings`, `update_item_license_text`, `move_item`, `bulk_publish`, `bulk_unpublish`, `bulk_delete`, `duplicate_item` — added `AND project_id IN (SELECT id FROM projects WHERE user_id = $N)` to WHERE. | |
| 164 | + | - Updated ~30 call sites across routes/api, routes/pages/dashboard/wizards, and routes/api/internal. | |
| 165 | + | - Skipped benign operations: `bump_cache_generation` (24+ callers, no data exposure), `increment/decrement_sales_count` (webhook-only), `increment_play_count`/`increment_download_count` (public counters), scheduler operations. | |
| 166 | + | ||
| 167 | + | --- | |
| 168 | + | ||
| 169 | + | # Fuzz Audit Round 2 (2026-04-24) | |
| 170 | + | ||
| 171 | + | Adversarial code fuzzing of the MNW server. Five parallel agents attacked payment processing, auth/sessions, file uploads, SyncKit/custom domains, and validation/DB queries. | |
| 172 | + | ||
| 173 | + | ## Critical | |
| 174 | + | ||
| 175 | + | ### 19. Partial refunds treated as full refunds | |
| 176 | + | **Location:** `src/routes/stripe/webhook/billing.rs:212-263`, `src/payments/webhooks.rs:119-129` | |
| 177 | + | ||
| 178 | + | `extract_charge_refunded` discards the `Charge` object, losing `amount_refunded`. The handler unconditionally marks the transaction as `refunded`, decrements `sales_count`, and revokes all license keys and bundle children. Stripe fires `charge.refunded` for both partial and full refunds. A $2 partial refund on a $10 purchase fully revokes access. | |
| 179 | + | ||
| 180 | + | ### 20. Webhook complete_transaction failure silently dropped | |
| 181 | + | **Location:** `src/routes/stripe/webhook/checkout.rs:93-96` | |
| 182 | + | ||
| 183 | + | When `complete_transaction` fails (e.g. transient DB error), the error is logged as a warning but `Ok(())` is returned. The outer webhook handler believes the event succeeded and does not queue a retry. The buyer paid but never gets access. | |
| 184 | + | ||
| 185 | + | ## Serious | |
| 186 | + | ||
| 187 | + | ### 21. Project slug collision on public routes | |
| 188 | + | **Location:** `src/db/projects.rs:209-221` | |
| 189 | + | ||
| 190 | + | `get_public_project_by_slug()` queries `WHERE slug = $1 AND is_public = true LIMIT 1` without user scoping. The UNIQUE constraint is `(user_id, slug)`, not global. Two users with the same project slug produce a nondeterministic result on `/p/{slug}` routes. | |
| 191 | + | ||
| 192 | + | ### 22. Storage counter leaked on rejected file type confirm | |
| 193 | + | **Location:** `src/routes/storage/uploads.rs:164-198` | |
| 194 | + | ||
| 195 | + | `try_increment_storage` runs before the `match file_type` block that rejects Download/Insertion types. Storage counter is incremented, then the request errors out. Never decremented. Orphaned S3 objects accumulate. | |
| 196 | + | ||
| 197 | + | ### 23. Unlimited storage via cover/media image uploads | |
| 198 | + | **Location:** `src/db/creator_tiers.rs:517-518` | |
| 199 | + | ||
| 200 | + | `check_upload_allowed` returns `i64::MAX` for Cover/MediaImage. The `try_increment_storage` WHERE clause always passes. No quota enforcement on these file types. | |
| 201 | + | ||
| 202 | + | ### 24. File replacement doesn't decrement old file storage | |
| 203 | + | **Location:** `src/routes/storage/uploads.rs:164-174` | |
| 204 | + | ||
| 205 | + | Uploading a new audio/cover/video increments the storage counter but never decrements the old file's size or deletes the old S3 object. Counter drifts upward on every replacement. | |
| 206 | + | ||
| 207 | + | ### 25. object_size None treated as 0 bytes | |
| 208 | + | **Location:** `src/routes/storage/uploads.rs:136`, `src/routes/storage/versions.rs:130`, `src/routes/storage/media.rs:210` | |
| 209 | + | ||
| 210 | + | `s3.object_size(&key).await?.unwrap_or(0)` means S3 eventual consistency or transient errors record the file as 0 bytes. File passes size checks and consumes no quota but is served at full size from CDN. | |
| 211 | + | ||
| 212 | + | ### 26. SyncKit auth timing oracle for user enumeration | |
| 213 | + | **Location:** `src/routes/synckit/auth.rs:42-44` | |
| 214 | + | ||
| 215 | + | Unlike the web login (which uses `DUMMY_HASH`), the SyncKit auth endpoint returns immediately on user-not-found. Response time difference reveals whether an email is registered. | |
| 216 | + | ||
| 217 | + | ### 27. 2FA has no per-account attempt limit | |
| 218 | + | **Location:** `src/routes/pages/public/two_factor.rs:57-101` | |
| 219 | + | ||
| 220 | + | Per-IP rate limiting exists, but no account-level counter. `pending_2fa_user_id` persists for the full session lifetime. Distributed attackers bypass IP limits. 6-digit TOTP codes have only 1M possibilities. | |
| 221 | + | ||
| 222 | + | ### 28. Blob confirm TOCTOU — duplicate insert causes 500 | |
| 223 | + | **Location:** `src/routes/synckit/blobs.rs:110-128` | |
| 224 | + | ||
| 225 | + | `get_sync_blob_by_hash` then `create_sync_blob` is not atomic. Two concurrent confirms for the same hash both pass the check; second insert hits UNIQUE constraint. | |
| 226 | + | ||
| 227 | + | ### 29. Blob confirm missing size_bytes validation | |
| 228 | + | **Location:** `src/routes/synckit/blobs.rs:88-131` | |
| 229 | + | ||
| 230 | + | Upload endpoint validates `size_bytes`, but confirm endpoint stores whatever the client sends (including 0, negative, or i64::MAX). | |
| 231 | + | ||
| 232 | + | ### 30. No device registration limit in SyncKit | |
| 233 | + | **Location:** `src/routes/synckit/sync.rs:197-218` | |
| 234 | + | ||
| 235 | + | Unlimited devices per (app, user) with distinct names. `get_sync_devices` has `LIMIT 100`, making excess devices invisible but still functional. | |
| 236 | + | ||
| 237 | + | ## Minor | |
| 238 | + | ||
| 239 | + | ### 31. PWYW has no maximum amount | |
| 240 | + | **Location:** `src/pricing.rs:186-195` | |
| 241 | + | ||
| 242 | + | Items/projects accept `amount_cents` up to i32::MAX ($21.4M). Tips correctly cap at $10K. | |
| 243 | + | ||
| 244 | + | ### 32. Revenue split truncation loses cents | |
| 245 | + | **Location:** `src/routes/stripe/webhook/checkout.rs:536-542` | |
| 246 | + | ||
| 247 | + | Integer division `amount * percent / 100` truncates. With 3 members at 33%, 12 cents per $9.99 sale disappear from accounting records. | |
| 248 | + | ||
| 249 | + | ### 33. estimate_stripe_fee negative for sub-31-cent items | |
| 250 | + | **Location:** `src/helpers.rs:269-276` | |
| 251 | + | ||
| 252 | + | Fee of 30 cents exceeds price; `creator_receives` goes negative. Display-only. | |
| 253 | + | ||
| 254 | + | ### 34. No password length cap on login paths | |
| 255 | + | **Location:** `src/routes/auth.rs:128`, `src/routes/synckit/auth.rs:65` | |
| 256 | + | ||
| 257 | + | Signup caps at 128 chars, login doesn't. Multi-MB passwords cause unnecessary Argon2 work. | |
| 258 | + | ||
| 259 | + | ### 35. Session not cycled before storing pending_2fa_user_id | |
| 260 | + | **Location:** `src/routes/auth.rs:172-189` | |
| 261 | + | ||
| 262 | + | Session fixation window during 2FA flow (pending state alone doesn't grant access). | |
| 263 | + | ||
| 264 | + | ### 36. slugify() allows non-ASCII that validate_slug() rejects | |
| 265 | + | **Location:** `src/helpers.rs:79` | |
| 266 | + | ||
| 267 | + | `is_alphanumeric()` accepts Unicode, `validate_slug()` requires ASCII. `Slug::from_trusted()` bypasses validation. | |
| 268 | + | ||
| 269 | + | ### 37. Slug::from_trusted on untrusted URL path params | |
| 270 | + | 23 occurrences across 12 files. Not exploitable (parameterized queries) but violates type invariant. | |
| 271 | + | ||
| 272 | + | ### 38. Deactivated SyncKit app usable for JWT lifetime | |
| 273 | + | **Location:** `src/routes/synckit/sync.rs:29-98` | |
| 274 | + | ||
| 275 | + | `is_active` checked at token issuance, not on each request. 7-day window. | |
| 276 | + | ||
| 277 | + | ### 39. SSE sync_notify entries never cleaned up | |
| 278 | + | **Location:** `src/routes/synckit/subscribe.rs:106-111` | |
| 279 | + | ||
| 280 | + | Slow memory leak proportional to distinct users who have ever used SSE. | |
| 281 | + | ||
| 282 | + | ### 40. ON CONFLICT DO NOTHING without conflict target | |
| 283 | + | **Location:** `src/routes/stripe/checkout/project.rs:89-104` | |
| 284 | + | ||
| 285 | + | Any unique violation silently swallowed, not just duplicate purchases. | |
| 286 | + | ||
| 287 | + | ### 41. Content-type check passes on unrecognized magic bytes | |
| 288 | + | **Location:** `src/scanning/content_type.rs:212-243` | |
| 289 | + | ||
| 290 | + | Small HTML files could bypass infer detection. Potential stored XSS depending on CDN config. |
| @@ -1,89 +1,39 @@ | |||
| 1 | 1 | # Money Transmitter Licenses | |
| 2 | 2 | ||
| 3 | - | What MTLs are, why they matter, and where we stand. | |
| 3 | + | If you receive funds from one party and send them to another, most US states require a money transmitter license (MTL). Internationally, the equivalent goes by different names — payment services license, e-money license — but the idea is the same: governments regulate who can move money. | |
| 4 | 4 | ||
| 5 | - | ## What Is a Money Transmitter License? | |
| 5 | + | Operating without one when you need one is a federal crime. Regulators can fine you, shut you down, or both. Banks won't touch you. | |
| 6 | 6 | ||
| 7 | - | A money transmitter license (MTL) is a state-level license required in the US to engage in the business of transmitting money. If you receive funds from one party and send them to another, most states require you to be licensed. | |
| 7 | + | ## We Don't Need One Today | |
| 8 | 8 | ||
| 9 | - | Similar regulations exist internationally under different names—payment services licenses, e-money licenses, etc. | |
| 9 | + | Stripe processes all our payments. Funds flow directly from fans to creators through Stripe Connect. We never receive, hold, or route creator earnings. We're a software platform that uses Stripe's licensed infrastructure — not a payment processor. | |
| 10 | 10 | ||
| 11 | - | ## Why MTLs Matter | |
| 11 | + | ## When That Would Change | |
| 12 | 12 | ||
| 13 | - | Without proper licensing, a company handling money transmission can face: | |
| 13 | + | We'd need MTLs if we: | |
| 14 | 14 | ||
| 15 | - | - **Regulatory action:** Fines, cease-and-desist orders | |
| 16 | - | - **Criminal liability:** Unlicensed money transmission is a federal crime | |
| 17 | - | - **Operational shutdown:** Regulators can force you to stop operating | |
| 18 | - | - **Banking problems:** Banks won't work with unlicensed transmitters | |
| 15 | + | - Processed payments directly instead of through Stripe | |
| 16 | + | - Held creator funds before payout (escrow, delayed disbursement) | |
| 17 | + | - Operated our own payment rails | |
| 18 | + | - Facilitated direct peer-to-peer transfers | |
| 19 | 19 | ||
| 20 | - | These aren't theoretical risks. Companies have been shut down for operating without proper licenses. | |
| 20 | + | Any of these would require licensing *before* launch, not after. | |
| 21 | 21 | ||
| 22 | - | ## Our Current Status | |
| 22 | + | ## What Licensing Looks Like | |
| 23 | 23 | ||
| 24 | - | **We don't currently need MTLs.** Here's why: | |
| 24 | + | **US:** FinCEN registration as a Money Services Business at the federal level, plus individual licenses in 47+ state jurisdictions. The process takes 1-2 years and carries ongoing compliance costs (audits, surety bonds, reporting). | |
| 25 | 25 | ||
| 26 | - | - **Stripe processes payments:** We don't receive or transmit funds | |
| 27 | - | - **Direct creator payouts:** Money flows from fans to creators via Stripe Connect | |
| 28 | - | - **No fund holding:** We never hold creator earnings | |
| 26 | + | **EU:** Payment Services Directive (PSD2) authorization, or E-Money Directive licensing depending on the model. | |
| 29 | 27 | ||
| 30 | - | Stripe is the payment processor. We're a software platform that uses Stripe's licensed infrastructure. | |
| 28 | + | **UK:** FCA authorization for payment services. | |
| 31 | 29 | ||
| 32 | - | ## When We Would Need MTLs | |
| 30 | + | **Canada:** FINTRAC registration plus provincial requirements. | |
| 33 | 31 | ||
| 34 | - | If we ever: | |
| 32 | + | Every other country has its own framework. There's no shortcut. | |
| 35 | 33 | ||
| 36 | - | - Process payments directly instead of through Stripe | |
| 37 | - | - Hold creator funds before payout | |
| 38 | - | - Facilitate direct peer-to-peer payments | |
| 39 | - | - Operate our own payment rails | |
| 34 | + | ## Our Position | |
| 40 | 35 | ||
| 41 | - | ...we would need to obtain appropriate licenses before doing so. | |
| 42 | - | ||
| 43 | - | ## The Licensing Landscape | |
| 44 | - | ||
| 45 | - | ### United States | |
| 46 | - | ||
| 47 | - | Money transmission is regulated at both federal and state levels: | |
| 48 | - | ||
| 49 | - | - **Federal:** FinCEN registration as a Money Services Business (MSB) | |
| 50 | - | - **State:** Individual licenses required in most states (47+ jurisdictions) | |
| 51 | - | ||
| 52 | - | Getting licensed in all states takes 1-2 years and significant ongoing compliance costs. | |
| 53 | - | ||
| 54 | - | ### International | |
| 55 | - | ||
| 56 | - | Every country has its own framework: | |
| 57 | - | ||
| 58 | - | - **EU:** Payment Services Directive (PSD2), E-Money Directive | |
| 59 | - | - **UK:** FCA authorization for payment services | |
| 60 | - | - **Canada:** FINTRAC registration, provincial requirements | |
| 61 | - | - **Others:** Varying requirements by jurisdiction | |
| 62 | - | ||
| 63 | - | ## Our Approach | |
| 64 | - | ||
| 65 | - | We're not rushing to handle payments directly. The current Stripe-based model: | |
| 66 | - | ||
| 67 | - | - Works reliably | |
| 68 | - | - Serves creators well | |
| 69 | - | - Doesn't require us to maintain complex licenses | |
| 70 | - | - Lets us focus on the platform, not payment infrastructure | |
| 71 | - | ||
| 72 | - | If we eventually pursue direct payment handling (see [Payment Independence](./payment-independence.md)), we'll obtain proper licenses first. We won't cut corners on compliance to save money or move faster. | |
| 73 | - | ||
| 74 | - | ## Common Questions | |
| 75 | - | ||
| 76 | - | **"Will you ever handle payments directly?"** | |
| 77 | - | ||
| 78 | - | Possibly, for specific use cases where it benefits creators (lower fees on certain transaction types, for example). Not soon, and not without proper licensing. | |
| 79 | - | ||
| 80 | - | **"Does this limit what you can offer?"** | |
| 81 | - | ||
| 82 | - | Somewhat. Some features (like holding escrow for disputes, or instant creator-to-creator payments) would require licensing. We prioritize features we can offer compliantly. | |
| 83 | - | ||
| 84 | - | **"What if Stripe changes terms or raises prices?"** | |
| 85 | - | ||
| 86 | - | We'd evaluate alternatives—other licensed processors, or potentially obtaining our own licenses. Having options matters, which is why we avoid lock-in. | |
| 36 | + | We're not pursuing MTLs now. The Stripe-based model works, serves creators well, and lets us focus on the platform. If we eventually pursue direct payment handling for specific use cases (micro-transactions, creator-to-creator splits — see [Payment Independence](./payment-independence.md)), we'll get licensed first. | |
| 87 | 37 | ||
| 88 | 38 | ## See Also | |
| 89 | 39 |
| @@ -1,127 +1,50 @@ | |||
| 1 | 1 | # Partnerships | |
| 2 | 2 | ||
| 3 | - | Our approach to partnerships: we work with organizations that share our values. | |
| 3 | + | We'll work with organizations that align with what we're building. We won't work with ones that don't. | |
| 4 | 4 | ||
| 5 | - | ## Our Position | |
| 5 | + | ## What This Means in Practice | |
| 6 | 6 | ||
| 7 | - | We're open to working with organizations that genuinely align with what we're building. | |
| 7 | + | A good partner is an organization we'd recommend to a creator even if we had no business relationship with them. They don't monetize user data, they don't use dark patterns, and they don't treat creators as a resource to extract from. | |
| 8 | 8 | ||
| 9 | - | ## What We Look For in Partners | |
| 9 | + | We're interested in: | |
| 10 | 10 | ||
| 11 | - | ### Shared Values | |
| 11 | + | - **Non-profits** in creator advocacy, digital rights, or open source | |
| 12 | + | - **Creator collectives and studios** doing real work for their members | |
| 13 | + | - **Complementary tools** that don't exploit their users | |
| 14 | + | - **Open source projects** we depend on or integrate with | |
| 15 | + | - **Educational programs** teaching creative skills | |
| 12 | 16 | ||
| 13 | - | Partners must share our commitment to: | |
| 17 | + | We're not interested in organizations that lock users in, surveil them, or have a track record of mistreating creators or workers. If we find out a partner does these things after we've started working together, we stop working with them. | |
| 14 | 18 | ||
| 15 | - | - **Creator ownership:** Creators own their work, their audience, and their data | |
| 16 | - | - **Transparent economics:** No hidden fees, no extractive practices | |
| 17 | - | - **Privacy:** No surveillance capitalism, no data monetization | |
| 18 | - | - **Sustainability over growth:** Long-term thinking, not growth-at-all-costs | |
| 19 | + | ## Sponsorship | |
| 19 | 20 | ||
| 20 | - | We don't expect partners to be identical to us. We do expect them to operate ethically and transparently. | |
| 21 | + | We may accept in-kind sponsorship for infrastructure costs — servers, storage, bandwidth. Sponsors get their name on a clearly marked sponsorship page. That's it. | |
| 21 | 22 | ||
| 22 | - | ### Types of Organizations We Work With | |
| 23 | + | Sponsors don't get placement in the product, access to user data, roadmap influence, or policy exceptions. The sponsorship page is acknowledgment, not advertising. | |
| 23 | 24 | ||
| 24 | - | - **Non-profits:** Organizations supporting creators, open source, digital rights, or related causes | |
| 25 | - | - **Creator spaces:** Collectives, studios, and communities that help creators thrive | |
| 26 | - | - **Creator tools:** Software and services that complement what we do without exploiting creators | |
| 27 | - | - **Open source projects:** Tools and infrastructure we depend on or integrate with | |
| 28 | - | - **Educational institutions:** Schools, programs, and initiatives teaching creative skills | |
| 25 | + | ## Integration | |
| 29 | 26 | ||
| 30 | - | ### What We Won't Accept | |
| 27 | + | For organizations doing work we respect, we're open to technical integrations, sharing audiences where it helps creators, or collaborating on shared problems. All partnerships are publicly disclosed — creators always know who we work with. | |
| 31 | 28 | ||
| 32 | - | We will not partner with organizations that: | |
| 29 | + | ## We Own Our Complexity | |
| 33 | 30 | ||
| 34 | - | - Monetize user data or enable surveillance | |
| 35 | - | - Use exploitative pricing or lock-in tactics | |
| 36 | - | - Have a pattern of mistreating creators or workers | |
| 37 | - | - Operate in bad faith with their users or partners | |
| 38 | - | - Fail to meet basic ethical standards in their industry | |
| 31 | + | We have a deliberate bias toward doing things ourselves. We run our own infrastructure, build our own integrations, and avoid vendor lock-in even when the managed alternative would be easier. Every external dependency is a cost we pass to creators and a decision we hand to someone else. | |
| 39 | 32 | ||
| 40 | - | If a partner violates these principles after we've begun working together, we end the relationship. | |
| 33 | + | When we do use external services, we pick them for transparent pricing, open standards, and business practices we're comfortable with. See [economics.md](./economics.md) for what this looks like in practice. | |
| 41 | 34 | ||
| 42 | - | ## What We Offer | |
| 35 | + | ## Streaming Platform Distribution | |
| 43 | 36 | ||
| 44 | - | ### Sponsorship Recognition | |
| 37 | + | We plan to offer distribution to Spotify, Apple Music, and other streaming platforms. Creators want their work available where audiences listen, so we'll make that possible. | |
| 45 | 38 | ||
| 46 | - | We may accept in-kind sponsorship for infrastructure costs—data, hardware, and eventually personnel. In return, sponsors receive recognition on a clearly designated sponsorship page. | |
| 39 | + | To be direct: we don't endorse the economics of most streaming platforms. Their incentive structures don't align with ours, and the per-stream rates are bad for most independent creators. We offer this integration because creators should decide where their art lives. If you want your music on Spotify, we'll get it there. If you don't, that's your call. | |
| 47 | 40 | ||
| 48 | - | This is not advertising. Sponsors don't get: | |
| 49 | - | ||
| 50 | - | - Placement in the product | |
| 51 | - | - Access to user data | |
| 52 | - | - Influence over our roadmap | |
| 53 | - | - Preferential treatment in our policies | |
| 54 | - | ||
| 55 | - | They get acknowledgment that they helped make this possible. That's it. | |
| 56 | - | ||
| 57 | - | ### Integration and Collaboration | |
| 58 | - | ||
| 59 | - | For aligned organizations, we're open to: | |
| 60 | - | ||
| 61 | - | - **Technical integrations:** Connecting our platform with complementary tools | |
| 62 | - | - **Co-marketing:** Sharing audiences when it benefits creators | |
| 63 | - | - **Knowledge sharing:** Collaborating on problems we both face | |
| 64 | - | - **Open source contributions:** Working together on shared infrastructure | |
| 65 | - | ||
| 66 | - | ### Transparency About the Relationship | |
| 67 | - | ||
| 68 | - | Any partnership will be publicly disclosed. Creators and users will always know who we work with and why. | |
| 69 | - | ||
| 70 | - | ## What We Expect from Partners | |
| 71 | - | ||
| 72 | - | ### Alignment, Not Just Agreement | |
| 73 | - | ||
| 74 | - | We expect partners to genuinely share our values, not just sign a document saying they do. This means: | |
| 75 | - | ||
| 76 | - | - Operating transparently with their own users | |
| 77 | - | - Treating creators and workers fairly | |
| 78 | - | - Avoiding dark patterns and manipulative design | |
| 79 | - | - Being honest about their business model | |
| 80 | - | ||
| 81 | - | ### Reliability | |
| 82 | - | ||
| 83 | - | If you commit to something, follow through. If circumstances change, communicate early. We extend the same courtesy. | |
| 84 | - | ||
| 85 | - | ### No Pressure to Compromise | |
| 86 | - | ||
| 87 | - | Partners must respect that we won't change our principles to accommodate a deal. If our values are incompatible with what you need, we're not the right fit. | |
| 88 | - | ||
| 89 | - | ## Doing Things the Hard Way | |
| 90 | - | ||
| 91 | - | We have a deliberate bias toward taking on complexity ourselves rather than overpaying for managed services. This means: | |
| 92 | - | ||
| 93 | - | - We run our own infrastructure where practical | |
| 94 | - | - We build integrations ourselves when possible | |
| 95 | - | - We avoid vendor lock-in even when it's convenient | |
| 96 | - | - We choose open source over proprietary when the option exists | |
| 97 | - | ||
| 98 | - | Every dependency is a potential point of compromise. Every managed service is a cost we pass to creators. | |
| 99 | - | ||
| 100 | - | When we do use external services, we choose carefully. We prefer providers with transparent pricing, open standards, and ethical business practices. | |
| 101 | - | ||
| 102 | - | ## Community | |
| 103 | - | ||
| 104 | - | Bug reports, documentation suggestions, and feature ideas from the community help shape this platform. | |
| 105 | - | ||
| 106 | - | If you've reported a bug, suggested an improvement, or shared an idea, you're part of this. Thank you. | |
| 107 | - | ||
| 108 | - | ## A Note on Streaming Platforms | |
| 109 | - | ||
| 110 | - | We plan to offer distribution to major streaming platforms (Spotify, Apple Music, etc.) because creators want their work available where audiences listen. | |
| 111 | - | ||
| 112 | - | To be direct: we do not endorse the business models of most streaming platforms. The economics of streaming are often unfavorable to creators, and the platforms' incentive structures don't align with ours. | |
| 113 | - | ||
| 114 | - | We offer this integration because we believe creators should decide where their art lives. If you want your music on Spotify, we'll help you get it there. If you don't, we respect that choice too. | |
| 115 | - | ||
| 116 | - | This is not a partnership. It's infrastructure we provide so creators can reach audiences wherever they are—within limits we're comfortable with. | |
| 41 | + | This isn't a partnership. It's plumbing. | |
| 117 | 42 | ||
| 118 | 43 | ## Contact | |
| 119 | 44 | ||
| 120 | - | If you represent an organization that aligns with these principles and want to explore working together: | |
| 121 | - | ||
| 122 | 45 | [partnerships@makenot.work](mailto:partnerships@makenot.work) | |
| 123 | 46 | ||
| 124 | - | We read everything. We respond to things that make sense. We don't respond to pitches that clearly didn't read this page. | |
| 47 | + | We read everything. We don't respond to pitches that clearly didn't read this page. | |
| 125 | 48 | ||
| 126 | 49 | ## See Also | |
| 127 | 50 |
| @@ -1,162 +1,65 @@ | |||
| 1 | 1 | # Payment Independence | |
| 2 | 2 | ||
| 3 | - | Our approach to payment processing: start simple, stay transparent, and work toward giving creators the best possible options. | |
| 3 | + | We use Stripe for everything today. That's fine for now, but long-term we want creators to have options. | |
| 4 | 4 | ||
| 5 | 5 | ## Current State: Stripe | |
| 6 | 6 | ||
| 7 | - | We use Stripe for all payment processing today. This includes: | |
| 7 | + | All payments run through Stripe: | |
| 8 | 8 | ||
| 9 | - | - **Fan payments:** Stripe processes purchases and subscriptions | |
| 10 | - | - **Creator payouts:** Stripe Connect routes funds directly to creators | |
| 11 | - | - **Platform billing:** Stripe handles creator subscription fees | |
| 9 | + | - **Fan purchases and subscriptions** go through Stripe Checkout | |
| 10 | + | - **Creator payouts** go through Stripe Connect — funds land in the creator's own Stripe account, not ours | |
| 11 | + | - **Platform billing** (creator subscriptions to us) is also Stripe | |
| 12 | 12 | ||
| 13 | - | ### Why Stripe First | |
| 14 | - | ||
| 15 | - | Stripe is the pragmatic choice for a new platform: | |
| 16 | - | ||
| 17 | - | - **Fast to implement:** We can focus on building the platform, not payment infrastructure | |
| 18 | - | - **Global reach:** Supports 40+ countries out of the box | |
| 19 | - | - **Creator-friendly:** Funds go directly to creator accounts, not through us | |
| 20 | - | - **Trusted:** Creators and fans already know Stripe | |
| 21 | - | ||
| 22 | - | Building our own payment infrastructure from day one would have delayed everything else by months or years. | |
| 23 | - | ||
| 24 | - | ### What This Means for Creators | |
| 25 | - | ||
| 26 | - | When you receive payments through Makenot.work: | |
| 27 | - | ||
| 28 | - | 1. Fan pays via Stripe (~2.9% + $0.30 per transaction) | |
| 29 | - | 2. Funds go directly to your connected Stripe account | |
| 30 | - | 3. You control payout timing (instant, daily, weekly, monthly) | |
| 31 | - | 4. We never hold your money | |
| 32 | - | ||
| 33 | - | The ~3% you see quoted as "payment processing" is Stripe's fee, not ours. We don't add anything on top. | |
| 34 | - | ||
| 35 | - | ## The Transparency Principle | |
| 36 | - | ||
| 37 | - | Even while using Stripe, we're committed to transparency: | |
| 38 | - | ||
| 39 | - | - **Clear fee breakdown:** You always see exactly what Stripe charges | |
| 40 | - | - **No hidden costs:** We don't mark up payment processing fees | |
| 41 | - | - **Direct relationship:** Your Stripe account is yours, not a sub-account we control | |
| 13 | + | Fan pays ~2.9% + $0.30 per transaction. That fee is Stripe's — we don't add anything on top. Creators control their own payout timing. We never hold funds. | |
| 42 | 14 | ||
| 43 | - | If Stripe's fees change, you'll know. If we change processors, you'll know. No surprises. | |
| 44 | - | ||
| 45 | - | ## Our Payment Roadmap | |
| 46 | - | ||
| 47 | - | We're not satisfied with "good enough." Our long-term goal is to help creators keep as much of their revenue as possible. The roadmap focuses on **payouts first**—that's where creators feel the cost most directly. | |
| 48 | - | ||
| 49 | - | ### Phase 1: Stripe (Current) | |
| 50 | - | ||
| 51 | - | - Stripe Connect for all payments and payouts | |
| 52 | - | - ~2.9% + $0.30 per transaction (Stripe's standard rate) | |
| 53 | - | - 40+ countries supported | |
| 54 | - | - Creators control their own payout schedules | |
| 55 | - | ||
| 56 | - | This works. It's reliable. But the fees add up, especially for smaller transactions. | |
| 57 | - | ||
| 58 | - | ### Phase 2: Payout Alternatives | |
| 59 | - | ||
| 60 | - | Our first focus is giving creators more payout options: | |
| 15 | + | ### Why Stripe First | |
| 61 | 16 | ||
| 62 | - | - **ACH direct deposit (US):** Lower fees for US creators who don't need instant access | |
| 63 | - | - **SEPA transfers (EU):** Lower-cost European payouts | |
| 64 | - | - **Crypto options:** For creators who want them, with lower conversion costs | |
| 65 | - | - **Local payment rails:** Country-specific options where they're cheaper | |
| 17 | + | Building payment infrastructure from scratch would have delayed the platform by months. Stripe gave us global reach (40+ countries), direct-to-creator payouts, and a processor fans already trust. The tradeoff is that we depend on a single vendor and eat their fee structure. | |
| 66 | 18 | ||
| 67 | - | The goal: let creators choose the payout method that works best for them, with full transparency about costs and timing tradeoffs. | |
| 19 | + | ## Where We Want to Go | |
| 68 | 20 | ||
| 69 | - | ### Phase 3: Payment Processing Options | |
| 21 | + | The goal is to give creators cheaper ways to get paid, starting with payouts (where the fees hurt most) and working toward intake. | |
| 70 | 22 | ||
| 71 | - | Longer-term, we want to offer alternatives to Stripe for incoming payments too: | |
| 23 | + | **Payout alternatives (next priority):** | |
| 72 | 24 | ||
| 73 | - | - **Lower-cost processors:** Some alternatives charge less for certain transaction types | |
| 74 | - | - **Regional options:** Local processors may be cheaper in specific markets | |
| 75 | - | - **Direct bank payments:** ACH/SEPA for fans who prefer it | |
| 25 | + | - ACH direct deposit for US creators who don't need instant access | |
| 26 | + | - SEPA transfers for EU creators | |
| 27 | + | - Crypto for creators who want it — with honest disclosure about volatility and tax complexity | |
| 28 | + | - Country-specific payment rails where they're cheaper than Stripe | |
| 76 | 29 | ||
| 77 | - | This is more complex than payouts—we need to maintain the seamless experience fans expect while offering creators real savings. | |
| 30 | + | **Intake alternatives (longer term):** | |
| 78 | 31 | ||
| 79 | - | ### Phase 4: Our Own Infrastructure | |
| 32 | + | - Lower-cost processors for specific transaction types | |
| 33 | + | - Regional processors where they beat Stripe on price | |
| 34 | + | - Direct bank payments (ACH/SEPA) for fans who prefer them | |
| 80 | 35 | ||
| 81 | - | Eventually, we may build or operate our own payment infrastructure for specific use cases: | |
| 36 | + | **Own infrastructure (eventually, maybe):** | |
| 82 | 37 | ||
| 83 | - | - **Micro-transactions:** Small payments where percentage fees hurt most | |
| 84 | - | - **Creator-to-creator payments:** Revenue splits without double fees | |
| 85 | - | - **Bulk operations:** Batch processing for high-volume creators | |
| 38 | + | - Micro-transactions where percentage fees eat the payment | |
| 39 | + | - Creator-to-creator splits without double-dipping on fees | |
| 40 | + | - Batch processing for high-volume creators | |
| 86 | 41 | ||
| 87 | - | This requires significant investment and regulatory compliance. We won't rush it—a bad payment experience is worse than a slightly more expensive good one. | |
| 42 | + | This last step requires money transmitter licenses in 47+ US jurisdictions and 1-2 years of regulatory work. We're not doing it until the math justifies it. See [mtl.md](./mtl.md) for details. | |
| 88 | 43 | ||
| 89 | 44 | ## What We Won't Do | |
| 90 | 45 | ||
| 91 | - | ### Hold Creator Funds | |
| 92 | - | ||
| 93 | - | We don't accumulate creator earnings and pay out later. Funds go directly to creator accounts. This is a fundamental principle, not a temporary convenience. | |
| 94 | - | ||
| 95 | - | ### Obscure Fees | |
| 46 | + | **Hold creator funds.** Money goes directly to creator accounts. This is structural, not a courtesy — we never want to be in a position where we're sitting on someone else's earnings. | |
| 96 | 47 | ||
| 97 | - | You'll always know what you're paying for payment processing and why. If we negotiate volume discounts with processors, those savings go to creators. | |
| 48 | + | **Obscure fees.** You see what Stripe charges. If we negotiate volume discounts, those savings go to creators. | |
| 98 | 49 | ||
| 99 | - | ### Lock You In | |
| 50 | + | **Lock you in.** Your Stripe account is yours. Your customer relationships are yours. If you leave, you take them with you. | |
| 100 | 51 | ||
| 101 | - | If you want to use a different payment method we support, you can switch. If you want to leave the platform, your payment relationships are yours—we don't own your Stripe account or customer relationships. | |
| 52 | + | ## Unsupported Countries | |
| 102 | 53 | ||
| 103 | - | ### Compromise Security | |
| 104 | - | ||
| 105 | - | Lower costs aren't worth fraud risk. Any payment option we offer will meet the same security standards as Stripe. | |
| 106 | - | ||
| 107 | - | ## Regulatory Reality | |
| 108 | - | ||
| 109 | - | Payment processing is heavily regulated. Some of what we'd like to do requires: | |
| 110 | - | ||
| 111 | - | - **Money transmitter licenses:** Required in most US states to handle funds | |
| 112 | - | - **PCI compliance:** Required for handling card data directly | |
| 113 | - | - **International banking relationships:** Required for cross-border payouts | |
| 114 | - | - **Know Your Customer (KYC):** Required for anti-money-laundering compliance | |
| 115 | - | ||
| 116 | - | We're not going to cut corners on compliance. This means some improvements will take time—but they'll be done right. | |
| 117 | - | ||
| 118 | - | ## For Creators in Unsupported Countries | |
| 119 | - | ||
| 120 | - | Stripe doesn't operate everywhere. If you're in a country Stripe doesn't support: | |
| 121 | - | ||
| 122 | - | - Contact us—we track demand by region | |
| 123 | - | - We're evaluating alternative processors for underserved markets | |
| 124 | - | - Some payout alternatives may be available sooner than full Stripe support | |
| 125 | - | ||
| 126 | - | We want to serve creators globally. Geography shouldn't determine whether you can get paid. | |
| 127 | - | ||
| 128 | - | ## Questions We Get | |
| 129 | - | ||
| 130 | - | **"Why not build your own payment system now?"** | |
| 131 | - | ||
| 132 | - | Because it would take years and delay everything else. Stripe lets us ship a working platform while we plan for the future. Perfect is the enemy of good. | |
| 133 | - | ||
| 134 | - | **"Will you ever charge more than Stripe?"** | |
| 135 | - | ||
| 136 | - | No. We might offer options that cost more (instant payouts, for example), but we won't mark up the base processing fees. Our revenue comes from subscriptions, not payment processing. | |
| 137 | - | ||
| 138 | - | **"What about crypto?"** | |
| 139 | - | ||
| 140 | - | We're interested, but carefully. Crypto can offer lower fees for certain use cases, but it also introduces volatility, tax complexity, and user experience challenges. We'll offer it when we can do it well, not as a gimmick. | |
| 141 | - | ||
| 142 | - | **"Can I use my own payment processor?"** | |
| 143 | - | ||
| 144 | - | Not today. Integrating arbitrary processors would be a significant engineering effort. If there's demand for a specific processor, let us know—we prioritize based on creator needs. | |
| 54 | + | Stripe doesn't operate everywhere. If you're in a country without Stripe support, contact us — we track demand by region and it influences which alternative processors we evaluate first. | |
| 145 | 55 | ||
| 146 | 56 | ## Timeline | |
| 147 | 57 | ||
| 148 | - | We don't give timeline predictions. Payment infrastructure is complex, and regulatory requirements vary by jurisdiction. | |
| 149 | - | ||
| 150 | - | What we can say: | |
| 151 | - | ||
| 152 | - | - Stripe is working now and will continue working | |
| 153 | - | - Payout alternatives are our next priority | |
| 154 | - | - We'll announce options as they become available | |
| 155 | - | - We won't rush something that handles your money | |
| 58 | + | We don't give timeline predictions for payment infrastructure. The regulatory surface is large and the consequences of getting it wrong are serious. Stripe works now and will keep working. We'll announce alternatives as they're ready. | |
| 156 | 59 | ||
| 157 | 60 | ## See Also | |
| 158 | 61 | ||
| 159 | - | - [Receiving Payouts](../creator/payouts.md) — how payouts work today | |
| 160 | 62 | - [Economics](./economics.md) — where payment costs fit in our model | |
| 161 | - | - [Roadmap](../../public/about/roadmap.md) — broader platform roadmap including payments | |
| 63 | + | - [Money Transmitter Licenses](./mtl.md) — regulatory requirements for direct payment handling | |
| 162 | 64 | - [Partnerships](./partnerships.md) — how we choose vendors | |
| 65 | + | - [Roadmap](../../public/about/roadmap.md) — broader platform roadmap |
| @@ -0,0 +1,4 @@ | |||
| 1 | + | -- Link child transactions (from bundle grants) to the parent bundle transaction. | |
| 2 | + | -- Enables revoking child item access when a bundle purchase is refunded. | |
| 3 | + | ALTER TABLE transactions ADD COLUMN parent_transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL; | |
| 4 | + | CREATE INDEX idx_transactions_parent_id ON transactions(parent_transaction_id) WHERE parent_transaction_id IS NOT NULL; |
| @@ -0,0 +1,6 @@ | |||
| 1 | + | -- Prevent two public projects from claiming the same slug. | |
| 2 | + | -- The /p/{slug} routes resolve by slug alone, so at most one | |
| 3 | + | -- public project may own a given slug at a time. | |
| 4 | + | CREATE UNIQUE INDEX idx_projects_public_slug | |
| 5 | + | ON projects (slug) | |
| 6 | + | WHERE is_public = true; |
| @@ -41,6 +41,7 @@ pub const SYNCKIT_API_KEY_LENGTH: usize = 32; // 32 bytes = 64 hex chars | |||
| 41 | 41 | pub const SYNC_LOG_RETAIN_DAYS: i64 = 90; | |
| 42 | 42 | pub const SYNCKIT_MAX_BLOB_SIZE_BYTES: i64 = 500 * 1024 * 1024; // 500 MB | |
| 43 | 43 | pub const SYNCKIT_BLOB_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour | |
| 44 | + | pub const SYNCKIT_MAX_SSE_CONNECTIONS_PER_USER: usize = 10; | |
| 44 | 45 | ||
| 45 | 46 | // -- Subscriptions -- | |
| 46 | 47 | pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum |
| @@ -150,7 +150,7 @@ pub async fn get_revenue_timeseries( | |||
| 150 | 150 | r#" | |
| 151 | 151 | SELECT | |
| 152 | 152 | date_trunc('{bucket}', completed_at) AS bucket, | |
| 153 | - | COALESCE(SUM(amount_cents), 0), | |
| 153 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 154 | 154 | COUNT(*) | |
| 155 | 155 | FROM transactions | |
| 156 | 156 | WHERE seller_id = $1 | |
| @@ -173,7 +173,7 @@ pub async fn get_revenue_timeseries( | |||
| 173 | 173 | r#" | |
| 174 | 174 | SELECT | |
| 175 | 175 | date_trunc('{bucket}', completed_at) AS bucket, | |
| 176 | - | COALESCE(SUM(amount_cents), 0), | |
| 176 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 177 | 177 | COUNT(*) | |
| 178 | 178 | FROM transactions | |
| 179 | 179 | WHERE seller_id = $1 | |
| @@ -199,7 +199,7 @@ pub async fn get_revenue_timeseries( | |||
| 199 | 199 | r#" | |
| 200 | 200 | SELECT | |
| 201 | 201 | date_trunc('{bucket}', t.completed_at) AS bucket, | |
| 202 | - | COALESCE(SUM(t.amount_cents), 0), | |
| 202 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT, | |
| 203 | 203 | COUNT(*) | |
| 204 | 204 | FROM transactions t | |
| 205 | 205 | WHERE t.seller_id = $1 | |
| @@ -222,7 +222,7 @@ pub async fn get_revenue_timeseries( | |||
| 222 | 222 | r#" | |
| 223 | 223 | SELECT | |
| 224 | 224 | date_trunc('{bucket}', t.completed_at) AS bucket, | |
| 225 | - | COALESCE(SUM(t.amount_cents), 0), | |
| 225 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT, | |
| 226 | 226 | COUNT(*) | |
| 227 | 227 | FROM transactions t | |
| 228 | 228 | WHERE t.seller_id = $1 | |
| @@ -248,7 +248,7 @@ pub async fn get_revenue_timeseries( | |||
| 248 | 248 | r#" | |
| 249 | 249 | SELECT | |
| 250 | 250 | date_trunc('{bucket}', completed_at) AS bucket, | |
| 251 | - | COALESCE(SUM(amount_cents), 0), | |
| 251 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 252 | 252 | COUNT(*) | |
| 253 | 253 | FROM transactions | |
| 254 | 254 | WHERE seller_id = $1 | |
| @@ -269,7 +269,7 @@ pub async fn get_revenue_timeseries( | |||
| 269 | 269 | r#" | |
| 270 | 270 | SELECT | |
| 271 | 271 | date_trunc('{bucket}', completed_at) AS bucket, | |
| 272 | - | COALESCE(SUM(amount_cents), 0), | |
| 272 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 273 | 273 | COUNT(*) | |
| 274 | 274 | FROM transactions | |
| 275 | 275 | WHERE seller_id = $1 | |
| @@ -342,7 +342,7 @@ async fn get_transaction_comparison( | |||
| 342 | 342 | sqlx::query_as( | |
| 343 | 343 | r#" | |
| 344 | 344 | SELECT | |
| 345 | - | COALESCE(SUM(amount_cents), 0), | |
| 345 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 346 | 346 | COUNT(*) | |
| 347 | 347 | FROM transactions | |
| 348 | 348 | WHERE seller_id = $1 AND item_id = $2 AND status = 'completed' | |
| @@ -357,7 +357,7 @@ async fn get_transaction_comparison( | |||
| 357 | 357 | sqlx::query_as( | |
| 358 | 358 | r#" | |
| 359 | 359 | SELECT | |
| 360 | - | COALESCE(SUM(t.amount_cents), 0), | |
| 360 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT, | |
| 361 | 361 | COUNT(*) | |
| 362 | 362 | FROM transactions t | |
| 363 | 363 | WHERE t.seller_id = $1 | |
| @@ -374,7 +374,7 @@ async fn get_transaction_comparison( | |||
| 374 | 374 | sqlx::query_as( | |
| 375 | 375 | r#" | |
| 376 | 376 | SELECT | |
| 377 | - | COALESCE(SUM(amount_cents), 0), | |
| 377 | + | COALESCE(SUM(amount_cents), 0)::BIGINT, | |
| 378 | 378 | COUNT(*) | |
| 379 | 379 | FROM transactions | |
| 380 | 380 | WHERE seller_id = $1 AND status = 'completed' | |
| @@ -395,9 +395,9 @@ async fn get_transaction_comparison( | |||
| 395 | 395 | &format!( | |
| 396 | 396 | r#" | |
| 397 | 397 | SELECT | |
| 398 | - | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0), | |
| 398 | + | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 399 | 399 | COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), | |
| 400 | - | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0), | |
| 400 | + | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 401 | 401 | COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}') | |
| 402 | 402 | FROM transactions | |
| 403 | 403 | WHERE seller_id = $1 | |
| @@ -417,9 +417,9 @@ async fn get_transaction_comparison( | |||
| 417 | 417 | &format!( | |
| 418 | 418 | r#" | |
| 419 | 419 | SELECT | |
| 420 | - | COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), 0), | |
| 420 | + | COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 421 | 421 | COUNT(*) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), | |
| 422 | - | COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}'), 0), | |
| 422 | + | COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 423 | 423 | COUNT(*) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}') | |
| 424 | 424 | FROM transactions t | |
| 425 | 425 | WHERE t.seller_id = $1 | |
| @@ -439,9 +439,9 @@ async fn get_transaction_comparison( | |||
| 439 | 439 | &format!( | |
| 440 | 440 | r#" | |
| 441 | 441 | SELECT | |
| 442 | - | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0), | |
| 442 | + | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 443 | 443 | COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), | |
| 444 | - | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0), | |
| 444 | + | COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT, | |
| 445 | 445 | COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}') | |
| 446 | 446 | FROM transactions | |
| 447 | 447 | WHERE seller_id = $1 |
| @@ -7,23 +7,50 @@ use super::models::*; | |||
| 7 | 7 | use super::UserId; | |
| 8 | 8 | use crate::error::Result; | |
| 9 | 9 | ||
| 10 | - | /// Increment failed login attempts for a user | |
| 10 | + | /// Result of an atomic failed-login increment. | |
| 11 | + | pub struct FailedLoginResult { | |
| 12 | + | /// New failed_login_attempts count after increment. | |
| 13 | + | pub attempts: i32, | |
| 14 | + | /// Whether the account was just locked by this increment. | |
| 15 | + | pub just_locked: bool, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /// Atomically increment failed login attempts and lock the account if the | |
| 19 | + | /// threshold is reached. This prevents race conditions where concurrent | |
| 20 | + | /// requests could each pass the lockout check before either increments. | |
| 21 | + | /// | |
| 22 | + | /// The UPDATE uses a single SQL statement with conditional locked_until | |
| 23 | + | /// assignment, so PostgreSQL's row-level locking serializes concurrent callers. | |
| 11 | 24 | #[tracing::instrument(skip_all)] | |
| 12 | - | pub async fn increment_failed_login(pool: &PgPool, user_id: UserId) -> Result<i32> { | |
| 13 | - | let attempts: i32 = sqlx::query_scalar( | |
| 25 | + | pub async fn increment_failed_login( | |
| 26 | + | pool: &PgPool, | |
| 27 | + | user_id: UserId, | |
| 28 | + | max_attempts: i32, | |
| 29 | + | lockout_minutes: i64, | |
| 30 | + | ) -> Result<FailedLoginResult> { | |
| 31 | + | let row: (i32, bool) = sqlx::query_as( | |
| 14 | 32 | r#" | |
| 15 | 33 | UPDATE users | |
| 16 | 34 | SET failed_login_attempts = failed_login_attempts + 1, | |
| 17 | - | last_failed_login_at = NOW() | |
| 35 | + | last_failed_login_at = NOW(), | |
| 36 | + | locked_until = CASE | |
| 37 | + | WHEN failed_login_attempts + 1 >= $2 THEN NOW() + ($3 || ' minutes')::interval | |
| 38 | + | ELSE locked_until | |
| 39 | + | END | |
| 18 | 40 | WHERE id = $1 | |
| 19 | - | RETURNING failed_login_attempts | |
| 41 | + | RETURNING failed_login_attempts, (failed_login_attempts >= $2) AS just_locked | |
| 20 | 42 | "#, | |
| 21 | 43 | ) | |
| 22 | 44 | .bind(user_id) | |
| 45 | + | .bind(max_attempts) | |
| 46 | + | .bind(lockout_minutes.to_string()) | |
| 23 | 47 | .fetch_one(pool) | |
| 24 | 48 | .await?; | |
| 25 | 49 | ||
| 26 | - | Ok(attempts) | |
| 50 | + | Ok(FailedLoginResult { | |
| 51 | + | attempts: row.0, | |
| 52 | + | just_locked: row.1, | |
| 53 | + | }) | |
| 27 | 54 | } | |
| 28 | 55 | ||
| 29 | 56 | /// Reset failed login attempts (on successful login) | |
| @@ -39,18 +66,6 @@ pub async fn reset_failed_login(pool: &PgPool, user_id: UserId) -> Result<()> { | |||
| 39 | 66 | Ok(()) | |
| 40 | 67 | } | |
| 41 | 68 | ||
| 42 | - | /// Lock a user account until a specific time | |
| 43 | - | #[tracing::instrument(skip_all)] | |
| 44 | - | pub async fn lock_account(pool: &PgPool, user_id: UserId, until: DateTime<Utc>) -> Result<()> { | |
| 45 | - | sqlx::query("UPDATE users SET locked_until = $2 WHERE id = $1") | |
| 46 | - | .bind(user_id) | |
| 47 | - | .bind(until) | |
| 48 | - | .execute(pool) | |
| 49 | - | .await?; | |
| 50 | - | ||
| 51 | - | Ok(()) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | 69 | /// Create a one-time login token | |
| 55 | 70 | #[tracing::instrument(skip_all)] | |
| 56 | 71 | pub async fn create_login_token( |
| @@ -200,6 +200,7 @@ pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) - | |||
| 200 | 200 | pub async fn update_item( | |
| 201 | 201 | pool: &PgPool, | |
| 202 | 202 | id: ItemId, | |
| 203 | + | user_id: UserId, | |
| 203 | 204 | title: Option<&str>, | |
| 204 | 205 | description: Option<&str>, | |
| 205 | 206 | price_cents: Option<i32>, | |
| @@ -211,27 +212,29 @@ pub async fn update_item( | |||
| 211 | 212 | web_only: Option<bool>, | |
| 212 | 213 | ) -> Result<DbItem> { | |
| 213 | 214 | // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE). | |
| 214 | - | // $9 = whether to update publish_at, $10 = the new value (NULL to clear). | |
| 215 | + | // $10 = whether to update publish_at, $11 = the new value (NULL to clear). | |
| 215 | 216 | let update_publish_at = publish_at.is_some(); | |
| 216 | 217 | let publish_at_value = publish_at.flatten(); | |
| 217 | 218 | ||
| 218 | 219 | let item = sqlx::query_as::<_, DbItem>( | |
| 219 | 220 | r#" | |
| 220 | 221 | UPDATE items | |
| 221 | - | SET title = COALESCE($2, title), | |
| 222 | - | description = COALESCE($3, description), | |
| 223 | - | price_cents = COALESCE($4, price_cents), | |
| 224 | - | item_type = COALESCE($5, item_type), | |
| 225 | - | is_public = COALESCE($6, is_public), | |
| 226 | - | pwyw_enabled = COALESCE($7, pwyw_enabled), | |
| 227 | - | pwyw_min_cents = COALESCE($8, pwyw_min_cents), | |
| 228 | - | publish_at = CASE WHEN $9 THEN $10 ELSE publish_at END, | |
| 229 | - | web_only = COALESCE($11, web_only) | |
| 222 | + | SET title = COALESCE($3, title), | |
| 223 | + | description = COALESCE($4, description), | |
| 224 | + | price_cents = COALESCE($5, price_cents), | |
| 225 | + | item_type = COALESCE($6, item_type), | |
| 226 | + | is_public = COALESCE($7, is_public), | |
| 227 | + | pwyw_enabled = COALESCE($8, pwyw_enabled), | |
| 228 | + | pwyw_min_cents = COALESCE($9, pwyw_min_cents), | |
| 229 | + | publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END, | |
| 230 | + | web_only = COALESCE($12, web_only) | |
| 230 | 231 | WHERE id = $1 | |
| 232 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 231 | 233 | RETURNING * | |
| 232 | 234 | "#, | |
| 233 | 235 | ) | |
| 234 | 236 | .bind(id) | |
| 237 | + | .bind(user_id) | |
| 235 | 238 | .bind(title) | |
| 236 | 239 | .bind(description) | |
| 237 | 240 | .bind(price_cents) | |
| @@ -270,11 +273,14 @@ pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> { | |||
| 270 | 273 | ||
| 271 | 274 | /// Permanently delete an item by ID. | |
| 272 | 275 | #[tracing::instrument(skip_all)] | |
| 273 | - | pub async fn delete_item(pool: &PgPool, id: ItemId) -> Result<()> { | |
| 274 | - | sqlx::query("DELETE FROM items WHERE id = $1") | |
| 275 | - | .bind(id) | |
| 276 | - | .execute(pool) | |
| 277 | - | .await?; | |
| 276 | + | pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> { | |
| 277 | + | sqlx::query( | |
| 278 | + | "DELETE FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)", | |
| 279 | + | ) | |
| 280 | + | .bind(id) | |
| 281 | + | .bind(user_id) | |
| 282 | + | .execute(pool) | |
| 283 | + | .await?; | |
| 278 | 284 | ||
| 279 | 285 | Ok(()) | |
| 280 | 286 | } | |
| @@ -371,19 +377,21 @@ pub async fn get_item_owner(pool: &PgPool, item_id: ItemId) -> Result<Option<Use | |||
| 371 | 377 | /// Reading time uses ~200 wpm (average adult reading speed) with floor | |
| 372 | 378 | /// division, clamped to a minimum of 1 minute so the UI never shows "0 min". | |
| 373 | 379 | #[tracing::instrument(skip_all)] | |
| 374 | - | pub async fn update_item_text(pool: &PgPool, id: ItemId, body: &str) -> Result<DbItem> { | |
| 380 | + | pub async fn update_item_text(pool: &PgPool, id: ItemId, user_id: UserId, body: &str) -> Result<DbItem> { | |
| 375 | 381 | let word_count = body.split_whitespace().count() as i32; | |
| 376 | 382 | let reading_time = (word_count / 200).max(1); | |
| 377 | 383 | ||
| 378 | 384 | let item = sqlx::query_as::<_, DbItem>( | |
| 379 | 385 | r#" | |
| 380 | 386 | UPDATE items | |
| 381 | - | SET body = $2, word_count = $3, reading_time_minutes = $4, updated_at = NOW() | |
| 387 | + | SET body = $3, word_count = $4, reading_time_minutes = $5, updated_at = NOW() | |
| 382 | 388 | WHERE id = $1 | |
| 389 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 383 | 390 | RETURNING * | |
| 384 | 391 | "#, | |
| 385 | 392 | ) | |
| 386 | 393 | .bind(id) | |
| 394 | + | .bind(user_id) | |
| 387 | 395 | .bind(body) | |
| 388 | 396 | .bind(word_count) | |
| 389 | 397 | .bind(reading_time) | |
| @@ -398,17 +406,20 @@ pub async fn update_item_text(pool: &PgPool, id: ItemId, body: &str) -> Result<D | |||
| 398 | 406 | pub async fn update_item_license_settings( | |
| 399 | 407 | pool: &PgPool, | |
| 400 | 408 | item_id: ItemId, | |
| 409 | + | user_id: UserId, | |
| 401 | 410 | enable_license_keys: bool, | |
| 402 | 411 | default_max_activations: Option<i32>, | |
| 403 | 412 | ) -> Result<()> { | |
| 404 | 413 | sqlx::query( | |
| 405 | 414 | r#" | |
| 406 | 415 | UPDATE items | |
| 407 | - | SET enable_license_keys = $2, default_max_activations = $3, updated_at = NOW() | |
| 416 | + | SET enable_license_keys = $3, default_max_activations = $4, updated_at = NOW() | |
| 408 | 417 | WHERE id = $1 | |
| 418 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 409 | 419 | "#, | |
| 410 | 420 | ) | |
| 411 | 421 | .bind(item_id) | |
| 422 | + | .bind(user_id) | |
| 412 | 423 | .bind(enable_license_keys) | |
| 413 | 424 | .bind(default_max_activations) | |
| 414 | 425 | .execute(pool) | |
| @@ -422,17 +433,20 @@ pub async fn update_item_license_settings( | |||
| 422 | 433 | pub async fn update_item_license_text( | |
| 423 | 434 | pool: &PgPool, | |
| 424 | 435 | item_id: ItemId, | |
| 436 | + | user_id: UserId, | |
| 425 | 437 | license_preset: Option<&str>, | |
| 426 | 438 | custom_license_text: Option<&str>, | |
| 427 | 439 | ) -> Result<()> { | |
| 428 | 440 | sqlx::query( | |
| 429 | 441 | r#" | |
| 430 | 442 | UPDATE items | |
| 431 | - | SET license_preset = $2, custom_license_text = $3, updated_at = NOW() | |
| 443 | + | SET license_preset = $3, custom_license_text = $4, updated_at = NOW() | |
| 432 | 444 | WHERE id = $1 | |
| 445 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 433 | 446 | "#, | |
| 434 | 447 | ) | |
| 435 | 448 | .bind(item_id) | |
| 449 | + | .bind(user_id) | |
| 436 | 450 | .bind(license_preset) | |
| 437 | 451 | .bind(custom_license_text) | |
| 438 | 452 | .execute(pool) | |
| @@ -573,16 +587,23 @@ pub async fn increment_item_download_count(pool: &PgPool, item_id: ItemId) -> Re | |||
| 573 | 587 | pub async fn move_item( | |
| 574 | 588 | pool: &PgPool, | |
| 575 | 589 | project_id: ProjectId, | |
| 590 | + | user_id: UserId, | |
| 576 | 591 | item_id: ItemId, | |
| 577 | 592 | direction: &str, | |
| 578 | 593 | ) -> Result<()> { | |
| 579 | 594 | let mut tx = pool.begin().await?; | |
| 580 | 595 | ||
| 581 | - | // Lock and fetch item IDs in display order | |
| 596 | + | // Lock and fetch item IDs in display order (scoped to projects owned by user) | |
| 582 | 597 | let item_ids: Vec<ItemId> = sqlx::query_scalar( | |
| 583 | - | "SELECT id FROM items WHERE project_id = $1 ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE", | |
| 598 | + | r#" | |
| 599 | + | SELECT id FROM items | |
| 600 | + | WHERE project_id = $1 | |
| 601 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $2) | |
| 602 | + | ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE | |
| 603 | + | "#, | |
| 584 | 604 | ) | |
| 585 | 605 | .bind(project_id) | |
| 606 | + | .bind(user_id) | |
| 586 | 607 | .fetch_all(&mut *tx) | |
| 587 | 608 | .await?; | |
| 588 | 609 | ||
| @@ -624,16 +645,19 @@ pub async fn bulk_publish( | |||
| 624 | 645 | pool: &PgPool, | |
| 625 | 646 | item_ids: &[ItemId], | |
| 626 | 647 | project_id: ProjectId, | |
| 648 | + | user_id: UserId, | |
| 627 | 649 | ) -> Result<u64> { | |
| 628 | 650 | let result = sqlx::query( | |
| 629 | 651 | r#" | |
| 630 | 652 | UPDATE items | |
| 631 | 653 | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 632 | 654 | WHERE id = ANY($1) AND project_id = $2 | |
| 655 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 633 | 656 | "#, | |
| 634 | 657 | ) | |
| 635 | 658 | .bind(item_ids) | |
| 636 | 659 | .bind(project_id) | |
| 660 | + | .bind(user_id) | |
| 637 | 661 | .execute(pool) | |
| 638 | 662 | .await?; | |
| 639 | 663 | ||
| @@ -648,16 +672,19 @@ pub async fn bulk_unpublish( | |||
| 648 | 672 | pool: &PgPool, | |
| 649 | 673 | item_ids: &[ItemId], | |
| 650 | 674 | project_id: ProjectId, | |
| 675 | + | user_id: UserId, | |
| 651 | 676 | ) -> Result<u64> { | |
| 652 | 677 | let result = sqlx::query( | |
| 653 | 678 | r#" | |
| 654 | 679 | UPDATE items | |
| 655 | 680 | SET is_public = false, updated_at = NOW() | |
| 656 | 681 | WHERE id = ANY($1) AND project_id = $2 | |
| 682 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 657 | 683 | "#, | |
| 658 | 684 | ) | |
| 659 | 685 | .bind(item_ids) | |
| 660 | 686 | .bind(project_id) | |
| 687 | + | .bind(user_id) | |
| 661 | 688 | .execute(pool) | |
| 662 | 689 | .await?; | |
| 663 | 690 | ||
| @@ -672,12 +699,17 @@ pub async fn bulk_delete( | |||
| 672 | 699 | pool: &PgPool, | |
| 673 | 700 | item_ids: &[ItemId], | |
| 674 | 701 | project_id: ProjectId, | |
| 702 | + | user_id: UserId, | |
| 675 | 703 | ) -> Result<u64> { | |
| 676 | 704 | let result = sqlx::query( | |
| 677 | - | "DELETE FROM items WHERE id = ANY($1) AND project_id = $2", | |
| 705 | + | r#" | |
| 706 | + | DELETE FROM items WHERE id = ANY($1) AND project_id = $2 | |
| 707 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 708 | + | "#, | |
| 678 | 709 | ) | |
| 679 | 710 | .bind(item_ids) | |
| 680 | 711 | .bind(project_id) | |
| 712 | + | .bind(user_id) | |
| 681 | 713 | .execute(pool) | |
| 682 | 714 | .await?; | |
| 683 | 715 | ||
| @@ -689,14 +721,17 @@ pub async fn bulk_delete( | |||
| 689 | 721 | /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files), | |
| 690 | 722 | /// license keys, download codes, or discount codes. | |
| 691 | 723 | #[tracing::instrument(skip_all)] | |
| 692 | - | pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem> { | |
| 724 | + | pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> { | |
| 693 | 725 | let mut tx = pool.begin().await?; | |
| 694 | 726 | ||
| 695 | - | // Generate a unique slug for the copy | |
| 696 | - | let source = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1") | |
| 697 | - | .bind(source_id) | |
| 698 | - | .fetch_one(&mut *tx) | |
| 699 | - | .await?; | |
| 727 | + | // Generate a unique slug for the copy (verify ownership via project) | |
| 728 | + | let source = sqlx::query_as::<_, DbItem>( | |
| 729 | + | "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)", | |
| 730 | + | ) | |
| 731 | + | .bind(source_id) | |
| 732 | + | .bind(user_id) | |
| 733 | + | .fetch_one(&mut *tx) | |
| 734 | + | .await?; | |
| 700 | 735 | let copy_title = format!("Copy of {}", &source.title); | |
| 701 | 736 | let copy_title: String = copy_title.chars().take(200).collect(); | |
| 702 | 737 | let mut slug = crate::helpers::slugify(©_title); |
| @@ -329,12 +329,12 @@ pub async fn revoke_keys_by_transaction( | |||
| 329 | 329 | .execute(&mut *conn) | |
| 330 | 330 | .await?; | |
| 331 | 331 | ||
| 332 | - | // Deactivate all activations for those keys | |
| 333 | - | for key_id in &key_ids { | |
| 332 | + | // Deactivate all activations for those keys in a single query | |
| 333 | + | if !key_ids.is_empty() { | |
| 334 | 334 | sqlx::query( | |
| 335 | - | "UPDATE license_activations SET is_active = false WHERE license_key_id = $1", | |
| 335 | + | "UPDATE license_activations SET is_active = false WHERE license_key_id = ANY($1)", | |
| 336 | 336 | ) | |
| 337 | - | .bind(key_id) | |
| 337 | + | .bind(&key_ids) | |
| 338 | 338 | .execute(&mut *conn) | |
| 339 | 339 | .await?; | |
| 340 | 340 | } |
| @@ -58,6 +58,8 @@ pub struct DbTransaction { | |||
| 58 | 58 | pub share_contact: bool, | |
| 59 | 59 | /// Purchased project ID (for project-level purchases). Nullable. | |
| 60 | 60 | pub project_id: Option<ProjectId>, | |
| 61 | + | /// Parent bundle transaction that granted this child item. Nullable. | |
| 62 | + | pub parent_transaction_id: Option<TransactionId>, | |
| 61 | 63 | } | |
| 62 | 64 | ||
| 63 | 65 | impl DbTransaction { | |
| @@ -148,6 +150,7 @@ mod tests { | |||
| 148 | 150 | seller_username: None, | |
| 149 | 151 | share_contact: false, | |
| 150 | 152 | project_id: None, | |
| 153 | + | parent_transaction_id: None, | |
| 151 | 154 | } | |
| 152 | 155 | } | |
| 153 | 156 |
| @@ -74,14 +74,22 @@ pub async fn list_releases(pool: &PgPool, app_id: SyncAppId) -> Result<Vec<DbOta | |||
| 74 | 74 | Ok(releases) | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | /// Get the latest release for an app (by pub_date). | |
| 77 | + | /// Get the latest release for an app by semantic version (highest version wins). | |
| 78 | + | /// | |
| 79 | + | /// Falls back to pub_date ordering if version parts aren't numeric. | |
| 78 | 80 | #[tracing::instrument(skip_all)] | |
| 79 | 81 | pub async fn get_latest_release( | |
| 80 | 82 | pool: &PgPool, | |
| 81 | 83 | app_id: SyncAppId, | |
| 82 | 84 | ) -> Result<Option<DbOtaRelease>> { | |
| 83 | 85 | let release = sqlx::query_as::<_, DbOtaRelease>( | |
| 84 | - | "SELECT * FROM ota_releases WHERE app_id = $1 ORDER BY pub_date DESC LIMIT 1", | |
| 86 | + | r#" | |
| 87 | + | SELECT * FROM ota_releases WHERE app_id = $1 | |
| 88 | + | ORDER BY | |
| 89 | + | (string_to_array(split_part(version, '-', 1), '.'))::int[] DESC, | |
| 90 | + | pub_date DESC | |
| 91 | + | LIMIT 1 | |
| 92 | + | "#, | |
| 85 | 93 | ) | |
| 86 | 94 | .bind(app_id) | |
| 87 | 95 | .fetch_optional(pool) |
| @@ -22,11 +22,26 @@ pub async fn add_project_member( | |||
| 22 | 22 | split_percent: i16, | |
| 23 | 23 | added_by: UserId, | |
| 24 | 24 | ) -> Result<DbProjectMember> { | |
| 25 | - | let current_total = get_total_split_percent(pool, project_id).await?; | |
| 26 | - | if current_total + split_percent as i64 > 100 { | |
| 25 | + | let mut tx = pool.begin().await?; | |
| 26 | + | ||
| 27 | + | // Lock all existing members to prevent concurrent split modifications | |
| 28 | + | let current_total: (Option<i64>,) = sqlx::query_as( | |
| 29 | + | r#" | |
| 30 | + | SELECT SUM(split_percent)::BIGINT | |
| 31 | + | FROM project_members | |
| 32 | + | WHERE project_id = $1 | |
| 33 | + | FOR UPDATE | |
| 34 | + | "#, | |
| 35 | + | ) | |
| 36 | + | .bind(project_id) | |
| 37 | + | .fetch_one(&mut *tx) | |
| 38 | + | .await?; | |
| 39 | + | ||
| 40 | + | let total = current_total.0.unwrap_or(0); | |
| 41 | + | if total + split_percent as i64 > 100 { | |
| 27 | 42 | return Err(AppError::BadRequest(format!( | |
| 28 | 43 | "Total split would be {}%, exceeding 100%", | |
| 29 | - | current_total + split_percent as i64 | |
| 44 | + | total + split_percent as i64 | |
| 30 | 45 | ))); | |
| 31 | 46 | } | |
| 32 | 47 | ||
| @@ -45,9 +60,10 @@ pub async fn add_project_member( | |||
| 45 | 60 | .bind(role) | |
| 46 | 61 | .bind(split_percent) | |
| 47 | 62 | .bind(added_by) | |
| 48 | - | .fetch_one(pool) | |
| 63 | + | .fetch_one(&mut *tx) | |
| 49 | 64 | .await?; | |
| 50 | 65 | ||
| 66 | + | tx.commit().await?; | |
| 51 | 67 | Ok(member) | |
| 52 | 68 | } | |
| 53 | 69 | ||
| @@ -113,16 +129,32 @@ pub async fn update_member_split( | |||
| 113 | 129 | user_id: UserId, | |
| 114 | 130 | new_split_percent: i16, | |
| 115 | 131 | ) -> Result<()> { | |
| 132 | + | let mut tx = pool.begin().await?; | |
| 133 | + | ||
| 134 | + | // Lock all members for this project to prevent concurrent split modifications | |
| 116 | 135 | let current: Option<DbProjectMember> = sqlx::query_as( | |
| 117 | - | "SELECT * FROM project_members WHERE project_id = $1 AND user_id = $2", | |
| 136 | + | "SELECT * FROM project_members WHERE project_id = $1 AND user_id = $2 FOR UPDATE", | |
| 118 | 137 | ) | |
| 119 | 138 | .bind(project_id) | |
| 120 | 139 | .bind(user_id) | |
| 121 | - | .fetch_optional(pool) | |
| 140 | + | .fetch_optional(&mut *tx) | |
| 122 | 141 | .await?; | |
| 123 | 142 | ||
| 124 | 143 | let current_member_split = current.map(|m| m.split_percent as i64).unwrap_or(0); | |
| 125 | - | let total = get_total_split_percent(pool, project_id).await?; | |
| 144 | + | ||
| 145 | + | let total_row: (Option<i64>,) = sqlx::query_as( | |
| 146 | + | r#" | |
| 147 | + | SELECT SUM(split_percent)::BIGINT | |
| 148 | + | FROM project_members | |
| 149 | + | WHERE project_id = $1 | |
| 150 | + | FOR UPDATE | |
| 151 | + | "#, | |
| 152 | + | ) | |
| 153 | + | .bind(project_id) | |
| 154 | + | .fetch_one(&mut *tx) | |
| 155 | + | .await?; | |
| 156 | + | ||
| 157 | + | let total = total_row.0.unwrap_or(0); | |
| 126 | 158 | let new_total = total - current_member_split + new_split_percent as i64; | |
| 127 | 159 | if new_total > 100 { | |
| 128 | 160 | return Err(AppError::BadRequest(format!( | |
| @@ -137,9 +169,10 @@ pub async fn update_member_split( | |||
| 137 | 169 | .bind(project_id) | |
| 138 | 170 | .bind(user_id) | |
| 139 | 171 | .bind(new_split_percent) | |
| 140 | - | .execute(pool) | |
| 172 | + | .execute(&mut *tx) | |
| 141 | 173 | .await?; | |
| 142 | 174 | ||
| 175 | + | tx.commit().await?; | |
| 143 | 176 | Ok(()) | |
| 144 | 177 | } | |
| 145 | 178 | ||
| @@ -152,20 +185,22 @@ pub async fn create_tip_splits( | |||
| 152 | 185 | tip_id: TipId, | |
| 153 | 186 | splits: &[(UserId, i32, i16)], // (recipient_id, amount_cents, split_percent) | |
| 154 | 187 | ) -> Result<()> { | |
| 155 | - | for (recipient_id, amount_cents, split_percent) in splits { | |
| 156 | - | sqlx::query( | |
| 157 | - | r#" | |
| 158 | - | INSERT INTO revenue_splits (tip_id, recipient_id, amount_cents, split_percent, status) | |
| 159 | - | VALUES ($1, $2, $3, $4, 'pending') | |
| 160 | - | "#, | |
| 161 | - | ) | |
| 162 | - | .bind(tip_id) | |
| 163 | - | .bind(recipient_id) | |
| 164 | - | .bind(amount_cents) | |
| 165 | - | .bind(split_percent) | |
| 166 | - | .execute(pool) | |
| 167 | - | .await?; | |
| 168 | - | } | |
| 188 | + | if splits.is_empty() { return Ok(()); } | |
| 189 | + | let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect(); | |
| 190 | + | let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a).collect(); | |
| 191 | + | let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect(); | |
| 192 | + | sqlx::query( | |
| 193 | + | r#" | |
| 194 | + | INSERT INTO revenue_splits (tip_id, recipient_id, amount_cents, split_percent, status) | |
| 195 | + | SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending' | |
| 196 | + | "#, | |
| 197 | + | ) | |
| 198 | + | .bind(tip_id) | |
| 199 | + | .bind(&recipient_ids) | |
| 200 | + | .bind(&amounts) | |
| 201 | + | .bind(&percents) | |
| 202 | + | .execute(pool) | |
| 203 | + | .await?; | |
| 169 | 204 | Ok(()) | |
| 170 | 205 | } | |
| 171 | 206 | ||
| @@ -176,20 +211,22 @@ pub async fn create_transaction_splits( | |||
| 176 | 211 | transaction_id: TransactionId, | |
| 177 | 212 | splits: &[(UserId, i32, i16)], // (recipient_id, amount_cents, split_percent) | |
| 178 | 213 | ) -> Result<()> { | |
| 179 | - | for (recipient_id, amount_cents, split_percent) in splits { | |
| 180 | - | sqlx::query( | |
| 181 | - | r#" | |
| 182 | - | INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status) | |
| 183 | - | VALUES ($1, $2, $3, $4, 'pending') | |
| 184 | - | "#, | |
| 185 | - | ) | |
| 186 | - | .bind(transaction_id) | |
| 187 | - | .bind(recipient_id) | |
| 188 | - | .bind(amount_cents) | |
| 189 | - | .bind(split_percent) | |
| 190 | - | .execute(pool) | |
| 191 | - | .await?; | |
| 192 | - | } | |
| 214 | + | if splits.is_empty() { return Ok(()); } | |
| 215 | + | let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect(); | |
| 216 | + | let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a).collect(); | |
| 217 | + | let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect(); | |
| 218 | + | sqlx::query( | |
| 219 | + | r#" | |
| 220 | + | INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status) | |
| 221 | + | SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending' | |
| 222 | + | "#, | |
| 223 | + | ) | |
| 224 | + | .bind(transaction_id) | |
| 225 | + | .bind(&recipient_ids) | |
| 226 | + | .bind(&amounts) | |
| 227 | + | .bind(&percents) | |
| 228 | + | .execute(pool) | |
| 229 | + | .await?; | |
| 193 | 230 | Ok(()) | |
| 194 | 231 | } | |
| 195 | 232 |
| @@ -108,6 +108,7 @@ pub async fn get_projects_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec< | |||
| 108 | 108 | pub async fn update_project( | |
| 109 | 109 | pool: &PgPool, | |
| 110 | 110 | id: ProjectId, | |
| 111 | + | user_id: UserId, | |
| 111 | 112 | title: Option<&str>, | |
| 112 | 113 | description: Option<&str>, | |
| 113 | 114 | features: Option<&[String]>, | |
| @@ -117,16 +118,17 @@ pub async fn update_project( | |||
| 117 | 118 | let project = sqlx::query_as::<_, DbProject>( | |
| 118 | 119 | r#" | |
| 119 | 120 | UPDATE projects | |
| 120 | - | SET title = COALESCE($2, title), | |
| 121 | - | description = COALESCE($3, description), | |
| 122 | - | project_type = COALESCE($4, project_type), | |
| 123 | - | is_public = COALESCE($5, is_public), | |
| 124 | - | features = COALESCE($6, features) | |
| 125 | - | WHERE id = $1 | |
| 121 | + | SET title = COALESCE($3, title), | |
| 122 | + | description = COALESCE($4, description), | |
| 123 | + | project_type = COALESCE($5, project_type), | |
| 124 | + | is_public = COALESCE($6, is_public), | |
| 125 | + | features = COALESCE($7, features) | |
| 126 | + | WHERE id = $1 AND user_id = $2 | |
| 126 | 127 | RETURNING * | |
| 127 | 128 | "#, | |
| 128 | 129 | ) | |
| 129 | 130 | .bind(id) | |
| 131 | + | .bind(user_id) | |
| 130 | 132 | .bind(title) | |
| 131 | 133 | .bind(description) | |
| 132 | 134 | .bind(project_type) | |
| @@ -143,10 +145,12 @@ pub async fn update_project( | |||
| 143 | 145 | pub async fn set_project_category( | |
| 144 | 146 | pool: &PgPool, | |
| 145 | 147 | id: ProjectId, | |
| 148 | + | user_id: UserId, | |
| 146 | 149 | category_id: Option<super::CategoryId>, | |
| 147 | 150 | ) -> Result<()> { | |
| 148 | - | sqlx::query("UPDATE projects SET category_id = $2 WHERE id = $1") | |
| 151 | + | sqlx::query("UPDATE projects SET category_id = $3 WHERE id = $1 AND user_id = $2") | |
| 149 | 152 | .bind(id) | |
| 153 | + | .bind(user_id) | |
| 150 | 154 | .bind(category_id) | |
| 151 | 155 | .execute(pool) | |
| 152 | 156 | .await?; | |
| @@ -156,9 +160,10 @@ pub async fn set_project_category( | |||
| 156 | 160 | ||
| 157 | 161 | /// Permanently delete a project by ID (cascades to items). | |
| 158 | 162 | #[tracing::instrument(skip_all)] | |
| 159 | - | pub async fn delete_project(pool: &PgPool, id: ProjectId) -> Result<()> { | |
| 160 | - | sqlx::query("DELETE FROM projects WHERE id = $1") | |
| 163 | + | pub async fn delete_project(pool: &PgPool, id: ProjectId, user_id: UserId) -> Result<()> { | |
| 164 | + | sqlx::query("DELETE FROM projects WHERE id = $1 AND user_id = $2") | |
| 161 | 165 | .bind(id) | |
| 166 | + | .bind(user_id) | |
| 162 | 167 | .execute(pool) | |
| 163 | 168 | .await?; | |
| 164 | 169 | ||
| @@ -206,7 +211,7 @@ pub async fn get_public_project_by_slug( | |||
| 206 | 211 | slug: &Slug, | |
| 207 | 212 | ) -> Result<Option<DbProject>> { | |
| 208 | 213 | let project = sqlx::query_as::<_, DbProject>( | |
| 209 | - | "SELECT * FROM projects WHERE slug = $1 AND is_public = true LIMIT 1", | |
| 214 | + | "SELECT * FROM projects WHERE slug = $1 AND is_public = true ORDER BY created_at ASC LIMIT 1", | |
| 210 | 215 | ) | |
| 211 | 216 | .bind(slug) | |
| 212 | 217 | .fetch_optional(pool) | |
| @@ -223,7 +228,7 @@ pub async fn get_public_project_by_slug_str( | |||
| 223 | 228 | slug: &str, | |
| 224 | 229 | ) -> Result<Option<DbProject>> { | |
| 225 | 230 | let project = sqlx::query_as::<_, DbProject>( | |
| 226 | - | "SELECT * FROM projects WHERE slug = $1 AND is_public = true LIMIT 1", | |
| 231 | + | "SELECT * FROM projects WHERE slug = $1 AND is_public = true ORDER BY created_at ASC LIMIT 1", | |
| 227 | 232 | ) | |
| 228 | 233 | .bind(slug) | |
| 229 | 234 | .fetch_optional(pool) | |
| @@ -251,7 +256,7 @@ pub async fn set_mt_community_id( | |||
| 251 | 256 | #[tracing::instrument(skip_all)] | |
| 252 | 257 | pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbProject>> { | |
| 253 | 258 | let projects = sqlx::query_as::<_, DbProject>( | |
| 254 | - | "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at", | |
| 259 | + | "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at LIMIT 500", | |
| 255 | 260 | ) | |
| 256 | 261 | .fetch_all(pool) | |
| 257 | 262 | .await?; | |
| @@ -263,11 +268,13 @@ pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbPr | |||
| 263 | 268 | pub async fn update_project_image_url( | |
| 264 | 269 | pool: &PgPool, | |
| 265 | 270 | id: ProjectId, | |
| 271 | + | user_id: UserId, | |
| 266 | 272 | url: &str, | |
| 267 | 273 | ) -> Result<()> { | |
| 268 | - | sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2") | |
| 274 | + | sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3") | |
| 269 | 275 | .bind(url) | |
| 270 | 276 | .bind(id) | |
| 277 | + | .bind(user_id) | |
| 271 | 278 | .execute(pool) | |
| 272 | 279 | .await?; | |
| 273 | 280 | ||
| @@ -279,6 +286,7 @@ pub async fn update_project_image_url( | |||
| 279 | 286 | pub async fn update_project_pricing( | |
| 280 | 287 | pool: &PgPool, | |
| 281 | 288 | id: ProjectId, | |
| 289 | + | user_id: UserId, | |
| 282 | 290 | pricing_model: super::PricingKind, | |
| 283 | 291 | price_cents: i32, | |
| 284 | 292 | pwyw_min_cents: Option<i32>, | |
| @@ -286,11 +294,12 @@ pub async fn update_project_pricing( | |||
| 286 | 294 | sqlx::query( | |
| 287 | 295 | r#" | |
| 288 | 296 | UPDATE projects | |
| 289 | - | SET pricing_model = $2, price_cents = $3, pwyw_min_cents = $4, updated_at = NOW() | |
| 290 | - | WHERE id = $1 | |
| 297 | + | SET pricing_model = $3, price_cents = $4, pwyw_min_cents = $5, updated_at = NOW() | |
| 298 | + | WHERE id = $1 AND user_id = $2 | |
| 291 | 299 | "#, | |
| 292 | 300 | ) | |
| 293 | 301 | .bind(id) | |
| 302 | + | .bind(user_id) | |
| 294 | 303 | .bind(pricing_model) | |
| 295 | 304 | .bind(price_cents) | |
| 296 | 305 | .bind(pwyw_min_cents) |
| @@ -118,6 +118,24 @@ pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result< | |||
| 118 | 118 | Ok(row.0) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | /// Mark a tip as refunded by payment intent ID. | |
| 122 | + | /// Returns true if a tip was refunded, false if not found (idempotent). | |
| 123 | + | #[tracing::instrument(skip(pool))] | |
| 124 | + | pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> { | |
| 125 | + | let result = sqlx::query( | |
| 126 | + | r#" | |
| 127 | + | UPDATE tips | |
| 128 | + | SET status = 'refunded' | |
| 129 | + | WHERE stripe_payment_intent_id = $1 AND status = 'completed' | |
| 130 | + | "#, | |
| 131 | + | ) | |
| 132 | + | .bind(payment_intent_id) | |
| 133 | + | .execute(pool) | |
| 134 | + | .await?; | |
| 135 | + | ||
| 136 | + | Ok(result.rows_affected() > 0) | |
| 137 | + | } | |
| 138 | + | ||
| 121 | 139 | /// Get tips sent by a user, most recent first. | |
| 122 | 140 | #[tracing::instrument(skip(pool))] | |
| 123 | 141 | pub async fn get_tips_sent( |
| @@ -75,23 +75,24 @@ pub async fn create_backup_codes( | |||
| 75 | 75 | user_id: UserId, | |
| 76 | 76 | code_hashes: &[String], | |
| 77 | 77 | ) -> Result<()> { | |
| 78 | + | let mut tx = pool.begin().await?; | |
| 79 | + | ||
| 78 | 80 | // Delete any existing codes | |
| 79 | 81 | sqlx::query("DELETE FROM backup_codes WHERE user_id = $1") | |
| 80 | 82 | .bind(user_id) | |
| 81 | - | .execute(pool) | |
| 83 | + | .execute(&mut *tx) | |
| 82 | 84 | .await?; | |
| 83 | 85 | ||
| 84 | - | // Insert new codes | |
| 85 | - | for hash in code_hashes { | |
| 86 | - | sqlx::query( | |
| 87 | - | "INSERT INTO backup_codes (user_id, code_hash) VALUES ($1, $2)", | |
| 88 | - | ) | |
| 89 | - | .bind(user_id) | |
| 90 | - | .bind(hash) | |
| 91 | - | .execute(pool) | |
| 92 | - | .await?; | |
| 93 | - | } | |
| 86 | + | // Batch insert all codes in a single query | |
| 87 | + | sqlx::query( | |
| 88 | + | "INSERT INTO backup_codes (user_id, code_hash) SELECT $1, UNNEST($2::text[])", | |
| 89 | + | ) | |
| 90 | + | .bind(user_id) | |
| 91 | + | .bind(code_hashes) | |
| 92 | + | .execute(&mut *tx) | |
| 93 | + | .await?; | |
| 94 | 94 | ||
| 95 | + | tx.commit().await?; | |
| 95 | 96 | Ok(()) | |
| 96 | 97 | } | |
| 97 | 98 |
| @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; | |||
| 4 | 4 | use sqlx::PgPool; | |
| 5 | 5 | ||
| 6 | 6 | use super::models::*; | |
| 7 | - | use super::{ItemId, ProjectId, PromoCodeId, UserId}; | |
| 7 | + | use super::{ItemId, ProjectId, PromoCodeId, TransactionId, UserId}; | |
| 8 | 8 | use crate::error::Result; | |
| 9 | 9 | ||
| 10 | 10 | /// Parameters for creating a pending Stripe checkout transaction. | |
| @@ -30,6 +30,8 @@ pub struct ClaimParams<'a> { | |||
| 30 | 30 | pub item_title: &'a str, | |
| 31 | 31 | pub seller_username: &'a str, | |
| 32 | 32 | pub share_contact: bool, | |
| 33 | + | /// If this claim was granted via a bundle purchase, the parent transaction ID. | |
| 34 | + | pub parent_transaction_id: Option<TransactionId>, | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | /// Record a new pending transaction for a Stripe checkout session. | |
| @@ -173,8 +175,8 @@ pub async fn claim_free_item<'e>( | |||
| 173 | 175 | let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id); | |
| 174 | 176 | let result = sqlx::query( | |
| 175 | 177 | r#" | |
| 176 | - | INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact) | |
| 177 | - | VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7) | |
| 178 | + | INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, parent_transaction_id) | |
| 179 | + | VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8) | |
| 178 | 180 | ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' DO NOTHING | |
| 179 | 181 | "#, | |
| 180 | 182 | ) | |
| @@ -185,6 +187,7 @@ pub async fn claim_free_item<'e>( | |||
| 185 | 187 | .bind(params.item_title) | |
| 186 | 188 | .bind(params.seller_username) | |
| 187 | 189 | .bind(params.share_contact) | |
| 190 | + | .bind(params.parent_transaction_id) | |
| 188 | 191 | .execute(executor) | |
| 189 | 192 | .await?; | |
| 190 | 193 | ||
| @@ -343,7 +346,7 @@ pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Res | |||
| 343 | 346 | let row: (Option<i64>, Option<i64>) = sqlx::query_as( | |
| 344 | 347 | r#" | |
| 345 | 348 | SELECT | |
| 346 | - | COALESCE(SUM(t.amount_cents), 0), | |
| 349 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT, | |
| 347 | 350 | COUNT(*) | |
| 348 | 351 | FROM transactions t | |
| 349 | 352 | JOIN items i ON t.item_id = i.id | |
| @@ -431,6 +434,29 @@ pub async fn refund_transaction_by_payment_intent<'e>( | |||
| 431 | 434 | Ok(row) | |
| 432 | 435 | } | |
| 433 | 436 | ||
| 437 | + | /// Revoke all child transactions linked to a parent (bundle) transaction. | |
| 438 | + | /// | |
| 439 | + | /// Returns the item IDs of revoked children so callers can decrement sales counts. | |
| 440 | + | #[tracing::instrument(skip_all)] | |
| 441 | + | pub async fn revoke_child_transactions<'e>( | |
| 442 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 443 | + | parent_transaction_id: TransactionId, | |
| 444 | + | ) -> Result<Vec<ItemId>> { | |
| 445 | + | let item_ids: Vec<(Option<ItemId>,)> = sqlx::query_as( | |
| 446 | + | r#" | |
| 447 | + | UPDATE transactions | |
| 448 | + | SET status = 'refunded' | |
| 449 | + | WHERE parent_transaction_id = $1 AND status = 'completed' | |
| 450 | + | RETURNING item_id | |
| 451 | + | "#, | |
| 452 | + | ) | |
| 453 | + | .bind(parent_transaction_id) | |
| 454 | + | .fetch_all(executor) | |
| 455 | + | .await?; | |
| 456 | + | ||
| 457 | + | Ok(item_ids.into_iter().filter_map(|(id,)| id).collect()) | |
| 458 | + | } | |
| 459 | + | ||
| 434 | 460 | /// A contact row: a buyer who opted to share their email with the seller. | |
| 435 | 461 | #[derive(sqlx::FromRow)] | |
| 436 | 462 | pub struct DbContactRow { | |
| @@ -465,7 +491,7 @@ pub async fn get_seller_contacts( | |||
| 465 | 491 | u.username, | |
| 466 | 492 | u.email, | |
| 467 | 493 | COUNT(*) as total_purchases, | |
| 468 | - | COALESCE(SUM(t.amount_cents), 0) as total_spent_cents, | |
| 494 | + | COALESCE(SUM(t.amount_cents), 0)::BIGINT as total_spent_cents, | |
| 469 | 495 | MAX(t.completed_at) as last_purchase_at | |
| 470 | 496 | FROM transactions t | |
| 471 | 497 | JOIN users u ON u.id = t.buyer_id | |
| @@ -529,9 +555,9 @@ pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64) | |||
| 529 | 555 | let row: (Option<i64>, Option<i64>, Option<i64>) = sqlx::query_as( | |
| 530 | 556 | r#" | |
| 531 | 557 | SELECT | |
| 532 | - | COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0), | |
| 533 | - | COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0), | |
| 534 | - | COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0) | |
| 558 | + | COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0)::BIGINT, | |
| 559 | + | COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)::BIGINT, | |
| 560 | + | COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)::BIGINT | |
| 535 | 561 | FROM transactions | |
| 536 | 562 | "#, | |
| 537 | 563 | ) |
| @@ -367,6 +367,7 @@ pub async fn get_pending_appeals(pool: &PgPool) -> Result<Vec<DbUser>> { | |||
| 367 | 367 | WHERE appeal_submitted_at IS NOT NULL | |
| 368 | 368 | AND appeal_decided_at IS NULL | |
| 369 | 369 | ORDER BY appeal_submitted_at ASC | |
| 370 | + | LIMIT 500 | |
| 370 | 371 | "#, | |
| 371 | 372 | ) | |
| 372 | 373 | .fetch_all(pool) |
| @@ -130,13 +130,18 @@ define_pg_string_newtype!( | |||
| 130 | 130 | pub struct PriceCents(i32); | |
| 131 | 131 | ||
| 132 | 132 | impl PriceCents { | |
| 133 | - | /// Validate and construct. Rejects negative values. | |
| 133 | + | /// Validate and construct. Rejects negative values and values exceeding the cap. | |
| 134 | 134 | pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> { | |
| 135 | 135 | if cents < 0 { | |
| 136 | 136 | return Err(crate::error::AppError::Validation( | |
| 137 | 137 | "Price cannot be negative".to_string(), | |
| 138 | 138 | )); | |
| 139 | 139 | } | |
| 140 | + | if cents > crate::constants::MAX_PRICE_CENTS { | |
| 141 | + | return Err(crate::error::AppError::Validation( | |
| 142 | + | "Price cannot exceed $10,000".to_string(), | |
| 143 | + | )); | |
| 144 | + | } | |
| 140 | 145 | Ok(Self(cents)) | |
| 141 | 146 | } | |
| 142 | 147 | ||
| @@ -257,7 +262,7 @@ mod tests { | |||
| 257 | 262 | fn price_cents_valid() { | |
| 258 | 263 | assert!(PriceCents::new(0).is_ok()); | |
| 259 | 264 | assert!(PriceCents::new(999).is_ok()); | |
| 260 | - | assert!(PriceCents::new(999_999_99).is_ok()); | |
| 265 | + | assert!(PriceCents::new(1_000_000).is_ok()); // $10,000 — exactly at cap | |
| 261 | 266 | } | |
| 262 | 267 | ||
| 263 | 268 | #[test] | |
| @@ -267,6 +272,12 @@ mod tests { | |||
| 267 | 272 | } | |
| 268 | 273 | ||
| 269 | 274 | #[test] | |
| 275 | + | fn price_cents_rejects_overflow() { | |
| 276 | + | assert!(PriceCents::new(1_000_001).is_err()); // over $10,000 cap | |
| 277 | + | assert!(PriceCents::new(99_999_999).is_err()); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 270 | 281 | fn price_cents_deref() { | |
| 271 | 282 | let p = PriceCents::new(1500).unwrap(); | |
| 272 | 283 | assert_eq!(*p, 1500); |