mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-03-26 05:55:15 +00:00
Compare commits
74 Commits
v5.4.7
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65c56bb5e | ||
|
|
ac716150eb | ||
|
|
2fe28d1707 | ||
|
|
dcf3ab74b8 | ||
|
|
fe0c6a4deb | ||
|
|
1e6de5e251 | ||
|
|
af2299c666 | ||
|
|
b37111f007 | ||
|
|
043a8a84f3 | ||
|
|
a2fad2cab3 | ||
|
|
5ab1ce751b | ||
|
|
1a2b5ffc3a | ||
|
|
080c4f7cb5 | ||
|
|
9d389e22d3 | ||
|
|
fc123b38b4 | ||
|
|
6c4ae1d2c1 | ||
|
|
eff1ed4a5e | ||
|
|
7ea2b126f2 | ||
|
|
bfca6248ae | ||
|
|
1ff665a33a | ||
|
|
8250029eb7 | ||
|
|
5e92fa9a17 | ||
|
|
d23ad9c8ad | ||
|
|
d80249da2d | ||
|
|
d6c69fdc2c | ||
|
|
06d6db5a07 | ||
|
|
3f81883bc7 | ||
|
|
27be0f6fd1 | ||
|
|
8880f83d09 | ||
|
|
23a4ca5933 | ||
|
|
4dc44c6156 | ||
|
|
8defb2f4c8 | ||
|
|
067349f742 | ||
|
|
032953e7d6 | ||
|
|
1878a39188 | ||
|
|
8e6f1c4e99 | ||
|
|
2004934399 | ||
|
|
827ba1990d | ||
|
|
9961578fc1 | ||
|
|
53c2ef53d5 | ||
|
|
7619062ad2 | ||
|
|
18fb1cfa74 | ||
|
|
e9ce2df5a9 | ||
|
|
c165ade4ba | ||
|
|
7e8b3694be | ||
|
|
6ee715419a | ||
|
|
7da6adfe4f | ||
|
|
05c31db46a | ||
|
|
666e872ac8 | ||
|
|
de6eb9e281 | ||
|
|
299ada1220 | ||
|
|
5757f086ec | ||
|
|
8955ffe126 | ||
|
|
b26152cf0e | ||
|
|
78db179ff3 | ||
|
|
5fc6d25a69 | ||
|
|
b877540f5f | ||
|
|
04c0adf960 | ||
|
|
1c89e9979e | ||
|
|
eba2fbcc30 | ||
|
|
940aa57daf | ||
|
|
766bedb773 | ||
|
|
6fe2357ca0 | ||
|
|
bd01b5f113 | ||
|
|
e565243ce4 | ||
|
|
0f3b299544 | ||
|
|
1332051a6e | ||
|
|
fb2dfed6bf | ||
|
|
cf53ac1990 | ||
|
|
fdd22bc097 | ||
|
|
956c4a27c2 | ||
|
|
2b32f6b909 | ||
|
|
303b91af5e | ||
|
|
ce96b58800 |
26
Gemfile.lock
26
Gemfile.lock
@@ -167,7 +167,7 @@ GEM
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
acts_as_list (1.0.4)
|
||||
activerecord (>= 4.2)
|
||||
addressable (2.8.8)
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
afm (1.0.0)
|
||||
@@ -212,7 +212,7 @@ GEM
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.22.0)
|
||||
msgpack (~> 1.2)
|
||||
bugsnag (6.28.0)
|
||||
bugsnag (6.29.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
builder (3.3.0)
|
||||
bullet (8.1.0)
|
||||
@@ -385,11 +385,11 @@ GEM
|
||||
good_migrations (0.3.1)
|
||||
activerecord (>= 3.1)
|
||||
railties (>= 3.1)
|
||||
haml (6.3.0)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
haml_lint (0.68.0)
|
||||
haml_lint (0.72.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
@@ -402,18 +402,19 @@ GEM
|
||||
highline (3.1.2)
|
||||
reline
|
||||
htmlentities (4.4.2)
|
||||
http_parser.rb (0.8.0)
|
||||
http_parser.rb (0.8.1)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-js (3.9.2)
|
||||
i18n (>= 0.6.6)
|
||||
i18n-tasks (1.0.15)
|
||||
i18n-tasks (1.1.2)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
erubi
|
||||
highline (>= 2.0.0)
|
||||
highline (>= 3.0.0)
|
||||
i18n
|
||||
parser (>= 3.2.2.1)
|
||||
prism
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.8, >= 1.8.1)
|
||||
@@ -514,7 +515,7 @@ GEM
|
||||
money (6.16.0)
|
||||
i18n (>= 0.6.4, <= 2)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.17.0)
|
||||
multi_json (1.19.1)
|
||||
multi_xml (0.6.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.9.1)
|
||||
@@ -595,7 +596,7 @@ GEM
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
private_address_check (0.5.0)
|
||||
private_address_check (0.6.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
@@ -603,9 +604,10 @@ GEM
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
puffing-billy (4.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puffing-billy (4.0.4)
|
||||
addressable (~> 2.5)
|
||||
cgi
|
||||
em-http-request (~> 1.1, >= 1.1.0)
|
||||
em-synchrony
|
||||
eventmachine (~> 1.2)
|
||||
@@ -896,7 +898,7 @@ GEM
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
sysexits (1.2.0)
|
||||
taler (0.2.0)
|
||||
taler (0.3.0)
|
||||
temple (0.10.4)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
|
||||
@@ -3,7 +3,15 @@ angular.module('admin.orderCycles').controller 'AdminOrderCycleIncomingCtrl', ($
|
||||
|
||||
$scope.view = 'incoming'
|
||||
# NB: weirdly at this next line $scope.order_cycle.id comes out undefined so we use $scope.order_cycle_id instead
|
||||
$scope.enterprise_fees = EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true)
|
||||
$scope.enterprise_fees = null
|
||||
$scope.enterprise_fees = EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true) unless EnterpriseFee.loading
|
||||
|
||||
# We want to make sure to load the filtered EnterpriseFee when any previous request is finished
|
||||
# otherwise the enterprise_fees migh get overriden by non filtered ones.
|
||||
$scope.$watch(( -> EnterpriseFee.loading), (isLoading) =>
|
||||
$scope.enterprise_fees ||= EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true) unless isLoading
|
||||
)
|
||||
|
||||
$scope.exchangeTotalVariants = (exchange) ->
|
||||
return unless $scope.enterprises? && $scope.enterprises[exchange.enterprise_id]?
|
||||
|
||||
|
||||
@@ -14,10 +14,14 @@ angular.module('admin.orderCycles').factory('EnterpriseFee', ($resource) ->
|
||||
EnterpriseFee: EnterpriseFee
|
||||
enterprise_fees: {}
|
||||
loaded: false
|
||||
loading: false
|
||||
|
||||
index: (params={}) ->
|
||||
return if @loading == true
|
||||
@loading = true
|
||||
EnterpriseFee.index params, (data) =>
|
||||
@enterprise_fees = data
|
||||
@loading = false
|
||||
@loaded = true
|
||||
|
||||
forEnterprise: (enterprise_id) ->
|
||||
|
||||
@@ -6,6 +6,7 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
|
||||
'manage_products'
|
||||
'edit_profile'
|
||||
'create_variant_overrides'
|
||||
'create_linked_variants'
|
||||
]
|
||||
|
||||
constructor: ->
|
||||
@@ -30,3 +31,4 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
|
||||
when "manage_products" then t('js.services.manage_products')
|
||||
when "edit_profile" then t('js.services.edit_profile')
|
||||
when "create_variant_overrides" then t('js.services.add_products_to_inventory')
|
||||
when "create_linked_variants" then t('js.services.create_linked_variants')
|
||||
|
||||
@@ -107,6 +107,33 @@ module Admin
|
||||
end
|
||||
end
|
||||
|
||||
# Clone a variant, retaining a link to the "source"
|
||||
def create_linked_variant
|
||||
linked_variant = Spree::Variant.find(params[:variant_id])
|
||||
product_index = params[:product_index]
|
||||
authorize! :create_linked_variant, linked_variant
|
||||
status = :ok
|
||||
|
||||
begin
|
||||
variant = linked_variant.create_linked_variant(spree_current_user)
|
||||
|
||||
flash.now[:success] = t('.success')
|
||||
variant_index = "-#{variant.id}"
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
flash.now[:error] = variant.errors.full_messages.to_sentence
|
||||
status = :unprocessable_entity
|
||||
variant_index = "-1" # Create a unique-enough index
|
||||
end
|
||||
|
||||
respond_with do |format|
|
||||
format.turbo_stream {
|
||||
locals = { linked_variant:, variant:, product_index:, variant_index:,
|
||||
producer_options:, category_options: categories, tax_category_options: }
|
||||
render :create_linked_variant, status:, locals:
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def index_url(params)
|
||||
"/admin/products?#{params.to_query}" # todo: fix routing so this can be automaticly generated
|
||||
end
|
||||
|
||||
@@ -52,7 +52,7 @@ module Spree
|
||||
# (we can't use respond_override because Spree no longer uses respond_with)
|
||||
def fire
|
||||
event = params[:e]
|
||||
return unless event && @payment.payment_source
|
||||
return unless event
|
||||
|
||||
# capture_and_complete_order will complete the order, so we want to try to redeem VINE
|
||||
# voucher first and exit if it fails
|
||||
|
||||
@@ -47,5 +47,10 @@ module Admin
|
||||
def variant_tag_enabled?(user)
|
||||
feature?(:variant_tag, user) || feature?(:variant_tag, *user.enterprises)
|
||||
end
|
||||
|
||||
def allowed_source_producers
|
||||
@allowed_source_producers ||= OpenFoodNetwork::Permissions.new(spree_current_user)
|
||||
.enterprises_granting_linked_variants
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,10 +22,10 @@ class PaymentMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def refund_available(payment, taler_order_status_url)
|
||||
def refund_available(amount, payment, taler_order_status_url)
|
||||
@order = payment.order
|
||||
@shop = @order.distributor.name
|
||||
@amount = payment.display_amount
|
||||
@amount = amount
|
||||
@taler_order_status_url = taler_order_status_url
|
||||
|
||||
I18n.with_locale valid_locale(@order.user) do
|
||||
|
||||
@@ -202,7 +202,7 @@ module Spree
|
||||
def add_product_management_abilities(user)
|
||||
# Enterprise User can only access products that they are a supplier for
|
||||
can [:create], Spree::Product
|
||||
# An enterperprise user can change a product if they are supplier of at least
|
||||
# An enterprise user can change a product if they are supplier of at least
|
||||
# one of the product's associated variants
|
||||
can [:admin, :read, :index, :update,
|
||||
:seo, :group_buy_options,
|
||||
@@ -214,7 +214,16 @@ module Spree
|
||||
)
|
||||
end
|
||||
|
||||
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone], :products_v3
|
||||
# An enterprise user can clone if they have been granted permission to the source variant.
|
||||
# Technically I'd call this permission clone_linked_variant, but it would be less confusing to
|
||||
# use the same name as everywhere else.
|
||||
can [:create_linked_variant], Spree::Variant do |variant|
|
||||
OpenFoodNetwork::Permissions.new(user).
|
||||
enterprises_granting_linked_variants.include? variant.supplier
|
||||
end
|
||||
|
||||
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone,
|
||||
:create_linked_variant], :products_v3
|
||||
|
||||
can [:create], Spree::Variant
|
||||
can [:admin, :index, :read, :edit,
|
||||
|
||||
@@ -63,35 +63,6 @@ module Spree
|
||||
"XXXX-XXXX-XXXX-#{last_digits}"
|
||||
end
|
||||
|
||||
def actions
|
||||
%w{capture_and_complete_order void credit resend_authorization_email}
|
||||
end
|
||||
|
||||
def can_resend_authorization_email?(payment)
|
||||
payment.requires_authorization?
|
||||
end
|
||||
|
||||
# Indicates whether its possible to capture the payment
|
||||
def can_capture_and_complete_order?(payment)
|
||||
return false if payment.requires_authorization?
|
||||
|
||||
payment.pending? || payment.checkout?
|
||||
end
|
||||
|
||||
# Indicates whether its possible to void the payment.
|
||||
def can_void?(payment)
|
||||
!payment.void?
|
||||
end
|
||||
|
||||
# Indicates whether its possible to credit the payment. Note that most gateways require that the
|
||||
# payment be settled first which generally happens within 12-24 hours of the transaction.
|
||||
def can_credit?(payment)
|
||||
return false unless payment.completed?
|
||||
return false unless payment.order.payment_state == 'credit_owed'
|
||||
|
||||
payment.credit_allowed.positive?
|
||||
end
|
||||
|
||||
# Allows us to use a gateway_payment_profile_id to store Stripe Tokens
|
||||
def has_payment_profile?
|
||||
gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
|
||||
|
||||
@@ -13,6 +13,35 @@ module Spree
|
||||
preference :server, :string, default: 'live'
|
||||
preference :test_mode, :boolean, default: false
|
||||
|
||||
def actions
|
||||
%w{capture_and_complete_order void credit resend_authorization_email}
|
||||
end
|
||||
|
||||
# Indicates whether its possible to capture the payment
|
||||
def can_capture_and_complete_order?(payment)
|
||||
return false if payment.requires_authorization?
|
||||
|
||||
payment.pending? || payment.checkout?
|
||||
end
|
||||
|
||||
# Indicates whether its possible to void the payment.
|
||||
def can_void?(payment)
|
||||
!payment.void?
|
||||
end
|
||||
|
||||
# Indicates whether its possible to credit the payment. Note that most gateways require that the
|
||||
# payment be settled first which generally happens within 12-24 hours of the transaction.
|
||||
def can_credit?(payment)
|
||||
return false unless payment.completed?
|
||||
return false unless payment.order.payment_state == 'credit_owed'
|
||||
|
||||
payment.credit_allowed.positive?
|
||||
end
|
||||
|
||||
def can_resend_authorization_email?(payment)
|
||||
payment.requires_authorization?
|
||||
end
|
||||
|
||||
def payment_source_class
|
||||
CreditCard
|
||||
end
|
||||
|
||||
@@ -152,11 +152,10 @@ module Spree
|
||||
end
|
||||
|
||||
def actions
|
||||
return [] unless payment_source.respond_to?(:actions)
|
||||
return [] unless payment_method.respond_to?(:actions)
|
||||
|
||||
payment_source.actions.select do |action|
|
||||
!payment_source.respond_to?("can_#{action}?") ||
|
||||
payment_source.__send__("can_#{action}?", self)
|
||||
payment_method.actions.select do |action|
|
||||
payment_method.__send__("can_#{action}?", self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -166,11 +165,6 @@ module Spree
|
||||
PaymentMailer.authorize_payment(self).deliver_later
|
||||
end
|
||||
|
||||
def payment_source
|
||||
res = source.is_a?(Payment) ? source.source : source
|
||||
res || payment_method
|
||||
end
|
||||
|
||||
def ensure_correct_adjustment
|
||||
revoke_adjustment_eligibility if ['failed', 'invalid', 'void'].include?(state)
|
||||
return if adjustment.try(:finalized?)
|
||||
|
||||
@@ -18,15 +18,27 @@ module Spree
|
||||
# - backend_url: https://backend.demo.taler.net/instances/sandbox
|
||||
# - api_key: sandbox
|
||||
class Taler < PaymentMethod
|
||||
# Demo backend instances will use the KUDOS currency.
|
||||
DEMO_PREFIX = "https://backend.demo.taler.net/instances"
|
||||
|
||||
preference :backend_url, :string
|
||||
preference :api_key, :password
|
||||
|
||||
def actions
|
||||
%w{void}
|
||||
%w[credit void]
|
||||
end
|
||||
|
||||
def can_void?(payment)
|
||||
payment.state == "completed"
|
||||
# The source can be another payment. Then this is an offset payment
|
||||
# like a credit record. We can't void a refund.
|
||||
payment.source == self && payment.state == "completed"
|
||||
end
|
||||
|
||||
def can_credit?(payment)
|
||||
return false unless payment.completed?
|
||||
return false unless payment.order.payment_state == 'credit_owed'
|
||||
|
||||
payment.credit_allowed.positive?
|
||||
end
|
||||
|
||||
# Name of the view to display during checkout
|
||||
@@ -68,6 +80,23 @@ module Spree
|
||||
ActiveMerchant::Billing::Response.new(success, message)
|
||||
end
|
||||
|
||||
def credit(money, response_code, gateway_options)
|
||||
amount = money / 100 # called with cents
|
||||
payment = gateway_options[:payment]
|
||||
taler_order = taler_order(id: response_code)
|
||||
status = taler_order.fetch("order_status")
|
||||
|
||||
raise "Unsupported action" if status != "paid"
|
||||
|
||||
taler_amount = "KUDOS:#{amount}"
|
||||
taler_order.refund(refund: taler_amount, reason: "credit")
|
||||
|
||||
spree_money = Spree::Money.new(amount, currency: payment.currency).to_s
|
||||
PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later
|
||||
|
||||
ActiveMerchant::Billing::Response.new(true, "Refund initiated")
|
||||
end
|
||||
|
||||
def void(response_code, gateway_options)
|
||||
payment = gateway_options[:payment]
|
||||
taler_order = taler_order(id: response_code)
|
||||
@@ -82,7 +111,8 @@ module Spree
|
||||
amount = taler_order.fetch("contract_terms")["amount"]
|
||||
taler_order.refund(refund: amount, reason: "void")
|
||||
|
||||
PaymentMailer.refund_available(payment, taler_order.status_url).deliver_later
|
||||
spree_money = payment.money.to_s
|
||||
PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later
|
||||
|
||||
ActiveMerchant::Billing::Response.new(true, "Refund initiated")
|
||||
end
|
||||
@@ -96,7 +126,7 @@ module Spree
|
||||
def create_taler_order(payment)
|
||||
# We are ignoring currency for now so that we can test with the
|
||||
# current demo backend only working with the KUDOS currency.
|
||||
taler_amount = "KUDOS:#{payment.amount}"
|
||||
taler_amount = "#{currency(payment)}:#{payment.amount}"
|
||||
urls = Rails.application.routes.url_helpers
|
||||
fulfillment_url = urls.payment_gateways_confirm_taler_url(payment_id: payment.id)
|
||||
taler_order.create(
|
||||
@@ -113,6 +143,12 @@ module Spree
|
||||
id:,
|
||||
)
|
||||
end
|
||||
|
||||
def currency(payment)
|
||||
return "KUDOS" if preferred_backend_url.starts_with?(DEMO_PREFIX)
|
||||
|
||||
payment.order.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,6 +40,7 @@ module Spree
|
||||
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', optional: false
|
||||
belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true, optional: false
|
||||
belongs_to :supplier, class_name: 'Enterprise', optional: false, touch: true
|
||||
belongs_to :hub, class_name: 'Enterprise', optional: true
|
||||
|
||||
delegate :name, :name=, :description, :description=, :meta_keywords, to: :product
|
||||
|
||||
@@ -72,6 +73,15 @@ module Spree
|
||||
has_many :semantic_links, as: :subject, dependent: :delete_all
|
||||
has_many :supplier_properties, through: :supplier, source: :properties
|
||||
|
||||
# Linked variants: I may have one or many sources.
|
||||
has_many :variant_links_as_target, class_name: 'VariantLink', foreign_key: :target_variant_id,
|
||||
dependent: :delete_all, inverse_of: :target_variant
|
||||
has_many :source_variants, through: :variant_links_as_target, source: :source_variant
|
||||
# I may also have one more many targets.
|
||||
has_many :variant_links_as_source, class_name: 'VariantLink', foreign_key: :source_variant_id,
|
||||
dependent: :delete_all, inverse_of: :source_variant
|
||||
has_many :target_variants, through: :variant_links_as_source, source: :target_variant
|
||||
|
||||
localize_number :price, :weight
|
||||
|
||||
validates_lengths_from_database
|
||||
@@ -263,6 +273,24 @@ module Spree
|
||||
@on_hand_desired = ActiveModel::Type::Integer.new.cast(val)
|
||||
end
|
||||
|
||||
# Clone this variant, retaining a 'source' link to it
|
||||
def create_linked_variant(user)
|
||||
# Hub owner is my enterprise which has permission to create variant sourced from that supplier
|
||||
hub_id = EnterpriseRelationship.permitted_by(supplier).permitting(user.enterprises)
|
||||
.with_permission(:create_linked_variants)
|
||||
.pick(:child_id)
|
||||
|
||||
dup.tap do |variant|
|
||||
variant.price = price
|
||||
variant.source_variants = [self]
|
||||
variant.stock_items << Spree::StockItem.new(variant:)
|
||||
variant.hub_id = hub_id
|
||||
variant.on_demand = on_demand
|
||||
variant.on_hand = on_hand
|
||||
variant.save!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_currency
|
||||
|
||||
6
app/models/variant_link.rb
Normal file
6
app/models/variant_link.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VariantLink < ApplicationRecord
|
||||
belongs_to :source_variant, class_name: 'Spree::Variant'
|
||||
belongs_to :target_variant, class_name: 'Spree::Variant'
|
||||
end
|
||||
@@ -14,7 +14,6 @@ module Checkout
|
||||
apply_strong_parameters
|
||||
set_pickup_address
|
||||
set_address_details
|
||||
set_payment_amount
|
||||
set_existing_card
|
||||
|
||||
@order_params
|
||||
@@ -58,12 +57,6 @@ module Checkout
|
||||
end
|
||||
end
|
||||
|
||||
def set_payment_amount
|
||||
return unless @order_params[:payments_attributes]
|
||||
|
||||
@order_params[:payments_attributes].first[:amount] = order.outstanding_balance.amount
|
||||
end
|
||||
|
||||
def set_existing_card
|
||||
return unless existing_card_selected?
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
-# Filter out variant a user has not permission to update, but keep variant with no supplier
|
||||
- next if variant.supplier.present? && !allowed_producers.include?(variant.supplier)
|
||||
|
||||
= form.fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |variant_form|
|
||||
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper", 'data-new-record': variant.new_record? ? "true" : false }
|
||||
= render partial: 'variant_row', locals: { variant:, f: variant_form, category_options:, tax_category_options:, producer_options: }
|
||||
= render partial: 'variant_row', locals: { variant:, f: variant_form, product_index:, category_options:, tax_category_options:, producer_options: }
|
||||
|
||||
= form.fields_for("products][#{product_index}][variants_attributes][NEW_RECORD", prepare_new_variant(product, producer_options)) do |new_variant_form|
|
||||
%template{ 'data-nested-form-target': "template" }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
-# locals: (variant:, f:, category_options:, tax_category_options:, producer_options:)
|
||||
-# haml-lint:disable ViewLength (This file is big, but doesn't make sense to split up at this point)
|
||||
-# locals: (variant:, f:, product_index: nil, category_options:, tax_category_options:, producer_options:)
|
||||
- method_on_demand, method_on_hand = variant.new_record? ? [:on_demand_desired, :on_hand_desired ]: [:on_demand, :on_hand]
|
||||
%td.col-image
|
||||
-# empty
|
||||
- variant.source_variants.each do |source_variant|
|
||||
= content_tag(:span, "🔗", title: t('admin.products_page.variant_row.sourced_from', source_name: source_variant.display_name, source_id: source_variant.id, hub_name: variant.hub&.name))
|
||||
%td.col-name.field.naked_inputs
|
||||
= f.hidden_field :id
|
||||
= f.text_field :display_name, 'aria-label': t('admin.products_page.columns.name'), placeholder: variant.product.name
|
||||
@@ -88,6 +91,10 @@
|
||||
= render(VerticalEllipsisMenuComponent.new) do
|
||||
- if variant.persisted?
|
||||
= link_to t('admin.products_page.actions.edit'), edit_admin_product_variant_path(variant.product, variant)
|
||||
|
||||
- if allowed_source_producers.include?(variant.supplier)
|
||||
= link_to t('admin.products_page.actions.create_linked_variant'), admin_create_linked_variant_path(variant_id: variant.id, product_index:), 'data-turbo-method': :post
|
||||
|
||||
- if variant.product.variants.size > 1
|
||||
%a{ "data-controller": "modal-link", "data-action": "click->modal-link#setModalDataSetOnConfirm click->modal-link#open",
|
||||
"data-modal-link-target-value": "variant-delete-modal", "class": "delete",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-# locals: (variant:, linked_variant:, product_index:, variant_index:, producer_options:, category_options:, tax_category_options:)
|
||||
-# Pre-render the form, because you can't do it inside turbo stream block
|
||||
- variant_row = nil
|
||||
- fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |f|
|
||||
- variant_row = render(partial: 'variant_row', formats: :html,
|
||||
locals: { f:,
|
||||
variant:,
|
||||
producer_options:,
|
||||
category_options:,
|
||||
tax_category_options:})
|
||||
= turbo_stream.after dom_id(linked_variant) do
|
||||
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper slide-in",'data-variant-bulk-form-outlet': "#products-form"}
|
||||
= variant_row
|
||||
|
||||
= turbo_stream.append "flashes" do
|
||||
= render(partial: 'admin/shared/flashes', locals: { flashes: flash })
|
||||
@@ -6,7 +6,7 @@
|
||||
%p= t ".description"
|
||||
|
||||
%fieldset.no-border-top.no-border-bottom
|
||||
.row
|
||||
.row.field
|
||||
= f.label :email, t(:email)
|
||||
= f.email_field :email, placeholder: t('.eg_email_address'), data: { controller: "select-user" }, inputmode: "email", autocomplete: "off"
|
||||
= f.error_message_on :email
|
||||
|
||||
@@ -44,12 +44,17 @@ export default class BulkFormController extends Controller {
|
||||
}
|
||||
|
||||
// Register any new elements (may be called by another controller after dynamically adding fields)
|
||||
registerElements() {
|
||||
const registeredElements = Object.values(this.recordElements).flat();
|
||||
// Select only elements that haven't been registered yet
|
||||
const newElements = Array.from(this.form.elements).filter(
|
||||
(n) => !registeredElements.includes(n),
|
||||
);
|
||||
// May be called with array of elements to register, otherwise finds all un-registered elements.
|
||||
registerElements(eventOrElements = null) {
|
||||
let newElements;
|
||||
|
||||
if (Array.isArray(eventOrElements)) {
|
||||
newElements = eventOrElements;
|
||||
} else {
|
||||
const registeredElements = Object.values(this.recordElements).flat();
|
||||
// Select only elements that haven't been registered yet
|
||||
newElements = Array.from(this.form.elements).filter((n) => !registeredElements.includes(n));
|
||||
}
|
||||
|
||||
this.#registerElements(newElements);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import OptionValueNamer from "js/services/option_value_namer";
|
||||
// Dynamically update related variant fields
|
||||
//
|
||||
export default class VariantController extends Controller {
|
||||
static outlets = ["bulk-form"];
|
||||
|
||||
connect() {
|
||||
// idea: create a helper that includes a nice getter/setter for Rails model attr values, just pass it the attribute name.
|
||||
// It could automatically find (and cache a ref to) each dom element and get/set the values.
|
||||
@@ -40,6 +42,12 @@ export default class VariantController extends Controller {
|
||||
// on display_as changed; update unit_to_display
|
||||
// TODO: optimise to avoid unnecessary OptionValueNamer calc
|
||||
this.displayAs.addEventListener("input", this.#updateUnitDisplay.bind(this), { passive: true });
|
||||
|
||||
// Register with bulk products form to listen for changes. Used when dynamically appending variants.
|
||||
if (this.hasBulkFormOutlet) {
|
||||
const formElements = this.element.querySelectorAll("input, select, textarea, button");
|
||||
this.bulkFormOutlet.registerElements(formElements);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
.button, button {
|
||||
@include border-radius(0.5em);
|
||||
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
|
||||
&.x-small {
|
||||
@@ -65,7 +66,6 @@
|
||||
}
|
||||
|
||||
.button.primary, button.primary {
|
||||
font-family: $body-font;
|
||||
background: $orange-450;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -717,8 +717,11 @@ en:
|
||||
delete: Delete
|
||||
remove: Remove
|
||||
preview: Preview
|
||||
create_linked_variant: Create linked variant
|
||||
image:
|
||||
edit: Edit
|
||||
variant_row:
|
||||
sourced_from: "Sourced from: %{source_name} (%{source_id}); Hub: %{hub_name}"
|
||||
product_preview:
|
||||
product_preview: Product preview
|
||||
shop_tab: Shop
|
||||
@@ -1098,6 +1101,8 @@ en:
|
||||
clone:
|
||||
success: Successfully cloned the product
|
||||
error: Unable to clone the product
|
||||
create_linked_variant:
|
||||
success: "Successfully created linked variant"
|
||||
tag_rules:
|
||||
rules_per_tag:
|
||||
one: "%{tag} has 1 rule"
|
||||
@@ -3901,6 +3906,7 @@ en:
|
||||
manage_products: "manage products"
|
||||
edit_profile: "edit profile"
|
||||
add_products_to_inventory: "add products to inventory"
|
||||
create_linked_variants: "create linked variants [BETA]"
|
||||
resources:
|
||||
could_not_delete_customer: 'Could not delete customer'
|
||||
product_import:
|
||||
|
||||
@@ -82,6 +82,7 @@ Openfoodnetwork::Application.routes.draw do
|
||||
delete 'products_v3/:id', to: 'products_v3#destroy', as: 'product_destroy'
|
||||
delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant'
|
||||
post 'clone/:id', to: 'products_v3#clone', as: 'clone_product'
|
||||
post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant'
|
||||
resources :product_preview, only: [:show]
|
||||
|
||||
resources :variant_overrides do
|
||||
|
||||
19
db/migrate/20260211055758_create_variant_links.rb
Normal file
19
db/migrate/20260211055758_create_variant_links.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateVariantLinks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
# Create a join table to join two variants. One is the source of the other.
|
||||
# Primary key index ensures uniqueness and assists querying. target_variant_id is the most
|
||||
# likely subject and so is first in the index.
|
||||
# An additional index for source_variant is also included because it may be helpful
|
||||
# (https://stackoverflow.com/questions/10790518/best-sql-indexes-for-join-table).
|
||||
create_table :variant_links, primary_key: [:target_variant_id, :source_variant_id] do |t|
|
||||
t.integer :source_variant_id, null: false, index: true
|
||||
t.integer :target_variant_id, null: false
|
||||
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
add_foreign_key :variant_links, :spree_variants, column: :source_variant_id
|
||||
add_foreign_key :variant_links, :spree_variants, column: :target_variant_id
|
||||
end
|
||||
end
|
||||
7
db/migrate/20260225022934_add_hub_to_spree_variants.rb
Normal file
7
db/migrate/20260225022934_add_hub_to_spree_variants.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddHubToSpreeVariants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :spree_variants, :hub, foreign_key: { to_table: :enterprises }
|
||||
end
|
||||
end
|
||||
12
db/schema.rb
12
db/schema.rb
@@ -1009,6 +1009,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
|
||||
t.bigint "supplier_id"
|
||||
t.float "variant_unit_scale"
|
||||
t.string "variant_unit_name", limit: 255
|
||||
t.bigint "hub_id"
|
||||
t.index ["hub_id"], name: "index_spree_variants_on_hub_id"
|
||||
t.index ["primary_taxon_id"], name: "index_spree_variants_on_primary_taxon_id"
|
||||
t.index ["product_id"], name: "index_variants_on_product_id"
|
||||
t.index ["shipping_category_id"], name: "index_spree_variants_on_shipping_category_id"
|
||||
@@ -1113,6 +1115,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
end
|
||||
|
||||
create_table "variant_links", primary_key: ["target_variant_id", "source_variant_id"], force: :cascade do |t|
|
||||
t.integer "source_variant_id", null: false
|
||||
t.integer "target_variant_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["source_variant_id"], name: "index_variant_links_on_source_variant_id"
|
||||
end
|
||||
|
||||
create_table "variant_overrides", id: :serial, force: :cascade do |t|
|
||||
t.integer "variant_id", null: false
|
||||
t.integer "hub_id", null: false
|
||||
@@ -1262,6 +1271,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
|
||||
add_foreign_key "spree_tax_rates", "spree_zones", column: "zone_id", name: "spree_tax_rates_zone_id_fk"
|
||||
add_foreign_key "spree_users", "spree_addresses", column: "bill_address_id", name: "spree_users_bill_address_id_fk"
|
||||
add_foreign_key "spree_users", "spree_addresses", column: "ship_address_id", name: "spree_users_ship_address_id_fk"
|
||||
add_foreign_key "spree_variants", "enterprises", column: "hub_id"
|
||||
add_foreign_key "spree_variants", "enterprises", column: "supplier_id"
|
||||
add_foreign_key "spree_variants", "spree_products", column: "product_id", name: "spree_variants_product_id_fk"
|
||||
add_foreign_key "spree_variants", "spree_shipping_categories", column: "shipping_category_id"
|
||||
@@ -1278,6 +1288,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
|
||||
add_foreign_key "subscriptions", "spree_payment_methods", column: "payment_method_id", name: "subscriptions_payment_method_id_fk"
|
||||
add_foreign_key "subscriptions", "spree_shipping_methods", column: "shipping_method_id", name: "subscriptions_shipping_method_id_fk"
|
||||
add_foreign_key "tag_rules", "enterprises"
|
||||
add_foreign_key "variant_links", "spree_variants", column: "source_variant_id"
|
||||
add_foreign_key "variant_links", "spree_variants", column: "target_variant_id"
|
||||
add_foreign_key "variant_overrides", "enterprises", column: "hub_id", name: "variant_overrides_hub_id_fk"
|
||||
add_foreign_key "variant_overrides", "spree_variants", column: "variant_id", name: "variant_overrides_variant_id_fk"
|
||||
add_foreign_key "vouchers", "enterprises"
|
||||
|
||||
@@ -30,17 +30,19 @@ module OrderManagement
|
||||
other_permitted_producer_ids = EnterpriseRelationship.joins(:parent)
|
||||
.permitting(distributor.id).with_permission(:add_to_order_cycle)
|
||||
.merge(Enterprise.is_primary_producer)
|
||||
.pluck(:parent_id)
|
||||
.select(:parent_id)
|
||||
|
||||
# Append to the potentially gigantic array instead of using union, which creates a new array
|
||||
# The db IN statement won't care if there's a duplicate.
|
||||
other_permitted_producer_ids << distributor.id
|
||||
Enterprise.where(id: distributor.id)
|
||||
.select(:id)
|
||||
.or(Enterprise.where(id: other_permitted_producer_ids))
|
||||
end
|
||||
|
||||
def self.outgoing_exchange_variant_ids(distributor)
|
||||
ExchangeVariant.select("DISTINCT exchange_variants.variant_id").joins(:exchange)
|
||||
# DISTINCT is not required here since this subquery is used within an IN clause,
|
||||
# where duplicate values do not impact the result.
|
||||
ExchangeVariant.joins(:exchange)
|
||||
.where(exchanges: { incoming: false, receiver_id: distributor.id })
|
||||
.pluck(:variant_id)
|
||||
.select(:variant_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -86,6 +86,10 @@ module OpenFoodNetwork
|
||||
managed_and_related_enterprises_granting :manage_products
|
||||
end
|
||||
|
||||
def enterprises_granting_linked_variants
|
||||
related_enterprises_granting :create_linked_variants
|
||||
end
|
||||
|
||||
def manages_one_enterprise?
|
||||
@user.enterprises.length == 1
|
||||
end
|
||||
|
||||
@@ -1,54 +1,5 @@
|
||||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"order":{"amount":"KUDOS:10.0","summary":"Open Food Network order","fulfillment_url":"http://test.host/payment_gateways/taler/61"},"create_token":false}'
|
||||
headers:
|
||||
Authorization:
|
||||
- "<HIDDEN-AUTHORIZATION-HEADER>"
|
||||
Accept:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Taler Ruby
|
||||
Content-Type:
|
||||
- application/json
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx/1.26.3
|
||||
Date:
|
||||
- Thu, 22 Jan 2026 04:43:32 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '42'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Access-Control-Allow-Origin:
|
||||
- "*"
|
||||
Access-Control-Expose-Headers:
|
||||
- "*"
|
||||
Cache-Control:
|
||||
- no-store
|
||||
Via:
|
||||
- 1.1 Caddy
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains; preload
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: |-
|
||||
{
|
||||
"order_id": "2026.022-0284X4GE8WKMJ"
|
||||
}
|
||||
recorded_at: Thu, 22 Jan 2026 04:43:33 GMT
|
||||
- request:
|
||||
method: get
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.022-0284X4GE8WKMJ
|
||||
@@ -103,4 +54,108 @@ http_interactions:
|
||||
}
|
||||
}
|
||||
recorded_at: Thu, 22 Jan 2026 04:43:34 GMT
|
||||
recorded_with: VCR 6.3.1
|
||||
- request:
|
||||
method: post
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/token
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"scope":"write"}'
|
||||
headers:
|
||||
Authorization:
|
||||
- "<HIDDEN-AUTHORIZATION-HEADER>"
|
||||
Accept:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Taler Ruby
|
||||
Content-Type:
|
||||
- application/json
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx/1.26.3
|
||||
Date:
|
||||
- Fri, 20 Mar 2026 04:31:47 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '258'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Access-Control-Allow-Origin:
|
||||
- "*"
|
||||
Access-Control-Expose-Headers:
|
||||
- "*"
|
||||
Cache-Control:
|
||||
- no-store
|
||||
Via:
|
||||
- 1.1 Caddy
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains; preload
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"access_token": "secret-token:J38S28NEJ6T07H1WP60F3T6PPWQYNKMR251TEZEX3CXP3SH54210",
|
||||
"token": "secret-token:J38S28NEJ6T07H1WP60F3T6PPWQYNKMR251TEZEX3CXP3SH54210",
|
||||
"scope": "write",
|
||||
"refreshable": false,
|
||||
"expiration": {
|
||||
"t_s": 1774067507
|
||||
}
|
||||
}
|
||||
recorded_at: Fri, 20 Mar 2026 04:31:48 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"order":{"amount":"KUDOS:10.0","summary":"Open Food Network order","fulfillment_url":"http://test.host/payment_gateways/taler/198"},"create_token":false}'
|
||||
headers:
|
||||
Authorization:
|
||||
- "<HIDDEN-AUTHORIZATION-HEADER>"
|
||||
Accept:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Taler Ruby
|
||||
Content-Type:
|
||||
- application/json
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx/1.26.3
|
||||
Date:
|
||||
- Fri, 20 Mar 2026 04:31:48 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '42'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Access-Control-Allow-Origin:
|
||||
- "*"
|
||||
Access-Control-Expose-Headers:
|
||||
- "*"
|
||||
Cache-Control:
|
||||
- no-store
|
||||
Via:
|
||||
- 1.1 Caddy
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains; preload
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: |-
|
||||
{
|
||||
"order_id": "2026.079-0189PJNWMX6JA"
|
||||
}
|
||||
recorded_at: Fri, 20 Mar 2026 04:31:48 GMT
|
||||
recorded_with: VCR 6.4.0
|
||||
|
||||
@@ -47,6 +47,61 @@ http_interactions:
|
||||
"detail": "taler-order-id:12345"
|
||||
}
|
||||
recorded_at: Sat, 24 Jan 2026 00:51:31 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/token
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"scope":"write"}'
|
||||
headers:
|
||||
Authorization:
|
||||
- "<HIDDEN-AUTHORIZATION-HEADER>"
|
||||
Accept:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Taler Ruby
|
||||
Content-Type:
|
||||
- application/json
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Server:
|
||||
- nginx/1.26.3
|
||||
Date:
|
||||
- Fri, 20 Mar 2026 04:52:23 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '258'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Access-Control-Allow-Origin:
|
||||
- "*"
|
||||
Access-Control-Expose-Headers:
|
||||
- "*"
|
||||
Cache-Control:
|
||||
- no-store
|
||||
Via:
|
||||
- 1.1 Caddy
|
||||
Strict-Transport-Security:
|
||||
- max-age=63072000; includeSubDomains; preload
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"access_token": "secret-token:176N5XTVVSR98FE6V4QR2Y35HKS61ZW5CK1BC7YEZYHX9M41N5GG",
|
||||
"token": "secret-token:176N5XTVVSR98FE6V4QR2Y35HKS61ZW5CK1BC7YEZYHX9M41N5GG",
|
||||
"scope": "write",
|
||||
"refreshable": false,
|
||||
"expiration": {
|
||||
"t_s": 1774068743
|
||||
}
|
||||
}
|
||||
recorded_at: Fri, 20 Mar 2026 04:52:23 GMT
|
||||
- request:
|
||||
method: get
|
||||
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.020-03R3ETNZZ0DVA
|
||||
@@ -70,7 +125,7 @@ http_interactions:
|
||||
Server:
|
||||
- nginx/1.26.3
|
||||
Date:
|
||||
- Sat, 24 Jan 2026 00:55:33 GMT
|
||||
- Fri, 20 Mar 2026 04:52:24 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
@@ -205,5 +260,5 @@ http_interactions:
|
||||
"refund_details": [],
|
||||
"order_status_url": "https://backend.demo.taler.net/instances/sandbox/orders/2026.020-03R3ETNZZ0DVA"
|
||||
}
|
||||
recorded_at: Sat, 24 Jan 2026 00:55:32 GMT
|
||||
recorded_with: VCR 6.3.1
|
||||
recorded_at: Fri, 20 Mar 2026 04:52:24 GMT
|
||||
recorded_with: VCR 6.4.0
|
||||
|
||||
@@ -16,3 +16,4 @@ describe "enterprise relationships", ->
|
||||
expect(EnterpriseRelationships.permission_presentation("manage_products")).toEqual "manage products"
|
||||
expect(EnterpriseRelationships.permission_presentation("edit_profile")).toEqual "edit profile"
|
||||
expect(EnterpriseRelationships.permission_presentation("create_variant_overrides")).toEqual "add products to inventory"
|
||||
expect(EnterpriseRelationships.permission_presentation("create_linked_variants")).toEqual "create linked variants [BETA]"
|
||||
|
||||
@@ -56,7 +56,7 @@ RSpec.describe PaymentMailer do
|
||||
payment = build(:payment)
|
||||
payment.order.distributor = build(:enterprise, name: "Carrot Castle")
|
||||
link = "https://taler.example.com/order/1"
|
||||
mail = PaymentMailer.refund_available(payment, link)
|
||||
mail = PaymentMailer.refund_available(payment.money.to_s, payment, link)
|
||||
|
||||
expect(mail.subject).to eq "Refund from Carrot Castle"
|
||||
expect(mail.body).to include "Your payment of $45.75 to Carrot Castle is being refunded."
|
||||
|
||||
@@ -364,6 +364,19 @@ RSpec.describe Spree::Ability do
|
||||
for: p2.variants.first)
|
||||
end
|
||||
|
||||
describe "create_linked_variant" do
|
||||
it "should not be able to create linked variant without permission" do
|
||||
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
|
||||
end
|
||||
|
||||
it "should be able to create linked variant when granted permission" do
|
||||
create(:enterprise_relationship, parent: s_related, child: s1,
|
||||
permissions_list: [:create_linked_variants])
|
||||
|
||||
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
|
||||
end
|
||||
end
|
||||
|
||||
it "should not be able to access admin actions on orders" do
|
||||
is_expected.not_to have_ability([:admin], for: Spree::Order)
|
||||
end
|
||||
@@ -729,6 +742,19 @@ RSpec.describe Spree::Ability do
|
||||
it "can request permitted enterprise fees for an order cycle" do
|
||||
is_expected.to have_ability([:for_order_cycle], for: EnterpriseFee)
|
||||
end
|
||||
|
||||
describe "create_linked_variant" do
|
||||
it "should not be able to create linked variant without permission" do
|
||||
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
|
||||
end
|
||||
|
||||
it "should be able to create linked variant when granted permission" do
|
||||
create(:enterprise_relationship, parent: s_related, child: d1,
|
||||
permissions_list: [:create_linked_variants])
|
||||
|
||||
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'Order Cycle co-ordinator, distributor enterprise manager' do
|
||||
@@ -804,6 +830,19 @@ RSpec.describe Spree::Ability do
|
||||
it "has the ability to manage vouchers" do
|
||||
is_expected.to have_ability([:admin, :create], for: Voucher)
|
||||
end
|
||||
|
||||
describe "create_linked_variant for own enterprise" do
|
||||
it "should not be able to create own sourced variant without permission" do
|
||||
is_expected.not_to have_ability([:create_linked_variant], for: p1.variants.first)
|
||||
end
|
||||
|
||||
it "should be able to create own sourced variant when granted self permission" do
|
||||
create(:enterprise_relationship, parent: s1, child: s1,
|
||||
permissions_list: [:create_linked_variants])
|
||||
|
||||
is_expected.to have_ability([:create_linked_variant], for: p1.variants.first)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'enterprise owner' do
|
||||
|
||||
@@ -21,53 +21,6 @@ RSpec.describe Spree::CreditCard do
|
||||
|
||||
let(:credit_card) { described_class.new }
|
||||
|
||||
context "#can_capture?" do
|
||||
it "should be true if payment is pending" do
|
||||
payment = build_stubbed(:payment, created_at: Time.zone.now)
|
||||
allow(payment).to receive(:pending?) { true }
|
||||
expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy
|
||||
end
|
||||
|
||||
it "should be true if payment is checkout" do
|
||||
payment = build_stubbed(:payment, created_at: Time.zone.now)
|
||||
allow(payment).to receive_messages pending?: false,
|
||||
checkout?: true
|
||||
expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context "#can_void?" do
|
||||
it "should be true if payment is not void" do
|
||||
payment = build_stubbed(:payment)
|
||||
allow(payment).to receive(:void?) { false }
|
||||
expect(credit_card.can_void?(payment)).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context "#can_credit?" do
|
||||
it "should be false if payment is not completed" do
|
||||
payment = build_stubbed(:payment)
|
||||
allow(payment).to receive(:completed?) { false }
|
||||
expect(credit_card.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
|
||||
it "should be false when order payment_state is not 'credit_owed'" do
|
||||
payment = build_stubbed(:payment,
|
||||
order: create(:order, payment_state: 'paid'))
|
||||
allow(payment).to receive(:completed?) { true }
|
||||
expect(credit_card.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
|
||||
it "should be false when credit_allowed is zero" do
|
||||
payment = build_stubbed(:payment,
|
||||
order: create(:order, payment_state: 'credit_owed'))
|
||||
allow(payment).to receive_messages completed?: true,
|
||||
credit_allowed: 0
|
||||
|
||||
expect(credit_card.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
end
|
||||
|
||||
context "#valid?" do
|
||||
it "should validate presence of number" do
|
||||
credit_card.attributes = valid_credit_card_attributes.except(:number)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Spree::Gateway do
|
||||
subject(:gateway) { test_gateway.new }
|
||||
let(:test_gateway) do
|
||||
Class.new(Spree::Gateway) do
|
||||
def provider_class
|
||||
@@ -15,13 +16,58 @@ RSpec.describe Spree::Gateway do
|
||||
|
||||
it "passes through all arguments on a method_missing call" do
|
||||
expect(Rails.env).to receive(:local?).and_return(false)
|
||||
gateway = test_gateway.new
|
||||
expect(gateway.provider).to receive(:imaginary_method).with('foo')
|
||||
gateway.imaginary_method('foo')
|
||||
end
|
||||
|
||||
it "raises an error in test env" do
|
||||
gateway = test_gateway.new
|
||||
expect { gateway.imaginary_method('foo') }.to raise_error StandardError
|
||||
end
|
||||
|
||||
describe "#can_capture?" do
|
||||
it "should be true if payment is pending" do
|
||||
payment = build_stubbed(:payment, created_at: Time.zone.now)
|
||||
allow(payment).to receive(:pending?) { true }
|
||||
expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy
|
||||
end
|
||||
|
||||
it "should be true if payment is checkout" do
|
||||
payment = build_stubbed(:payment, created_at: Time.zone.now)
|
||||
allow(payment).to receive_messages pending?: false,
|
||||
checkout?: true
|
||||
expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
describe "#can_void?" do
|
||||
it "should be true if payment is not void" do
|
||||
payment = build_stubbed(:payment)
|
||||
allow(payment).to receive(:void?) { false }
|
||||
expect(gateway.can_void?(payment)).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
describe "#can_credit?" do
|
||||
it "should be false if payment is not completed" do
|
||||
payment = build_stubbed(:payment)
|
||||
allow(payment).to receive(:completed?) { false }
|
||||
expect(gateway.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
|
||||
it "should be false when order payment_state is not 'credit_owed'" do
|
||||
payment = build_stubbed(:payment,
|
||||
order: create(:order, payment_state: 'paid'))
|
||||
allow(payment).to receive(:completed?) { true }
|
||||
expect(gateway.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
|
||||
it "should be false when credit_allowed is zero" do
|
||||
payment = build_stubbed(:payment,
|
||||
order: create(:order, payment_state: 'credit_owed'))
|
||||
allow(payment).to receive_messages completed?: true,
|
||||
credit_allowed: 0
|
||||
|
||||
expect(gateway.can_credit?(payment)).to be_falsy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,16 +10,38 @@ RSpec.describe Spree::PaymentMethod::Taler do
|
||||
)
|
||||
}
|
||||
let(:backend_url) { "https://backend.demo.taler.net/instances/sandbox" }
|
||||
let(:token_url) { "#{backend_url}/private/token" }
|
||||
|
||||
describe "#external_payment_url", vcr: true do
|
||||
it "creates an order reference and retrieves a URL to pay at" do
|
||||
describe "#external_payment_url" do
|
||||
it "creates an order reference and retrieves a URL to pay at", vcr: true do
|
||||
order = create(:order_ready_for_confirmation, payment_method: taler)
|
||||
|
||||
url = subject.external_payment_url(order:)
|
||||
expect(url).to eq "#{backend_url}/orders/2026.022-0284X4GE8WKMJ"
|
||||
expect(url).to start_with "#{backend_url}/orders/"
|
||||
expect(url).to match "orders/20...[0-9A-Z-]{17}$"
|
||||
|
||||
payment = order.payments.last.reload
|
||||
expect(payment.response_code).to match "2026.022-0284X4GE8WKMJ"
|
||||
expect(payment.response_code).to match "20...[0-9A-Z-]{17}$"
|
||||
end
|
||||
|
||||
it "creates the Taler order with the right currency" do
|
||||
order = create(:order_ready_for_confirmation, payment_method: taler)
|
||||
|
||||
backend_url = "https://taler.example.com"
|
||||
token_url = "https://taler.example.com/private/token"
|
||||
order_url = "https://taler.example.com/private/orders"
|
||||
taler = Spree::PaymentMethod::Taler.new(
|
||||
preferred_backend_url: "https://taler.example.com",
|
||||
preferred_api_key: "sandbox",
|
||||
)
|
||||
|
||||
stub_request(:post, token_url).to_return(body: { token: "1234" }.to_json)
|
||||
stub_request(:post, order_url)
|
||||
.with(body: /"amount":"AUD:10.0"/)
|
||||
.to_return(body: { order_id: "one" }.to_json)
|
||||
|
||||
url = taler.external_payment_url(order:)
|
||||
expect(url).to eq "#{backend_url}/orders/one"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,6 +51,10 @@ RSpec.describe Spree::PaymentMethod::Taler do
|
||||
let(:payment) { build(:payment, response_code: "taler-order-7") }
|
||||
let(:order_url) { "#{backend_url}/private/orders/taler-order-7" }
|
||||
|
||||
before do
|
||||
stub_request(:post, token_url).to_return(body: { token: "12345" }.to_json)
|
||||
end
|
||||
|
||||
it "returns an ActiveMerchant response" do
|
||||
order_status = "paid"
|
||||
stub_request(:get, order_url).to_return(body: { order_status: }.to_json)
|
||||
@@ -50,6 +76,46 @@ RSpec.describe Spree::PaymentMethod::Taler do
|
||||
end
|
||||
end
|
||||
|
||||
describe "#credit" do
|
||||
let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" }
|
||||
let(:refund_endpoint) { "#{order_endpoint}/refund" }
|
||||
let(:taler_refund_uri) {
|
||||
"taler://refund/backend.demo.taler.net/instances/sandbox/taler-order-8/"
|
||||
}
|
||||
|
||||
it "starts the refund process" do
|
||||
order_status = { order_status: "paid" }
|
||||
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
|
||||
stub_request(:post, refund_endpoint).to_return(body: { taler_refund_uri: }.to_json)
|
||||
|
||||
order = create(:completed_order_with_totals)
|
||||
order.payments.create(
|
||||
amount: order.total, state: :completed,
|
||||
payment_method: taler,
|
||||
response_code: "taler-order-8",
|
||||
)
|
||||
expect {
|
||||
response = taler.credit(100, "taler-order-8", { payment: order.payments[0] })
|
||||
expect(response.success?).to eq true
|
||||
}.to enqueue_mail(PaymentMailer, :refund_available)
|
||||
end
|
||||
|
||||
it "raises an error if payment hasn't been taken yet" do
|
||||
order_status = { order_status: "claimed" }
|
||||
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
|
||||
|
||||
order = create(:completed_order_with_totals)
|
||||
order.payments.create(
|
||||
amount: order.total, state: :completed,
|
||||
payment_method: taler,
|
||||
response_code: "taler-order-8",
|
||||
)
|
||||
expect {
|
||||
taler.credit(100, "taler-order-8", { payment: order.payments[0] })
|
||||
}.to raise_error StandardError, "Unsupported action"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#void" do
|
||||
let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" }
|
||||
let(:refund_endpoint) { "#{order_endpoint}/refund" }
|
||||
@@ -57,6 +123,10 @@ RSpec.describe Spree::PaymentMethod::Taler do
|
||||
"taler://refund/backend.demo.taler.net/instances/sandbox/taler-order-8/"
|
||||
}
|
||||
|
||||
before do
|
||||
stub_request(:post, token_url).to_return(body: { token: "12345" }.to_json)
|
||||
end
|
||||
|
||||
it "starts the refund process" do
|
||||
order_status = {
|
||||
order_status: "paid",
|
||||
|
||||
@@ -855,7 +855,8 @@ RSpec.describe Spree::Payment do
|
||||
|
||||
describe "available actions" do
|
||||
context "for most gateways" do
|
||||
let(:payment) { build_stubbed(:payment, source: build_stubbed(:credit_card)) }
|
||||
let(:payment) { build_stubbed(:payment, payment_method:) }
|
||||
let(:payment_method) { Spree::Gateway::StripeSCA.new }
|
||||
|
||||
it "can capture and void" do
|
||||
expect(payment.actions).to match_array %w(capture_and_complete_order void)
|
||||
|
||||
@@ -8,6 +8,7 @@ RSpec.describe Spree::Variant do
|
||||
it { is_expected.to have_many :semantic_links }
|
||||
it { is_expected.to belong_to(:product).required }
|
||||
it { is_expected.to belong_to(:supplier).required }
|
||||
it { is_expected.to belong_to(:hub).optional }
|
||||
it { is_expected.to have_many(:inventory_units) }
|
||||
it { is_expected.to have_many(:line_items) }
|
||||
it { is_expected.to have_many(:stock_items) }
|
||||
@@ -20,6 +21,9 @@ RSpec.describe Spree::Variant do
|
||||
it { is_expected.to have_many(:inventory_items) }
|
||||
it { is_expected.to have_many(:supplier_properties).through(:supplier) }
|
||||
|
||||
it { is_expected.to have_many(:source_variants).through(:variant_links_as_target) }
|
||||
it { is_expected.to have_many(:target_variants).through(:variant_links_as_source) }
|
||||
|
||||
describe "shipping category" do
|
||||
it "sets a shipping category if none provided" do
|
||||
variant = build(:variant, shipping_category: nil)
|
||||
@@ -1001,4 +1005,30 @@ RSpec.describe Spree::Variant do
|
||||
expect(variant.unit_presentation).to eq "My display"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#create_linked_variant" do
|
||||
let(:user) { create(:user, enterprises: [enterprise]) }
|
||||
let(:supplier) { variant.supplier }
|
||||
let(:enterprise) { create(:enterprise) }
|
||||
|
||||
context "with create_linked_variants permissions on supplier" do
|
||||
let!(:enterprise_relationship) {
|
||||
create(:enterprise_relationship,
|
||||
parent: supplier,
|
||||
child: enterprise,
|
||||
permissions_list: [:create_linked_variants])
|
||||
}
|
||||
let(:variant) { create(:variant, price: 10.95, on_demand: false, on_hand: 5) }
|
||||
|
||||
it "clones the variant, retaining a link to the source" do
|
||||
linked_variant = variant.create_linked_variant(user)
|
||||
|
||||
expect(linked_variant.source_variants).to eq [variant]
|
||||
expect(linked_variant.hub).to eq enterprise
|
||||
expect(linked_variant.price).to eq 10.95
|
||||
expect(linked_variant.on_demand).to eq false
|
||||
expect(linked_variant.on_hand).to eq 5
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,4 +59,71 @@ RSpec.describe "Admin::ProductsV3" do
|
||||
expect(response).to redirect_to('/unauthorized')
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /admin/products/create_linked_variant" do
|
||||
let(:enterprise) { create(:supplier_enterprise) }
|
||||
let(:user) { create(:user, enterprises: [enterprise]) }
|
||||
|
||||
let(:supplier) { create(:supplier_enterprise) }
|
||||
let(:variant) { create(:variant, display_name: "Original variant", supplier: supplier) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it "checks for permission" do
|
||||
params = { variant_id: variant.id, product_index: 1 }
|
||||
|
||||
expect {
|
||||
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
|
||||
expect(response).to redirect_to('/unauthorized')
|
||||
}.not_to change { variant.product.variants.count }
|
||||
end
|
||||
|
||||
context "With create_linked_variants permissions on supplier" do
|
||||
let!(:enterprise_relationship) {
|
||||
create(:enterprise_relationship,
|
||||
parent: supplier,
|
||||
child: enterprise,
|
||||
permissions_list: [:create_linked_variants])
|
||||
}
|
||||
|
||||
it "clones the variant, retaining link as source" do
|
||||
params = { variant_id: variant.id, product_index: 1 }
|
||||
|
||||
expect {
|
||||
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to match "Original variant" # cloned variant name
|
||||
}.to change { variant.product.variants.count }.by(1)
|
||||
|
||||
new_variant = variant.product.variants.order(:id).last
|
||||
# The new variant is a target of the original. It is a "sourced" variant.
|
||||
expect(variant.target_variants.first).to eq new_variant
|
||||
# The new variant's source is the original
|
||||
expect(new_variant.source_variants.first).to eq variant
|
||||
end
|
||||
|
||||
context "and I'm also owner of another enterprise" do
|
||||
let!(:enterprise2) { create(:enterprise) }
|
||||
let(:user) { create(:user, enterprises: [enterprise, enterprise2]) }
|
||||
|
||||
it "clones the variant, owned by my enterprise that has permission" do
|
||||
enterprise2.owner = user
|
||||
params = { variant_id: variant.id, product_index: 1 }
|
||||
|
||||
expect {
|
||||
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
}.to change { variant.product.variants.count }.by(1)
|
||||
|
||||
# The new variant is owned by my enterprise that has permission, not the other one
|
||||
new_variant = variant.product.variants.order(:id).last
|
||||
expect(new_variant.hub).to eq enterprise
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -157,8 +157,6 @@ RSpec.describe Spree::Admin::PaymentsController do
|
||||
|
||||
context "with no payment source" do
|
||||
it "redirect to payments page" do
|
||||
allow(payment).to receive(:payment_source).and_return(nil)
|
||||
|
||||
put(
|
||||
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/fire?e=void",
|
||||
params: {},
|
||||
|
||||
@@ -47,18 +47,23 @@ create(:enterprise)
|
||||
uncheck 'to manage products'
|
||||
check 'to edit profile'
|
||||
check 'to add products to inventory'
|
||||
check 'to create linked variants'
|
||||
select2_select 'Two', from: 'enterprise_relationship_child_id'
|
||||
click_button 'Create'
|
||||
|
||||
# Wait for row to appear since have_relationship doesn't wait
|
||||
expect(page).to have_selector 'tr', count: 2
|
||||
# Permissions appear.. in a different order for some reason.
|
||||
expect_relationship_with_permissions e1, e2,
|
||||
['to add to order cycle',
|
||||
'to add products to inventory', 'to edit profile']
|
||||
'to create linked variants [BETA]',
|
||||
'to add products to inventory',
|
||||
'to edit profile']
|
||||
er = EnterpriseRelationship.where(parent_id: e1, child_id: e2).first
|
||||
expect(er).to be_present
|
||||
expect(er.permissions.map(&:name)).to match_array ['add_to_order_cycle', 'edit_profile',
|
||||
'create_variant_overrides']
|
||||
'create_variant_overrides',
|
||||
'create_linked_variants']
|
||||
end
|
||||
|
||||
it "attempting to create a relationship with invalid data" do
|
||||
|
||||
@@ -25,15 +25,17 @@ RSpec.describe "Admin -> Order -> Payments" do
|
||||
login_as distributor.owner
|
||||
end
|
||||
|
||||
it "allows to refund a Taler payment" do
|
||||
it "allows to void a Taler payment" do
|
||||
order_status = {
|
||||
order_status: "paid",
|
||||
contract_terms: {
|
||||
amount: "KUDOS:2",
|
||||
}
|
||||
}
|
||||
token_endpoint = "https://taler.example.com/private/token"
|
||||
order_endpoint = "https://taler.example.com/private/orders/taler-id-1"
|
||||
refund_endpoint = "https://taler.example.com/private/orders/taler-id-1/refund"
|
||||
stub_request(:post, token_endpoint).to_return(body: { token: "abc" }.to_json)
|
||||
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
|
||||
stub_request(:post, refund_endpoint).to_return(body: "{}")
|
||||
|
||||
@@ -49,4 +51,34 @@ RSpec.describe "Admin -> Order -> Payments" do
|
||||
expect(page).not_to have_link "Void"
|
||||
end
|
||||
end
|
||||
|
||||
it "allows to credit a Taler payment" do
|
||||
order_status = {
|
||||
order_status: "paid",
|
||||
contract_terms: {
|
||||
amount: "KUDOS:2",
|
||||
}
|
||||
}
|
||||
order_endpoint = "https://taler.example.com/private/orders/taler-id-1"
|
||||
refund_endpoint = "https://taler.example.com/private/orders/taler-id-1/refund"
|
||||
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
|
||||
stub_request(:post, refund_endpoint).to_return(body: "{}")
|
||||
|
||||
visit spree.admin_order_payments_path(order.number)
|
||||
|
||||
within row_containing("Taler") do
|
||||
expect(page).to have_text "COMPLETED"
|
||||
expect(page).to have_link "Credit"
|
||||
|
||||
click_link class: "icon-credit"
|
||||
|
||||
expect(page).to have_text "COMPLETED"
|
||||
expect(page).not_to have_link "Credit"
|
||||
end
|
||||
|
||||
# Our payment system creates a new payment to show the credit.
|
||||
within row_containing("$-9.75") do
|
||||
expect(page).not_to have_link "Void"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require "system_helper"
|
||||
|
||||
RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
RSpec.describe 'As an enterprise user, I can perform actions on the products screen' do
|
||||
include AdminHelper
|
||||
include WebHelper
|
||||
include AuthenticationHelper
|
||||
@@ -19,18 +19,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
let(:categories_search_selector) { 'input[placeholder="Select category"]' }
|
||||
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
|
||||
|
||||
describe "with no products" do
|
||||
before { visit admin_products_url }
|
||||
it "can see the new product page" do
|
||||
expect(page).to have_content "Bulk Edit Products"
|
||||
expect(page).to have_text "No products found"
|
||||
# displays buttons to add products with the correct links
|
||||
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
|
||||
expect(page).to have_link(class: "button", text: "Import multiple products",
|
||||
href: admin_product_import_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe "column selector" do
|
||||
let!(:product) { create(:simple_product) }
|
||||
|
||||
@@ -105,8 +93,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
end
|
||||
end
|
||||
|
||||
describe "columns"
|
||||
|
||||
describe "Changing producers, category and tax category" do
|
||||
let!(:variant_a1) {
|
||||
product_a.variants.first.tap{ |v|
|
||||
@@ -260,24 +246,24 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
|
||||
describe "Cloning product" do
|
||||
it "shows the cloned product on page when clicked on the cloned option" do
|
||||
# TODO, variant supplier missing, needs to be copied from variant and not product
|
||||
within "table.products" do
|
||||
# Gather input values, because page.content doesn't include them.
|
||||
input_content = page.find_all('input[type=text]').map(&:value).join
|
||||
|
||||
# Products does not include the cloned product.
|
||||
expect(input_content).not_to match /COPY OF Apples/
|
||||
end
|
||||
|
||||
click_product_clone "Apples"
|
||||
|
||||
expect(page).to have_content "Successfully cloned the product"
|
||||
within "table.products" do
|
||||
# Gather input values, because page.content doesn't include them.
|
||||
input_content = page.find_all('input[type=text]').map(&:value).join
|
||||
# Product list includes the cloned product.
|
||||
expect(all_input_values).to match /COPY OF Apples/
|
||||
|
||||
# Products include the cloned product.
|
||||
expect(input_content).to match /COPY OF Apples/
|
||||
# And I can perform actions on the new product
|
||||
within row_containing_name "COPY OF Apples" do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
expect(page).to have_link "Edit"
|
||||
expect(page).to have_link "Clone"
|
||||
# expect(page).to have_link "Delete" # it's not a proper link :/
|
||||
|
||||
fill_in "Name", with: "My copy of Apples"
|
||||
end
|
||||
|
||||
click_button "Save changes"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -298,6 +284,88 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
end
|
||||
end
|
||||
|
||||
describe "Create linked variant" do
|
||||
let!(:variant) {
|
||||
create(:variant, display_name: "My box", supplier: producer)
|
||||
}
|
||||
let!(:other_producer) { create(:supplier_enterprise) }
|
||||
let!(:other_variant) {
|
||||
create(:variant, display_name: "My friends box", supplier: other_producer)
|
||||
}
|
||||
let!(:enterprise_relationship) {
|
||||
# Other producer grants me access to manage their variant
|
||||
create(:enterprise_relationship, parent: other_producer, child: producer,
|
||||
permissions_list: [:manage_products])
|
||||
}
|
||||
|
||||
context "with create_linked_variants permission for my, and other's variants" do
|
||||
it "creates a linked variant" do
|
||||
create(:enterprise_relationship, parent: producer, child: producer,
|
||||
permissions_list: [:create_linked_variants])
|
||||
enterprise_relationship.permissions.create! name: :create_linked_variants
|
||||
|
||||
visit admin_products_url
|
||||
|
||||
# Check my own variant
|
||||
within row_containing_name("My box") do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
|
||||
expect(page).to have_link "Create linked variant"
|
||||
end
|
||||
|
||||
# Create linked variant sourced from my friend
|
||||
within row_containing_name("My friends box") do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
|
||||
click_link "Create linked variant"
|
||||
end
|
||||
|
||||
expect(page).to have_content "Successfully created linked variant"
|
||||
|
||||
within "table.products" do
|
||||
# There are now two copies
|
||||
expect(all_input_values).to match /My friends box.*My friends box/
|
||||
# One of them is designated as a linked variant
|
||||
expect(page).to have_content "🔗"
|
||||
|
||||
last_box = page.all(row_containing_name("My friends box")).last
|
||||
# Close action menu (shouldn't need this, it should close itself)
|
||||
last_box.click
|
||||
|
||||
# And I can perform actions on the new product
|
||||
within last_box do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
expect(page).to have_link "Edit"
|
||||
# expect(page).to have_link "Clone" # tofix: menu is partially obscured
|
||||
# expect(page).to have_link "Delete" # it's not a proper link
|
||||
|
||||
fill_in "Name", with: "My copy of Apples"
|
||||
end
|
||||
click_button "Save changes"
|
||||
|
||||
# initially obscured by the previous message, then disappears before capybara sees it.
|
||||
# expect(page).to have_content "Changes saved"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "without create_linked_variants permission" do
|
||||
it "does not show the option in the menu" do
|
||||
visit admin_products_url
|
||||
|
||||
within row_containing_name("My box") do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
expect(page).not_to have_link "Create linked variant"
|
||||
end
|
||||
|
||||
within row_containing_name("My friends box") do
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
expect(page).not_to have_link "Create linked variant"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete" do
|
||||
let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") }
|
||||
let(:delete_option_selector) { "a[data-controller='modal-link'].delete" }
|
||||
@@ -527,90 +595,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
end
|
||||
end
|
||||
|
||||
context "as an enterprise manager" do
|
||||
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
|
||||
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
|
||||
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
|
||||
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
|
||||
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
|
||||
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
|
||||
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
|
||||
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
|
||||
let!(:product_supplied_permitted) {
|
||||
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
|
||||
}
|
||||
let(:product_supplied_inactive) {
|
||||
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
|
||||
}
|
||||
|
||||
let!(:supplier_permitted_relationship) do
|
||||
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
|
||||
permissions_list: [:manage_products])
|
||||
end
|
||||
|
||||
before do
|
||||
enterprise_user = create(:user)
|
||||
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
|
||||
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
|
||||
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
|
||||
|
||||
login_as enterprise_user
|
||||
end
|
||||
|
||||
it "shows only products that I supply" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
# displays permitted product list only
|
||||
expect(page).to have_selector row_containing_name(product_supplied.name)
|
||||
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
|
||||
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
|
||||
end
|
||||
|
||||
it "shows only suppliers that I manage or have permission to" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
within row_containing_placeholder(product_supplied.name) do
|
||||
expect(page).to have_select(
|
||||
'_products_0_variants_attributes_0_supplier_id',
|
||||
options: [
|
||||
'Select producer',
|
||||
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
|
||||
], selected: supplier_managed1.name
|
||||
)
|
||||
end
|
||||
|
||||
within row_containing_placeholder(product_supplied_permitted.name) do
|
||||
expect(page).to have_select(
|
||||
'_products_1_variants_attributes_0_supplier_id',
|
||||
options: [
|
||||
'Select producer',
|
||||
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
|
||||
], selected: supplier_permitted.name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "shows inactive products that I supply" do
|
||||
product_supplied_inactive
|
||||
|
||||
visit spree.admin_products_path
|
||||
|
||||
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
|
||||
end
|
||||
|
||||
it "allows me to update a product" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
within row_containing_name(product_supplied.name) do
|
||||
fill_in "Name", with: "Pommes"
|
||||
end
|
||||
click_button "Save changes"
|
||||
|
||||
expect(page).to have_content "Changes saved"
|
||||
expect(page).to have_selector row_containing_name("Pommes")
|
||||
end
|
||||
end
|
||||
|
||||
def open_action_menu
|
||||
page.find(".vertical-ellipsis-menu").click
|
||||
end
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
require "system_helper"
|
||||
|
||||
RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
RSpec.describe 'As an enterprise user, I can browse my products' do
|
||||
include AdminHelper
|
||||
include WebHelper
|
||||
include AuthenticationHelper
|
||||
include FileHelper
|
||||
|
||||
let(:producer) { create(:supplier_enterprise) }
|
||||
let(:producer) { create(:supplier_enterprise, name: "My Enterprise") }
|
||||
let(:user) { create(:user, enterprises: [producer]) }
|
||||
|
||||
before do
|
||||
@@ -19,15 +19,25 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
let(:categories_search_selector) { 'input[placeholder="Search for categories"]' }
|
||||
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
|
||||
|
||||
describe "with no products" do
|
||||
before { visit admin_products_url }
|
||||
it "can see the new product page" do
|
||||
expect(page).to have_content "Bulk Edit Products"
|
||||
expect(page).to have_text "No products found"
|
||||
# displays buttons to add products with the correct links
|
||||
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
|
||||
expect(page).to have_link(class: "button", text: "Import multiple products",
|
||||
href: admin_product_import_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe "listing" do
|
||||
let!(:p1) { create(:product, name: "Product1") }
|
||||
let!(:p2) { create(:product, name: "Product2") }
|
||||
|
||||
before do
|
||||
visit admin_products_url
|
||||
end
|
||||
|
||||
it "displays a list of products" do
|
||||
visit admin_products_path
|
||||
|
||||
within ".products" do
|
||||
# displays table header
|
||||
expect(page).to have_selector "th", text: "Name"
|
||||
@@ -129,6 +139,34 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
expect(page).to have_select "variant_unit_with_scale", selected: "Items"
|
||||
expect(page).to have_field "variant_unit_name", with: "packet"
|
||||
end
|
||||
|
||||
context "with sourced variant" do
|
||||
let(:source_producer) { create(:supplier_enterprise) }
|
||||
let(:p3) { create(:product, name: "Product3", supplier_id: source_producer.id) }
|
||||
|
||||
let!(:v3_source) { p3.variants.first }
|
||||
let!(:v3_sourced) {
|
||||
create(:variant, display_name: "Variant3-sourced", product: p3, supplier: source_producer,
|
||||
hub: producer)
|
||||
}
|
||||
let!(:enterprise_relationship) {
|
||||
# Other producer grants me access to manage their variant
|
||||
create(:enterprise_relationship, parent: source_producer, child: producer,
|
||||
permissions_list: [:manage_products])
|
||||
}
|
||||
|
||||
before do
|
||||
v3_sourced.source_variants << v3_source
|
||||
visit admin_products_url
|
||||
end
|
||||
|
||||
it "shows sourced variant with indicator" do
|
||||
within row_containing_name("Variant3-sourced") do
|
||||
expect(page).to have_selector 'span[title*="Sourced from: "]'
|
||||
expect(page).to have_selector 'span[title*="Hub: My Enterprise"]'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "sorting" do
|
||||
@@ -463,4 +501,88 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "as an enterprise manager" do
|
||||
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
|
||||
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
|
||||
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
|
||||
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
|
||||
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
|
||||
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
|
||||
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
|
||||
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
|
||||
let!(:product_supplied_permitted) {
|
||||
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
|
||||
}
|
||||
let(:product_supplied_inactive) {
|
||||
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
|
||||
}
|
||||
|
||||
let!(:supplier_permitted_relationship) do
|
||||
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
|
||||
permissions_list: [:manage_products])
|
||||
end
|
||||
|
||||
before do
|
||||
enterprise_user = create(:user)
|
||||
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
|
||||
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
|
||||
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
|
||||
|
||||
login_as enterprise_user
|
||||
end
|
||||
|
||||
it "shows only products that I supply" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
# displays permitted product list only
|
||||
expect(page).to have_selector row_containing_name(product_supplied.name)
|
||||
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
|
||||
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
|
||||
end
|
||||
|
||||
it "shows only suppliers that I manage or have permission to" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
within row_containing_placeholder(product_supplied.name) do
|
||||
expect(page).to have_select(
|
||||
'_products_0_variants_attributes_0_supplier_id',
|
||||
options: [
|
||||
'Select producer',
|
||||
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
|
||||
], selected: supplier_managed1.name
|
||||
)
|
||||
end
|
||||
|
||||
within row_containing_placeholder(product_supplied_permitted.name) do
|
||||
expect(page).to have_select(
|
||||
'_products_1_variants_attributes_0_supplier_id',
|
||||
options: [
|
||||
'Select producer',
|
||||
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
|
||||
], selected: supplier_permitted.name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "shows inactive products that I supply" do
|
||||
product_supplied_inactive
|
||||
|
||||
visit spree.admin_products_path
|
||||
|
||||
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
|
||||
end
|
||||
|
||||
it "allows me to update a product" do
|
||||
visit spree.admin_products_path
|
||||
|
||||
within row_containing_name(product_supplied.name) do
|
||||
fill_in "Name", with: "Pommes"
|
||||
end
|
||||
click_button "Save changes"
|
||||
|
||||
expect(page).to have_content "Changes saved"
|
||||
expect(page).to have_selector row_containing_name("Pommes")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -370,6 +370,7 @@ RSpec.describe "As a consumer, I want to checkout my order" do
|
||||
Spree::PaymentMethod::Taler.create!(
|
||||
name: "Taler",
|
||||
environment: "test",
|
||||
preferred_backend_url: "https://taler.example.com/",
|
||||
distributors: [distributor]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -6,13 +6,13 @@ RSpec.describe "registration/steps/_details.html.haml" do
|
||||
it "uses Google Maps when it is enabled" do
|
||||
allow(view).to receive_messages(using_google_maps?: true)
|
||||
|
||||
is_expected.to match /<ui-gmap-google-map center='map.center' zoom='map.zoom'>/
|
||||
is_expected.to match /<ui-gmap-google-map center="map.center" zoom="map.zoom">/
|
||||
end
|
||||
|
||||
it "uses OpenStreetMap when it is enabled" do
|
||||
ContentConfig.open_street_map_enabled = true
|
||||
allow(view).to receive_messages(using_google_maps?: false)
|
||||
|
||||
is_expected.to match /<div class='map-container--registration' id='open-street-map'>/
|
||||
is_expected.to match /<div class="map-container--registration" id="open-street-map">/
|
||||
end
|
||||
end
|
||||
|
||||
31
yarn.lock
31
yarn.lock
@@ -816,9 +816,9 @@
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
|
||||
"@babel/preset-env@^7.28.5":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.0.tgz#c55db400c515a303662faaefd2d87e796efa08d0"
|
||||
integrity sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==
|
||||
version "7.29.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.2.tgz#5a173f22c7d8df362af1c9fe31facd320de4a86c"
|
||||
integrity sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==
|
||||
dependencies:
|
||||
"@babel/compat-data" "^7.29.0"
|
||||
"@babel/helper-compilation-targets" "^7.28.6"
|
||||
@@ -900,15 +900,10 @@
|
||||
"@babel/types" "^7.4.4"
|
||||
esutils "^2.0.2"
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4":
|
||||
version "7.28.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
|
||||
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
|
||||
|
||||
"@babel/runtime@^7.28.4":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b"
|
||||
integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.28.4", "@babel/runtime@^7.8.4":
|
||||
version "7.29.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
|
||||
integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==
|
||||
|
||||
"@babel/template@^7.28.6":
|
||||
version "7.28.6"
|
||||
@@ -5482,14 +5477,14 @@ picocolors@1.1.1, picocolors@^1.1.1:
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
|
||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||
|
||||
picomatch@^4.0.2, picomatch@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||
|
||||
pify@^2.3.0:
|
||||
version "2.3.0"
|
||||
|
||||
Reference in New Issue
Block a user