Compare commits

..

2 Commits

Author SHA1 Message Date
74d6a9fdf2 style(tui): run gofmt to fix formatting 2026-05-25 22:00:44 +02:00
3737eea7f6 feat(refund): add refund request flow
- tui modal composer with reason select + free-form message
  (pkg/tui/refund.go)
- POST /api/v1/orders/{id}/refund-request posts to orders slack thread
- 15 min server side cooldown via Order.LastRefundRequestAt (429 +
  retry-after)
- shared refundrequestreasons + can requestrefund hoisted to pkg/models
- slack message includes customer name, email, order total, quoted body
- tests over invalid-state, missing-message, and cooldown paths
2026-05-25 21:51:01 +02:00
6 changed files with 23 additions and 71 deletions

View File

@@ -114,7 +114,7 @@ func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Reques
text += "\n\n" + quoted
}
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
if err := notify.SlackPostToOrderThread(order.ID, text); err != nil {
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
return
}

View File

@@ -11,27 +11,10 @@ const (
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
DisplayKindSuccess DisplayKind = "success" // delivered
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
DisplayKindWarning DisplayKind = "warning" // refund pending, delivery issue
)
// refundPending reports whether the order has an open customer refund request
// that has not yet been resolved by a terminal status change.
func (order *Order) refundPending() bool {
if order.LastRefundRequestAt == nil {
return false
}
switch order.Status {
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
return false
}
return true
}
func (order *Order) DisplayState() string {
if order.refundPending() {
return "Refund Requested"
}
switch order.Status {
case OrderStatusPending:
return "Awaiting payment"
@@ -71,9 +54,6 @@ func (order *Order) DisplayState() string {
}
func (order *Order) DisplayKind() DisplayKind {
if order.refundPending() {
return DisplayKindRefund
}
switch order.Status {
case OrderStatusPending:
return DisplayKindNeutral

View File

@@ -1,12 +1,8 @@
package models
import (
"testing"
"time"
)
import "testing"
func TestOrderDisplayState(t *testing.T) {
now := time.Now()
tests := []struct {
name string
order Order
@@ -27,10 +23,6 @@ func TestOrderDisplayState(t *testing.T) {
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -191,17 +191,6 @@ func SlackUnpinOrder(orderID uint) {
// It looks up the thread ts from the database and returns an error when the
// reply cannot be posted.
func SlackPostToOrderThread(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, false)
}
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
// to the channel (reply_broadcast=true) so channel members get a normal
// notification. Use for messages that need immediate operator attention.
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, true)
}
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
@@ -217,16 +206,11 @@ func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
return fmt.Errorf("order has no slack thread")
}
payload := map[string]any{
if _, ok := postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": text,
}
if broadcast {
payload["reply_broadcast"] = true
}
if _, ok := postSlackMessage(token, payload); !ok {
}); !ok {
slog.Warn("failed to post slack thread reply", "order_id", orderID)
return fmt.Errorf("failed to post slack thread reply")
}

View File

@@ -278,9 +278,10 @@ func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
case models.DisplayKindError:
return m.theme.TextError()
case models.DisplayKindWarning:
// !TODO add refunds and change this
// No warning color in theme yet, reuse loading pink as visual flag
// for now
return m.theme.TextLoading()
case models.DisplayKindRefund:
return m.theme.TextBrand()
}
return m.theme.TextHighlight()
}

View File

@@ -164,8 +164,10 @@ func (m Model) newRefundTextarea() textarea.Model {
ta.Placeholder = "Describe the issue (optional)..."
ta.ShowLineNumbers = true
ta.CharLimit = 65536
ta.DynamicHeight = false
ta.SetHeight(m.refundTextareaHeight())
ta.DynamicHeight = true
ta.MinHeight = refundMinTextareaRows
ta.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
ta.SetHeight(refundMinTextareaRows)
ta.SetWidth(m.refundTextareaWidth())
base := lipgloss.NewStyle()
@@ -198,7 +200,8 @@ func (m Model) newRefundTextarea() textarea.Model {
}
func (m Model) resizeRefundTextarea(state *refundState) {
state.textarea.SetHeight(m.refundTextareaHeight())
state.textarea.MinHeight = refundMinTextareaRows
state.textarea.MaxHeight = max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
state.textarea.SetWidth(m.refundTextareaWidth())
}
@@ -320,26 +323,22 @@ func (m Model) renderRefundTextarea(state *refundState) string {
border = m.theme.Highlight()
}
wrapper := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(border)
contentWidth := m.refundContentWidth()
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
state.textarea.SetHeight(m.refundTextareaHeight())
textareaBox := wrapper.Render(state.textarea.View())
counter := m.theme.TextDim().
Width(contentWidth).
Width(m.refundTextareaWidth()).
Align(lipgloss.Right).
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
box := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(border).
Padding(0, 1).
Width(m.refundContentWidth()).
Render(state.textarea.View() + "\n" + counter)
out := lipgloss.JoinVertical(
lipgloss.Left,
m.theme.TextLabel().Render(label),
textareaBox,
counter,
box,
)
if state.messageErr != "" {
out += "\n" + m.theme.TextError().Render(state.messageErr)
@@ -368,7 +367,3 @@ func (m Model) refundContentWidth() int {
func (m Model) refundTextareaWidth() int {
return max(1, m.refundContentWidth()-refundTextareaHPad)
}
func (m Model) refundTextareaHeight() int {
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
}