diff --git a/app/helpers/design_system_helper.rb b/app/helpers/design_system_helper.rb index 6f5e184..ee5cb23 100644 --- a/app/helpers/design_system_helper.rb +++ b/app/helpers/design_system_helper.rb @@ -6,95 +6,95 @@ module DesignSystemHelper BTN_ACCENT = "#{BTN_BASE} bg-gradient-accent text-white shadow-md hover:shadow-lg focus:ring-accent-700/50 hover:brightness-110" BTN_GHOST = "#{BTN_BASE} bg-transparent text-primary-600 hover:bg-primary-50 focus:ring-primary-500/50" BTN_DANGER = "#{BTN_BASE} bg-error text-white shadow-md hover:shadow-lg focus:ring-error/50 hover:brightness-110" - + # Button Sizes BTN_SM = "px-4 py-2 text-sm rounded-lg" BTN_LG = "px-8 py-4 text-lg rounded-2xl" BTN_XL = "px-10 py-5 text-xl rounded-2xl" - + # Card Classes CARD = "bg-white rounded-2xl shadow-md border border-neutral-100 overflow-hidden transition-all duration-300 hover:shadow-lg" CARD_BODY = "p-6 md:p-8" CARD_HEADER = "px-6 py-4 md:px-8 md:py-6 border-b border-neutral-100 bg-gradient-to-b from-neutral-50 to-transparent" - + # Alert Classes ALERT_BASE = "p-4 rounded-xl border backdrop-blur-sm animate-slide-up" ALERT_SUCCESS = "#{ALERT_BASE} bg-success/10 border-success/20 text-success" ALERT_ERROR = "#{ALERT_BASE} bg-error/10 border-error/20 text-error" ALERT_WARNING = "#{ALERT_BASE} bg-warning/10 border-warning/20 text-warning" ALERT_INFO = "#{ALERT_BASE} bg-info/10 border-info/20 text-info" - + # Form Classes FORM_INPUT = "w-full px-4 py-3 text-base bg-white border border-neutral-200 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-primary-500/20 focus:border-primary-500 placeholder:text-neutral-400" FORM_LABEL = "block text-sm font-medium text-neutral-700 mb-2" FORM_ERROR = "text-sm text-error mt-1" - + # Badge Classes BADGE_BASE = "inline-flex items-center px-3 py-1 text-sm font-medium rounded-full" BADGE_PRIMARY = "#{BADGE_BASE} bg-primary-100 text-primary-700" BADGE_SUCCESS = "#{BADGE_BASE} bg-success/10 text-success" BADGE_WARNING = "#{BADGE_BASE} bg-warning/10 text-warning" - + # Link Classes LINK = "text-primary-600 underline decoration-primary-300 decoration-2 underline-offset-2 hover:decoration-primary-600 transition-colors duration-200" LINK_SUBTLE = "text-neutral-600 no-underline hover:text-primary-600 hover:underline transition-all duration-200" - + # Hero Classes HERO = "relative py-20 md:py-32 overflow-hidden" HERO_CONTENT = "relative z-10 max-w-4xl mx-auto text-center px-4" HERO_TITLE = "text-5xl md:text-6xl font-bold text-primary-900 mb-6 animate-fade-in" HERO_SUBTITLE = "text-xl md:text-2xl text-neutral-600 mb-8 animate-slide-up" - + # Navigation Classes NAV_LINK = "px-4 py-2 text-base font-medium text-neutral-600 rounded-lg transition-all duration-200 hover:bg-primary-50 hover:text-primary-700" NAV_LINK_ACTIVE = "#{NAV_LINK} bg-primary-50 text-primary-700" - + # Section Classes SECTION = "py-16 md:py-24" SECTION_TITLE = "text-3xl md:text-4xl font-bold text-primary-900 mb-4" SECTION_SUBTITLE = "text-lg md:text-xl text-neutral-600" - + # Container Classes CONTAINER_NARROW = "max-w-4xl mx-auto px-4" CONTAINER_WIDE = "max-w-7xl mx-auto px-4" - + # Other Classes DIVIDER = "h-px bg-gradient-to-r from-transparent via-neutral-200 to-transparent my-8" SKELETON = "animate-pulse bg-neutral-200 rounded" SPINNER = "inline-block w-5 h-5 border-2 border-primary-600 border-t-transparent rounded-full animate-spin" - + def btn_primary(size = nil) - classes = [BTN_PRIMARY] + classes = [ BTN_PRIMARY ] classes << btn_size_class(size) if size - classes.join(' ') + classes.join(" ") end - + def btn_secondary(size = nil) - classes = [BTN_SECONDARY] + classes = [ BTN_SECONDARY ] classes << btn_size_class(size) if size - classes.join(' ') + classes.join(" ") end - + def btn_accent(size = nil) - classes = [BTN_ACCENT] + classes = [ BTN_ACCENT ] classes << btn_size_class(size) if size - classes.join(' ') + classes.join(" ") end - + def btn_ghost(size = nil) - classes = [BTN_GHOST] + classes = [ BTN_GHOST ] classes << btn_size_class(size) if size - classes.join(' ') + classes.join(" ") end - + def btn_danger(size = nil) - classes = [BTN_DANGER] + classes = [ BTN_DANGER ] classes << btn_size_class(size) if size - classes.join(' ') + classes.join(" ") end - + private - + def btn_size_class(size) case size when :sm then BTN_SM @@ -103,4 +103,4 @@ def btn_size_class(size) else "" end end -end \ No newline at end of file +end diff --git a/app/services/stripe_webhook/base_handler.rb b/app/services/stripe_webhook/base_handler.rb new file mode 100644 index 0000000..2b5b0ba --- /dev/null +++ b/app/services/stripe_webhook/base_handler.rb @@ -0,0 +1,19 @@ +module StripeWebhook + class BaseHandler + attr_reader :event + + def initialize(event) + @event = event + end + + def handle + raise NotImplementedError + end + + private + + def event_object + event.data.object + end + end +end diff --git a/app/services/stripe_webhook/checkout_session_completed_handler.rb b/app/services/stripe_webhook/checkout_session_completed_handler.rb new file mode 100644 index 0000000..9beb101 --- /dev/null +++ b/app/services/stripe_webhook/checkout_session_completed_handler.rb @@ -0,0 +1,12 @@ +module StripeWebhook + class CheckoutSessionCompletedHandler < BaseHandler + def handle + return unless event_object.mode == "subscription" + + user = User.find_by(id: event_object.metadata.user_id) + return unless user + + SubscriptionService.new(user).create_subscription(event_object.subscription) + end + end +end diff --git a/app/services/stripe_webhook/payment_failed_handler.rb b/app/services/stripe_webhook/payment_failed_handler.rb new file mode 100644 index 0000000..76c210f --- /dev/null +++ b/app/services/stripe_webhook/payment_failed_handler.rb @@ -0,0 +1,11 @@ +module StripeWebhook + class PaymentFailedHandler < BaseHandler + def handle + subscription = Subscription.find_by(stripe_subscription_id: event_object.subscription) + return unless subscription + + Rails.logger.warn "Payment failed for subscription #{subscription.id}" + # Future: send notification email to user + end + end +end diff --git a/app/services/stripe_webhook/subscription_deleted_handler.rb b/app/services/stripe_webhook/subscription_deleted_handler.rb new file mode 100644 index 0000000..962f253 --- /dev/null +++ b/app/services/stripe_webhook/subscription_deleted_handler.rb @@ -0,0 +1,10 @@ +module StripeWebhook + class SubscriptionDeletedHandler < BaseHandler + def handle + subscription = Subscription.find_by(stripe_subscription_id: event_object.id) + return unless subscription + + subscription.update!(status: "canceled") + end + end +end diff --git a/app/services/stripe_webhook/subscription_updated_handler.rb b/app/services/stripe_webhook/subscription_updated_handler.rb new file mode 100644 index 0000000..4129773 --- /dev/null +++ b/app/services/stripe_webhook/subscription_updated_handler.rb @@ -0,0 +1,14 @@ +module StripeWebhook + class SubscriptionUpdatedHandler < BaseHandler + def handle + subscription = Subscription.find_by(stripe_subscription_id: event_object.id) + return unless subscription + + subscription.update!( + status: event_object.status, + current_period_end: Time.at(event_object.current_period_end), + cancel_at_period_end: event_object.cancel_at_period_end + ) + end + end +end diff --git a/app/services/stripe_webhook_service.rb b/app/services/stripe_webhook_service.rb index 4a28e66..60ca2bb 100644 --- a/app/services/stripe_webhook_service.rb +++ b/app/services/stripe_webhook_service.rb @@ -1,12 +1,19 @@ class StripeWebhookService attr_reader :event + HANDLER_MAP = { + "checkout.session.completed" => StripeWebhook::CheckoutSessionCompletedHandler, + "customer.subscription.updated" => StripeWebhook::SubscriptionUpdatedHandler, + "customer.subscription.deleted" => StripeWebhook::SubscriptionDeletedHandler, + "invoice.payment_failed" => StripeWebhook::PaymentFailedHandler + }.freeze + def initialize(event) @event = event end def process - handler_class = handler_for(event.type) + handler_class = HANDLER_MAP[event.type] return log_unhandled_event unless handler_class handler_class.new(event).handle @@ -17,81 +24,7 @@ def process private - def handler_for(event_type) - case event_type - when "checkout.session.completed" - CheckoutSessionCompletedHandler - when "customer.subscription.updated" - SubscriptionUpdatedHandler - when "customer.subscription.deleted" - SubscriptionDeletedHandler - when "invoice.payment_failed" - PaymentFailedHandler - end - end - def log_unhandled_event Rails.logger.info "Unhandled Stripe event type: #{event.type}" end - - class BaseHandler - attr_reader :event - - def initialize(event) - @event = event - end - - def handle - raise NotImplementedError - end - - protected - - def event_object - event.data.object - end - end - - class CheckoutSessionCompletedHandler < BaseHandler - def handle - return unless event_object.mode == "subscription" - - user = User.find_by(id: event_object.metadata.user_id) - return unless user - - SubscriptionService.new(user).create_subscription(event_object.subscription) - end - end - - class SubscriptionUpdatedHandler < BaseHandler - def handle - subscription = Subscription.find_by(stripe_subscription_id: event_object.id) - return unless subscription - - subscription.update!( - status: event_object.status, - current_period_end: Time.at(event_object.current_period_end), - cancel_at_period_end: event_object.cancel_at_period_end - ) - end - end - - class SubscriptionDeletedHandler < BaseHandler - def handle - subscription = Subscription.find_by(stripe_subscription_id: event_object.id) - return unless subscription - - subscription.update!(status: "canceled") - end - end - - class PaymentFailedHandler < BaseHandler - def handle - subscription = Subscription.find_by(stripe_subscription_id: event_object.subscription) - return unless subscription - - Rails.logger.warn "Payment failed for subscription #{subscription.id}" - # Future: send notification email to user - end - end end diff --git a/spec/services/stripe_webhook_service_spec.rb b/spec/services/stripe_webhook_service_spec.rb index 8adbbcc..c94570e 100644 --- a/spec/services/stripe_webhook_service_spec.rb +++ b/spec/services/stripe_webhook_service_spec.rb @@ -25,7 +25,7 @@ end it 'delegates to CheckoutSessionCompletedHandler' do - expect_any_instance_of(StripeWebhookService::CheckoutSessionCompletedHandler) + expect_any_instance_of(StripeWebhook::CheckoutSessionCompletedHandler) .to receive(:handle) service.process @@ -75,7 +75,7 @@ end it 'delegates to SubscriptionUpdatedHandler' do - expect_any_instance_of(StripeWebhookService::SubscriptionUpdatedHandler) + expect_any_instance_of(StripeWebhook::SubscriptionUpdatedHandler) .to receive(:handle) service.process @@ -125,7 +125,7 @@ end it 'delegates to SubscriptionDeletedHandler' do - expect_any_instance_of(StripeWebhookService::SubscriptionDeletedHandler) + expect_any_instance_of(StripeWebhook::SubscriptionDeletedHandler) .to receive(:handle) service.process @@ -151,7 +151,7 @@ end it 'delegates to PaymentFailedHandler' do - expect_any_instance_of(StripeWebhookService::PaymentFailedHandler) + expect_any_instance_of(StripeWebhook::PaymentFailedHandler) .to receive(:handle) service.process