max / makenotwork
11 files changed,
+285 insertions,
-1 deletion
| @@ -61,6 +61,11 @@ v0.3.23. Audit grade A. ~1,233 tests. | |||
| 61 | 61 | - [ ] liability.md legal review (has [PENDING LEGAL REVIEW] placeholders) | |
| 62 | 62 | - [ ] dmca-counter.md designated agent address (needs DMCA agent registration) | |
| 63 | 63 | ||
| 64 | + | ### Git Access Provisioning | |
| 65 | + | - [ ] Web UI for managing SSH keys per MNW account (residents/collaborators add keys in dashboard) | |
| 66 | + | - [ ] Per-repo collaborator access (grant push by MNW username, stored in DB, wired to authorized_keys rebuild) | |
| 67 | + | - [ ] Replace manual `setup-ssh-keys.sh` with account-driven key management | |
| 68 | + | ||
| 64 | 69 | ### Frontend — Remaining | |
| 65 | 70 | - [ ] Git browser integration: add discover/follow integration (post-beta) | |
| 66 | 71 | ||
| @@ -199,7 +204,8 @@ v0.3.23. Audit grade A. ~1,233 tests. | |||
| 199 | 204 | Weak points identified vs Ko-fi. Ordered by effort/impact. | |
| 200 | 205 | ||
| 201 | 206 | #### Easy Wins | |
| 202 | - | - [ ] Tips/donations — accept one-time payments without a product attached (Ko-fi's core feature, trivial on Stripe, no inventory/file delivery needed) | |
| 207 | + | - [x] Tips/donations — accept one-time payments without a product attached | |
| 208 | + | - [x] Revenue splits — record split obligations on purchases/tips for multi-author projects | |
| 203 | 209 | - [ ] Embeddable widgets — buy button / audio preview / checkout popup for external sites (Ko-fi's embed model is how many creators discover the platform) | |
| 204 | 210 | - [ ] Fundraising goals — display campaign target + progress bar on project page (simple DB field + UI, high engagement signal) | |
| 205 | 211 | ||
| @@ -213,6 +219,13 @@ Weak points identified vs Ko-fi. Ordered by effort/impact. | |||
| 213 | 219 | - [ ] Moderation team size — Ko-fi has dedicated Trust & Safety staff. MNW is one person. Acknowledged in docs, hiring is priority #2 in surplus allocation. | |
| 214 | 220 | ||
| 215 | 221 | ||
| 222 | + | ### Phase 20D: Automated Revenue Split Payouts | |
| 223 | + | - [ ] Automated Stripe Transfers for revenue splits (currently splits are recorded as obligations; owners settle with collaborators directly) | |
| 224 | + | - [ ] Requires switching split-enabled projects from direct charges to destination charges or separate charges + transfers | |
| 225 | + | - [ ] Legal review: money transmitter implications of holding and distributing funds | |
| 226 | + | - [ ] Dashboard: mark splits as settled (manual confirmation while automated transfers are not yet available) | |
| 227 | + | - [ ] Trigger: stable split recording for 3+ months, legal review complete | |
| 228 | + | ||
| 216 | 229 | ### Phase 21: Scheduled Content — Remaining | |
| 217 | 230 | - [ ] Pre-save + pre-order, countdown display, calendar view | |
| 218 | 231 |
| @@ -17,6 +17,7 @@ mod git; | |||
| 17 | 17 | mod admin; | |
| 18 | 18 | mod misc; | |
| 19 | 19 | mod tips; | |
| 20 | + | mod splits_export; | |
| 20 | 21 | ||
| 21 | 22 | pub use user::*; | |
| 22 | 23 | pub use project::*; | |
| @@ -35,3 +36,4 @@ pub use git::*; | |||
| 35 | 36 | pub use admin::*; | |
| 36 | 37 | pub use misc::*; | |
| 37 | 38 | pub use tips::*; | |
| 39 | + | pub use splits_export::*; |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //! Split export row model. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use sqlx::FromRow; | |
| 5 | + | ||
| 6 | + | use super::super::id_types::*; | |
| 7 | + | ||
| 8 | + | /// A revenue split record with context for CSV export. | |
| 9 | + | #[derive(Debug, Clone, FromRow)] | |
| 10 | + | pub struct DbSplitExportRow { | |
| 11 | + | pub id: RevenueSplitId, | |
| 12 | + | pub recipient_id: UserId, | |
| 13 | + | pub amount_cents: i32, | |
| 14 | + | pub split_percent: i16, | |
| 15 | + | pub created_at: DateTime<Utc>, | |
| 16 | + | /// "sale" or "tip" | |
| 17 | + | pub source_type: String, | |
| 18 | + | /// Username of the member who receives this split | |
| 19 | + | pub recipient_username: String, | |
| 20 | + | } |
| @@ -216,3 +216,78 @@ pub async fn get_splits_for_recipient( | |||
| 216 | 216 | ||
| 217 | 217 | Ok(splits) | |
| 218 | 218 | } | |
| 219 | + | ||
| 220 | + | /// Total split revenue owed to a recipient (all completed splits). | |
| 221 | + | #[tracing::instrument(skip(pool))] | |
| 222 | + | pub async fn total_split_revenue(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 223 | + | let row: (Option<i64>,) = sqlx::query_as( | |
| 224 | + | "SELECT SUM(amount_cents)::BIGINT FROM revenue_splits WHERE recipient_id = $1", | |
| 225 | + | ) | |
| 226 | + | .bind(recipient_id) | |
| 227 | + | .fetch_one(pool) | |
| 228 | + | .await?; | |
| 229 | + | ||
| 230 | + | Ok(row.0.unwrap_or(0)) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// Count of split records for a recipient. | |
| 234 | + | #[tracing::instrument(skip(pool))] | |
| 235 | + | pub async fn count_splits_for_recipient(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 236 | + | let row: (i64,) = sqlx::query_as( | |
| 237 | + | "SELECT COUNT(*) FROM revenue_splits WHERE recipient_id = $1", | |
| 238 | + | ) | |
| 239 | + | .bind(recipient_id) | |
| 240 | + | .fetch_one(pool) | |
| 241 | + | .await?; | |
| 242 | + | ||
| 243 | + | Ok(row.0) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Get all splits involving a user (as owner or recipient) for CSV export. | |
| 247 | + | /// Returns splits where the user is either: | |
| 248 | + | /// - The recipient (collaborator receiving a share), or | |
| 249 | + | /// - The seller/tip recipient (owner who owes collaborators) | |
| 250 | + | #[tracing::instrument(skip(pool))] | |
| 251 | + | pub async fn get_splits_for_export( | |
| 252 | + | pool: &PgPool, | |
| 253 | + | user_id: UserId, | |
| 254 | + | ) -> Result<Vec<DbSplitExportRow>> { | |
| 255 | + | let rows = sqlx::query_as::<_, DbSplitExportRow>( | |
| 256 | + | r#" | |
| 257 | + | SELECT rs.id, rs.recipient_id, rs.amount_cents, rs.split_percent, rs.created_at, | |
| 258 | + | CASE WHEN rs.transaction_id IS NOT NULL THEN 'sale' ELSE 'tip' END AS source_type, | |
| 259 | + | u.username AS recipient_username | |
| 260 | + | FROM revenue_splits rs | |
| 261 | + | JOIN users u ON u.id = rs.recipient_id | |
| 262 | + | LEFT JOIN transactions t ON t.id = rs.transaction_id | |
| 263 | + | LEFT JOIN tips tip ON tip.id = rs.tip_id | |
| 264 | + | WHERE rs.recipient_id = $1 | |
| 265 | + | OR COALESCE(t.seller_id, tip.recipient_id) = $1 | |
| 266 | + | ORDER BY rs.created_at DESC | |
| 267 | + | "#, | |
| 268 | + | ) | |
| 269 | + | .bind(user_id) | |
| 270 | + | .fetch_all(pool) | |
| 271 | + | .await?; | |
| 272 | + | ||
| 273 | + | Ok(rows) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// Total split obligations owed by a project owner (splits on their transactions/tips). | |
| 277 | + | #[tracing::instrument(skip(pool))] | |
| 278 | + | pub async fn total_split_obligations(pool: &PgPool, owner_id: UserId) -> Result<i64> { | |
| 279 | + | let row: (Option<i64>,) = sqlx::query_as( | |
| 280 | + | r#" | |
| 281 | + | SELECT SUM(rs.amount_cents)::BIGINT | |
| 282 | + | FROM revenue_splits rs | |
| 283 | + | LEFT JOIN transactions t ON t.id = rs.transaction_id | |
| 284 | + | LEFT JOIN tips tip ON tip.id = rs.tip_id | |
| 285 | + | WHERE COALESCE(t.seller_id, tip.recipient_id) = $1 | |
| 286 | + | "#, | |
| 287 | + | ) | |
| 288 | + | .bind(owner_id) | |
| 289 | + | .fetch_one(pool) | |
| 290 | + | .await?; | |
| 291 | + | ||
| 292 | + | Ok(row.0.unwrap_or(0)) | |
| 293 | + | } |
| @@ -275,6 +275,49 @@ pub(super) async fn export_sales( | |||
| 275 | 275 | .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e))) | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | + | /// Export revenue splits as a downloadable CSV file. | |
| 279 | + | #[tracing::instrument(skip_all, name = "exports::export_splits")] | |
| 280 | + | pub(super) async fn export_splits( | |
| 281 | + | State(state): State<AppState>, | |
| 282 | + | headers: HeaderMap, | |
| 283 | + | AuthUser(user): AuthUser, | |
| 284 | + | ) -> Result<Response> { | |
| 285 | + | let is_htmx = is_htmx_request(&headers); | |
| 286 | + | ||
| 287 | + | let splits = db::project_members::get_splits_for_export(&state.db, user.id).await?; | |
| 288 | + | ||
| 289 | + | let mut csv_content = String::from("Date,Type,Direction,Recipient,Amount,Split %\n"); | |
| 290 | + | for split in &splits { | |
| 291 | + | let direction = if split.recipient_id == user.id { "incoming" } else { "outgoing" }; | |
| 292 | + | csv_content.push_str(&format!( | |
| 293 | + | "{},{},{},{},{:.2},{}\n", | |
| 294 | + | split.created_at.format("%Y-%m-%d %H:%M:%S"), | |
| 295 | + | sanitize_csv_cell(&split.source_type), | |
| 296 | + | direction, | |
| 297 | + | sanitize_csv_cell(&split.recipient_username), | |
| 298 | + | split.amount_cents as f64 / 100.0, | |
| 299 | + | split.split_percent, | |
| 300 | + | )); | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | if is_htmx { | |
| 304 | + | let data_uri = format!( | |
| 305 | + | "data:text/csv;charset=utf-8,{}", | |
| 306 | + | urlencoding::encode(&csv_content) | |
| 307 | + | ); | |
| 308 | + | return Ok(ExportDownloadTemplate { | |
| 309 | + | data_uri, | |
| 310 | + | filename: "makenot-work-splits.csv".to_string(), | |
| 311 | + | }.into_response()); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | Response::builder() | |
| 315 | + | .header("Content-Type", "text/csv") | |
| 316 | + | .header("Content-Disposition", "attachment; filename=\"makenot-work-splits.csv\"") | |
| 317 | + | .body(csv_content.into()) | |
| 318 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e))) | |
| 319 | + | } | |
| 320 | + | ||
| 278 | 321 | /// Export all purchase transactions as a downloadable CSV file. | |
| 279 | 322 | #[tracing::instrument(skip_all, name = "exports::export_purchases")] | |
| 280 | 323 | pub(super) async fn export_purchases( |
| @@ -340,6 +340,7 @@ pub fn api_routes() -> Router<AppState> { | |||
| 340 | 340 | .route("/api/export/projects", post(exports::export_projects)) | |
| 341 | 341 | .route("/api/export/sales", post(exports::export_sales)) | |
| 342 | 342 | .route("/api/export/purchases", post(exports::export_purchases)) | |
| 343 | + | .route("/api/export/splits", post(exports::export_splits)) | |
| 343 | 344 | .route("/api/export/followers", post(exports::export_followers)) | |
| 344 | 345 | .route("/api/export/content", post(exports::export_content)) | |
| 345 | 346 | .route_layer(GovernorLayer { |
| @@ -165,6 +165,11 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( | |||
| 165 | 165 | }) | |
| 166 | 166 | .collect(); | |
| 167 | 167 | ||
| 168 | + | // Revenue splits | |
| 169 | + | let splits_incoming_cents = db::project_members::total_split_revenue(&state.db, session_user.id).await?; | |
| 170 | + | let splits_incoming_count = db::project_members::count_splits_for_recipient(&state.db, session_user.id).await?; | |
| 171 | + | let splits_outgoing_cents = db::project_members::total_split_obligations(&state.db, session_user.id).await?; | |
| 172 | + | ||
| 168 | 173 | Ok(UserPaymentsTabTemplate { | |
| 169 | 174 | user, | |
| 170 | 175 | payout_summary, | |
| @@ -172,6 +177,9 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( | |||
| 172 | 177 | tips_received, | |
| 173 | 178 | tips_total: helpers::format_revenue(tips_total_cents), | |
| 174 | 179 | tips_count, | |
| 180 | + | splits_incoming_total: helpers::format_revenue(splits_incoming_cents), | |
| 181 | + | splits_incoming_count, | |
| 182 | + | splits_outgoing_total: helpers::format_revenue(splits_outgoing_cents), | |
| 175 | 183 | }) | |
| 176 | 184 | } | |
| 177 | 185 |
| @@ -67,6 +67,9 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 67 | 67 | db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await?; | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | // Record revenue splits if the item's project has members | |
| 71 | + | record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await; | |
| 72 | + | ||
| 70 | 73 | maybe_generate_license_key(state, item_id, buyer_id, tx.id).await; | |
| 71 | 74 | send_purchase_emails(state, &tx, buyer_id, seller_id); | |
| 72 | 75 | subscribe_buyer_to_mailing_list(state, item_id, buyer_id); | |
| @@ -440,6 +443,11 @@ pub(super) async fn handle_tip_checkout_completed( | |||
| 440 | 443 | amount_cents = %tip.amount_cents, "tip completed" | |
| 441 | 444 | ); | |
| 442 | 445 | ||
| 446 | + | // Record revenue splits if the tip's project has members | |
| 447 | + | if let Some(project_id) = tip.project_id { | |
| 448 | + | record_tip_splits(state, tip.id, project_id, tip.amount_cents).await; | |
| 449 | + | } | |
| 450 | + | ||
| 443 | 451 | // Send tip notification email (fire-and-forget) | |
| 444 | 452 | send_tip_email(state, &tip, tipper_id, recipient_id); | |
| 445 | 453 | } | |
| @@ -487,3 +495,69 @@ fn send_tip_email( | |||
| 487 | 495 | } | |
| 488 | 496 | }); | |
| 489 | 497 | } | |
| 498 | + | ||
| 499 | + | /// Record revenue splits for a completed item purchase. | |
| 500 | + | /// | |
| 501 | + | /// Looks up the item's project and its members. If the project has members | |
| 502 | + | /// with split percentages, creates split records for each member. The owner | |
| 503 | + | /// receives the remainder (100% minus all member splits). | |
| 504 | + | /// | |
| 505 | + | /// Splits are recorded as obligations — actual payment transfer to members | |
| 506 | + | /// is handled by the project owner outside the platform for now. | |
| 507 | + | async fn record_transaction_splits( | |
| 508 | + | state: &AppState, | |
| 509 | + | transaction_id: db::TransactionId, | |
| 510 | + | item_id: db::ItemId, | |
| 511 | + | amount_cents: i32, | |
| 512 | + | ) { | |
| 513 | + | let item = match db::items::get_item_by_id(&state.db, item_id).await { | |
| 514 | + | Ok(Some(item)) => item, | |
| 515 | + | _ => return, | |
| 516 | + | }; | |
| 517 | + | ||
| 518 | + | let members = match db::project_members::get_project_members(&state.db, item.project_id).await { | |
| 519 | + | Ok(m) if !m.is_empty() => m, | |
| 520 | + | _ => return, | |
| 521 | + | }; | |
| 522 | + | ||
| 523 | + | let splits: Vec<(db::UserId, i32, i16)> = members | |
| 524 | + | .iter() | |
| 525 | + | .map(|m| { | |
| 526 | + | let member_amount = (amount_cents as i64 * m.split_percent as i64 / 100) as i32; | |
| 527 | + | (m.user_id, member_amount, m.split_percent) | |
| 528 | + | }) | |
| 529 | + | .collect(); | |
| 530 | + | ||
| 531 | + | if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await { | |
| 532 | + | tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits"); | |
| 533 | + | } else { | |
| 534 | + | tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded"); | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | /// Record revenue splits for a completed tip on a project with members. | |
| 539 | + | async fn record_tip_splits( | |
| 540 | + | state: &AppState, | |
| 541 | + | tip_id: db::TipId, | |
| 542 | + | project_id: db::ProjectId, | |
| 543 | + | amount_cents: i32, | |
| 544 | + | ) { | |
| 545 | + | let members = match db::project_members::get_project_members(&state.db, project_id).await { | |
| 546 | + | Ok(m) if !m.is_empty() => m, | |
| 547 | + | _ => return, | |
| 548 | + | }; | |
| 549 | + | ||
| 550 | + | let splits: Vec<(db::UserId, i32, i16)> = members | |
| 551 | + | .iter() | |
| 552 | + | .map(|m| { | |
| 553 | + | let member_amount = (amount_cents as i64 * m.split_percent as i64 / 100) as i32; | |
| 554 | + | (m.user_id, member_amount, m.split_percent) | |
| 555 | + | }) | |
| 556 | + | .collect(); | |
| 557 | + | ||
| 558 | + | if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await { | |
| 559 | + | tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits"); | |
| 560 | + | } else { | |
| 561 | + | tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded"); | |
| 562 | + | } | |
| 563 | + | } |
| @@ -192,6 +192,11 @@ pub struct UserPaymentsTabTemplate { | |||
| 192 | 192 | pub tips_received: Vec<TipReceived>, | |
| 193 | 193 | pub tips_total: String, | |
| 194 | 194 | pub tips_count: i64, | |
| 195 | + | /// Revenue owed to you from other creators' projects (as a collaborator). | |
| 196 | + | pub splits_incoming_total: String, | |
| 197 | + | pub splits_incoming_count: i64, | |
| 198 | + | /// Revenue you owe to collaborators on your projects. | |
| 199 | + | pub splits_outgoing_total: String, | |
| 195 | 200 | } | |
| 196 | 201 | ||
| 197 | 202 | #[derive(Template)] |
| @@ -136,6 +136,23 @@ | |||
| 136 | 136 | ||
| 137 | 137 | <div class="export-card"> | |
| 138 | 138 | <div class="export-card-info"> | |
| 139 | + | <div class="export-card-title">Revenue Splits</div> | |
| 140 | + | <div class="export-card-desc">Record of all revenue splits from collaborative projects, both incoming and outgoing.</div> | |
| 141 | + | <div class="export-card-meta">CSV format</div> | |
| 142 | + | <div class="export-status" id="splits-status"></div> | |
| 143 | + | </div> | |
| 144 | + | <button class="secondary" | |
| 145 | + | hx-post="/api/export/splits" | |
| 146 | + | hx-target="#splits-status" | |
| 147 | + | hx-swap="innerHTML" | |
| 148 | + | hx-indicator="#splits-spinner"> | |
| 149 | + | Download | |
| 150 | + | <span id="splits-spinner" class="htmx-indicator"> ...</span> | |
| 151 | + | </button> | |
| 152 | + | </div> | |
| 153 | + | ||
| 154 | + | <div class="export-card"> | |
| 155 | + | <div class="export-card-info"> | |
| 139 | 156 | <div class="export-card-title">Purchase History</div> | |
| 140 | 157 | <div class="export-card-desc">Record of all items you've purchased, for your personal records.</div> | |
| 141 | 158 | <div class="export-card-meta">CSV format</div> |
| @@ -202,6 +202,32 @@ | |||
| 202 | 202 | </div> | |
| 203 | 203 | {% endif %} | |
| 204 | 204 | ||
| 205 | + | {% if splits_incoming_count > 0 || splits_outgoing_total != "$0.00" %} | |
| 206 | + | <details class="form-section" open> | |
| 207 | + | <summary><h2>Revenue Splits</h2></summary> | |
| 208 | + | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;"> | |
| 209 | + | {% if splits_incoming_count > 0 %} | |
| 210 | + | <div style="background: var(--surface-muted); padding: 1.25rem;"> | |
| 211 | + | <div class="text-xs dimmed">Owed to you (as collaborator)</div> | |
| 212 | + | <div style="font-size: 1.25rem; font-weight: bold;">{{ splits_incoming_total }}</div> | |
| 213 | + | <div style="font-size: 0.85rem; opacity: 0.7;">from {{ splits_incoming_count }} sales</div> | |
| 214 | + | </div> | |
| 215 | + | {% endif %} | |
| 216 | + | {% if splits_outgoing_total != "$0.00" %} | |
| 217 | + | <div style="background: var(--surface-muted); padding: 1.25rem;"> | |
| 218 | + | <div class="text-xs dimmed">You owe collaborators</div> | |
| 219 | + | <div style="font-size: 1.25rem; font-weight: bold;">{{ splits_outgoing_total }}</div> | |
| 220 | + | </div> | |
| 221 | + | {% endif %} | |
| 222 | + | </div> | |
| 223 | + | <p style="font-size: 0.85rem; opacity: 0.6;"> | |
| 224 | + | Revenue splits are recorded automatically when sales complete on projects with collaborators. | |
| 225 | + | Payouts to collaborators are currently settled between project members directly. | |
| 226 | + | We plan to automate this process in a future update. | |
| 227 | + | </p> | |
| 228 | + | </details> | |
| 229 | + | {% endif %} | |
| 230 | + | ||
| 205 | 231 | {% if tips_count > 0 %} | |
| 206 | 232 | <details class="form-section" open> | |
| 207 | 233 | <summary><h2>Tips Received ({{ tips_count }})</h2></summary> |