diff --git a/CHANGELOG.md b/CHANGELOG.md index 7668b491b..fdb0d158a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [TODO] -## [1.5.0] - 2025-08-20 +## [1.5.0] - 2025-08-21 ### Changes - !BREAKING! Revamp CMDx for clarity, transparency, and higher performance diff --git a/LLM.md b/LLM.md index 16bf3cc4f..cd64adbb6 100644 --- a/LLM.md +++ b/LLM.md @@ -1,6 +1,6 @@ # CMDx Documentation -This file contains all documentation from the CMDx project, organized for easy consumption by LLMs and other tools. +This file contains all the CMDx documentation consolidated from the docs directory. --- @@ -35,8 +35,7 @@ CMDx follows a two-tier configuration hierarchy: 2. **Task Settings**: Class-level overrides via `settings` > [!IMPORTANT] -> Task-level settings take precedence over global configuration. -> Settings are inherited from superclasses and can be overridden in subclasses. +> Task-level settings take precedence over global configuration. Settings are inherited from superclasses and can be overridden in subclasses. ## Global Configuration @@ -71,15 +70,15 @@ CMDx.configure do |config| # Via proc or lambda config.middlewares.register proc { |task, options| - start = Time.now + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) result = yield - finish = Time.now - Rails.logger.debug { "task complete in #{finish - start}ms" } + end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Rails.logger.debug { "task completed in #{((end_time - start_time) * 1000).round(2)}ms" } result } # With options - config.middlewares.register MetricsMiddleware, namespace: "app.tasks" + config.middlewares.register AuditTrailMiddleware, service_name: "document_processor" # Remove middleware config.middlewares.deregister CMDx::Middlewares::Timeout @@ -87,30 +86,29 @@ end ``` > [!NOTE] -> Middlewares are executed in registration order. Each middleware wraps the next, -> creating an execution chain around task logic. +> Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic. ### Callbacks ```ruby CMDx.configure do |config| # Via method - config.callbacks.register :before_execution, :setup_request_context + config.callbacks.register :before_execution, :initialize_user_session # Via callable (must respond to `call(task)`) - config.callbacks.register :on_success, TrackSuccessfulPurchase + config.callbacks.register :on_success, LogUserActivity # Via proc or lambda config.callbacks.register :on_complete, proc { |task| - duration = task.metadata[:runtime] - StatsD.histogram("task.duration", duration, tags: ["class:#{task.class.name}"]) + execution_time = task.metadata[:runtime] + Metrics.timer("task.execution_time", execution_time, tags: ["task:#{task.class.name.underscore}"]) } # With options - config.callbacks.register :on_failure, :notify_admin, if: :production? + config.callbacks.register :on_failure, :send_alert_notification, if: :critical_task? # Remove callback - config.callbacks.deregister :on_success, TrackSuccessfulPurchase + config.callbacks.deregister :on_success, LogUserActivity end ``` @@ -119,22 +117,22 @@ end ```ruby CMDx.configure do |config| # Via callable (must respond to `call(value, options)`) - config.coercions.register :money, MoneyCoercion + config.coercions.register :currency, CurrencyCoercion - # Via method (must match signature `def point_coercion(value, options)`) - config.coercions.register :point, :point_coercion + # Via method (must match signature `def coordinates_coercion(value, options)`) + config.coercions.register :coordinates, :coordinates_coercion # Via proc or lambda - config.coercions.register :csv_array, proc { |value, options| - separator = options[:separator] || ',' - max_items = options[:max_items] || 100 + config.coercions.register :tag_list, proc { |value, options| + delimiter = options[:delimiter] || ',' + max_tags = options[:max_tags] || 50 - items = value.to_s.split(separator).map(&:strip).reject(&:empty?) - items.first(max_items) + tags = value.to_s.split(delimiter).map(&:strip).reject(&:empty?) + tags.first(max_tags) } # Remove coercion - config.coercions.deregister :money + config.coercions.deregister :currency end ``` @@ -143,21 +141,21 @@ end ```ruby CMDx.configure do |config| # Via callable (must respond to `call(value, options)`) - config.validators.register :email, EmailValidator + config.validators.register :username, UsernameValidator - # Via method (must match signature `def phone_validator(value, options)`) - config.validators.register :phone, :phone_validator + # Via method (must match signature `def url_validator(value, options)`) + config.validators.register :url, :url_validator # Via proc or lambda - config.validators.register :api_key, proc { |value, options| - required_prefix = options[:prefix] || "sk_" - min_length = options[:min_length] || 32 + config.validators.register :access_token, proc { |value, options| + expected_prefix = options[:prefix] || "tok_" + minimum_length = options[:min_length] || 40 - value.start_with?(required_prefix) && value.length >= min_length + value.start_with?(expected_prefix) && value.length >= minimum_length } # Remove validator - config.validators.deregister :email + config.validators.deregister :username end ``` @@ -168,7 +166,7 @@ end Override global configuration for specific tasks using `settings`: ```ruby -class ProcessPayment < CMDx::Task +class GenerateInvoice < CMDx::Task settings( # Global configuration overrides task_breakpoints: ["failed"], # Breakpoint override @@ -179,7 +177,7 @@ class ProcessPayment < CMDx::Task breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints log_level: :info, # Log level override log_formatter: CMDx::LogFormatters::Json.new # Log formatter override - tags: ["payments", "critical"], # Logging tags + tags: ["billing", "financial"], # Logging tags deprecated: true # Task deprecations ) @@ -190,8 +188,7 @@ end ``` > [!TIP] -> Use task-level settings for tasks that require special handling, such as payment processing, -> external API calls, or critical system operations. +> Use task-level settings for tasks that require special handling, such as financial reporting, external API integrations, or critical system operations. ### Registrations @@ -199,25 +196,25 @@ Register middlewares, callbacks, coercions, and validators on a specific task. Deregister options that should not be available. ```ruby -class ProcessPayment < CMDx::Task +class SendCampaignEmail < CMDx::Task # Middlewares register :middleware, CMDx::Middlewares::Timeout - deregister :middleware, MetricsMiddleware + deregister :middleware, AuditTrailMiddleware # Callbacks register :callback, :on_complete, proc { |task| - duration = task.metadata[:runtime] - StatsD.histogram("task.duration", duration, tags: ["class:#{task.class.name}"]) + runtime = task.metadata[:runtime] + Analytics.track("email_campaign.sent", runtime, tags: ["task:#{task.class.name}"]) } - deregister :callback, :before_execution, :setup_request_context + deregister :callback, :before_execution, :initialize_user_session # Coercions - register :coercion, :money, MoneyCoercion - deregister :coercion, :point + register :coercion, :currency, CurrencyCoercion + deregister :coercion, :coordinates # Validators - register :validator, :email, :email_validator - deregister :validator, :phone + register :validator, :username, :username_validator + deregister :validator, :url def work # Your logic here... @@ -236,12 +233,12 @@ CMDx.configuration.task_breakpoints #=> ["failed"] CMDx.configuration.middlewares.registry #=> [, ...] # Task configuration access -class AnalyzeData < CMDx::Task - settings(tags: ["data", "analytics"]) +class ProcessUpload < CMDx::Task + settings(tags: ["files", "storage"]) def work self.class.settings[:logger] #=> Global configuration value - self.class.settings[:tags] #=> Task configuration value => ["data", "analytics"] + self.class.settings[:tags] #=> Task configuration value => ["files", "storage"] end end ``` @@ -249,8 +246,7 @@ end ### Resetting > [!WARNING] -> Resetting configuration affects the entire application. Use primarily in -> test environments or during application initialization. +> Resetting configuration affects the entire application. Use primarily in test environments or during application initialization. ```ruby # Reset to framework defaults @@ -273,14 +269,14 @@ end Generate new CMDx tasks quickly using the built-in generator: ```bash -rails generate cmdx:task ProcessOrder +rails generate cmdx:task ModerateBlogPost ``` This creates a new task file with the basic structure: ```ruby -# app/tasks/process_order.rb -class ProcessOrder < CMDx::Task +# app/tasks/moderate_blog_post.rb +class ModerateBlogPost < CMDx::Task def work # Your logic here... end @@ -288,8 +284,7 @@ end ``` > [!TIP] -> Use **present tense verbs + noun** for task names, eg: -> `ProcessOrder`, `SendWelcomeEmail`, `ValidatePaymentDetails` +> Use **present tense verbs + noun** for task names, eg: `ModerateBlogPost`, `ScheduleAppointment`, `ValidateDocument` --- @@ -305,7 +300,7 @@ Tasks are the core building blocks of CMDx, encapsulating business logic within Tasks inherit from `CMDx::Task` and require only a `work` method: ```ruby -class ProcessUserOrder < CMDx::Task +class ValidateDocument < CMDx::Task def work # Your logic here... end @@ -315,11 +310,11 @@ end An exception will be raised if a work method is not defined. ```ruby -class InvalidTask < CMDx::Task +class IncompleteTask < CMDx::Task # No `work` method defined end -InvalidTask.execute #=> raises CMDx::UndefinedMethodError +IncompleteTask.execute #=> raises CMDx::UndefinedMethodError ``` ## Inheritance @@ -329,20 +324,20 @@ Create a base class to share common configuration across tasks: ```ruby class ApplicationTask < CMDx::Task - register :middleware, AuthenticateUserMiddleware + register :middleware, SecurityMiddleware - before_execution :set_correlation_id + before_execution :initialize_request_tracking - attribute :request_id + attribute :session_id private - def set_correlation_id - context.correlation_id ||= SecureRandom.uuid + def initialize_request_tracking + context.tracking_id ||= SecureRandom.uuid end end -class ProcessOrder < ApplicationTask +class SyncInventory < ApplicationTask def work # Your logic here... end @@ -353,6 +348,9 @@ end Tasks follow a predictable call pattern with specific states and statuses: +> [!CAUTION] +> Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. + | Stage | State | Status | Description | |-------|-------|--------|-------------| | **Instantiation** | `initialized` | `success` | Task created with context | @@ -361,9 +359,6 @@ Tasks follow a predictable call pattern with specific states and statuses: | **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized | | **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable | -> [!WARNING] -> Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. - --- url: https://github.com/drexed/cmdx/blob/main/docs/basics/execution.md @@ -391,7 +386,7 @@ This is the preferred method for most use cases. Any unhandled exceptions will be caught and returned as a task failure. ```ruby -result = ProcessOrder.execute(order_id: 12345) +result = CreateAccount.execute(email: "user@example.com") # Check execution state result.success? #=> true/false @@ -399,7 +394,7 @@ result.failed? #=> true/false result.skipped? #=> true/false # Access result data -result.context.order_id #=> 12345 +result.context.email #=> "user@example.com" result.state #=> "complete" result.status #=> "success" ``` @@ -415,20 +410,19 @@ It raises any unhandled non-fault exceptions caused during execution. | `CMDx::FailFault` | Task execution fails | | `CMDx::SkipFault` | Task execution is skipped | -> [!WARNING] -> `execute!` behavior depends on the `task_breakpoints` or `workflow_breakpoints` configuration. -> By default, it raises exceptions only on failures. +> [!IMPORTANT] +> `execute!` behavior depends on the `task_breakpoints` or `workflow_breakpoints` configuration. By default, it raises exceptions only on failures. ```ruby begin - result = ProcessOrder.execute!(order_id: 12345) - SendConfirmation.execute(result.context) -rescue CMDx::FailFault => e - RetryOrderJob.perform_later(e.result.context.order_id) + result = CreateAccount.execute!(email: "user@example.com") + SendWelcomeEmail.execute(result.context) +rescue CMDx::Fault => e + ScheduleAccountRetryJob.perform_later(e.result.context.email) rescue CMDx::SkipFault => e - RetryOrderJob.perform_later(e.result.context.order_id) + Rails.logger.info("Account creation skipped: #{e.result.reason}") rescue Exception => e - BugTracker.notify(unhandled_exception: e) + ErrorTracker.capture(unhandled_exception: e) end ``` @@ -438,14 +432,14 @@ Tasks can be instantiated directly for advanced use cases, testing, and custom e ```ruby # Direct instantiation -task = ProcessOrder.new(order_id: 12345, notify_customer: true) +task = CreateAccount.new(email: "user@example.com", send_welcome: true) # Access properties before execution task.id #=> "abc123..." (unique task ID) -task.context.order_id #=> 12345 -task.context.notify_customer #=> true +task.context.email #=> "user@example.com" +task.context.send_welcome #=> true task.result.state #=> "initialized" -task.result.status #=> "success" +result.status #=> "success" # Manual execution task.execute @@ -460,17 +454,16 @@ task.result.success? #=> true/false The `Result` object provides comprehensive execution information: ```ruby -result = ProcessOrder.execute(order_id: 12345) +result = CreateAccount.execute(email: "user@example.com") # Execution metadata result.id #=> "abc123..." (unique execution ID) -result.task #=> ProcessOrderTask instance (frozen) +result.task #=> CreateAccount instance (frozen) result.chain #=> Task execution chain # Context and metadata result.context #=> Context with all task data result.metadata #=> Hash with execution metadata -``` --- @@ -487,13 +480,13 @@ Context is automatically populated with all inputs passed to a task. All keys ar ```ruby # Direct execution -ProcessOrder.execute(user_id: 123, currency: "USD") +CalculateShipping.execute(weight: 2.5, destination: "CA") # Instance creation -ProcessOrder.new(user_id: 123, "currency" => "USD") +CalculateShipping.new(weight: 2.5, "destination" => "CA") ``` -> [!NOTE] +> [!IMPORTANT] > String keys are automatically converted to symbols. Use symbols for consistency in your code. ## Accessing Data @@ -501,27 +494,27 @@ ProcessOrder.new(user_id: 123, "currency" => "USD") Context provides multiple access patterns with automatic nil safety: ```ruby -class ProcessOrder < CMDx::Task +class CalculateShipping < CMDx::Task def work # Method style access (preferred) - user_id = context.user_id - amount = context.amount + weight = context.weight + destination = context.destination # Hash style access - order_id = context[:order_id] - metadata = context["metadata"] + service_type = context[:service_type] + options = context["options"] # Safe access with defaults - priority = context.fetch!(:priority, "normal") - source = context.dig(:metadata, :source) + rush_delivery = context.fetch!(:rush_delivery, false) + carrier = context.dig(:options, :carrier) # Shorter alias - total = ctx.amount * ctx.tax_rate # ctx aliases context + cost = ctx.weight * ctx.rate_per_pound # ctx aliases context end end ``` -> [!NOTE] +> [!IMPORTANT] > Accessing undefined context attributes returns `nil` instead of raising errors, enabling graceful handling of optional attributes. ## Modifying Context @@ -529,41 +522,42 @@ end Context supports dynamic modification during task execution: ```ruby -class ProcessOrder < CMDx::Task +class CalculateShipping < CMDx::Task def work # Direct assignment - context.user = User.find(context.user_id) - context.order = Order.find(context.order_id) - context.processed_at = Time.now + context.carrier = Carrier.find_by(code: context.carrier_code) + context.package = Package.new(weight: context.weight) + context.calculated_at = Time.now # Hash-style assignment - context[:status] = "processing" - context["result_code"] = "SUCCESS" + context[:status] = "calculating" + context["tracking_number"] = "SHIP#{SecureRandom.hex(6)}" # Conditional assignment - context.notification_sent ||= false + context.insurance_included ||= false # Batch updates context.merge!( status: "completed", - total_amount: calculate_total, - completion_time: Time.now + shipping_cost: calculate_cost, + estimated_delivery: Time.now + 3.days ) # Remove sensitive data - context.delete!(:credit_card_number) + context.delete!(:credit_card_token) end private - def calculate_total - context.amount + (context.amount * context.tax_rate) + def calculate_cost + base_rate = context.weight * context.rate_per_pound + base_rate + (base_rate * context.tax_percentage) end end ``` > [!TIP] -> Use context for automatic input attributes and intermediate results. This creates natural data flow through your task execution pipeline. +> Use context for both input values and intermediate results. This creates natural data flow through your task execution pipeline. ## Data Sharing @@ -571,29 +565,28 @@ Context enables seamless data flow between related tasks in complex workflows: ```ruby # During execution -class ProcessOrder < CMDx::Task +class CalculateShipping < CMDx::Task def work - # Validate order data - validation_result = ValidateOrder.execute(context) + # Validate shipping data + validation_result = ValidateAddress.execute(context) # Via context - ProcessPayment.execute(context) + CalculateInsurance.execute(context) # Via result - NotifyOrderProcessed.execute(validation_result) + NotifyShippingCalculated.execute(validation_result) # Context now contains accumulated data from all tasks - context.order_validated #=> true (from validation) - context.payment_processed #=> true (from payment) - context.notification_sent #=> true (from notification) + context.address_validated #=> true (from validation) + context.insurance_calculated #=> true (from insurance) + context.notification_sent #=> true (from notification) end end # After execution -result = ProcessOrder.execute(order_number: 123) +result = CalculateShipping.execute(destination: "New York, NY") -ShipOrder.execute(result) -``` +CreateShippingLabel.execute(result) --- @@ -608,16 +601,19 @@ Chains automatically group related task executions within a thread, providing un Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination. +> [!WARNING] +> Chain operations are thread-local. Never share chain references across threads as this can lead to race conditions and data corruption. + ```ruby # Thread A Thread.new do - result = ProcessOrder.execute(order_id: 123) + result = ImportDataset.execute(file_path: "/data/batch1.csv") result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" end # Thread B (completely separate chain) Thread.new do - result = ProcessOrder.execute(order_id: 456) + result = ImportDataset.execute(file_path: "/data/batch2.csv") result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3" end @@ -626,23 +622,23 @@ CMDx::Chain.current #=> Returns current chain or nil CMDx::Chain.clear #=> Clears current thread's chain ``` -> [!IMPORTANT] -> Chain operations are thread-local. Never share chain references across threads as this can lead to race conditions and data corruption. - ## Links Every task execution automatically creates or joins the current thread's chain: +> [!IMPORTANT] +> Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle. + ```ruby -class ProcessOrder < CMDx::Task +class ImportDataset < CMDx::Task def work # First task creates new chain - result1 = ProcessOrder.execute(order_id: 123) + result1 = ValidateHeaders.execute(file_path: context.file_path) result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" result1.chain.results.size #=> 1 # Second task joins existing chain - result2 = SendEmail.execute(to: "user@example.com") + result2 = SendNotification.execute(to: "admin@company.com") result2.chain.id == result1.chain.id #=> true result2.chain.results.size #=> 2 @@ -652,40 +648,40 @@ class ProcessOrder < CMDx::Task end ``` -> [!NOTE] -> Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle. - ## Inheritance When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating a unified execution trail. ```ruby -class ProcessOrder < CMDx::Task +class ImportDataset < CMDx::Task def work - context.order = Order.find(order_id) + context.dataset = Dataset.find(context.dataset_id) # Subtasks automatically inherit current chain - ValidateOrder.execute - ChargePayment.execute!(context) - SendConfirmation.execute(order_id: order_id) + ValidateSchema.execute + TransformData.execute!(context) + SaveToDatabase.execute(dataset_id: context.dataset_id) end end -result = ProcessOrder.execute(order_id: 123) +result = ImportDataset.execute(dataset_id: 456) chain = result.chain # All tasks share the same chain chain.results.size #=> 4 (main task + 3 subtasks) chain.results.map { |r| r.task.class } -#=> [ProcessOrder, ValidateOrder, ChargePayment, SendConfirmation] +#=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase] ``` ## Structure Chains provide comprehensive execution information with state delegation: +> [!IMPORTANT] +> Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure states. + ```ruby -result = ProcessOrder.execute(order_id: 123) +result = ImportDataset.execute(dataset_id: 456) chain = result.chain # Chain identification @@ -701,10 +697,6 @@ chain.outcome #=> "success" chain.results.each_with_index do |result, index| puts "#{index}: #{result.task.class} - #{result.status}" end -``` - -> [!NOTE] -> Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure states. --- @@ -719,26 +711,29 @@ Halting stops task execution with explicit intent signaling. Tasks provide two p The `skip!` method indicates a task did not meet criteria to continue execution. This represents a controlled, intentional interruption where the task determines that execution is not necessary or appropriate. +> [!IMPORTANT] +> Skipping is not a failure or error. Skipped tasks are considered successful outcomes. + ```ruby -class ProcessOrder < CMDx::Task +class ProcessInventory < CMDx::Task def work # Without a reason - skip! if Array(ENV["PHASED_OUT_TASKS"]).include?(self.class.name) + skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) # With a reason - skip!("Outside business hours") unless Time.now.hour.between?(9, 17) + skip!("Warehouse closed") unless Time.now.hour.between?(8, 18) - order = Order.find(context.order_id) + inventory = Inventory.find(context.inventory_id) - if order.processed? - skip!("Order already processed") + if inventory.already_counted? + skip!("Inventory already counted today") else - order.process! + inventory.count! end end end -result = ProcessSubscription.execute(user_id: 123) +result = ProcessInventory.execute(inventory_id: 456) # Executed result.status #=> "skipped" @@ -747,36 +742,33 @@ result.status #=> "skipped" result.reason #=> "no reason given" # With a reason -result.reason #=> "Outside business hours" +result.reason #=> "Warehouse closed" ``` -> [!NOTE] -> Skipping is not a failure or error. Skipped tasks are considered successful outcomes. - ## Failing The `fail!` method indicates a task encountered an error condition that prevents successful completion. This represents controlled failure where the task explicitly determines that execution cannot continue. ```ruby -class ProcessPayment < CMDx::Task +class ProcessRefund < CMDx::Task def work # Without a reason - skip! if Array(ENV["PHASED_OUT_TASKS"]).include?(self.class.name) + skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) - payment = Payment.find(context.payment_id) + refund = Refund.find(context.refund_id) # With a reason - if payment.unsupported_type? - fail!("Unsupported payment type") - elsif !payment.amount.positive? - fail!("Payment amount must be positive") + if refund.expired? + fail!("Refund period has expired") + elsif !refund.amount.positive? + fail!("Refund amount must be positive") else - payment.charge! + refund.process! end end end -result = ProcessSubscription.execute(user_id: 123) +result = ProcessRefund.execute(refund_id: 789) # Executed result.status #=> "failed" @@ -785,7 +777,7 @@ result.status #=> "failed" result.reason #=> "no reason given" # With a reason -result.reason #=> "Unsupported payment type" +result.reason #=> "Refund period has expired" ``` ## Metadata Enrichment @@ -793,37 +785,37 @@ result.reason #=> "Unsupported payment type" Both halt methods accept metadata to provide additional context about the interruption. Metadata is stored as a hash and becomes available through the result object. ```ruby -class ProcessSubscription < CMDx::Task +class ProcessRenewal < CMDx::Task def work - user = User.find(context.user_id) + license = License.find(context.license_id) - if user.subscription_expired? + if license.already_renewed? # Without metadata - skip!("Subscription expired") + skip!("License already renewed") end - unless user.payment_method_valid? + unless license.renewal_eligible? # With metadata fail!( - "Invalid payment method", - error_code: "PAYMENT_METHOD.INVALID", - retry_after: Time.current + 1.hour + "License not eligible for renewal", + error_code: "LICENSE.NOT_ELIGIBLE", + retry_after: Time.current + 30.days ) end - process_subscription + process_renewal end end -result = ProcessSubscription.execute(user_id: 123) +result = ProcessRenewal.execute(license_id: 567) # Without metadata result.metadata #=> {} # With metadata result.metadata #=> { - # error_code: "PAYMENT_METHOD.INVALID", - # retry_after: