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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 }
@@ -175,6 +175,7 @@
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 @@
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 @@
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