docbrown/app/services/payments/process_credit_purchase.rb
Daniel Uber 0eb4e690ed
Validate payment info present before attempting to process (#15629)
* Add two guards when purchasing credits

This prevents sending an empty card to stripe, and avoids a
foreseeable error when purchasing 0 credits. Error returned from
stripe is "This value must be greater than or equal to 1".

* Move redirect back into action method

Only use validation methods to check input and set appropriate messages.

* Accept organization id if set

The payment service will use the organization id (and charge the
organization) when that's the purchaser. Don't fail when no stripe
token or card selected if an organization admin is purchasing credits.

* Move validation into the payment processing service

This cleans up the number of details the controller needs to worry
about

Now only if the process payment service had any tests...

* Prevent only organization_id from permitting purchase

and move this check to a method, with an intention revealing name

* Add i18n translation for error message

There weren't any service side error messages - this will be displayed
in a view (credits/purchase) but comes from the payment service -
other messages (from stripe) are passed through as-is there.

If this is the wrong _place_ for the translation I can retool.

* Add unit test for payment processing

Only checking errors are raised as expected and the internal error
message is translated.

* Leave translation alone

Revert test translation
2021-12-03 13:09:23 -06:00

134 lines
3.8 KiB
Ruby

module Payments
# This service encapsulates purchasing credits via the Stripe API.
#
# NOTE: We still use the legacy checkout API here. While this doesn't
# support all features of Stripe's new API (e.g. SCA) it's well suited
# for our use-case of occasional one-off purchases.
# An exploration of alternatives and the decision to stick to the legacy API
# can be found here: https://github.com/forem/internalEngineering/issues/364
class ProcessCreditPurchase
def self.call(user, credits_count, purchase_options:)
new(
user,
credits_count,
purchase_options: purchase_options,
).call
end
attr_reader :purchaser, :error
def initialize(user, credits_count, purchase_options:)
self.user = user
self.credits_count = credits_count
self.purchase_options = purchase_options
self.success = false
end
def call
if payment_method_provided?
process_purchase
create_credits if success?
else
self.error = I18n.t("services.payments.errors.select_payment_method")
end
self
end
def success?
success
end
private
attr_accessor :user, :credits_count, :success, :purchase_options
attr_writer :purchaser, :error
def payment_method_provided?
# we expect at least one of :stripe_token or :selected_card to process
purchase_options.slice(:stripe_token, :selected_card).compact_blank.present?
end
def process_purchase
customer = find_or_create_customer
update_user_stripe_info(customer)
card = find_or_create_card(customer)
create_charge(customer, card)
self.success = true
rescue Payments::PaymentsError => e
self.error = e.message
end
def find_or_create_customer
if user.stripe_id_code
Payments::Customer.get(user.stripe_id_code)
else
Payments::Customer.create(email: user.email)
end
end
def find_or_create_card(customer)
if purchase_options[:stripe_token]
Payments::Customer.create_source(customer.id, purchase_options[:stripe_token])
else
Payments::Customer.get_source(customer, purchase_options[:selected_card])
end
end
def update_user_stripe_info(customer)
user.update_column(:stripe_id_code, customer.id) if user.stripe_id_code.nil?
end
def create_charge(customer, card)
Payments::Customer.charge(
customer: customer,
amount: credits_count * cost_per_credit,
description: "Purchase of #{credits_count} credits.",
card_id: card&.id,
)
end
def create_credits
purchaser = user
credits_attributes = Array.new(credits_count) do
# unfortunately Rails requires the timestamps to be present and doesn't add them automatically
# see <https://github.com/rails/rails/issues/35493>
now = Time.current
attrs = { created_at: now, updated_at: now, cost: cost_per_credit / 100.0 }
if purchase_options[:organization_id].present?
purchaser = Organization.find(purchase_options[:organization_id])
attrs[:organization_id] = purchase_options[:organization_id]
else
attrs[:user_id] = user.id
end
attrs
end
Credit.insert_all(credits_attributes)
purchaser.update(
credits_count: purchaser.credits.size,
spent_credits_count: purchaser.credits.spent.size,
unspent_credits_count: purchaser.credits.unspent.size,
)
self.purchaser = purchaser
end
def cost_per_credit
prices = Settings::General.credit_prices_in_cents
case credits_count
when ..9
prices[:small]
when 10..99
prices[:medium]
when 100..999
prices[:large]
else
prices[:xlarge]
end
end
end
end