package billing import ( "context" "encoding/json" "fmt" "time" "github.com/flowy-live/llink/internal/utils/flog" "github.com/stripe/stripe-go/v85" "github.com/stripe/stripe-go/v85/webhook" ) func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error { event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret) if err != nil { return fmt.Errorf("verify stripe signature: %w", err) } flog.Info("stripe webhook", "type", event.Type, "id", event.ID) switch event.Type { case "checkout.session.completed": // subscription.created fires right after with full detail; we handle // the subscription there. return nil case "customer.subscription.created", "customer.subscription.updated": return s.handleSubscriptionUpsert(ctx, event) case "customer.subscription.deleted": return s.handleSubscriptionDeleted(ctx, event) default: return nil } } func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error { var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { return fmt.Errorf("decode subscription: %w", err) } networkID := sub.Metadata["network_id"] if networkID == "" { return fmt.Errorf("subscription %s missing network_id metadata", sub.ID) } local, err := subscriptionFromStripe(&sub, networkID) if err != nil { return err } if err := s.repo.upsertSubscription(ctx, local); err != nil { return fmt.Errorf("upsert subscription: %w", err) } return nil } func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error { var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { return fmt.Errorf("decode subscription: %w", err) } networkID := sub.Metadata["network_id"] if networkID == "" { return fmt.Errorf("subscription %s missing network_id metadata", sub.ID) } if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil { return fmt.Errorf("delete subscription: %w", err) } return nil } func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) { if len(sub.Items.Data) == 0 { return nil, fmt.Errorf("subscription %s has no items", sub.ID) } item := sub.Items.Data[0] cadence, err := cadenceFromInterval(item.Price) if err != nil { return nil, fmt.Errorf("subscription %s: %w", sub.ID, err) } var customerID string if sub.Customer != nil { customerID = sub.Customer.ID } return &Subscription{ ID: sub.ID, NetworkID: networkID, StripeCustomerID: customerID, Status: string(sub.Status), PriceID: item.Price.ID, Cadence: cadence, Quantity: int(item.Quantity), CancelAtPeriodEnd: sub.CancelAtPeriodEnd, CurrentPeriodStart: time.Unix(item.CurrentPeriodStart, 0).UTC(), CurrentPeriodEnd: time.Unix(item.CurrentPeriodEnd, 0).UTC(), }, nil } func cadenceFromInterval(price *stripe.Price) (Cadence, error) { if price == nil || price.Recurring == nil { return "", fmt.Errorf("price is not recurring") } switch price.Recurring.Interval { case stripe.PriceRecurringIntervalMonth: return CadenceMonthly, nil case stripe.PriceRecurringIntervalYear: return CadenceAnnual, nil default: return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval) } }