Skip to main content

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-ByClaude 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

OldNewLine
@@ -61,6 +61,11 @@
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 @@
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 @@
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
OldNewLine
@@ -216,3 +216,78 @@
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
}
OldNewLine
@@ -192,6 +192,11 @@
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)]
OldNewLine
@@ -134,6 +134,23 @@
134
134
</button>
135
135
</div>
136
136
137
<div class="export-card">
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
137
154
<div class="export-card">
138
155
<div class="export-card-info">
139
156
<div class="export-card-title">Purchase History</div>
OldNewLine
@@ -17,6 +17,7 @@
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 @@
35
36
pub use admin::*;
36
37
pub use misc::*;
37
38
pub use tips::*;
39
pub use splits_export::*;
OldNewLine
@@ -275,6 +275,49 @@
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(