tenseleyflow/shithub / f076e26

Browse files

web/orgs/billing_webhook: charge.refunded handler flips invoice to refunded

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f076e26e43da0689dcdda5f7faa5e9fdd45a8efc
Parents
5949bec
Tree
21c279c

1 changed file

StatusFile+-
M internal/web/handlers/orgs/billing_webhook.go 58 0
internal/web/handlers/orgs/billing_webhook.gomodified
@@ -19,6 +19,7 @@ import (
1919
 	"strings"
2020
 	"time"
2121
 
22
+	"github.com/jackc/pgx/v5"
2223
 	stripeapi "github.com/stripe/stripe-go/v85"
2324
 
2425
 	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
@@ -121,6 +122,8 @@ func (h *Handlers) processStripeWebhook(ctx context.Context, event stripeapi.Eve
121122
 		return h.applyStripeSubscriptionEvent(ctx, event)
122123
 	case "invoice.payment_succeeded", "invoice.payment_failed", "invoice.voided", "invoice.marked_uncollectible":
123124
 		return h.applyStripeInvoiceEvent(ctx, event)
125
+	case "charge.refunded":
126
+		return h.applyStripeChargeRefunded(ctx, event)
124127
 	default:
125128
 		return nil
126129
 	}
@@ -447,6 +450,61 @@ func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi.
447450
 	return nil
448451
 }
449452
 
453
+// applyStripeChargeRefunded handles PRO08 D2: a Stripe `charge.refunded`
454
+// event flips the corresponding shithub invoice row to status='refunded'.
455
+// Stripe itself leaves invoice.status='paid' after a refund; shithub
456
+// maintains its own status for UI display.
457
+//
458
+// Refunds DO NOT automatically cancel the subscription. Operators
459
+// who want to revoke Pro access in addition to issuing a refund must
460
+// cancel the subscription via the Stripe Dashboard (which fires
461
+// customer.subscription.deleted and is handled separately).
462
+//
463
+// A refund for an invoice shithub has never seen (the original
464
+// invoice.payment_succeeded event never reached us, or it predates
465
+// the polymorphic invoices table) logs a warning and returns nil so
466
+// Stripe stops retrying — the operator must reconcile manually.
467
+func (h *Handlers) applyStripeChargeRefunded(ctx context.Context, event stripeapi.Event) error {
468
+	// The Stripe Go v85 Charge struct does not expose `invoice` as a
469
+	// typed field; parse the raw event payload directly for the
470
+	// invoice id (and customer id, used for fallback resolution).
471
+	var charge struct {
472
+		ID       string `json:"id"`
473
+		Invoice  string `json:"invoice"`
474
+		Customer string `json:"customer"`
475
+	}
476
+	if err := unmarshalStripeEventObject(event, &charge); err != nil {
477
+		return err
478
+	}
479
+	invoiceID := strings.TrimSpace(charge.Invoice)
480
+	if invoiceID == "" {
481
+		// Standalone refund (no invoice linkage) — nothing to mark.
482
+		h.d.Logger.InfoContext(ctx, "org billing: charge.refunded without invoice — skip",
483
+			"event_id", event.ID, "charge_id", strings.TrimSpace(charge.ID))
484
+		return nil
485
+	}
486
+	row, err := orgbilling.MarkInvoiceRefunded(ctx, orgbilling.Deps{Pool: h.d.Pool}, invoiceID)
487
+	if err != nil {
488
+		if errors.Is(err, pgx.ErrNoRows) {
489
+			h.d.Logger.WarnContext(ctx, "org billing: charge.refunded for unknown invoice — operator must reconcile",
490
+				"event_id", event.ID,
491
+				"stripe_invoice_id", invoiceID)
492
+			return nil
493
+		}
494
+		return err
495
+	}
496
+	// Stamp the receipt's subject from the persisted invoice row.
497
+	// The polymorphic invoices schema guarantees subject_kind +
498
+	// subject_id are NOT NULL on every row, so direct access is safe.
499
+	principal := orgbilling.Principal{
500
+		Kind: orgbilling.SubjectKind(row.SubjectKind),
501
+		ID:   row.SubjectID,
502
+	}
503
+	h.recordWebhookSubject(ctx, event.ID, principal)
504
+	h.touchLastEventAt(ctx, event, principal)
505
+	return nil
506
+}
507
+
450508
 // resolvePrincipalStateFromInvoice resolves Principal AND fetches
451509
 // the current billing state in one shot — the apply branch needs
452510
 // the SubscriptionStatus to decide whether to flip payment-