usage_credits 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- metadata +115 -0
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# View helpers for displaying credit information
|
5
|
+
module CreditsHelper
|
6
|
+
# Format credit amount for display
|
7
|
+
def format_credits(amount)
|
8
|
+
UsageCredits.configuration.credit_formatter.call(amount)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Format price in currency
|
12
|
+
def format_credit_price(cents, currency = nil)
|
13
|
+
currency ||= UsageCredits.configuration.default_currency
|
14
|
+
format("%.2f %s", cents / 100.0, currency.to_s.upcase)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Credit pack purchase button
|
18
|
+
def credit_pack_button(pack, options = {})
|
19
|
+
button_to options[:path] || credit_pack_purchase_path(pack),
|
20
|
+
class: options[:class] || "credit-pack-button",
|
21
|
+
method: :post,
|
22
|
+
data: {
|
23
|
+
turbo: false,
|
24
|
+
pack_name: pack.name,
|
25
|
+
credits: pack.credits,
|
26
|
+
bonus_credits: pack.bonus_credits,
|
27
|
+
price: pack.price_cents
|
28
|
+
} do
|
29
|
+
render_credit_pack_button_content(pack)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def render_credit_pack_button_content(pack)
|
37
|
+
safe_join([
|
38
|
+
content_tag(:span, "#{format_credits(pack.credits)} Credits", class: "credit-amount"),
|
39
|
+
pack.bonus_credits.positive? ? content_tag(:span, "+ #{format_credits(pack.bonus_credits)} Bonus", class: "bonus-amount") : nil,
|
40
|
+
content_tag(:span, format_credit_price(pack.price_cents, pack.price_currency), class: "price")
|
41
|
+
].compact, " ")
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Handles parsing and normalization of time periods throughout the gem.
|
5
|
+
# Converts strings like "1.month" or symbols like :monthly into ActiveSupport::Duration objects.
|
6
|
+
module PeriodParser
|
7
|
+
|
8
|
+
# Canonical periods and their aliases
|
9
|
+
VALID_PERIODS = {
|
10
|
+
day: [:day, :daily], # 1.day
|
11
|
+
week: [:week, :weekly], # 1.week
|
12
|
+
month: [:month, :monthly], # 1.month
|
13
|
+
quarter: [:quarter, :quarterly], # 3.months
|
14
|
+
year: [:year, :yearly, :annually] # 1.year
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
MIN_PERIOD = 1.day
|
18
|
+
|
19
|
+
module_function
|
20
|
+
|
21
|
+
# Turns things like `:monthly` into `1.month` to always store consistent time periods
|
22
|
+
def normalize_period(period)
|
23
|
+
return nil unless period
|
24
|
+
|
25
|
+
# Handle ActiveSupport::Duration objects directly
|
26
|
+
if period.is_a?(ActiveSupport::Duration)
|
27
|
+
raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if period < MIN_PERIOD
|
28
|
+
period
|
29
|
+
else
|
30
|
+
# Convert symbols to canonical durations
|
31
|
+
case period
|
32
|
+
when *VALID_PERIODS[:day] then 1.day
|
33
|
+
when *VALID_PERIODS[:week] then 1.week
|
34
|
+
when *VALID_PERIODS[:month] then 1.month
|
35
|
+
when *VALID_PERIODS[:quarter] then 3.months
|
36
|
+
when *VALID_PERIODS[:year] then 1.year
|
37
|
+
else
|
38
|
+
raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Parse a period string into an ActiveSupport::Duration
|
44
|
+
# @param period_str [String, ActiveSupport::Duration] A string like "1.month" or "1 month" or an existing duration
|
45
|
+
# @return [ActiveSupport::Duration] The parsed duration
|
46
|
+
# @raise [ArgumentError] If the period string is invalid
|
47
|
+
def parse_period(period_str)
|
48
|
+
return period_str if period_str.is_a?(ActiveSupport::Duration)
|
49
|
+
|
50
|
+
if period_str.to_s =~ /\A(\d+)[.\s](\w+)\z/
|
51
|
+
amount = $1.to_i
|
52
|
+
unit = $2.singularize.to_sym
|
53
|
+
|
54
|
+
# Validate the unit is supported
|
55
|
+
valid_units = VALID_PERIODS.values.flatten
|
56
|
+
unless valid_units.include?(unit)
|
57
|
+
raise ArgumentError, "Unsupported period unit: #{unit}. Supported units: #{valid_units.inspect}"
|
58
|
+
end
|
59
|
+
|
60
|
+
duration = amount.send(unit)
|
61
|
+
raise ArgumentError, "Period must be at least #{MIN_PERIOD.inspect}" if duration < MIN_PERIOD
|
62
|
+
duration
|
63
|
+
else
|
64
|
+
raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Validates that a period string matches the expected format and units
|
69
|
+
def valid_period_format?(period_str)
|
70
|
+
parse_period(period_str)
|
71
|
+
true
|
72
|
+
rescue ArgumentError
|
73
|
+
false
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# lib/usage_credits/jobs/fulfillment_job.rb
|
2
|
+
module UsageCredits
|
3
|
+
class FulfillmentJob < ApplicationJob
|
4
|
+
queue_as :default
|
5
|
+
|
6
|
+
def perform
|
7
|
+
Rails.logger.info "Starting credit fulfillment processing"
|
8
|
+
start_time = Time.current
|
9
|
+
|
10
|
+
count = FulfillmentService.process_pending_fulfillments
|
11
|
+
|
12
|
+
elapsed = Time.current - start_time
|
13
|
+
formatted_time = if elapsed >= 60
|
14
|
+
"#{(elapsed / 60).floor}m #{(elapsed % 60).round}s"
|
15
|
+
else
|
16
|
+
"#{elapsed.round(2)}s"
|
17
|
+
end
|
18
|
+
|
19
|
+
Rails.logger.info "Completed processing #{count} fulfillments in #{formatted_time}"
|
20
|
+
rescue StandardError => e
|
21
|
+
Rails.logger.error "Error processing credit fulfillments: #{e.message}"
|
22
|
+
raise # Re-raise to trigger job retry
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# An Allocation links a *negative* (spend) transaction
|
5
|
+
# to a *positive* (credit) transaction, indicating how many
|
6
|
+
# credits were taken from that specific credit source.
|
7
|
+
#
|
8
|
+
# Allocations are the basis for the bucket-based, FIFO-with-expiration inventory-like system
|
9
|
+
# This is critical for calculating balances when there are mixed expiring and non-expiring credits
|
10
|
+
# Otherwise, balance calculations will always be wrong because negative transactions get dragged forever
|
11
|
+
# More info: https://x.com/rameerez/status/1884246492837302759
|
12
|
+
class Allocation < ApplicationRecord
|
13
|
+
self.table_name = "usage_credits_allocations"
|
14
|
+
|
15
|
+
belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id"
|
16
|
+
belongs_to :source_transaction, class_name: "UsageCredits::Transaction"
|
17
|
+
|
18
|
+
validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
19
|
+
|
20
|
+
validate :allocation_does_not_exceed_remaining_amount
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def allocation_does_not_exceed_remaining_amount
|
25
|
+
if source_transaction.remaining_amount < amount
|
26
|
+
errors.add(:amount, "exceeds the remaining amount of the source transaction")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Adds credit wallet functionality to a model
|
5
|
+
module HasWallet
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
has_one :credit_wallet,
|
10
|
+
class_name: "UsageCredits::Wallet",
|
11
|
+
as: :owner,
|
12
|
+
dependent: :destroy
|
13
|
+
|
14
|
+
alias_method :credits_wallet, :credit_wallet
|
15
|
+
alias_method :wallet, :credit_wallet
|
16
|
+
|
17
|
+
after_create :create_credit_wallet, if: :should_create_wallet?
|
18
|
+
|
19
|
+
# More intuitive delegations
|
20
|
+
delegate :credits,
|
21
|
+
:credit_history,
|
22
|
+
:has_enough_credits_to?,
|
23
|
+
:estimate_credits_to,
|
24
|
+
:spend_credits_on,
|
25
|
+
:give_credits,
|
26
|
+
to: :ensure_credit_wallet,
|
27
|
+
allow_nil: false # Never return nil for these methods
|
28
|
+
|
29
|
+
# Fix recursion by properly aliasing the original method
|
30
|
+
alias_method :original_credit_wallet, :credit_wallet
|
31
|
+
|
32
|
+
# Then override it
|
33
|
+
define_method(:credit_wallet) do
|
34
|
+
ensure_credit_wallet
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns all active subscriptions as CreditSubscriptionPlan objects
|
38
|
+
def subscriptions
|
39
|
+
return [] unless credit_wallet
|
40
|
+
|
41
|
+
credit_wallet.fulfillments
|
42
|
+
.where(fulfillment_type: "subscription")
|
43
|
+
.active
|
44
|
+
.map { |f| UsageCredits.find_subscription_plan_by_processor_id(f.metadata["plan"]) }
|
45
|
+
.compact
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
# Class methods added to the model
|
51
|
+
class_methods do
|
52
|
+
def has_credits(**options)
|
53
|
+
include UsageCredits::HasWallet unless included_modules.include?(UsageCredits::HasWallet)
|
54
|
+
|
55
|
+
# Initialize class instance variable instead of class variable
|
56
|
+
@credit_options = options
|
57
|
+
|
58
|
+
# Ensure wallet is created by default unless explicitly disabled
|
59
|
+
@credit_options[:auto_create] = true if @credit_options[:auto_create].nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
def credit_options
|
63
|
+
@credit_options ||= { auto_create: true }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def credit_options
|
68
|
+
self.class.credit_options
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def should_create_wallet?
|
74
|
+
credit_options[:auto_create] != false
|
75
|
+
end
|
76
|
+
|
77
|
+
def ensure_credit_wallet
|
78
|
+
return original_credit_wallet if original_credit_wallet.present?
|
79
|
+
return unless should_create_wallet?
|
80
|
+
|
81
|
+
if persisted?
|
82
|
+
build_credit_wallet(
|
83
|
+
balance: credit_options[:initial_balance] || 0
|
84
|
+
).tap(&:save!)
|
85
|
+
else
|
86
|
+
raise "Cannot create wallet for unsaved owner"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def create_credit_wallet
|
91
|
+
ensure_credit_wallet
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Extends Pay::Charge with credit pack functionality
|
5
|
+
module PayChargeExtension
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
after_initialize :init_metadata
|
10
|
+
after_commit :fulfill_credit_pack!
|
11
|
+
after_commit :handle_refund!, on: :update, if: :refund_needed?
|
12
|
+
end
|
13
|
+
|
14
|
+
def init_metadata
|
15
|
+
self.metadata ||= {}
|
16
|
+
self.data ||= {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def succeeded?
|
20
|
+
return true if data["status"] == "succeeded" || data[:status] == "succeeded"
|
21
|
+
# For Stripe charges, a successful charge has amount_captured equal to the charge amount
|
22
|
+
return true if type == "Pay::Stripe::Charge" && data["amount_captured"] == amount
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
def refunded?
|
27
|
+
return false unless amount_refunded
|
28
|
+
amount_refunded > 0
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# Returns true if the charge has a valid credit wallet to operate on
|
34
|
+
def has_valid_wallet?
|
35
|
+
return false unless customer&.owner&.respond_to?(:credit_wallet)
|
36
|
+
return false unless customer.owner.credit_wallet.present?
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
def credit_wallet
|
41
|
+
return nil unless has_valid_wallet?
|
42
|
+
customer.owner.credit_wallet
|
43
|
+
end
|
44
|
+
|
45
|
+
def refund_needed?
|
46
|
+
saved_change_to_amount_refunded? && amount_refunded.to_i.positive?
|
47
|
+
end
|
48
|
+
|
49
|
+
def is_credit_pack_purchase?
|
50
|
+
metadata["purchase_type"] == "credit_pack"
|
51
|
+
end
|
52
|
+
|
53
|
+
def pack_identifier
|
54
|
+
metadata["pack_name"]
|
55
|
+
end
|
56
|
+
|
57
|
+
def credits_already_fulfilled?
|
58
|
+
# First check if there's a fulfillment record for this charge
|
59
|
+
return true if UsageCredits::Fulfillment.exists?(source: self)
|
60
|
+
|
61
|
+
# Fallback: check transactions directly
|
62
|
+
credit_wallet&.transactions&.where(category: "credit_pack_purchase")
|
63
|
+
.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
|
64
|
+
end
|
65
|
+
|
66
|
+
def fulfill_credit_pack!
|
67
|
+
return unless is_credit_pack_purchase?
|
68
|
+
return unless pack_identifier
|
69
|
+
return unless has_valid_wallet?
|
70
|
+
return unless succeeded?
|
71
|
+
return if refunded?
|
72
|
+
return if credits_already_fulfilled?
|
73
|
+
|
74
|
+
Rails.logger.info "Starting to process charge #{id} to fulfill credits"
|
75
|
+
|
76
|
+
pack_name = pack_identifier.to_sym
|
77
|
+
pack = UsageCredits.find_pack(pack_name)
|
78
|
+
|
79
|
+
unless pack
|
80
|
+
Rails.logger.error "Credit pack not found: #{pack_name} for charge #{id}"
|
81
|
+
return
|
82
|
+
end
|
83
|
+
|
84
|
+
# Validate that the pack details match if they're provided in metadata
|
85
|
+
if metadata["credits"].present?
|
86
|
+
expected_credits = metadata["credits"].to_i
|
87
|
+
if expected_credits != pack.credits
|
88
|
+
Rails.logger.error "Credit pack mismatch: expected #{expected_credits} credits but pack #{pack_name} provides #{pack.credits}"
|
89
|
+
return
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
begin
|
94
|
+
# Wrap credit addition in a transaction for atomicity
|
95
|
+
ActiveRecord::Base.transaction do
|
96
|
+
# Add credits to the user's wallet
|
97
|
+
credit_wallet.add_credits(
|
98
|
+
pack.total_credits,
|
99
|
+
category: "credit_pack_purchase",
|
100
|
+
metadata: {
|
101
|
+
purchase_charge_id: id,
|
102
|
+
purchased_at: created_at,
|
103
|
+
credits_fulfilled: true,
|
104
|
+
fulfilled_at: Time.current,
|
105
|
+
**pack.base_metadata
|
106
|
+
}
|
107
|
+
)
|
108
|
+
|
109
|
+
# Also create a one-time fulfillment record for audit and consistency
|
110
|
+
# This Fulfillment record won't get picked up by the fulfillment job because `next_fulfillment_at` is nil
|
111
|
+
Fulfillment.create!(
|
112
|
+
wallet: credit_wallet,
|
113
|
+
source: self, # the Pay::Charge
|
114
|
+
fulfillment_type: "credit_pack",
|
115
|
+
credits_last_fulfillment: pack.total_credits,
|
116
|
+
last_fulfilled_at: Time.current,
|
117
|
+
next_fulfillment_at: nil, # so it doesn't get re-processed
|
118
|
+
metadata: {
|
119
|
+
purchase_charge_id: id,
|
120
|
+
purchased_at: created_at,
|
121
|
+
**pack.base_metadata
|
122
|
+
}
|
123
|
+
)
|
124
|
+
end
|
125
|
+
|
126
|
+
Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
|
127
|
+
rescue StandardError => e
|
128
|
+
Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}"
|
129
|
+
raise
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def credits_already_refunded?
|
134
|
+
# Check if refund was already processed with credits deducted by looking for a refund transaction
|
135
|
+
credit_wallet&.transactions&.where(category: "credit_pack_refund")
|
136
|
+
.exists?(['metadata @> ?', { refunded_purchase_charge_id: id, credits_refunded: true }.to_json])
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_refund!
|
140
|
+
# Guard clauses for required data and state
|
141
|
+
return unless refunded?
|
142
|
+
return unless pack_identifier
|
143
|
+
return unless has_valid_wallet?
|
144
|
+
return unless amount.is_a?(Numeric) && amount.positive?
|
145
|
+
return if credits_already_refunded?
|
146
|
+
|
147
|
+
pack_name = pack_identifier.to_sym
|
148
|
+
pack = UsageCredits.find_pack(pack_name)
|
149
|
+
|
150
|
+
unless pack
|
151
|
+
Rails.logger.error "Credit pack not found for refund: #{pack_name} for charge #{id}"
|
152
|
+
return
|
153
|
+
end
|
154
|
+
|
155
|
+
# Validate refund amount
|
156
|
+
if amount_refunded > amount
|
157
|
+
Rails.logger.error "Invalid refund amount: #{amount_refunded} exceeds original charge amount #{amount} for charge #{id}"
|
158
|
+
return
|
159
|
+
end
|
160
|
+
|
161
|
+
# Calculate refund ratio and credits to remove
|
162
|
+
# Always use ceil for credit calculations to avoid giving more credits than paid for
|
163
|
+
refund_ratio = amount_refunded.to_f / amount.to_f
|
164
|
+
credits_to_remove = (pack.total_credits * refund_ratio).ceil
|
165
|
+
|
166
|
+
begin
|
167
|
+
Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (#{(refund_ratio * 100).round(2)}% of #{pack.total_credits})"
|
168
|
+
|
169
|
+
# Wrap credit deduction in a transaction for atomicity
|
170
|
+
ActiveRecord::Base.transaction do
|
171
|
+
credit_wallet.deduct_credits(
|
172
|
+
credits_to_remove,
|
173
|
+
category: "credit_pack_refund",
|
174
|
+
metadata: {
|
175
|
+
refunded_purchase_charge_id: id,
|
176
|
+
credits_refunded: true,
|
177
|
+
refunded_at: Time.current,
|
178
|
+
refund_percentage: refund_ratio,
|
179
|
+
refund_amount_cents: amount_refunded,
|
180
|
+
**pack.base_metadata
|
181
|
+
}
|
182
|
+
)
|
183
|
+
end
|
184
|
+
|
185
|
+
Rails.logger.info "Successfully processed refund for charge #{id}"
|
186
|
+
rescue UsageCredits::InsufficientCredits => e
|
187
|
+
Rails.logger.error "Insufficient credits for refund on charge #{id}: #{e.message}"
|
188
|
+
# If negative balance not allowed and user has used credits,
|
189
|
+
# we'll let the error propagate
|
190
|
+
raise
|
191
|
+
rescue StandardError => e
|
192
|
+
Rails.logger.error "Failed to process refund for charge #{id}: #{e.message}"
|
193
|
+
raise
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
end
|
198
|
+
end
|