Wire revenue splits: webhook recording, dashboard display, CSV export Split recording: - Purchase webhook records splits for items in projects with members - Tip webhook records splits for tips on projects with members - Split amounts calculated as percentage of total payment per member Dashboard display: - Payments tab shows incoming splits (owed to you as collaborator) - Payments tab shows outgoing splits (you owe to your collaborators) - Note that automated payouts are planned for a future update Export: - New /api/export/splits endpoint exports all splits as CSV - Columns: date, type (sale/tip), direction, recipient, amount, split % - Export card added to dashboard export portal Todo updates: - Tips and revenue splits marked complete - Phase 20D: Automated Revenue Split Payouts added as future work
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-23 04:23 UTC
Commit:
fffc44962f8ad14d4fc2ec4a0c54b6c3395cefb2Parent:
11 files changed,
+285 insertions,
-1 deletion
- [ ] liability.md legal review (has [PENDING LEGAL REVIEW] placeholders)- [ ] dmca-counter.md designated agent address (needs DMCA agent registration)### Git Access Provisioning- [ ] Web UI for managing SSH keys per MNW account (residents/collaborators add keys in dashboard)- [ ] Per-repo collaborator access (grant push by MNW username, stored in DB, wired to authorized_keys rebuild)- [ ] Replace manual `setup-ssh-keys.sh` with account-driven key management### Frontend — Remaining- [ ] Git browser integration: add discover/follow integration (post-beta)Weak points identified vs Ko-fi. Ordered by effort/impact.#### Easy Wins- [ ] Tips/donations — accept one-time payments without a product attached (Ko-fi's core feature, trivial on Stripe, no inventory/file delivery needed)- [x] Tips/donations — accept one-time payments without a product attached- [x] Revenue splits — record split obligations on purchases/tips for multi-author projects- [ ] Embeddable widgets — buy button / audio preview / checkout popup for external sites (Ko-fi's embed model is how many creators discover the platform)- [ ] Fundraising goals — display campaign target + progress bar on project page (simple DB field + UI, high engagement signal)- [ ] Moderation team size — Ko-fi has dedicated Trust & Safety staff. MNW is one person. Acknowledged in docs, hiring is priority #2 in surplus allocation.### Phase 20D: Automated Revenue Split Payouts- [ ] Automated Stripe Transfers for revenue splits (currently splits are recorded as obligations; owners settle with collaborators directly)- [ ] Requires switching split-enabled projects from direct charges to destination charges or separate charges + transfers- [ ] Legal review: money transmitter implications of holding and distributing funds- [ ] Dashboard: mark splits as settled (manual confirmation while automated transfers are not yet available)- [ ] Trigger: stable split recording for 3+ months, legal review complete### Phase 21: Scheduled Content — Remaining- [ ] Pre-save + pre-order, countdown display, calendar view Ok(splits)}/// Total split revenue owed to a recipient (all completed splits).#[tracing::instrument(skip(pool))]pub async fn total_split_revenue(pool: &PgPool, recipient_id: UserId) -> Result<i64> { let row: (Option<i64>,) = sqlx::query_as( "SELECT SUM(amount_cents)::BIGINT FROM revenue_splits WHERE recipient_id = $1", ) .bind(recipient_id) .fetch_one(pool) .await?; Ok(row.0.unwrap_or(0))}/// Count of split records for a recipient.#[tracing::instrument(skip(pool))]pub async fn count_splits_for_recipient(pool: &PgPool, recipient_id: UserId) -> Result<i64> { let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM revenue_splits WHERE recipient_id = $1", ) .bind(recipient_id) .fetch_one(pool) .await?; Ok(row.0)}/// Get all splits involving a user (as owner or recipient) for CSV export./// Returns splits where the user is either:/// - The recipient (collaborator receiving a share), or/// - The seller/tip recipient (owner who owes collaborators)#[tracing::instrument(skip(pool))]pub async fn get_splits_for_export( pool: &PgPool, user_id: UserId,) -> Result<Vec<DbSplitExportRow>> { let rows = sqlx::query_as::<_, DbSplitExportRow>( r#" SELECT rs.id, rs.recipient_id, rs.amount_cents, rs.split_percent, rs.created_at, CASE WHEN rs.transaction_id IS NOT NULL THEN 'sale' ELSE 'tip' END AS source_type, u.username AS recipient_username FROM revenue_splits rs JOIN users u ON u.id = rs.recipient_id LEFT JOIN transactions t ON t.id = rs.transaction_id LEFT JOIN tips tip ON tip.id = rs.tip_id WHERE rs.recipient_id = $1 OR COALESCE(t.seller_id, tip.recipient_id) = $1 ORDER BY rs.created_at DESC "#, ) .bind(user_id) .fetch_all(pool) .await?; Ok(rows)}/// Total split obligations owed by a project owner (splits on their transactions/tips).#[tracing::instrument(skip(pool))]pub async fn total_split_obligations(pool: &PgPool, owner_id: UserId) -> Result<i64> { let row: (Option<i64>,) = sqlx::query_as( r#" SELECT SUM(rs.amount_cents)::BIGINT FROM revenue_splits rs LEFT JOIN transactions t ON t.id = rs.transaction_id LEFT JOIN tips tip ON tip.id = rs.tip_id WHERE COALESCE(t.seller_id, tip.recipient_id) = $1 "#, ) .bind(owner_id) .fetch_one(pool) .await?; Ok(row.0.unwrap_or(0))} pub tips_received: Vec<TipReceived>, pub tips_total: String, pub tips_count: i64, /// Revenue owed to you from other creators' projects (as a collaborator). pub splits_incoming_total: String, pub splits_incoming_count: i64, /// Revenue you owe to collaborators on your projects. pub splits_outgoing_total: String,}#[derive(Template)] </button> </div> <div class="export-card"> <div class="export-card-info"> <div class="export-card-title">Revenue Splits</div> <div class="export-card-desc">Record of all revenue splits from collaborative projects, both incoming and outgoing.</div> <div class="export-card-meta">CSV format</div> <div class="export-status" id="splits-status"></div> </div> <button class="secondary" hx-post="/api/export/splits" hx-target="#splits-status" hx-swap="innerHTML" hx-indicator="#splits-spinner"> Download <span id="splits-spinner" class="htmx-indicator"> ...</span> </button> </div> <div class="export-card"> <div class="export-card-info"> <div class="export-card-title">Purchase History</div>mod admin;mod misc;mod tips;mod splits_export;pub use user::*;pub use project::*;pub use admin::*;pub use misc::*;pub use tips::*;pub use splits_export::*; .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e)))}/// Export revenue splits as a downloadable CSV file.#[tracing::instrument(skip_all, name = "exports::export_splits")]pub(super) async fn export_splits( State(state): State<AppState>, headers: HeaderMap, AuthUser(user): AuthUser,) -> Result<Response> { let is_htmx = is_htmx_request(&headers); let splits = db::project_members::get_splits_for_export(&state.db, user.id).await?; let mut csv_content = String::from("Date,Type,Direction,Recipient,Amount,Split %\n"); for split in &splits { let direction = if split.recipient_id == user.id { "incoming" } else { "outgoing" }; csv_content.push_str(&format!( "{},{},{},{},{:.2},{}\n", split.created_at.format("%Y-%m-%d %H:%M:%S"), sanitize_csv_cell(&split.source_type), direction, sanitize_csv_cell(&split.recipient_username), split.amount_cents as f64 / 100.0, split.split_percent, )); } if is_htmx { let data_uri = format!( "data:text/csv;charset=utf-8,{}", urlencoding::encode(&csv_content) ); return Ok(ExportDownloadTemplate { data_uri, filename: "makenot-work-splits.csv".to_string(), }.into_response()); } Response::builder() .header("Content-Type", "text/csv") .header("Content-Disposition", "attachment; filename=\"makenot-work-splits.csv\"") .body(csv_content.into()) .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build response: {}", e)))}/// Export all purchase transactions as a downloadable CSV file.#[tracing::instrument(skip_all, name = "exports::export_purchases")]pub(super) async fn export_purchases(