main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
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>
15 files changed,
+259 insertions,
-2 deletions
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
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
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
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
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
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
175
ItemPricingTabTemplate,
176
176
ItemFilesTabTemplate,
177
177
ItemSettingsTabTemplate,
178
+
ItemSalesTabTemplate,
178
179
ItemEmbedTabTemplate,
179
180
// Onboarding checklist
180
181
OnboardingChecklistPartialTemplate,
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
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