max / makenotwork
15 files changed,
+259 insertions,
-2 deletions
| @@ -40,7 +40,7 @@ Usability audit grade: B. Complexity C+, Completeness B+, Learnability B, Discov | |||
| 40 | 40 | - [ ] **[MEDIUM]** Restructure user dashboard tabs — show 4 core tabs (Account, Projects, Payments, Support) by default. Collapse SyncKit, SSH Keys, Forums, Media into "More Tools" overflow section. Current 11 tabs overwhelm new creators | |
| 41 | 41 | - [ ] **[MEDIUM]** Fix price input to use dollars, not cents — promo code and subscription tier forms accept cents (e.g. "500" for $5). Add live preview ("= $5.00") or switch to dollar input with auto-conversion | |
| 42 | 42 | - [x] **[MEDIUM]** Standardize pricing terminology — item wizard now says "One-Time Purchase" to match project wizard, paywall, landing page, and docs | |
| 43 | - | - [ ] **[MEDIUM]** Add self-service refund UI for creators — backend exists (`pending_refunds.rs`) but no dashboard UI. Add "Refund" button in creator transaction history | |
| 43 | + | - [x] **[MEDIUM]** Add self-service refund UI for creators — new "Sales" tab on item dashboard with per-transaction refund buttons, Stripe refund API integration | |
| 44 | 44 | ||
| 45 | 45 | ### Medium (discoverability and learnability) | |
| 46 | 46 |
| @@ -538,6 +538,19 @@ pub async fn remove_free_item_from_library( | |||
| 538 | 538 | Ok(result.rows_affected() > 0) | |
| 539 | 539 | } | |
| 540 | 540 | ||
| 541 | + | /// Fetch a single transaction by ID. | |
| 542 | + | #[tracing::instrument(skip_all)] | |
| 543 | + | pub async fn get_transaction_by_id( | |
| 544 | + | pool: &PgPool, | |
| 545 | + | id: TransactionId, | |
| 546 | + | ) -> Result<Option<DbTransaction>> { | |
| 547 | + | let tx = sqlx::query_as::<_, DbTransaction>("SELECT * FROM transactions WHERE id = $1") | |
| 548 | + | .bind(id) | |
| 549 | + | .fetch_optional(pool) | |
| 550 | + | .await?; | |
| 551 | + | Ok(tx) | |
| 552 | + | } | |
| 553 | + | ||
| 541 | 554 | /// Mark a transaction as refunded, returning its ID and item_id for downstream cleanup. | |
| 542 | 555 | /// | |
| 543 | 556 | /// The WHERE clause requires `status = 'completed'` so that already-refunded | |
| @@ -899,3 +912,27 @@ pub async fn claim_free_project( | |||
| 899 | 912 | ||
| 900 | 913 | Ok(()) | |
| 901 | 914 | } | |
| 915 | + | ||
| 916 | + | /// Completed and refunded sales for a specific item, for the item dashboard Sales tab. | |
| 917 | + | #[tracing::instrument(skip_all)] | |
| 918 | + | pub async fn get_sales_by_item( | |
| 919 | + | pool: &PgPool, | |
| 920 | + | item_id: ItemId, | |
| 921 | + | seller_id: UserId, | |
| 922 | + | ) -> Result<Vec<DbTransaction>> { | |
| 923 | + | let rows = sqlx::query_as::<_, DbTransaction>( | |
| 924 | + | r#" | |
| 925 | + | SELECT * FROM transactions | |
| 926 | + | WHERE item_id = $1 AND seller_id = $2 | |
| 927 | + | AND status IN ('completed', 'refunded') | |
| 928 | + | ORDER BY created_at DESC | |
| 929 | + | LIMIT 200 | |
| 930 | + | "#, | |
| 931 | + | ) | |
| 932 | + | .bind(item_id) | |
| 933 | + | .bind(seller_id) | |
| 934 | + | .fetch_all(pool) | |
| 935 | + | .await?; | |
| 936 | + | ||
| 937 | + | Ok(rows) | |
| 938 | + | } |
| @@ -244,4 +244,35 @@ impl StripeClient { | |||
| 244 | 244 | ||
| 245 | 245 | Ok(()) | |
| 246 | 246 | } | |
| 247 | + | ||
| 248 | + | /// Issue a full refund for a payment on a connected account. | |
| 249 | + | /// | |
| 250 | + | /// Uses the raw Stripe API because Direct Charges require the | |
| 251 | + | /// `Stripe-Account` header to target the connected account. | |
| 252 | + | pub async fn create_refund( | |
| 253 | + | &self, | |
| 254 | + | payment_intent_id: &str, | |
| 255 | + | connected_account_id: &str, | |
| 256 | + | ) -> Result<()> { | |
| 257 | + | let resp = reqwest::Client::new() | |
| 258 | + | .post("https://api.stripe.com/v1/refunds") | |
| 259 | + | .header("Authorization", format!("Bearer {}", self.config.secret_key)) | |
| 260 | + | .header("Stripe-Account", connected_account_id) | |
| 261 | + | .form(&[("payment_intent", payment_intent_id)]) | |
| 262 | + | .timeout(std::time::Duration::from_secs(30)) | |
| 263 | + | .send() | |
| 264 | + | .await | |
| 265 | + | .map_err(|e| { | |
| 266 | + | tracing::error!(payment_intent_id = %payment_intent_id, error = ?e, "failed to create Stripe refund"); | |
| 267 | + | AppError::Internal(anyhow::anyhow!("Failed to create refund")) | |
| 268 | + | })?; | |
| 269 | + | ||
| 270 | + | if !resp.status().is_success() { | |
| 271 | + | let body = resp.text().await.unwrap_or_default(); | |
| 272 | + | tracing::error!(payment_intent_id = %payment_intent_id, body = %body, "Stripe refund returned error"); | |
| 273 | + | return Err(AppError::Internal(anyhow::anyhow!("Failed to create refund"))); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | Ok(()) | |
| 277 | + | } | |
| 247 | 278 | } |
| @@ -77,6 +77,9 @@ pub trait PaymentProvider: Send + Sync { | |||
| 77 | 77 | async fn resume_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| 78 | 78 | async fn cancel_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| 79 | 79 | ||
| 80 | + | // Refunds | |
| 81 | + | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>; | |
| 82 | + | ||
| 80 | 83 | // Webhooks | |
| 81 | 84 | fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event>; | |
| 82 | 85 | fn verify_webhook_v2(&self, payload: &str, signature: &str) -> crate::error::Result<serde_json::Value>; | |
| @@ -159,6 +162,10 @@ impl PaymentProvider for StripeClient { | |||
| 159 | 162 | StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await | |
| 160 | 163 | } | |
| 161 | 164 | ||
| 165 | + | async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()> { | |
| 166 | + | StripeClient::create_refund(self, payment_intent_id, connected_account_id).await | |
| 167 | + | } | |
| 168 | + | ||
| 162 | 169 | fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event> { | |
| 163 | 170 | StripeClient::verify_webhook(self, payload, signature) | |
| 164 | 171 | } |
| @@ -4,6 +4,7 @@ mod bulk; | |||
| 4 | 4 | mod bundles; | |
| 5 | 5 | mod chapters; | |
| 6 | 6 | mod crud; | |
| 7 | + | mod refund; | |
| 7 | 8 | mod sections; | |
| 8 | 9 | mod tags; | |
| 9 | 10 | mod versions; | |
| @@ -12,6 +13,7 @@ pub(super) use bulk::{bulk_delete, bulk_publish, bulk_unpublish}; | |||
| 12 | 13 | pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed}; | |
| 13 | 14 | pub(super) use chapters::{create_chapter, delete_chapter, list_chapters, update_chapter}; | |
| 14 | 15 | pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, update_item}; | |
| 16 | + | pub(super) use refund::refund_transaction; | |
| 15 | 17 | pub(super) use sections::{create_section, delete_section, list_sections, reorder_sections, update_section}; | |
| 16 | 18 | pub(super) use tags::{add_tag, remove_tag, set_primary_tag}; | |
| 17 | 19 | pub(super) use crud::update_item_text; |
| @@ -0,0 +1,68 @@ | |||
| 1 | + | //! Self-service refund endpoint for creators. | |
| 2 | + | ||
| 3 | + | use axum::extract::{Path, State}; | |
| 4 | + | use axum::response::IntoResponse; | |
| 5 | + | use axum::Json; | |
| 6 | + | use serde::Deserialize; | |
| 7 | + | ||
| 8 | + | use crate::{ | |
| 9 | + | auth::AuthUser, | |
| 10 | + | db::{self, ItemId, TransactionId}, | |
| 11 | + | error::{AppError, Result}, | |
| 12 | + | AppState, | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | use super::super::verify_item_ownership; | |
| 16 | + | ||
| 17 | + | #[derive(Debug, Deserialize)] | |
| 18 | + | pub struct RefundRequest { | |
| 19 | + | pub transaction_id: TransactionId, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Issue a full refund for a transaction on this item. | |
| 23 | + | /// | |
| 24 | + | /// The refund is sent to Stripe; the existing `charge.refunded` webhook | |
| 25 | + | /// handler marks the transaction as refunded, revokes license keys, and | |
| 26 | + | /// decrements the sales count. | |
| 27 | + | #[tracing::instrument(skip_all, name = "items::refund_transaction")] | |
| 28 | + | pub(in crate::routes::api) async fn refund_transaction( | |
| 29 | + | State(state): State<AppState>, | |
| 30 | + | AuthUser(user): AuthUser, | |
| 31 | + | Path(id): Path<ItemId>, | |
| 32 | + | Json(req): Json<RefundRequest>, | |
| 33 | + | ) -> Result<impl IntoResponse> { | |
| 34 | + | user.check_not_suspended()?; | |
| 35 | + | verify_item_ownership(&state, id, user.id).await?; | |
| 36 | + | ||
| 37 | + | // Fetch the transaction and validate it belongs to this item | |
| 38 | + | let tx = db::transactions::get_transaction_by_id(&state.db, req.transaction_id) | |
| 39 | + | .await? | |
| 40 | + | .ok_or(AppError::NotFound)?; | |
| 41 | + | ||
| 42 | + | if tx.item_id != Some(id) { | |
| 43 | + | return Err(AppError::Forbidden); | |
| 44 | + | } | |
| 45 | + | if tx.seller_id != Some(user.id) { | |
| 46 | + | return Err(AppError::Forbidden); | |
| 47 | + | } | |
| 48 | + | if tx.status != db::TransactionStatus::Completed { | |
| 49 | + | return Err(AppError::BadRequest("Transaction is not in a refundable state".into())); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | let payment_intent_id = tx.stripe_payment_intent_id.as_deref() | |
| 53 | + | .ok_or_else(|| AppError::BadRequest("No payment intent — free claims cannot be refunded".into()))?; | |
| 54 | + | ||
| 55 | + | // Get the creator's Stripe connected account ID | |
| 56 | + | let seller = db::users::get_user_by_id(&state.db, user.id) | |
| 57 | + | .await? | |
| 58 | + | .ok_or(AppError::NotFound)?; | |
| 59 | + | let stripe_account_id = seller.stripe_account_id.as_deref() | |
| 60 | + | .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?; | |
| 61 | + | ||
| 62 | + | // Issue the refund via Stripe — the webhook handler does the rest | |
| 63 | + | let stripe = state.stripe.as_ref() | |
| 64 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Stripe not configured")))?; | |
| 65 | + | stripe.create_refund(payment_intent_id, stripe_account_id).await?; | |
| 66 | + | ||
| 67 | + | Ok(Json(serde_json::json!({ "ok": true }))) | |
| 68 | + | } |
| @@ -233,6 +233,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 233 | 233 | .route("/api/items/{id}/bundle/add", post(items::bundle_add)) | |
| 234 | 234 | .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove)) | |
| 235 | 235 | .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed)) | |
| 236 | + | // Refund | |
| 237 | + | .route("/api/items/{id}/refund", post(items::refund_transaction)) | |
| 236 | 238 | // Tag routes (HTMX) | |
| 237 | 239 | .route("/api/items/{id}/tags", post(items::add_tag)) | |
| 238 | 240 | .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag)) |
| @@ -71,6 +71,7 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 71 | 71 | .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing)) | |
| 72 | 72 | .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files)) | |
| 73 | 73 | .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings)) | |
| 74 | + | .route("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales)) | |
| 74 | 75 | .route("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed)) | |
| 75 | 76 | .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics)) | |
| 76 | 77 | .route_layer(GovernorLayer { config: read_rate_limit }); |
| @@ -203,3 +203,41 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_settings( | |||
| 203 | 203 | project_labels, | |
| 204 | 204 | }) | |
| 205 | 205 | } | |
| 206 | + | ||
| 207 | + | /// Item sales tab: transaction history with refund buttons. | |
| 208 | + | #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")] | |
| 209 | + | pub(in crate::routes::pages::dashboard) async fn item_tab_sales( | |
| 210 | + | State(state): State<AppState>, | |
| 211 | + | AuthUser(session_user): AuthUser, | |
| 212 | + | Path(id): Path<String>, | |
| 213 | + | ) -> Result<impl IntoResponse> { | |
| 214 | + | let (db_item, _db_project) = | |
| 215 | + | resolve_owned_item(&state, session_user.id, &id).await?; | |
| 216 | + | let item_id = db_item.id; | |
| 217 | + | let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?; | |
| 218 | + | let item = build_item_view(&db_item, &item_tags); | |
| 219 | + | ||
| 220 | + | let sales = db::transactions::get_sales_by_item(&state.db, item_id, session_user.id).await?; | |
| 221 | + | let rows: Vec<SaleRow> = sales.iter().map(|tx| { | |
| 222 | + | let buyer_display = tx.guest_email.clone() | |
| 223 | + | .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string())) | |
| 224 | + | .unwrap_or_else(|| "Unknown".to_string()); | |
| 225 | + | let cents = tx.amount_cents.as_i64(); | |
| 226 | + | SaleRow { | |
| 227 | + | transaction_id: tx.id.to_string(), | |
| 228 | + | buyer: buyer_display, | |
| 229 | + | amount_display: if cents == 0 { | |
| 230 | + | "Free".to_string() | |
| 231 | + | } else { | |
| 232 | + | format!("${}.{:02}", cents / 100, cents % 100) | |
| 233 | + | }, | |
| 234 | + | status: tx.status.to_string(), | |
| 235 | + | date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(), | |
| 236 | + | refundable: tx.status == db::TransactionStatus::Completed | |
| 237 | + | && tx.stripe_payment_intent_id.is_some() | |
| 238 | + | && cents > 0, | |
| 239 | + | } | |
| 240 | + | }).collect(); | |
| 241 | + | ||
| 242 | + | Ok(ItemSalesTabTemplate { item, sales: rows }) | |
| 243 | + | } |
| @@ -5,7 +5,7 @@ mod user; | |||
| 5 | 5 | ||
| 6 | 6 | pub(super) use item::{ | |
| 7 | 7 | item_tab_details, item_tab_embed, item_tab_files, item_tab_overview, item_tab_pricing, | |
| 8 | - | item_tab_settings, | |
| 8 | + | item_tab_sales, item_tab_settings, | |
| 9 | 9 | }; | |
| 10 | 10 | pub(super) use user::{ | |
| 11 | 11 | dashboard_tab_analytics, dashboard_tab_creator, dashboard_tab_details, |
| @@ -175,6 +175,7 @@ impl_into_response!( | |||
| 175 | 175 | ItemPricingTabTemplate, | |
| 176 | 176 | ItemFilesTabTemplate, | |
| 177 | 177 | ItemSettingsTabTemplate, | |
| 178 | + | ItemSalesTabTemplate, | |
| 178 | 179 | ItemEmbedTabTemplate, | |
| 179 | 180 | // Onboarding checklist | |
| 180 | 181 | OnboardingChecklistPartialTemplate, |
| @@ -839,6 +839,14 @@ pub struct ItemSettingsTabTemplate { | |||
| 839 | 839 | pub project_labels: Vec<String>, | |
| 840 | 840 | } | |
| 841 | 841 | ||
| 842 | + | /// Item sales tab: transaction history with refund actions. | |
| 843 | + | #[derive(Template)] | |
| 844 | + | #[template(path = "partials/tabs/item_sales.html")] | |
| 845 | + | pub struct ItemSalesTabTemplate { | |
| 846 | + | pub item: Item, | |
| 847 | + | pub sales: Vec<SaleRow>, | |
| 848 | + | } | |
| 849 | + | ||
| 842 | 850 | /// Item embed tab: copy-paste embed codes for this item. | |
| 843 | 851 | #[derive(Template)] | |
| 844 | 852 | #[template(path = "partials/tabs/item_embed.html")] |
| @@ -741,6 +741,17 @@ pub struct PromoCodeRow { | |||
| 741 | 741 | pub created_at: String, | |
| 742 | 742 | } | |
| 743 | 743 | ||
| 744 | + | /// Row data for displaying a sale in the item dashboard Sales tab. | |
| 745 | + | #[derive(Clone)] | |
| 746 | + | pub struct SaleRow { | |
| 747 | + | pub transaction_id: String, | |
| 748 | + | pub buyer: String, | |
| 749 | + | pub amount_display: String, | |
| 750 | + | pub status: String, | |
| 751 | + | pub date: String, | |
| 752 | + | pub refundable: bool, | |
| 753 | + | } | |
| 754 | + | ||
| 744 | 755 | /// Admin view of a report for the reports queue | |
| 745 | 756 | #[derive(Clone)] | |
| 746 | 757 | #[allow(dead_code)] // Fields used by Askama templates |
| @@ -91,6 +91,16 @@ | |||
| 91 | 91 | role="tab" | |
| 92 | 92 | aria-selected="false" | |
| 93 | 93 | aria-controls="tab-content" | |
| 94 | + | id="tab-sales" | |
| 95 | + | hx-get="/dashboard/item/{{ item.id }}/tabs/sales" | |
| 96 | + | hx-target="#tab-content" | |
| 97 | + | hx-swap="innerHTML" | |
| 98 | + | hx-indicator="#tab-spinner" | |
| 99 | + | onclick="setActiveTab(this)">Sales</button> | |
| 100 | + | <button class="tab" | |
| 101 | + | role="tab" | |
| 102 | + | aria-selected="false" | |
| 103 | + | aria-controls="tab-content" | |
| 94 | 104 | id="tab-embed" | |
| 95 | 105 | hx-get="/dashboard/item/{{ item.id }}/tabs/embed" | |
| 96 | 106 | hx-target="#tab-content" |
| @@ -0,0 +1,41 @@ | |||
| 1 | + | <h2>Sales</h2> | |
| 2 | + | ||
| 3 | + | {% if sales.is_empty() %} | |
| 4 | + | <div class="content-section"> | |
| 5 | + | <p class="muted">No sales yet. Sales will appear here once your first purchase is completed.</p> | |
| 6 | + | </div> | |
| 7 | + | {% else %} | |
| 8 | + | <table class="data-table"> | |
| 9 | + | <thead> | |
| 10 | + | <tr> | |
| 11 | + | <th>Date</th> | |
| 12 | + | <th>Buyer</th> | |
| 13 | + | <th>Amount</th> | |
| 14 | + | <th>Status</th> | |
| 15 | + | <th></th> | |
| 16 | + | </tr> | |
| 17 | + | </thead> | |
| 18 | + | <tbody> | |
| 19 | + | {% for sale in sales %} | |
| 20 | + | <tr id="sale-{{ sale.transaction_id }}"> | |
| 21 | + | <td>{{ sale.date }}</td> | |
| 22 | + | <td>{{ sale.buyer }}</td> | |
| 23 | + | <td>{{ sale.amount_display }}</td> | |
| 24 | + | <td><span class="badge {{ sale.status }}">{{ sale.status }}</span></td> | |
| 25 | + | <td> | |
| 26 | + | {% if sale.refundable %} | |
| 27 | + | <button class="secondary small" | |
| 28 | + | hx-post="/api/items/{{ item.id }}/refund" | |
| 29 | + | hx-vals='{"transaction_id": "{{ sale.transaction_id }}"}' | |
| 30 | + | hx-confirm="Issue a full refund for {{ sale.amount_display }}? This cannot be undone." | |
| 31 | + | hx-target="#sale-{{ sale.transaction_id }}" | |
| 32 | + | hx-swap="outerHTML" | |
| 33 | + | hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/item/{{ item.id }}/tabs/sales', '#tab-content')" | |
| 34 | + | style="color: var(--danger);">Refund</button> | |
| 35 | + | {% endif %} | |
| 36 | + | </td> | |
| 37 | + | </tr> | |
| 38 | + | {% endfor %} | |
| 39 | + | </tbody> | |
| 40 | + | </table> | |
| 41 | + | {% endif %} |