diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index f266244..ebe9b02 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -244,6 +244,11 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve if _, derr := paymentmethod.Detach(pm.ID, nil); derr != nil { webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr) } + // Bump UpdatedAt so the TUI poll signal can detect that the webhook + // fired even though no new row was inserted. + if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil { + webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr) + } webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID) return } diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index bf0e1cc..9246b70 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -132,7 +132,11 @@ func (m Model) generateReviewContent() string { view := strings.Builder{} if m.review.cardJustAdded { - view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n") + if m.review.cardWasDuplicate { + view.WriteString(m.theme.TextDim().Render("identical card already saved — using existing card") + "\n\n") + } else { + view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n") + } } if m.ShippingInfo != nil { diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 40f76e0..ae0fb87 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -170,6 +170,7 @@ type paymentState struct { cardCursor int collectURL *string collectCardCount int + collectBaseline time.Time viewport viewport.Model viewportReady bool } @@ -191,8 +192,9 @@ type accountState struct { } type reviewState struct { - cardJustAdded bool - success bool + cardJustAdded bool + cardWasDuplicate bool + success bool } type confirmState struct { @@ -951,18 +953,24 @@ func (m Model) pollOrderCmd() tea.Cmd { }) } -func (m Model) pollCardsCmd(cardCount int) tea.Cmd { +func (m Model) pollCardsCmd(baseline time.Time, baselineCount int) tea.Cmd { return tea.Tick(time.Second, func(_ time.Time) tea.Msg { if m.APIClient == nil { - return PollPaymentStatusMsg{CardCount: cardCount} + return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount} } cards, err := m.APIClient.GetCards() if err != nil { - return PollPaymentStatusMsg{CardCount: cardCount} + return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount} } - if len(cards) > cardCount { - return PollPaymentCompleteMsg{Cards: cards} + var newest time.Time + for _, card := range cards { + if card.UpdatedAt.After(newest) { + newest = card.UpdatedAt + } } - return PollPaymentStatusMsg{CardCount: cardCount} + if newest.After(baseline) { + return PollPaymentCompleteMsg{Cards: cards, Duplicate: len(cards) == baselineCount} + } + return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount} }) } diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 4b0da37..60ce682 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -59,13 +60,15 @@ type PollPaymentInitMsg struct { // PollPaymentStatusMsg is sent each polling tick while waiting for the browser // card entry to complete. type PollPaymentStatusMsg struct { - CardCount int + Baseline time.Time + BaselineCount int } // PollPaymentCompleteMsg is sent when the card count increases, meaning the // webhook saved the new card type PollPaymentCompleteMsg struct { - Cards []models.Card + Cards []models.Card + Duplicate bool } // buildPaymentForm creates the huh form bound to state's fields. @@ -436,13 +439,13 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { case PollPaymentInitMsg: url := msg.URL m.payment.collectURL = &url - return m, m.pollCardsCmd(m.payment.collectCardCount) + return m, m.pollCardsCmd(m.payment.collectBaseline, m.payment.collectCardCount) case PollPaymentStatusMsg: if m.payment.view != 2 { return m, nil // user navigated away, stop polling } - return m, m.pollCardsCmd(msg.CardCount) + return m, m.pollCardsCmd(msg.Baseline, msg.BaselineCount) case PollPaymentCompleteMsg: if m.payment.view != 2 { @@ -451,9 +454,19 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.SavedCards = msg.Cards m.payment.view = 0 m.payment.collectURL = nil - selected := m.SavedCards[len(m.SavedCards)-1] + var selected models.Card + if msg.Duplicate { + for _, card := range m.SavedCards { + if selected.ID == 0 || card.UpdatedAt.After(selected.UpdatedAt) { + selected = card + } + } + } else { + selected = m.SavedCards[len(m.SavedCards)-1] + } m.SelectedCard = &selected m.review.cardJustAdded = true + m.review.cardWasDuplicate = msg.Duplicate return m.ReviewSwitch() } @@ -496,6 +509,13 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { m.payment.view = 2 m.payment.collectURL = nil m.payment.collectCardCount = len(m.SavedCards) + var maxT time.Time + for _, card := range m.SavedCards { + if card.UpdatedAt.After(maxT) { + maxT = card.UpdatedAt + } + } + m.payment.collectBaseline = maxT m.footer = []footerCommand{ {key: "esc", value: "back"}, }