Skip to main content

max / makenotwork

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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-23 04:23 UTC
Commit: fffc44962f8ad14d4fc2ec4a0c54b6c3395cefb2
Parent: 1094cf7
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>