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