Merge pull request #12949 from rioug/12859-use-VINE-voucher

[City OFN Voucher] A shopper can use a VINE voucher
This commit is contained in:
Maikel
2024-12-03 14:04:44 +11:00
committed by GitHub
53 changed files with 2327 additions and 248 deletions

View File

@@ -34,12 +34,6 @@ Lint/EmptyClass:
Exclude:
- 'spec/lib/reports/report_loader_spec.rb'
# Offense count: 1
# Configuration parameters: AllowComments.
Lint/EmptyFile:
Exclude:
- 'spec/lib/open_food_network/enterprise_injection_data_spec.rb'
# Offense count: 2
Lint/FloatComparison:
Exclude:
@@ -92,7 +86,6 @@ Metrics/AbcSize:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/payments_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/admin/variants_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/spree/admin/navigation_helper.rb'
@@ -127,7 +120,7 @@ Metrics/BlockNesting:
Exclude:
- 'app/models/spree/payment/processing.rb'
# Offense count: 46
# Offense count: 47
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
@@ -137,6 +130,7 @@ Metrics/ClassLength:
- 'app/controllers/admin/resource_controller.rb'
- 'app/controllers/admin/subscriptions_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/controllers/checkout_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/orders_controller.rb'
- 'app/controllers/spree/admin/payment_methods_controller.rb'
@@ -183,7 +177,7 @@ Metrics/ClassLength:
Metrics/CyclomaticComplexity:
Exclude:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/admin/payments_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/checkout_helper.rb'
- 'app/helpers/order_cycles_helper.rb'
@@ -208,13 +202,12 @@ Metrics/CyclomaticComplexity:
- 'lib/spree/localized_number.rb'
- 'spec/models/product_importer_spec.rb'
# Offense count: 24
# Offense count: 23
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/controllers/admin/enterprises_controller.rb'
- 'app/controllers/payment_gateways/paypal_controller.rb'
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/controllers/spree/orders_controller.rb'
- 'app/helpers/spree/admin/navigation_helper.rb'
- 'app/models/spree/ability.rb'
@@ -293,19 +286,17 @@ Metrics/ParameterLists:
- 'spec/support/controller_requests_helper.rb'
- 'spec/system/admin/reports_spec.rb'
# Offense count: 4
# Offense count: 3
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
Metrics/PerceivedComplexity:
Exclude:
- 'app/controllers/spree/admin/taxons_controller.rb'
- 'app/models/enterprise_relationship.rb'
- 'app/models/spree/ability.rb'
- 'app/models/spree/order/checkout.rb'
# Offense count: 8
# Offense count: 7
Naming/AccessorMethodName:
Exclude:
- 'app/controllers/spree/admin/taxonomies_controller.rb'
- 'app/mailers/producer_mailer.rb'
- 'app/models/spree/order.rb'
- 'app/services/checkout/post_checkout_actions.rb'
@@ -353,7 +344,7 @@ Naming/VariableNumber:
- 'spec/models/spree/tax_rate_spec.rb'
- 'spec/requests/api/orders_spec.rb'
# Offense count: 142
# Offense count: 143
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ResponseMethods.
# ResponseMethods: response, last_response
@@ -557,7 +548,7 @@ RSpecRails/InferredSpecType:
- 'spec/requests/voucher_adjustments_spec.rb'
- 'spec/routing/stripe_spec.rb'
# Offense count: 22
# Offense count: 21
# Configuration parameters: IgnoreScopes, Include.
# Include: app/models/**/*.rb
Rails/InverseOf:
@@ -572,7 +563,6 @@ Rails/InverseOf:
- 'app/models/spree/price.rb'
- 'app/models/spree/product.rb'
- 'app/models/spree/stock_item.rb'
- 'app/models/spree/taxonomy.rb'
- 'app/models/spree/variant.rb'
- 'app/models/subscription_line_item.rb'
@@ -720,7 +710,7 @@ Style/GlobalStdStream:
- 'lib/tasks/subscriptions/debug.rake'
- 'lib/tasks/subscriptions/test.rake'
# Offense count: 12
# Offense count: 10
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowSplatArgument.
Style/HashConversion:
@@ -728,9 +718,7 @@ Style/HashConversion:
- 'app/controllers/admin/column_preferences_controller.rb'
- 'app/controllers/admin/variant_overrides_controller.rb'
- 'app/controllers/spree/admin/products_controller.rb'
- 'app/models/order_cycle.rb'
- 'app/models/product_import/product_importer.rb'
- 'app/models/spree/shipping_method.rb'
- 'app/serializers/api/admin/exchange_serializer.rb'
- 'app/services/variants_stock_levels.rb'
- 'spec/controllers/admin/inventory_items_controller_spec.rb'

View File

@@ -44,9 +44,9 @@ module Admin
create_connected_app
jwt_service = VineJwtService.new(secret: connected_app_params[:vine_secret])
vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key],
jwt_generator: jwt_service)
jwt_service = Vine::JwtService.new(secret: connected_app_params[:vine_secret])
vine_api = Vine::ApiService.new(api_key: connected_app_params[:vine_api_key],
jwt_generator: jwt_service)
if !@app.connect(api_key: connected_app_params[:vine_api_key],
secret: connected_app_params[:vine_secret], vine_api:)

View File

@@ -78,6 +78,18 @@ class CheckoutController < BaseController
return true if redirect_to_payment_gateway
# Redeem VINE voucher
vine_voucher_redeemer = Vine::VoucherRedeemerService.new(order: @order)
unless vine_voucher_redeemer.redeem
# rubocop:disable Rails/DeprecatedActiveModelErrorsMethods
flash[:error] = if vine_voucher_redeemer.errors.keys.include?(:redeeming_failed)
vine_voucher_redeemer.errors[:redeeming_failed]
else
I18n.t('checkout.errors.voucher_redeeming_error')
end
return false
# rubocop:enable Rails/DeprecatedActiveModelErrorsMethods
end
@order.process_payments!
@order.confirm!
order_completion_reset @order

View File

@@ -24,9 +24,12 @@ module Spree
end
def create
# Try to redeem VINE voucher first as we don't want to create a payment and complete
# the order if it fails
return redirect_to spree.admin_order_payments_path(@order) unless redeem_vine_voucher
@payment = @order.payments.build(object_params)
load_payment_source
begin
unless @payment.save
redirect_to spree.admin_order_payments_path(@order)
@@ -51,6 +54,10 @@ module Spree
event = params[:e]
return unless event && @payment.payment_source
# capture_and_complete_order will complete the order, so we want to try to redeem VINE
# voucher first and exit if it fails
return if event == "capture_and_complete_order" && !redeem_vine_voucher
# Because we have a transition method also called void, we do this to avoid conflicts.
event = "void_transaction" if event == "void"
if allowed_events.include?(event) && @payment.public_send("#{event}!")
@@ -182,6 +189,22 @@ module Spree
%w{capture void_transaction credit refund resend_authorization_email
capture_and_complete_order}
end
def redeem_vine_voucher
vine_voucher_redeemer = Vine::VoucherRedeemerService.new(order: @order)
if vine_voucher_redeemer.redeem == false
# rubocop:disable Rails/DeprecatedActiveModelErrorsMethods
flash[:error] = if vine_voucher_redeemer.errors.keys.include?(:redeeming_failed)
vine_voucher_redeemer.errors[:redeeming_failed]
else
I18n.t('checkout.errors.voucher_redeeming_error')
end
# rubocop:enable Rails/DeprecatedActiveModelErrorsMethods
return false
end
true
end
end
end
end

View File

@@ -70,8 +70,7 @@ module Spree
@order.recreate_all_fees! # Enterprise fees on line items and on the order itself
# Re apply the voucher
VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states
OrderManagement::Order::Updater.new(@order).update_voucher
if @order.complete?
@order.update_payment_fees!

View File

@@ -4,7 +4,16 @@ class VoucherAdjustmentsController < BaseController
before_action :set_order
def create
if add_voucher
if voucher_params[:voucher_code].blank?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return render_error
end
voucher = load_voucher
return render_error unless valid_voucher?(voucher)
if add_voucher_to_order(voucher)
update_payment_section
elsif @order.errors.present?
render_error
@@ -30,19 +39,28 @@ class VoucherAdjustmentsController < BaseController
@order = current_order
end
def add_voucher
if voucher_params[:voucher_code].blank?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return false
end
voucher = Voucher.find_by(code: voucher_params[:voucher_code], enterprise: @order.distributor)
def valid_voucher?(voucher)
return false if @order.errors.present?
if voucher.nil?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.voucher_not_found'))
return false
end
if !voucher.valid?
@order.errors.add(
:voucher_code,
I18n.t(
'checkout.errors.create_voucher_error', error: voucher.errors.full_messages.to_sentence
)
)
return false
end
true
end
def add_voucher_to_order(voucher)
adjustment = voucher.create_adjustment(voucher.code, @order)
unless adjustment.persisted?
@@ -51,14 +69,38 @@ class VoucherAdjustmentsController < BaseController
return false
end
# calculate_voucher_adjustment
clear_payments
VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states
OrderManagement::Order::Updater.new(@order).update_voucher
true
end
def load_voucher
voucher = Voucher.find_by(code: voucher_params[:voucher_code],
enterprise: @order.distributor)
return voucher unless voucher.nil? || voucher.is_a?(Vouchers::Vine)
vine_voucher
end
def vine_voucher
vine_voucher_validator = Vine::VoucherValidatorService.new(
voucher_code: voucher_params[:voucher_code], enterprise: @order.distributor
)
voucher = vine_voucher_validator.validate
return nil if vine_voucher_validator.errors[:not_found_voucher].present?
if vine_voucher_validator.errors.present?
@order.errors.add(:voucher_code, I18n.t('checkout.errors.add_voucher_error'))
return nil
end
voucher
end
def update_payment_section
render cable_ready: cable_car.replace(
selector: "#checkout-payment-methods",

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
require "active_support/concern"
module Vouchers
module FlatRatable
extend ActiveSupport::Concern
included do
validates :amount,
presence: true,
numericality: { greater_than: 0 }
end
def display_value
Spree::Money.new(amount)
end
# We limit adjustment to the maximum amount needed to cover the order, ie if the voucher
# covers more than the order.total we only need to create an adjustment covering the order.total
def compute_amount(order)
-amount.clamp(0, order.pre_discount_total)
end
def rate(order)
amount = compute_amount(order)
amount / order.pre_discount_total
end
end
end

View File

@@ -14,7 +14,7 @@ class Voucher < ApplicationRecord
class_name: 'Spree::Adjustment',
dependent: nil
validates :code, presence: true, uniqueness: { scope: :enterprise_id }
validates :code, presence: true
TYPES = ["Vouchers::FlatRate", "Vouchers::PercentageRate"].freeze

View File

@@ -2,24 +2,8 @@
module Vouchers
class FlatRate < Voucher
validates :amount,
presence: true,
numericality: { greater_than: 0 }
include FlatRatable
def display_value
Spree::Money.new(amount)
end
# We limit adjustment to the maximum amount needed to cover the order, ie if the voucher
# covers more than the order.total we only need to create an adjustment covering the order.total
def compute_amount(order)
-amount.clamp(0, order.pre_discount_total)
end
def rate(order)
amount = compute_amount(order)
amount / order.pre_discount_total
end
validates_with ScopedUniquenessValidator
end
end

View File

@@ -5,6 +5,7 @@ module Vouchers
validates :amount,
presence: true,
numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates_with ScopedUniquenessValidator
def display_value
ActionController::Base.helpers.number_to_percentage(amount, precision: 2)

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: false
module Vouchers
class Vine < Voucher
include FlatRatable
# a VINE voucher :
# - can potentially be associated with mutiple enterprise
# - code ( "short code" in VINE ) can be recycled, but they shouldn't be linked to the same
# voucher_id
validates :code, uniqueness: { scope: [:enterprise_id, :external_voucher_id] }
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
require "faraday"
module Vine
class ApiService
attr_reader :api_key, :jwt_generator
def initialize(api_key:, jwt_generator:)
@vine_api_url = ENV.fetch("VINE_API_URL")
@api_key = api_key
@jwt_generator = jwt_generator
end
def my_team
my_team_url = "#{@vine_api_url}/my-team"
call_with_logging do
connection.get(my_team_url)
end
end
def voucher_validation(voucher_short_code)
voucher_validation_url = "#{@vine_api_url}/voucher-validation"
call_with_logging do
connection.post(
voucher_validation_url,
{ type: "voucher_code", value: voucher_short_code },
'Content-Type': "application/json"
)
end
end
def voucher_redemptions(voucher_id, voucher_set_id, amount)
voucher_redemptions_url = "#{@vine_api_url}/voucher-redemptions"
call_with_logging do
connection.post(
voucher_redemptions_url,
{ voucher_id:, voucher_set_id:, amount: amount.to_i },
'Content-Type': "application/json"
)
end
end
private
def connection
jwt = jwt_generator.generate_token
Faraday.new(
request: { timeout: 30 },
headers: {
'X-Authorization': "JWT #{jwt}",
Accept: "application/json"
}
) do |f|
f.request :json
f.response :json
f.request :authorization, 'Bearer', api_key
f.use Faraday::Response::RaiseError
end
end
def call_with_logging
yield
rescue Faraday::ClientError, Faraday::ServerError => e
# caller_location(2,1) gets us the second entry in the stacktrace,
# ie the method where `call_with_logging` is called from
log_error("#{self.class}##{caller_locations(2, 1)[0].label}", e.response)
# Re raise the same exception
raise
end
def log_error(prefix, response)
Rails.logger.error "#{prefix} -- response_status: #{response[:status]}"
Rails.logger.error "#{prefix} -- response: #{response[:body]}"
end
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
module Vine
class JwtService
ALGORITHM = "HS256"
ISSUER = "openfoodnetwork"
def initialize(secret: )
@secret = secret
end
def generate_token
generation_time = Time.zone.now
payload = {
iss: ISSUER,
iat: generation_time.to_i,
exp: (generation_time + 1.minute).to_i,
}
JWT.encode(payload, @secret, ALGORITHM)
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
module Vine
class VoucherRedeemerService
attr_reader :order, :errors
def initialize(order: )
@order = order
@errors = {}
end
def redeem
# Do nothing if we don't have a vine voucher added to the order
@voucher_adjustment = order.voucher_adjustments.first
@voucher = @voucher_adjustment&.originator
return true if @voucher_adjustment.nil? || !@voucher.is_a?(Vouchers::Vine)
return false if vine_settings.nil?
call_vine_api
@voucher_adjustment.close
true
rescue Faraday::ClientError => e
handle_errors(e.response)
false
rescue Faraday::Error => e
Rails.logger.error e.inspect
Bugsnag.notify(e)
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
false
end
private
def vine_settings
@vine_settings ||= ConnectedApps::Vine.find_by(enterprise: order.distributor)&.data
end
def call_vine_api
jwt_service = Vine::JwtService.new(secret: vine_settings["secret"])
vine_api = Vine::ApiService.new(api_key: vine_settings["api_key"], jwt_generator: jwt_service)
# Voucher adjustment amount is stored in dollars and negative, VINE expect cents
amount = -1 * @voucher_adjustment.amount * 100
vine_api.voucher_redemptions(
@voucher.external_voucher_id, @voucher.external_voucher_set_id, amount
)
end
def handle_errors(response)
if response[:status] == 400
errors[:redeeming_failed] = I18n.t("vine_voucher_redeemer_service.errors.redeeming_failed")
else
errors[:vine_api] = I18n.t("vine_voucher_redeemer_service.errors.vine_api")
end
end
end
end

View File

@@ -0,0 +1,71 @@
# frozen_string_literal: true
module Vine
class VoucherValidatorService
attr_reader :voucher_code, :errors
def initialize(voucher_code:, enterprise:)
@voucher_code = voucher_code
@enterprise = enterprise
@errors = {}
end
def validate
return nil if vine_settings.nil?
response = call_vine_api
save_voucher(response)
rescue Faraday::ClientError => e
handle_errors(e.response)
nil
rescue Faraday::Error => e
Rails.logger.error e.inspect
Bugsnag.notify(e)
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
nil
end
private
def vine_settings
@vine_settings ||= ConnectedApps::Vine.find_by(enterprise: @enterprise)&.data
end
def call_vine_api
# Check voucher is valid
jwt_service = Vine::JwtService.new(secret: vine_settings["secret"])
vine_api = Vine::ApiService.new(api_key: vine_settings["api_key"], jwt_generator: jwt_service)
vine_api.voucher_validation(voucher_code)
end
def handle_errors(response)
if response[:status] == 400
errors[:invalid_voucher] = I18n.t("vine_voucher_validator_service.errors.invalid_voucher")
elsif response[:status] == 404
errors[:not_found_voucher] =
I18n.t("vine_voucher_validator_service.errors.not_found_voucher")
else
errors[:vine_api] = I18n.t("vine_voucher_validator_service.errors.vine_api")
end
end
def save_voucher(response)
voucher_data = response.body["data"]
# Check if voucher already exist
voucher = Vouchers::Vine.find_or_initialize_by(
code: voucher_code,
enterprise: @enterprise,
external_voucher_id: voucher_data["id"],
external_voucher_set_id: voucher_data["voucher_set_id"]
)
voucher.amount = voucher_data["voucher_value_remaining"].to_f / 100
voucher.save
voucher
end
end
end

View File

@@ -1,39 +0,0 @@
# frozen_string_literal: true
require "faraday"
class VineApiService
attr_reader :api_key, :jwt_generator
def initialize(api_key:, jwt_generator:)
@vine_api_url = ENV.fetch("VINE_API_URL")
@api_key = api_key
@jwt_generator = jwt_generator
end
def my_team
my_team_url = "#{@vine_api_url}/my-team"
jwt = jwt_generator.generate_token
connection = Faraday.new(
request: { timeout: 30 },
headers: {
'X-Authorization': "JWT #{jwt}",
Accept: "application/json"
}
) do |f|
f.request :json
f.response :json
f.request :authorization, 'Bearer', api_key
end
response = connection.get(my_team_url)
if !response.success?
Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}"
Rails.logger.error "VineApiService#my_team -- response: #{response.body}"
end
response
end
end

View File

@@ -1,21 +0,0 @@
# frozen_string_literal: true
class VineJwtService
ALGORITHM = "HS256"
ISSUER = "openfoodnetwork"
def initialize(secret: )
@secret = secret
end
def generate_token
generation_time = Time.zone.now
payload = {
iss: ISSUER,
iat: generation_time.to_i,
exp: (generation_time + 1.minute).to_i,
}
JWT.encode(payload, @secret, ALGORITHM)
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: false
# paranoia doesn't support unique validation including deleted records:
# https://github.com/rubysherpas/paranoia/pull/333
# We use a custom validator to fix the issue, so we don't need to fork/patch the gem
module Vouchers
class ScopedUniquenessValidator < ActiveModel::Validator
def validate(record)
@record = record
return unless unique_voucher_code_per_enterprise?
record.errors.add :code, :taken, value: @record.code
end
private
def unique_voucher_code_per_enterprise?
query = Voucher.with_deleted.where(code: @record.code, enterprise_id: @record.enterprise_id)
query = query.where.not(id: @record.id) unless @record.id.nil?
query.present?
end
end
end

View File

@@ -3,7 +3,7 @@
= t('.add_new')
%br
- if @enterprise.vouchers.with_deleted.present?
- if @enterprise.vouchers.where.not(type: "Vouchers::Vine").with_deleted.present?
%table
%thead
%tr
@@ -17,7 +17,7 @@
/%th= t('.customers')
/%th= t('.net_value')
%tbody
- @enterprise.vouchers.with_deleted.order(deleted_at: :desc, code: :asc).each do |voucher|
- @enterprise.vouchers.where.not(type: "Vouchers::Vine").with_deleted.order(deleted_at: :desc, code: :asc).each do |voucher|
%tr
%td= voucher.code
%td= voucher.display_value

View File

@@ -1,5 +1,5 @@
.medium-6#checkout-payment-methods
- if @order.distributor.vouchers.present?
- if @order.distributor.vouchers.present? || @order.distributor.connected_apps.vine.present?
%div.checkout-substep
= render partial: "checkout/voucher_section", locals: { order: @order, voucher_adjustment: @order.voucher_adjustments.first }

View File

@@ -574,7 +574,15 @@ en:
depth: "Depth"
payment_could_not_process: "The payment could not be processed"
payment_could_not_complete: "The payment could not be completed"
vine_voucher_validator_service:
errors:
vine_api: "There was an error communicating with the API, please try again later."
invalid_voucher: "The voucher is not valid"
not_found_voucher: "Sorry, we couldn't find that voucher, please check the code."
vine_voucher_redeemer_service:
errors:
vine_api: "There was an error communicating with the API"
redeeming_failed: "Redeeming the voucher failed"
actions:
create_and_add_another: "Create and Add Another"
create: "Create"
@@ -2124,6 +2132,8 @@ en:
no_shipping_methods_available: Checkout is not possible due to absence of shipping options. Please contact the shop owner.
voucher_not_found: Not found
add_voucher_error: There was an error while adding the voucher
create_voucher_error: "There was an error while creating the voucher: %{error}"
voucher_redeeming_error: There was an error while trying to redeem your voucher
shops:
hubs:
show_closed_shops: "Show closed shops"

View File

@@ -0,0 +1,7 @@
class AddExternalVoucherIdExternalVoucherSetIdVoucherTypeToVouchers < ActiveRecord::Migration[7.0]
def change
add_column :vouchers, :external_voucher_id, :uuid
add_column :vouchers, :external_voucher_set_id, :uuid
add_column :vouchers, :voucher_type, :string
end
end

View File

@@ -0,0 +1,9 @@
class UpdateIndexAndRemoveVoucherTypeFromVoucher < ActiveRecord::Migration[7.0]
def change
remove_column :vouchers, :voucher_type
remove_index :vouchers, [:code, :enterprise_id], unique: true
add_index :vouchers, [:code, :enterprise_id]
add_index :vouchers, [:code, :enterprise_id, :external_voucher_id], name: "index_vouchers_on_code_and_enterprise_id_and_ext_voucher_id"
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_10_30_033956) do
ActiveRecord::Schema[7.0].define(version: 2024_11_12_230401) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "plpgsql"
@@ -1110,7 +1110,10 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_30_033956) do
t.datetime "deleted_at", precision: nil
t.decimal "amount", precision: 10, scale: 2, default: "0.0", null: false
t.string "type", limit: 255, default: "Vouchers::FlatRate", null: false
t.index ["code", "enterprise_id"], name: "index_vouchers_on_code_and_enterprise_id", unique: true
t.uuid "external_voucher_id"
t.uuid "external_voucher_set_id"
t.index ["code", "enterprise_id", "external_voucher_id"], name: "index_vouchers_on_code_and_enterprise_id_and_ext_voucher_id"
t.index ["code", "enterprise_id"], name: "index_vouchers_on_code_and_enterprise_id"
t.index ["deleted_at"], name: "index_vouchers_on_deleted_at"
t.index ["enterprise_id"], name: "index_vouchers_on_enterprise_id"
end

View File

@@ -161,6 +161,11 @@ module OrderManagement
persist_totals
end
def update_voucher
VoucherAdjustmentsService.new(order).update
update_totals_and_states
end
private
def cancel_payments_requiring_auth

View File

@@ -460,6 +460,26 @@ module OrderManagement
end
end
describe "#update_voucher" do
let(:voucher_service) { instance_double(VoucherAdjustmentsService) }
it "calls VoucherAdjustmentsService" do
expect(VoucherAdjustmentsService).to receive(:new).and_return(voucher_service)
expect(voucher_service).to receive(:update)
updater.update_voucher
end
it "calls update_totals_and_states" do
allow(VoucherAdjustmentsService).to receive(:new).and_return(voucher_service)
allow(voucher_service).to receive(:update)
expect(updater).to receive(:update_totals_and_states)
updater.update_voucher
end
end
def update_order_quantity(order)
order.line_items.first.update_attribute(:quantity, 2)
end

View File

@@ -433,6 +433,7 @@ RSpec.describe CheckoutController, type: :controller do
context "summary step" do
let(:step) { "summary" }
let(:checkout_params) { { confirm_order: "Complete order" } }
before do
order.bill_address = address
@@ -496,6 +497,58 @@ RSpec.describe CheckoutController, type: :controller do
end
end
context "with a VINE voucher", feature: :connected_apps do
let(:vine_voucher) {
create(:vine_voucher, code: 'some_code', enterprise: distributor, amount: 6)
}
let(:vine_voucher_redeemer) { instance_double(Vine::VoucherRedeemerService) }
before do
# Adding voucher to the order
vine_voucher.create_adjustment(vine_voucher.code, order)
OrderManagement::Order::Updater.new(order).update_voucher
allow(Vine::VoucherRedeemerService).to receive(:new).and_return(vine_voucher_redeemer)
end
it "completes the order and redirects to order confirmation" do
expect(vine_voucher_redeemer).to receive(:redeem).and_return(true)
put(:update, params:)
expect(response).to redirect_to order_path(order, order_token: order.token)
expect(order.reload.state).to eq "complete"
end
context "when redeeming the voucher fails" do
it "returns 422 and some error" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ redeeming_failed: "Redeeming the voucher failed" }
)
put(:update, params:)
expect(response.status).to eq 422
expect(flash[:error]).to match "Redeeming the voucher failed"
end
end
context "when an other error happens" do
it "returns 422 and some error" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ vine_api: "There was an error communicating with the API" }
)
put(:update, params:)
expect(response.status).to eq 422
expect(flash[:error]).to match "There was an error while trying to redeem your voucher"
end
end
end
context "when an external payment gateway is used" do
before do
expect(Checkout::PaymentMethodFetcher).

View File

@@ -14,4 +14,10 @@ FactoryBot.define do
factory :voucher_percentage_rate, parent: :voucher, class: Vouchers::PercentageRate do
amount { rand(1..100) }
end
factory :vine_voucher, parent: :voucher, class: Vouchers::Vine do
amount { 20 }
external_voucher_id { SecureRandom.uuid }
external_voucher_set_id { SecureRandom.uuid }
end
end

View File

@@ -0,0 +1,57 @@
---
http_interactions:
- request:
method: post
uri: https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation
body:
encoding: UTF-8
string: '{"type":"voucher_code","value":"CI3922"}'
headers:
X-Authorization:
- "<HIDDEN-VINE-TOKEN>"
Accept:
- application/json
User-Agent:
- Faraday v2.9.0
Content-Type:
- application/json
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Vary:
- Accept-Encoding
Cache-Control:
- no-cache, private
Date:
- Sun, 20 Oct 2024 03:32:37 GMT
X-Ratelimit-Limit:
- '60'
X-Ratelimit-Remaining:
- '58'
Access-Control-Allow-Origin:
- "*"
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
body:
encoding: ASCII-8BIT
string: '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":""},"data":{"id":"9d2437c8-4559-4dda-802e-8d9c642a0c1d","voucher_short_code":"CI3922","voucher_set_id":"9d24349c-1fe8-4090-988b-d7355ed32559","is_test":1,"voucher_value_original":500,"voucher_value_remaining":500,"num_voucher_redemptions":0,"last_redemption_at":null,"created_at":"2024-10-01T13:20:02.000000Z","updated_at":"2024-10-01T13:20:02.000000Z","deleted_at":null}}'
recorded_at: Sun, 20 Oct 2024 03:32:37 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,50 @@
---
http_interactions:
- request:
method: post
uri: https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation
body:
encoding: UTF-8
string: '{"type":"voucher_code","value":"KM1891"}'
headers:
X-Authorization:
- "<HIDDEN-VINE-TOKEN>"
Accept:
- application/json
User-Agent:
- Faraday v2.9.0
Content-Type:
- application/json
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 400
message: Bad Request
headers:
Server:
- nginx
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cache-Control:
- no-cache, private
Date:
- Sun, 20 Oct 2024 03:42:25 GMT
X-Ratelimit-Limit:
- '60'
X-Ratelimit-Remaining:
- '59'
Access-Control-Allow-Origin:
- "*"
body:
encoding: UTF-8
string: '{"meta":{"responseCode":400,"limit":50,"offset":0,"message":"Invalid
merchant team."},"data":[]}'
recorded_at: Sun, 20 Oct 2024 03:42:25 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,55 @@
---
http_interactions:
- request:
method: post
uri: https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-redemptions
body:
encoding: UTF-8
string: '{"voucher_id":"9d316d27-4e3c-4c8c-b3c8-8e23cc171f20","voucher_set_id":"9d314daa-0878-4b73-922d-698047640cf4","amount":1}'
headers:
X-Authorization:
- "<HIDDEN-VINE-TOKEN>"
Accept:
- application/json
User-Agent:
- Faraday v2.9.0
Content-Type:
- application/json
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Vary:
- Accept-Encoding
Cache-Control:
- no-cache, private
Date:
- Wed, 23 Oct 2024 03:16:39 GMT
Access-Control-Allow-Origin:
- "*"
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
body:
encoding: ASCII-8BIT
string: '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":"Redemption
successful. This was a test redemption. Do NOT provide the person with goods
or services."},"data":{"voucher_id":"9d316d27-4e3c-4c8c-b3c8-8e23cc171f20","voucher_set_id":"9d314daa-0878-4b73-922d-698047640cf4","redeemed_by_user_id":8,"redeemed_by_team_id":4,"redeemed_amount":1,"is_test":1,"updated_at":"2024-10-23T03:16:39.000000Z","created_at":"2024-10-23T03:16:39.000000Z","id":7}}'
recorded_at: Wed, 23 Oct 2024 03:16:39 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,55 @@
---
http_interactions:
- request:
method: post
uri: https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-redemptions
body:
encoding: UTF-8
string: '{"voucher_id":"9d316d27-0dad-411a-8953-316a1aaf7742","voucher_set_id":"9d314daa-0878-4b73-922d-698047640cf4","amount":1}'
headers:
X-Authorization:
- "<HIDDEN-VINE-TOKEN>"
Accept:
- application/json
User-Agent:
- Faraday v2.9.0
Content-Type:
- application/json
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Vary:
- Accept-Encoding
Cache-Control:
- no-cache, private
Date:
- Mon, 21 Oct 2024 03:07:09 GMT
Access-Control-Allow-Origin:
- "*"
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
body:
encoding: ASCII-8BIT
string: '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":"Redemption
successful. This was a test redemption. Do NOT provide the person with goods
or services."},"data":{"voucher_id":"9d316d27-0dad-411a-8953-316a1aaf7742","voucher_set_id":"9d314daa-0878-4b73-922d-698047640cf4","redeemed_by_user_id":8,"redeemed_by_team_id":4,"redeemed_amount":1,"is_test":1,"updated_at":"2024-10-21T03:07:09.000000Z","created_at":"2024-10-21T03:07:09.000000Z","id":5}}'
recorded_at: Mon, 21 Oct 2024 03:07:09 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,57 @@
---
http_interactions:
- request:
method: post
uri: https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation
body:
encoding: UTF-8
string: '{"type":"voucher_code","value":"CI3922"}'
headers:
X-Authorization:
- "<HIDDEN-VINE-TOKEN>"
Accept:
- application/json
User-Agent:
- Faraday v2.9.0
Content-Type:
- application/json
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Vary:
- Accept-Encoding
Cache-Control:
- no-cache, private
Date:
- Mon, 07 Oct 2024 23:48:46 GMT
X-Ratelimit-Limit:
- '60'
X-Ratelimit-Remaining:
- '59'
Access-Control-Allow-Origin:
- "*"
X-Frame-Options:
- SAMEORIGIN
X-Xss-Protection:
- 1; mode=block
X-Content-Type-Options:
- nosniff
body:
encoding: ASCII-8BIT
string: '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":""},"data":{"id":"9d2437c8-4559-4dda-802e-8d9c642a0c1d","voucher_short_code":"CI3922","voucher_set_id":"9d24349c-1fe8-4090-988b-d7355ed32559","is_test":1,"voucher_value_original":500,"voucher_value_remaining":500,"num_voucher_redemptions":0,"last_redemption_at":null,"created_at":"2024-10-01T13:20:02.000000Z","updated_at":"2024-10-01T13:20:02.000000Z","deleted_at":null}}'
recorded_at: Mon, 07 Oct 2024 23:48:46 GMT
recorded_with: VCR 6.2.0

View File

@@ -272,8 +272,7 @@ RSpec.describe "Reporting::Reports::SalesTax::SalesTaxTotalsByOrder" do
def add_voucher(order, voucher)
# Add voucher to the order
voucher.create_adjustment(voucher.code, order)
VoucherAdjustmentsService.new(order).update
order.update_totals_and_states
OrderManagement::Order::Updater.new(order).update_voucher
Orders::WorkflowService.new(order).complete!
end

View File

@@ -7,7 +7,7 @@ RSpec.describe ConnectedApps::Vine do
let(:vine_api_key) { "12345" }
let(:secret) { "my_secret" }
let(:vine_api) { instance_double(VineApiService) }
let(:vine_api) { instance_double(Vine::ApiService) }
describe "#connect" do
it "send a request to VINE api" do

View File

@@ -27,7 +27,6 @@ RSpec.describe Voucher do
subject { build(:voucher_flat_rate, code: 'new_code', enterprise:) }
it { is_expected.to validate_presence_of(:code) }
it { is_expected.to validate_uniqueness_of(:code).scoped_to(:enterprise_id) }
end
describe '#display_value' do

View File

@@ -8,6 +8,7 @@ RSpec.describe Vouchers::FlatRate do
it { is_expected.to validate_presence_of(:amount) }
it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) }
it_behaves_like 'has a unique code per enterprise', "voucher_flat_rate"
end
describe '#compute_amount' do

View File

@@ -12,6 +12,7 @@ RSpec.describe Vouchers::PercentageRate do
.is_greater_than(0)
.is_less_than_or_equal_to(100)
end
it_behaves_like 'has a unique code per enterprise', "voucher_percentage_rate"
end
describe '#compute_amount' do

View File

@@ -0,0 +1,83 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vouchers::Vine do
describe 'validations' do
subject { build(:vine_voucher) }
it { is_expected.to validate_presence_of(:amount) }
it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) }
describe "#code" do
subject { build(:vine_voucher, code: 'vine_code', enterprise:, external_voucher_id: ) }
let(:external_voucher_id) { SecureRandom.uuid }
let(:enterprise) { create(:enterprise) }
it {
is_expected.to validate_uniqueness_of(:code).scoped_to(
[:enterprise_id, :external_voucher_id]
)
}
it "can be reused within the same enterprise" do
subject.save!
# Voucher with the same code but different external_voucher_id, it is mapped to a
# different voucher in VINE
voucher = build(:vine_voucher, code: 'vine_code', enterprise: )
expect(voucher.valid?).to be(true)
end
it "can be used by mutiple enterprises" do
subject.save!
# Voucher with the same code and external_voucher_id, ie exiting VINE voucher used by
# another enterprise
voucher = build(:vine_voucher, code: 'vine_code', enterprise: build(:enterprise),
external_voucher_id: )
expect(voucher.valid?).to be(true)
end
end
end
describe '#compute_amount' do
let(:order) { create(:order_with_totals) }
before do
order.update_columns(item_total: 15)
end
context 'when order total is more than the voucher' do
subject { create(:vine_voucher, amount: 5) }
it 'uses the voucher total' do
expect(subject.compute_amount(order).to_f).to eq(-5)
end
end
context 'when order total is less than the voucher' do
subject { create(:vine_voucher, amount: 20) }
it 'matches the order total' do
expect(subject.compute_amount(order).to_f).to eq(-15)
end
end
end
describe "#rate" do
subject do
create(:vine_voucher, code: 'new_code', amount: 5)
end
let(:order) { create(:order_with_totals) }
before do
order.update_columns(item_total: 10)
end
it "returns the voucher rate" do
# rate = -voucher_amount / order.pre_discount_total
# -5 / 10 = -0.5
expect(subject.rate(order).to_f).to eq(-0.5)
end
end
end

View File

@@ -13,11 +13,11 @@ RSpec.describe "Admin ConnectedApp" do
describe "POST /admin/enterprises/:enterprise_id/connected_apps" do
context "with type ConnectedApps::Vine" do
let(:vine_api) { instance_double(VineApiService) }
let(:vine_api) { instance_double(Vine::ApiService) }
before do
allow(VineJwtService).to receive(:new).and_return(instance_double(VineJwtService))
allow(VineApiService).to receive(:new).and_return(vine_api)
allow(Vine::JwtService).to receive(:new).and_return(instance_double(Vine::JwtService))
allow(Vine::ApiService).to receive(:new).and_return(vine_api)
end
it "creates a new connected app" do
@@ -115,7 +115,7 @@ RSpec.describe "Admin ConnectedApp" do
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("VINE_API_URL").and_raise(KeyError)
allow(VineApiService).to receive(:new).and_call_original
allow(Vine::ApiService).to receive(:new).and_call_original
end
it "redirects to enterprise edit page, with an error" do

View File

@@ -10,6 +10,120 @@ RSpec.describe Spree::Admin::PaymentsController, type: :request do
sign_in create(:admin_user)
end
describe "POST /admin/orders/:order_number/payments.json" do
let(:params) do
{
payment: {
payment_method_id: payment_method.id, amount: order.total
}
}
end
let(:payment_method) do
create(:payment_method, distributors: [order.distributor])
end
it "creates a payment" do
expect {
post("/admin/orders/#{order.number}/payments.json", params:)
}.to change { order.payments.count }.by(1)
end
it "redirect to payments page" do
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
expect(flash[:success]).to eq "Payment has been successfully created!"
end
context "when failing to create payment" do
it "redirects to payments page" do
payment_mock = instance_double(Spree::Payment)
allow(order.payments).to receive(:build).and_return(payment_mock)
allow(payment_mock).to receive(:save).and_return(false)
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
end
end
context "when a gateway error happens" do
let(:payment_method) do
create(:stripe_sca_payment_method, distributors: [order.distributor])
end
it "redirect to payments page" do
allow(Spree::Order).to receive(:find_by!).and_return(order)
stripe_sca_payment_authorize =
instance_double(OrderManagement::Order::StripeScaPaymentAuthorize)
allow(OrderManagement::Order::StripeScaPaymentAuthorize).to receive(:new)
.and_return(stripe_sca_payment_authorize)
# Simulate an error
allow(stripe_sca_payment_authorize).to receive(:call!) do
order.errors.add(:base, "authorization_failure")
end
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
expect(flash[:error]).to eq("Authorization Failure")
end
end
context "with a VINE voucher", feature: :connected_apps do
let(:vine_voucher) {
create(:vine_voucher, code: 'some_code', enterprise: order.distributor, amount: 6)
}
let(:vine_voucher_redeemer) { instance_double(Vine::VoucherRedeemerService) }
before do
add_voucher_to_order(vine_voucher, order)
allow(Vine::VoucherRedeemerService).to receive(:new).and_return(vine_voucher_redeemer)
end
it "completes the order and redirects to payment page" do
expect(vine_voucher_redeemer).to receive(:redeem).and_return(true)
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
expect(flash[:success]).to eq "Payment has been successfully created!"
expect(order.reload.state).to eq "complete"
end
context "when redeeming the voucher fails" do
it "redirect to payments page" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ redeeming_failed: "Redeeming the voucher failed" }
)
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
expect(flash[:error]).to match "Redeeming the voucher failed"
end
end
context "when an other error happens" do
it "redirect to payments page" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ vine_api: "There was an error communicating with the API" }
)
post("/admin/orders/#{order.number}/payments.json", params:)
expect(response).to redirect_to(spree.admin_order_payments_path(order))
expect(flash[:error]).to match "There was an error while trying to redeem your voucher"
end
end
end
end
describe "PUT /admin/orders/:order_number/payments/:id/fire" do
let(:payment) do
create(
@@ -57,7 +171,7 @@ RSpec.describe Spree::Admin::PaymentsController, type: :request do
end
end
context "with 'void' parameter" do
context "with 'void' event" do
before do
allow(Spree::Payment).to receive(:find).and_return(payment)
end
@@ -101,6 +215,120 @@ RSpec.describe Spree::Admin::PaymentsController, type: :request do
end
end
context "with 'capture_and_complete_order' event" do
before do
allow(Spree::Payment).to receive(:find).and_return(payment)
end
it "calls capture_and_complete_order! on payment" do
expect(payment).to receive(:capture_and_complete_order!)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
end
it "redirect to payments page" do
allow(payment).to receive(:capture_and_complete_order!).and_return(true)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
expect(response).to redirect_to(spree.admin_order_payments_url(order))
expect(flash[:success]).to eq "Payment Updated"
end
context "when capture_and_complete_order! fails" do
it "set an error flash message" do
allow(payment).to receive(:capture_and_complete_order!).and_return(false)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
expect(response).to redirect_to(spree.admin_order_payments_url(order))
expect(flash[:error]).to eq "Could not update the payment"
end
end
context "with a VINE voucher", feature: :connected_apps do
let(:vine_voucher) {
create(:vine_voucher, code: 'some_code', enterprise: order.distributor, amount: 6)
}
let(:vine_voucher_redeemer) { instance_double(Vine::VoucherRedeemerService) }
before do
add_voucher_to_order(vine_voucher, order)
allow(Vine::VoucherRedeemerService).to receive(:new).and_return(vine_voucher_redeemer)
end
it "completes the order and redirects to payment page" do
expect(vine_voucher_redeemer).to receive(:redeem).and_return(true)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
expect(response).to redirect_to(spree.admin_order_payments_url(order))
expect(flash[:success]).to eq "Payment Updated"
expect(order.reload.state).to eq "complete"
end
context "when redeeming the voucher fails" do
it "redirect to payments page" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ redeeming_failed: "Redeeming the voucher failed" }
)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
expect(response).to redirect_to(spree.admin_order_payments_url(order))
expect(flash[:error]).to match "Redeeming the voucher failed"
end
end
context "when an other error happens" do
it "redirect to payments page" do
allow(vine_voucher_redeemer).to receive(:redeem).and_return(false)
allow(vine_voucher_redeemer).to receive(:errors).and_return(
{ vine_api: "There was an error communicating with the API" }
)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/" \
"fire?e=capture_and_complete_order",
params: {},
headers:
)
expect(response).to redirect_to(spree.admin_order_payments_url(order))
expect(flash[:error]).to match "There was an error while trying to redeem your voucher"
end
end
end
end
context "when something unexpected happen" do
before do
allow(Spree::Payment).to receive(:find).and_return(payment)
@@ -133,4 +361,9 @@ RSpec.describe Spree::Admin::PaymentsController, type: :request do
end
end
end
def add_voucher_to_order(voucher, order)
voucher.create_adjustment(voucher.code, order)
OrderManagement::Order::Updater.new(order).update_voucher
end
end

View File

@@ -34,10 +34,10 @@ RSpec.describe VoucherAdjustmentsController, type: :request do
let(:params) { { order: { voucher_code: voucher.code } } }
it "adds a voucher to the user's current order" do
post("/voucher_adjustments", params:)
expect {
post("/voucher_adjustments", params:)
}.to change { order.reload.voucher_adjustments.count }.by(1)
expect(response).to be_successful
expect(order.reload.voucher_adjustments.length).to eq(1)
end
context "when voucher doesn't exist" do
@@ -85,6 +85,142 @@ RSpec.describe VoucherAdjustmentsController, type: :request do
end.to change { order.reload.all_adjustments.payment_fee.count }.from(1).to(0)
end
end
context "with a VINE voucher", feature: :connected_apps do
let(:vine_voucher_validator) { instance_double(Vine::VoucherValidatorService) }
before do
allow(Vine::VoucherValidatorService).to receive(:new).and_return(vine_voucher_validator)
end
context "with a new voucher" do
let(:params) { { order: { voucher_code: vine_voucher_code } } }
let(:vine_voucher_code) { "PQ3187" }
context "with a valid voucher" do
it "verifies the voucher with VINE API" do
expect(vine_voucher_validator).to receive(:validate)
allow(vine_voucher_validator).to receive(:errors).and_return({})
post "/voucher_adjustments", params:
end
it "adds a voucher to the user's current order" do
vine_voucher = create(:vine_voucher, code: vine_voucher_code)
mock_vine_voucher_validator(voucher: vine_voucher)
post("/voucher_adjustments", params:)
expect(response).to be_successful
expect(order.reload.voucher_adjustments.length).to eq(1)
end
end
context "when coordinator is not connected to VINE" do
it "returns 422 and an error message" do
mock_vine_voucher_validator(voucher: nil)
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match "Voucher code Not found"
end
end
context "when there is an API error" do
it "returns 422 and an error message" do
mock_vine_voucher_validator(
voucher: nil,
errors: { vine_api: "There was an error communicating with the API" }
)
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match "There was an error while adding the voucher"
end
end
context "when the voucher doesn't exist" do
it "returns 422 and an error message" do
mock_vine_voucher_validator(voucher: nil,
errors: { not_found_voucher: "The voucher doesn't exist" })
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match "Voucher code Not found"
end
end
context "when the voucher is invalid voucher" do
it "returns 422 and an error message" do
mock_vine_voucher_validator(voucher: nil,
errors: { invalid_voucher: "The voucher is not valid" })
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match "There was an error while adding the voucher"
end
end
context "when creating a new voucher fails" do
it "returns 422 and an error message" do
vine_voucher = build(:vine_voucher, code: vine_voucher_code,
enterprise: distributor, amount: "")
mock_vine_voucher_validator(voucher: vine_voucher)
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match(
"There was an error while creating the voucher: Amount can't be blank and " \
"Amount is not a number"
)
end
end
end
context "with an existing voucher" do
let(:params) { { order: { voucher_code: vine_voucher_code } } }
let(:vine_voucher_code) { "PQ3187" }
it "verify the voucher with VINE API" do
expect(vine_voucher_validator).to receive(:validate)
allow(vine_voucher_validator).to receive(:errors).and_return({})
post "/voucher_adjustments", params:
end
it "adds a voucher to the user's current order" do
vine_voucher = create(:vine_voucher, code: vine_voucher_code,
enterprise: distributor)
mock_vine_voucher_validator(voucher: vine_voucher)
expect {
post("/voucher_adjustments", params:)
}.to change { order.reload.voucher_adjustments.count }.by(1)
expect(response).to be_successful
end
context "when updating the voucher fails" do
it "returns 422 and an error message" do
vine_voucher = build(:vine_voucher, code: vine_voucher_code,
enterprise: distributor, amount: "")
mock_vine_voucher_validator(voucher: vine_voucher)
post("/voucher_adjustments", params:)
expect(response).to be_unprocessable
expect(flash[:error]).to match(
"There was an error while creating the voucher: Amount can't be blank and " \
"Amount is not a number"
)
end
end
end
end
end
describe "DELETE voucher_adjustments/:id" do
@@ -137,4 +273,9 @@ RSpec.describe VoucherAdjustmentsController, type: :request do
end
end
end
def mock_vine_voucher_validator(voucher:, errors: {})
allow(vine_voucher_validator).to receive(:validate).and_return(voucher)
allow(vine_voucher_validator).to receive(:errors).and_return(errors)
end
end

View File

@@ -0,0 +1,198 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe Vine::ApiService do
subject(:vine_api) { described_class.new(api_key: vine_api_key, jwt_generator: jwt_service) }
let(:vine_api_url) { "https://vine-staging.openfoodnetwork.org.au/api/v1" }
let(:vine_api_key) { "12345" }
let(:jwt_service) { Vine::JwtService.new(secret:) }
let(:secret) { "my_secret" }
let(:token) { "some.jwt.token" }
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("VINE_API_URL").and_return(vine_api_url)
end
describe "#my_team" do
let(:my_team_url) { "#{vine_api_url}/my-team" }
it "send a request to the team VINE api endpoint" do
stub_request(:get, my_team_url).to_return(status: 200)
vine_api.my_team
expect(a_request(
:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team"
)).to have_been_made
end
it "sends the VINE api key via a header" do
stub_request(:get, my_team_url).to_return(status: 200)
vine_api.my_team
expect_request_with_api_key(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team")
end
it "sends JWT token via a header" do
stub_request(:get, my_team_url).to_return(status: 200)
mock_jwt_service
vine_api.my_team
expect_request_with_jwt_token(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team")
end
context "when a request succeed", :vcr do
it "returns the response" do
response = vine_api.my_team
expect(response.success?).to be(true)
expect(response.body).not_to be_empty
end
end
context "when a request fails" do
it "logs the error" do
stub_request(:get, my_team_url).to_return(body: "error", status: 401)
expect(Rails.logger).to receive(:error).with(match("Vine::ApiService#my_team")).twice
expect { vine_api.my_team }.to raise_error(Faraday::UnauthorizedError)
end
end
end
describe "#voucher_validation" do
let(:voucher_validation_url) { "#{vine_api_url}/voucher-validation" }
let(:voucher_short_code) { "CI3922" }
it "send a POST request to the team VINE api endpoint" do
stub_request(:post, voucher_validation_url).to_return(status: 200)
vine_api.voucher_validation(voucher_short_code)
expect(a_request(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation"
).with(body: { type: "voucher_code", value: voucher_short_code } )).to have_been_made
end
it "sends the VINE api key via a header" do
stub_request(:post, voucher_validation_url).to_return(status: 200)
vine_api.voucher_validation(voucher_short_code)
expect_request_with_api_key(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation"
)
end
it "sends JWT token via a header" do
stub_request(:post, voucher_validation_url).to_return(status: 200)
mock_jwt_service
vine_api.voucher_validation(voucher_short_code)
expect_request_with_jwt_token(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-validation"
)
end
context "when a request succeed", :vcr do
it "returns the response" do
response = vine_api.voucher_validation(voucher_short_code)
expect(response.success?).to be(true)
expect(response.body).not_to be_empty
end
end
context "when a request fails" do
it "logs the error" do
stub_request(:post, voucher_validation_url).to_return(body: "error", status: 401)
expect(Rails.logger).to receive(:error).with(
match("Vine::ApiService#voucher_validation")
).twice
expect {
vine_api.voucher_validation(voucher_short_code)
}.to raise_error(Faraday::UnauthorizedError)
end
end
end
describe "#voucher_redemptions" do
let(:voucher_redemptions_url) { "#{vine_api_url}/voucher-redemptions" }
let(:voucher_id) { "quia" }
let(:voucher_set_id) { "natus" }
let(:amount) { 500 } # $5.00
it "send a POST request to the voucher redemptions VINE api endpoint" do
stub_request(:post, voucher_redemptions_url).to_return(status: 200)
vine_api.voucher_redemptions(voucher_id, voucher_set_id, amount)
expect(a_request(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-redemptions"
).with(body: { voucher_id:, voucher_set_id:, amount: })).to have_been_made
end
it "sends the VINE api key via a header" do
stub_request(:post, voucher_redemptions_url).to_return(status: 200)
vine_api.voucher_redemptions(voucher_id, voucher_set_id, amount)
expect_request_with_api_key(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-redemptions"
)
end
it "sends JWT token via a header" do
stub_request(:post, voucher_redemptions_url).to_return(status: 200)
mock_jwt_service
vine_api.voucher_redemptions(voucher_id, voucher_set_id, amount)
expect_request_with_jwt_token(
:post, "https://vine-staging.openfoodnetwork.org.au/api/v1/voucher-redemptions"
)
end
context "when a request succeed", :vcr do
it "returns the response" do
response = vine_api.voucher_redemptions(voucher_id, voucher_set_id, amount)
expect(response.success?).to be(true)
expect(response.body).not_to be_empty
end
end
context "when a request fails" do
it "logs the error" do
stub_request(:post, voucher_redemptions_url).to_return(body: "error", status: 401)
expect(Rails.logger).to receive(:error).with(
match("Vine::ApiService#voucher_redemptions")
).twice.and_call_original
expect {
vine_api.voucher_redemptions(voucher_id, voucher_set_id, amount)
}.to raise_error(Faraday::UnauthorizedError)
end
end
end
def expect_request_with_api_key(method, url)
expect(a_request(method, url).with( headers: { Authorization: "Bearer #{vine_api_key}" }))
.to have_been_made
end
def expect_request_with_jwt_token(method, url)
expect(a_request(method, url).with( headers: { 'X-Authorization': "JWT #{token}" }))
.to have_been_made
end
def mock_jwt_service
allow(jwt_service).to receive(:generate_token).and_return(token)
end
end

View File

@@ -2,7 +2,7 @@
require "spec_helper"
RSpec.describe VineJwtService do
RSpec.describe Vine::JwtService do
describe "#generate_token" do
subject { described_class.new(secret: vine_secret) }
let(:vine_secret) { "some_secret" }

View File

@@ -0,0 +1,269 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe Vine::VoucherRedeemerService, feature: :connected_apps do
subject(:voucher_redeemer_service) { described_class.new(order: ) }
let(:user) { order.user }
let(:distributor) { create(:distributor_enterprise) }
let(:order_cycle) { create(:order_cycle, distributors: [distributor]) }
let(:order) { create(:order_with_line_items, line_items_count: 1, distributor:, order_cycle:) }
let(:vine_voucher) {
create(:vine_voucher, code: 'some_code', enterprise: distributor,
amount: 50, external_voucher_id: voucher_id,
external_voucher_set_id: voucher_set_id )
}
let(:voucher_id) { "9d316d27-0dad-411a-8953-316a1aaf7742" }
let(:voucher_set_id) { "9d314daa-0878-4b73-922d-698047640cf4" }
let(:vine_api_service) { instance_double(Vine::ApiService) }
before do
allow(Vine::ApiService).to receive(:new).and_return(vine_api_service)
end
describe "#redeem" do
context "with a valid voucher" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: {
responseCode: 200,
limit: 50,
offset: 0,
message: "Redemption successful. This was a test redemption. Do NOT provide " \
"the person with goods or services."
},
data: {
voucher_id: "9d316d27-0dad-411a-8953-316a1aaf7742",
voucher_set_id: "9d314daa-0878-4b73-922d-698047640cf4",
redeemed_by_user_id: 8,
redeemed_by_team_id: 4,
redeemed_amount: 1,
is_test: 1,
updated_at: "2024-10-21T03:07:09.000000Z",
created_at: "2024-10-21T03:07:09.000000Z",
id: 5
}
}.deep_stringify_keys
}
before { add_voucher(vine_voucher) }
it "redeems the voucher with VINE" do
# Order pre discount total is $10, so we expect to redeen 1000 cents
expect(vine_api_service).to receive(:voucher_redemptions)
.with(voucher_id, voucher_set_id, 1000)
.and_return(mock_api_response(data:))
voucher_redeemer_service.redeem
end
it "closes the linked assement" do
allow(vine_api_service).to receive(:voucher_redemptions)
.and_return(mock_api_response(data:))
expect {
voucher_redeemer_service.redeem
}.to change { order.voucher_adjustments.first.state }.to("closed")
end
it "returns true" do
allow(vine_api_service).to receive(:voucher_redemptions)
.and_return(mock_api_response(data:))
expect(voucher_redeemer_service.redeem).to be(true)
end
context "when redeeming fails" do
let(:data) {
{
meta: { responseCode: 400, limit: 50, offset: 0, message: "Invalid merchant team." },
data: []
}.deep_stringify_keys
}
before do
mock_api_exception(type: Faraday::BadRequestError, status: 400, body: data)
end
it "doesn't close the linked assement" do
expect {
voucher_redeemer_service.redeem
}.not_to change { order.voucher_adjustments.first.state }
end
it "returns false" do
expect(voucher_redeemer_service.redeem).to be(false)
end
it "adds an error message" do
voucher_redeemer_service.redeem
expect(voucher_redeemer_service.errors).to include(
{ redeeming_failed: "Redeeming the voucher failed" }
)
end
end
end
context "when distributor is not connected to VINE" do
before { add_voucher(vine_voucher) }
it "returns false" do
expect(voucher_redeemer_service.redeem).to be(false)
end
it "doesn't redeem the VINE API" do
expect(vine_api_service).not_to receive(:voucher_redemptions)
voucher_redeemer_service.redeem
end
it "doesn't close the linked assement" do
expect {
voucher_redeemer_service.redeem
}.not_to change { order.voucher_adjustments.first.state }
end
end
context "when there are no voucher added to the order" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
it "returns true" do
expect(voucher_redeemer_service.redeem).to be(true)
end
it "doesn't redeem the VINE API" do
expect(vine_api_service).not_to receive(:voucher_redemptions)
voucher_redeemer_service.redeem
end
end
context "with a non vine voucher" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:voucher) { create(:voucher_flat_rate, enterprise: distributor) }
before { add_voucher(voucher) }
it "returns true" do
expect(voucher_redeemer_service.redeem).to be(true)
end
it "doesn't redeem the VINE API" do
expect(vine_api_service).not_to receive(:voucher_redemptions)
voucher_redeemer_service.redeem
end
end
context "when there is an API error" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
before do
add_voucher(vine_voucher)
mock_api_exception(type: Faraday::ConnectionFailed)
end
it "returns false" do
expect(voucher_redeemer_service.redeem).to be(false)
end
it "adds an error message" do
voucher_redeemer_service.redeem
expect(voucher_redeemer_service.errors).to include(
{ vine_api: "There was an error communicating with the API, please try again later." }
)
end
it "doesn't close the linked assement" do
expect {
voucher_redeemer_service.redeem
}.not_to change { order.voucher_adjustments.first.state }
end
it "logs the error and notify bugsnag" do
expect(Rails.logger).to receive(:error)
expect(Bugsnag).to receive(:notify)
voucher_redeemer_service.redeem
end
end
context "when there is an API authentication error" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: { numRecords: 0, totalRows: 0, responseCode: 401,
message: "Incorrect authorization signature." },
data: []
}.deep_stringify_keys
}
before do
add_voucher(vine_voucher)
mock_api_exception(type: Faraday::UnauthorizedError, status: 401, body: data)
end
it "returns false" do
expect(voucher_redeemer_service.redeem).to be(false)
end
it "adds an error message" do
voucher_redeemer_service.redeem
expect(voucher_redeemer_service.errors).to include(
{ vine_api: "There was an error communicating with the API" }
)
end
it "doesn't close the linked assement" do
expect {
voucher_redeemer_service.redeem
}.not_to change { order.voucher_adjustments.first.state }
end
end
end
def add_voucher(voucher)
voucher.create_adjustment(voucher.code, order)
OrderManagement::Order::Updater.new(order).update_voucher
end
def mock_api_response(data: nil)
mock_response = instance_double(Faraday::Response)
if data.present?
allow(mock_response).to receive(:body).and_return(data)
end
mock_response
end
def mock_api_exception(type: Faraday::Error, status: 503, body: nil)
allow(vine_api_service).to receive(:voucher_redemptions).and_raise(type.new(nil,
{ status:,
body: }) )
end
end

View File

@@ -0,0 +1,446 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe Vine::VoucherValidatorService, feature: :connected_apps do
subject(:validate_voucher_service) { described_class.new(voucher_code:, enterprise: distributor) }
let(:voucher_code) { "good_code" }
let(:distributor) { create(:distributor_enterprise) }
let(:vine_api_service) { instance_double(Vine::ApiService) }
before do
allow(Vine::ApiService).to receive(:new).and_return(vine_api_service)
end
describe "#validate" do
context "with a valid voucher" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: vine_voucher_id,
voucher_short_code: voucher_code,
voucher_set_id: vine_voucher_set_id,
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: 500,
num_voucher_redemptions: 0,
last_redemption_at: "null",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
let(:vine_voucher_id) { "9d2437c8-4559-4dda-802e-8d9c642a0c1d" }
let(:vine_voucher_set_id) { "9d24349c-1fe8-4090-988b-d7355ed32559" }
it "verifies the voucher with VINE API" do
expect(vine_api_service).to receive(:voucher_validation).and_return(
mock_api_response(data:)
)
validate_voucher_service.validate
end
it "creates a new VINE voucher" do
allow(vine_api_service).to receive(:voucher_validation).and_return(mock_api_response(data:))
vine_voucher = validate_voucher_service.validate
expect(vine_voucher).not_to be_nil
expect(vine_voucher).to be_a(Vouchers::Vine)
expect(vine_voucher.code).to eq(voucher_code)
expect(vine_voucher.amount).to eq(5.00)
expect(vine_voucher.external_voucher_id).to eq(vine_voucher_id)
expect(vine_voucher.external_voucher_set_id).to eq(vine_voucher_set_id)
end
context "when the VINE voucher has already been used by another enterprise" do
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: vine_voucher_id,
voucher_short_code: voucher_code,
voucher_set_id: vine_voucher_set_id,
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: 250,
num_voucher_redemptions: 0,
last_redemption_at: "null",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
it "creates a new voucher" do
existing_voucher = create(:vine_voucher, enterprise: create(:enterprise),
code: voucher_code,
external_voucher_id: vine_voucher_id,
external_voucher_set_id: vine_voucher_set_id)
allow(vine_api_service).to receive(:voucher_validation)
.and_return(mock_api_response(data:))
vine_voucher = validate_voucher_service.validate
expect(vine_voucher.id).not_to eq(existing_voucher.id)
expect(vine_voucher.enterprise).to eq(distributor)
expect(vine_voucher.code).to eq(voucher_code)
expect(vine_voucher.amount).to eq(2.50)
expect(vine_voucher).to be_a(Vouchers::Vine)
expect(vine_voucher.external_voucher_id).to eq(vine_voucher_id)
expect(vine_voucher.external_voucher_set_id).to eq(vine_voucher_set_id)
end
end
context "with a recycled code" do
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: new_vine_voucher_id,
voucher_short_code: voucher_code,
voucher_set_id: new_vine_voucher_set_id,
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: 140,
num_voucher_redemptions: 0,
last_redemption_at: "null",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
let(:new_vine_voucher_id) { "9d2437c8-4559-4dda-802e-8d9c642a0c5e" }
let(:new_vine_voucher_set_id) { "9d24349c-1fe8-4090-988b-d7355ed32590" }
it "creates a new voucher" do
existing_voucher = create(:vine_voucher, enterprise: distributor, code: voucher_code,
external_voucher_id: vine_voucher_id,
external_voucher_set_id: vine_voucher_set_id)
allow(vine_api_service).to receive(:voucher_validation)
.and_return(mock_api_response(data:))
vine_voucher = validate_voucher_service.validate
expect(vine_voucher.id).not_to eq(existing_voucher.id)
expect(vine_voucher.enterprise).to eq(distributor)
expect(vine_voucher.code).to eq(voucher_code)
expect(vine_voucher.amount).to eq(1.40)
expect(vine_voucher).to be_a(Vouchers::Vine)
expect(vine_voucher.external_voucher_id).to eq(new_vine_voucher_id)
expect(vine_voucher.external_voucher_set_id).to eq(new_vine_voucher_set_id)
end
end
end
context "when distributor is not connected to VINE" do
it "returns nil" do
expect_validate_to_be_nil
end
it "doesn't call the VINE API" do
expect(vine_api_service).not_to receive(:voucher_validation)
validate_voucher_service.validate
end
it "doesn't creates a new VINE voucher" do
expect_voucher_count_not_to_change
end
end
context "when there is an API error" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234567", secret: "my_secret" }
)
}
before do
mock_api_exception(type: Faraday::ConnectionFailed)
end
it "returns nil" do
expect_validate_to_be_nil
end
it "adds an error message" do
validate_voucher_service.validate
expect(validate_voucher_service.errors).to include(
{ vine_api: "There was an error communicating with the API, please try again later." }
)
end
it "doesn't creates a new VINE voucher" do
expect_voucher_count_not_to_change
end
it "logs the error and notify bugsnag" do
expect(Rails.logger).to receive(:error)
expect(Bugsnag).to receive(:notify)
validate_voucher_service.validate
end
end
context "when there is an API authentication error" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234567", secret: "my_secret" }
)
}
let(:data) {
{
meta: { numRecords: 0, totalRows: 0, responseCode: 401,
message: "Incorrect authorization signature." },
data: []
}.deep_stringify_keys
}
before do
mock_api_exception(type: Faraday::UnauthorizedError, status: 401, body: data)
end
it "returns nil" do
expect_validate_to_be_nil
end
it "adds an error message" do
validate_voucher_service.validate
expect(validate_voucher_service.errors).to include(
{ vine_api: "There was an error communicating with the API, please try again later." }
)
end
it "doesn't creates a new VINE voucher" do
expect_voucher_count_not_to_change
end
end
context "when the voucher doesn't exist" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: { responseCode: 404, limit: 50, offset: 0, message: "Not found" },
data: []
}.deep_stringify_keys
}
before do
mock_api_exception(type: Faraday::ResourceNotFound, status: 404, body: data)
end
it "returns nil" do
expect_validate_to_be_nil
end
it "adds an error message" do
validate_voucher_service.validate
expect(validate_voucher_service.errors).to include(
{ not_found_voucher: "Sorry, we couldn't find that voucher, please check the code." }
)
end
it "doesn't creates a new VINE voucher" do
expect_voucher_count_not_to_change
end
end
context "when the voucher is an invalid voucher" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: { responseCode: 400, limit: 50, offset: 0, message: "Invalid merchant team." },
data: []
}.deep_stringify_keys
}
before do
mock_api_exception(type: Faraday::BadRequestError, status: 400, body: data)
end
it "returns nil" do
expect_validate_to_be_nil
end
it "adds an error message" do
validate_voucher_service.validate
expect(validate_voucher_service.errors).to include(
{ invalid_voucher: "The voucher is not valid" }
)
end
it "doesn't creates a new VINE voucher" do
expect_voucher_count_not_to_change
end
end
context "when creating a new voucher fails" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: "9d2437c8-4559-4dda-802e-8d9c642a0c1d",
voucher_short_code: voucher_code,
voucher_set_id: "9d24349c-1fe8-4090-988b-d7355ed32559",
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: '',
num_voucher_redemptions: 0,
last_redemption_at: "null",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
before do
allow(vine_api_service).to receive(:voucher_validation).and_return(
mock_api_response(data: )
)
end
it "returns an invalid voucher" do
voucher = validate_voucher_service.validate
expect(voucher).not_to be_valid
expect(voucher.errors[:amount]).to include "must be greater than 0"
end
end
context "with an existing voucher" do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234567", secret: "my_secret" }
)
}
let!(:voucher) {
create(:vine_voucher, enterprise: distributor, code: voucher_code, amount: 500,
external_voucher_id: vine_voucher_id,
external_voucher_set_id: "9d24349c-1fe8-4090-988b-d7355ed32559")
}
let(:vine_voucher_id) { "9d2437c8-4559-4dda-802e-8d9c642a0c1d" }
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: vine_voucher_id,
voucher_short_code: voucher_code,
voucher_set_id: "9d24349c-1fe8-4090-988b-d7355ed32559",
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: 250,
num_voucher_redemptions: 1,
last_redemption_at: "2024-10-05T13:20:02.000000Z",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
before do
allow(vine_api_service).to receive(:voucher_validation).and_return(
mock_api_response(data: )
)
end
it "verify the voucher with VINE API" do
expect(vine_api_service).to receive(:voucher_validation).and_return(
mock_api_response(data: )
)
validate_voucher_service.validate
end
it "updates the VINE voucher" do
vine_voucher = validate_voucher_service.validate
expect(vine_voucher.id).to eq(voucher.id)
expect(vine_voucher.reload.amount).to eq(2.50)
end
context "when updating the voucher fails" do
let(:data) {
{
meta: { responseCode: 200, limit: 50, offset: 0, message: "" },
data: {
id: "9d2437c8-4559-4dda-802e-8d9c642a0c1d",
voucher_short_code: voucher_code,
voucher_set_id: "9d24349c-1fe8-4090-988b-d7355ed32559",
is_test: 1,
voucher_value_original: 500,
voucher_value_remaining: '',
num_voucher_redemptions: 0,
last_redemption_at: "null",
created_at: "2024-10-01T13:20:02.000000Z",
updated_at: "2024-10-01T13:20:02.000000Z",
deleted_at: "null"
}
}.deep_stringify_keys
}
it "returns an invalid voucher" do
vine_voucher = validate_voucher_service.validate
expect(vine_voucher).not_to be_valid
end
it "doesn't update existing voucher" do
expect {
validate_voucher_service.validate
}.not_to change { voucher.reload.amount }
end
end
end
end
def expect_validate_to_be_nil
expect(validate_voucher_service.validate).to be_nil
end
def expect_voucher_count_not_to_change
expect { validate_voucher_service.validate }.not_to change { Voucher.count }
end
def mock_api_response(data: nil)
mock_response = instance_double(Faraday::Response)
if data.present?
allow(mock_response).to receive(:body).and_return(data)
end
mock_response
end
def mock_api_exception(type: Faraday::Error, status: 503, body: nil)
allow(vine_api_service).to receive(:voucher_validation).and_raise(type.new(nil,
{ status:, body: }) )
end
end

View File

@@ -1,83 +0,0 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe VineApiService do
subject(:vine_api) { described_class.new(api_key: vine_api_key, jwt_generator: jwt_service) }
let(:vine_api_url) { "https://vine-staging.openfoodnetwork.org.au/api/v1" }
let(:vine_api_key) { "12345" }
let(:jwt_service) { VineJwtService.new(secret:) }
let(:secret) { "my_secret" }
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("VINE_API_URL").and_return(vine_api_url)
end
describe "#my_team" do
let(:my_team_url) { "#{vine_api_url}/my-team" }
it "send a request to the team VINE api endpoint" do
stub_request(:get, my_team_url).to_return(status: 200)
vine_api.my_team
expect(a_request(
:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team"
)).to have_been_made
end
it "sends the VINE api key via a header" do
stub_request(:get, my_team_url).to_return(status: 200)
vine_api.my_team
expect(a_request(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team").with(
headers: { Authorization: "Bearer #{vine_api_key}" }
)).to have_been_made
end
it "sends JWT token via a header" do
token = "some.jwt.token"
stub_request(:get, my_team_url).to_return(status: 200)
allow(jwt_service).to receive(:generate_token).and_return(token)
vine_api.my_team
expect(a_request(:get, "https://vine-staging.openfoodnetwork.org.au/api/v1/my-team").with(
headers: { 'X-Authorization': "JWT #{token}" }
)).to have_been_made
end
context "when a request succeed", :vcr do
it "returns the response" do
response = vine_api.my_team
expect(response.success?).to be(true)
expect(response.body).not_to be_empty
end
end
context "when a request fails" do
it "logs the error" do
stub_request(:get, my_team_url).to_return(body: "error", status: 401)
expect(Rails.logger).to receive(:error).twice
response = vine_api.my_team
expect(response.success?).to be(false)
end
it "return the response" do
stub_request(:get, my_team_url).to_return(body: "error", status: 401)
response = vine_api.my_team
expect(response.success?).to be(false)
expect(response.body).not_to be_empty
end
end
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
shared_examples_for 'has a unique code per enterprise' do |voucher_type|
describe "code" do
let(:code) { "super_code" }
let(:enterprise) { create(:enterprise) }
it "is unique per enterprise" do
voucher = create(voucher_type, code:, enterprise:)
expect(voucher).to be_valid
expect_voucher_with_same_enterprise_to_be_invalid(voucher_type)
expect_voucher_with_other_enterprise_to_be_valid(voucher_type)
end
context "with deleted voucher" do
it "is unique per enterprise" do
create(voucher_type, code:, enterprise:).destroy!
expect_voucher_with_same_enterprise_to_be_invalid(voucher_type)
expect_voucher_with_other_enterprise_to_be_valid(voucher_type)
end
end
end
def expect_voucher_with_same_enterprise_to_be_invalid(voucher_type)
new_voucher = build(voucher_type, code:, enterprise: )
expect(new_voucher).not_to be_valid
expect(new_voucher.errors.full_messages).to include("Code has already been taken")
end
def expect_voucher_with_other_enterprise_to_be_valid(voucher_type)
other_voucher = build(voucher_type, code:, enterprise: create(:enterprise) )
expect(other_voucher).to be_valid
end
end

View File

@@ -154,8 +154,6 @@ RSpec.describe "Revenues By Hub Reports" do
order.update_shipping_fees!
order.update_order!
VoucherAdjustmentsService.new(order).update
order.update_totals_and_states
OrderManagement::Order::Updater.new(order).update_voucher
end
end

View File

@@ -11,6 +11,7 @@ RSpec.describe '
let(:enterprise) { create(:supplier_enterprise, name: 'Feedme', sells: 'own') }
let(:voucher_code) { 'awesomevoucher' }
let(:vine_voucher_code) { 'vine_voucher' }
let(:amount) { 25 }
let(:enterprise_user) { create(:user, enterprise_limit: 1) }
@@ -22,6 +23,7 @@ RSpec.describe '
it 'lists enterprise vouchers' do
# Given an enterprise with vouchers
create(:voucher_flat_rate, enterprise:, code: voucher_code, amount:)
create(:vine_voucher, enterprise:, code: vine_voucher_code, amount:)
# When I go to the enterprise voucher tab
visit edit_admin_enterprise_path(enterprise)
@@ -31,6 +33,8 @@ RSpec.describe '
# Then I see a list of vouchers
expect(page).to have_content voucher_code
expect(page).to have_content amount
expect(page).not_to have_content vine_voucher_code
end
describe "adding voucher" do

View File

@@ -172,6 +172,37 @@ RSpec.describe "As a consumer, I want to checkout my order" do
expect(page).to have_content("Voucher code Not found")
end
end
context "with a VINE voucher", :vcr, feature: :connected_apps do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("VINE_API_URL").and_return("https://vine-staging.openfoodnetwork.org.au/api/v1")
end
it "adds a voucher to the order" do
apply_voucher "CI3922"
expect(page).to have_content "$5.00 Voucher"
expect(order.reload.voucher_adjustments.length).to eq(1)
expect(Vouchers::Vine.find_by(code: "CI3922",
enterprise: distributor)).not_to be_nil
end
context "with an invalid voucher" do
it "show an error" do
fill_in "Enter voucher code", with: "KM1891"
click_button("Apply")
expect(page).to have_content("There was an error while adding the voucher")
expect(Vouchers::Vine.find_by(code: "KM1891", enterprise: distributor)).to be_nil
end
end
end
end
describe "removing voucher from order" do
@@ -353,7 +384,6 @@ RSpec.describe "As a consumer, I want to checkout my order" do
def add_voucher_to_order(voucher, order)
voucher.create_adjustment(voucher.code, order)
VoucherAdjustmentsService.new(order).update
order.update_totals_and_states
OrderManagement::Order::Updater.new(order).update_voucher
end
end

View File

@@ -344,6 +344,48 @@ RSpec.describe "As a consumer, I want to checkout my order" do
end
end
end
context "with a VINE voucher", feature: :connected_apps do
let!(:vine_connected_app) {
ConnectedApps::Vine.create(
enterprise: distributor, data: { api_key: "1234568", secret: "my_secret" }
)
}
let(:vine_voucher) {
create(:vine_voucher, code: 'some_vine_code', enterprise: distributor, amount: 0.01)
}
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("VINE_API_URL")
.and_return("https://vine-staging.openfoodnetwork.org.au/api/v1")
add_voucher_to_order(vine_voucher, order)
end
it "shows the applied voucher" do
visit checkout_step_path(:summary)
within ".summary-right" do
expect(page).to have_content "some_vine_code"
expect(page).to have_content "-0.01"
end
end
context "when placing the order" do
it "completes the order", :vcr do
visit checkout_step_path(:summary)
place_order
within "#line-items" do
expect(page).to have_content "Voucher: some_vine_code"
expect(page).to have_content "$-0.01"
end
expect(order.reload.state).to eq "complete"
end
end
end
end
context "with previous open orders" do
@@ -393,53 +435,50 @@ RSpec.describe "As a consumer, I want to checkout my order" do
}
let(:payment) { completed_order.payments.first }
shared_examples "order confirmation page" do |paid_state, paid_amount|
it "displays the relevant information" do
expect(page).to have_content paid_state.to_s
expect(page).to have_selector('strong', text: "Amount Paid")
expect(page).to have_selector('strong', text: with_currency(paid_amount))
end
end
context "an order with balance due" do
before { set_up_order(-10, "balance_due") }
it_behaves_like "order confirmation page", "NOT PAID", "40" do
before do
expect(page).to have_selector('h5', text: "Balance Due")
expect(page).to have_selector('h5', text: with_currency(10))
end
it "displays balance due and paid state" do
expect(page).to have_selector('h5', text: "Balance Due")
expect(page).to have_selector('h5', text: with_currency(10))
confirmation_page_expect_paid(paid_state: "NOT PAID", paid_amount: 40)
end
end
context "an order with credit owed" do
before { set_up_order(10, "credit_owed") }
it_behaves_like "order confirmation page", "PAID", "60" do
before do
expect(page).to have_selector('h5', text: "Credit Owed")
expect(page).to have_selector('h5', text: with_currency(-10))
end
it "displays Credit owned and paid state" do
expect(page).to have_selector('h5', text: "Credit Owed")
expect(page).to have_selector('h5', text: with_currency(-10))
confirmation_page_expect_paid(paid_state: "PAID", paid_amount: 60)
end
end
context "an order with no outstanding balance" do
before { set_up_order(0, "paid") }
it_behaves_like "order confirmation page", "PAID", "50" do
before do
expect(page).not_to have_selector('h5', text: "Credit Owed")
expect(page).not_to have_selector('h5', text: "Balance Due")
end
it "displays paid state" do
expect(page).not_to have_selector('h5', text: "Credit Owed")
expect(page).not_to have_selector('h5', text: "Balance Due")
confirmation_page_expect_paid(paid_state: "PAID", paid_amount: 50)
end
end
end
end
def confirmation_page_expect_paid(paid_state:, paid_amount:)
expect(page).to have_content paid_state.to_s
expect(page).to have_selector('strong', text: "Amount Paid")
expect(page).to have_selector('strong', text: with_currency(paid_amount))
end
def add_voucher_to_order(voucher, order)
voucher.create_adjustment(voucher.code, order)
VoucherAdjustmentsService.new(order).update
order.update_totals_and_states
OrderManagement::Order::Updater.new(order).update_voucher
end
def set_up_order(balance, state)