Skip to main content

max / makenotwork

Add self-service refund UI to item dashboard New "Sales" tab on the item dashboard showing per-item transaction history with refund buttons. Creators can issue full refunds directly without going to the Stripe dashboard. - Add get_sales_by_item() and get_transaction_by_id() DB queries - Add create_refund() to StripeClient (raw API, Direct Charges) - Add POST /api/items/{id}/refund endpoint with ownership verification - Add Sales tab handler, template, and SaleRow view model - Existing charge.refunded webhook handles status update, license key revocation, and sales count decrement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 02:46 UTC
Commit: 523c90b21ee4887e46709244e5fc9a4f215dad07
Parent: e5cf461
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 %}