@@ -19,6 +19,7 @@ import ( |
| 19 | 19 | "strings" |
| 20 | 20 | "time" |
| 21 | 21 | |
| 22 | + "github.com/jackc/pgx/v5" |
| 22 | 23 | stripeapi "github.com/stripe/stripe-go/v85" |
| 23 | 24 | |
| 24 | 25 | orgbilling "github.com/tenseleyFlow/shithub/internal/billing" |
@@ -121,6 +122,8 @@ func (h *Handlers) processStripeWebhook(ctx context.Context, event stripeapi.Eve |
| 121 | 122 | return h.applyStripeSubscriptionEvent(ctx, event) |
| 122 | 123 | case "invoice.payment_succeeded", "invoice.payment_failed", "invoice.voided", "invoice.marked_uncollectible": |
| 123 | 124 | return h.applyStripeInvoiceEvent(ctx, event) |
| 125 | + case "charge.refunded": |
| 126 | + return h.applyStripeChargeRefunded(ctx, event) |
| 124 | 127 | default: |
| 125 | 128 | return nil |
| 126 | 129 | } |
@@ -447,6 +450,61 @@ func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi. |
| 447 | 450 | return nil |
| 448 | 451 | } |
| 449 | 452 | |
| 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 | + |
| 450 | 508 | // resolvePrincipalStateFromInvoice resolves Principal AND fetches |
| 451 | 509 | // the current billing state in one shot — the apply branch needs |
| 452 | 510 | // the SubscriptionStatus to decide whether to flip payment- |