spree_api 5.4.3 → 5.5.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +36 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
  4. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
  5. data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
  6. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
  7. data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
  8. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
  9. data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
  10. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
  11. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +104 -0
  12. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
  13. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +109 -0
  14. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
  15. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +84 -0
  16. data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
  17. data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
  18. data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
  19. data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
  20. data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
  21. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
  22. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
  23. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +129 -0
  24. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
  25. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +83 -0
  26. data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
  27. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +25 -0
  28. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +92 -0
  29. data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
  30. data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
  31. data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
  32. data/app/controllers/spree/api/v3/admin/exports_controller.rb +136 -0
  33. data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
  34. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
  35. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
  36. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +81 -0
  37. data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
  38. data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
  39. data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
  40. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
  41. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
  42. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +31 -0
  43. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
  44. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
  45. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
  46. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
  47. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
  48. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
  49. data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
  50. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
  51. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +179 -0
  52. data/app/controllers/spree/api/v3/admin/prices_controller.rb +157 -0
  53. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
  54. data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
  55. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
  56. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
  57. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
  58. data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
  59. data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
  60. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
  61. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
  62. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
  63. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
  64. data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
  65. data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
  66. data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
  67. data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
  68. data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
  69. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
  70. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
  71. data/app/controllers/spree/api/v3/resource_controller.rb +117 -8
  72. data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
  73. data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
  74. data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
  75. data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
  76. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
  77. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
  78. data/app/controllers/spree/api/v3/store/products_controller.rb +4 -3
  79. data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
  80. data/app/jobs/spree/webhook_delivery_job.rb +5 -0
  81. data/app/models/spree/api_key_ability.rb +16 -0
  82. data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
  83. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
  84. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
  85. data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
  86. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
  87. data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
  88. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
  89. data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
  91. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
  92. data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
  93. data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
  94. data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
  95. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
  96. data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
  97. data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
  98. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
  99. data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
  100. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
  101. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
  102. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
  103. data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
  104. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
  105. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
  106. data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
  107. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
  108. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
  109. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
  110. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
  111. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
  112. data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
  113. data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
  114. data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
  115. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
  116. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
  117. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
  118. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
  119. data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
  120. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
  121. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
  122. data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
  123. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
  124. data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
  125. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
  126. data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
  127. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
  128. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
  129. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
  130. data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
  131. data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
  132. data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
  133. data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
  134. data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
  135. data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
  136. data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
  137. data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
  138. data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
  139. data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
  140. data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
  141. data/config/locales/en.yml +2 -0
  142. data/config/routes.rb +235 -1
  143. data/lib/spree/api/configuration.rb +2 -2
  144. data/lib/spree/api/dependencies.rb +25 -1
  145. data/lib/spree/api/openapi/path_sorter.rb +126 -0
  146. data/lib/spree/api/openapi/schema_helper.rb +185 -6
  147. data/lib/spree/api/testing_support/v3/base.rb +28 -0
  148. metadata +98 -8
  149. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
@@ -0,0 +1,190 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class OrdersController < ResourceController
6
+ include Spree::Api::V3::OrderLock
7
+
8
+ scoped_resource :orders
9
+
10
+ skip_before_action :set_resource, only: [:index, :create]
11
+ before_action :set_resource, only: [:show, :update, :destroy, :complete, :cancel, :approve, :resume, :resend_confirmation]
12
+
13
+ # POST /api/v3/admin/orders
14
+ def create
15
+ authorize!(:create, Spree::Order)
16
+
17
+ result = Spree.order_create_service.call(
18
+ store: current_store,
19
+ user: resolve_user,
20
+ params: order_create_params
21
+ )
22
+
23
+ if result.success?
24
+ @resource = result.value
25
+ render json: serialize_resource(@resource), status: :created
26
+ else
27
+ render_service_error(result.error)
28
+ end
29
+ end
30
+
31
+ # PATCH /api/v3/admin/orders/:id
32
+ def update
33
+ with_order_lock do
34
+ result = Spree.order_update_service.call(
35
+ order: @resource,
36
+ params: order_update_params
37
+ )
38
+
39
+ if result.success?
40
+ render json: serialize_resource(result.value)
41
+ else
42
+ render_validation_error(@resource.errors.presence || result.error)
43
+ end
44
+ end
45
+ end
46
+
47
+ # PATCH /api/v3/admin/orders/:id/complete
48
+ def complete
49
+ with_order_lock do
50
+ result = Spree.order_complete_service.call(
51
+ order: @resource,
52
+ payment_pending: ActiveModel::Type::Boolean.new.cast(params[:payment_pending]),
53
+ notify_customer: ActiveModel::Type::Boolean.new.cast(params[:notify_customer])
54
+ )
55
+
56
+ if result.success?
57
+ render json: serialize_resource(@resource.reload)
58
+ else
59
+ render_service_error(@resource.errors.presence || result.error, code: ERROR_CODES[:order_cannot_complete])
60
+ end
61
+ end
62
+ end
63
+
64
+ # PATCH /api/v3/admin/orders/:id/cancel
65
+ def cancel
66
+ with_order_lock do
67
+ @resource.canceled_by(try_spree_current_user)
68
+ render json: serialize_resource(@resource.reload)
69
+ end
70
+ end
71
+
72
+ # PATCH /api/v3/admin/orders/:id/approve
73
+ def approve
74
+ with_order_lock do
75
+ @resource.approved_by(try_spree_current_user)
76
+ render json: serialize_resource(@resource.reload)
77
+ end
78
+ end
79
+
80
+ # PATCH /api/v3/admin/orders/:id/resume
81
+ def resume
82
+ with_order_lock do
83
+ @resource.resume!
84
+ render json: serialize_resource(@resource.reload)
85
+ end
86
+ end
87
+
88
+ # POST /api/v3/admin/orders/:id/resend_confirmation
89
+ def resend_confirmation
90
+ @resource.publish_event('order.completed')
91
+ render json: serialize_resource(@resource)
92
+ end
93
+
94
+ protected
95
+
96
+ def model_class
97
+ Spree::Order
98
+ end
99
+
100
+ def serializer_class
101
+ Spree.api.admin_order_serializer
102
+ end
103
+
104
+ # Override scope — Order uses SingleStoreResource (for_store)
105
+ def scope
106
+ current_store.orders.accessible_by(current_ability, :show).preload_associations_lazily
107
+ end
108
+
109
+ def set_resource
110
+ @resource = scope.find_by_prefix_id!(params[:id])
111
+ @order = @resource # needed for OrderLock
112
+ authorize_resource!(@resource)
113
+ end
114
+
115
+ # Map state transition actions to :update permission
116
+ def authorize_resource!(resource = @resource, action = action_name.to_sym)
117
+ mapped_action = case action
118
+ when :complete, :cancel, :approve, :resume, :resend_confirmation
119
+ :update
120
+ else
121
+ action
122
+ end
123
+ authorize!(mapped_action, resource)
124
+ end
125
+
126
+ def collection_includes
127
+ [:line_items, :user, :channel, :rich_text_internal_note]
128
+ end
129
+
130
+ private
131
+
132
+ def resolve_user
133
+ customer_param = params[:customer_id].presence || params[:user_id].presence
134
+ return unless customer_param
135
+
136
+ Spree.user_class.find_by_param!(customer_param)
137
+ end
138
+
139
+ def order_create_params
140
+ normalize_params(
141
+ params.permit(
142
+ :email, :customer_id, :user_id, :use_customer_default_address,
143
+ :currency, :market_id, :channel_id, :locale,
144
+ :customer_note, :internal_note,
145
+ :shipping_address_id, :billing_address_id,
146
+ :preferred_stock_location_id,
147
+ :coupon_code,
148
+ metadata: {},
149
+ tags: [],
150
+ shipping_address: address_permitted_keys,
151
+ billing_address: address_permitted_keys,
152
+ items: item_permitted_keys
153
+ )
154
+ )
155
+ end
156
+
157
+ def order_update_params
158
+ normalize_params(
159
+ params.permit(
160
+ :email, :customer_id, :user_id,
161
+ :customer_note, :internal_note,
162
+ :currency, :locale, :market_id, :channel_id,
163
+ :preferred_stock_location_id,
164
+ metadata: {},
165
+ tags: [],
166
+ ship_address: address_permitted_keys,
167
+ bill_address: address_permitted_keys,
168
+ items: item_permitted_keys
169
+ )
170
+ )
171
+ end
172
+
173
+ def address_permitted_keys
174
+ [
175
+ :firstname, :lastname, :first_name, :last_name,
176
+ :address1, :address2, :city,
177
+ :country_iso, :state_abbr, :country_id, :state_id,
178
+ :zipcode, :postal_code, :phone, :alternative_phone,
179
+ :state_name, :company, :label
180
+ ]
181
+ end
182
+
183
+ def item_permitted_keys
184
+ [:variant_id, :quantity, { metadata: {} }]
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,73 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class PaymentMethodsController < ResourceController
6
+ include Spree::Api::V3::Admin::SubclassedResource
7
+
8
+ scoped_resource :settings
9
+
10
+ subclassed_via -> { Spree::PaymentMethod.providers },
11
+ unknown_type_error: 'unknown_payment_method_type'
12
+
13
+ # Lists available payment provider subclasses for the create form.
14
+ # Returns: { data: [{ type, label, description, preference_schema }] }.
15
+ # The preference_schema array describes the provider-specific
16
+ # configuration fields, so admin UIs can render a generic
17
+ # preferences form without hard-coding per-provider knowledge.
18
+ # Filters out subclasses already installed in the current store —
19
+ # mirrors the legacy admin's "available_payment_methods" helper, so
20
+ # admins don't see (and accidentally double-install) the same
21
+ # provider twice.
22
+ def types
23
+ authorize! :create, model_class
24
+
25
+ # Query via direct join rather than `current_store.payment_methods`
26
+ # — the has_many-through association can cache stale results when
27
+ # `current_store` was loaded earlier in the request (e.g. by the
28
+ # auth layer).
29
+ installed_class_names = Spree::PaymentMethod
30
+ .joins(:store_payment_methods)
31
+ .where(spree_payment_methods_stores: { store_id: current_store.id })
32
+ .pluck(:type)
33
+ installed_shorthands = installed_class_names.filter_map do |name|
34
+ name.safe_constantize&.api_type
35
+ end
36
+ available = model_class.subclasses_with_preference_schema.reject do |entry|
37
+ installed_shorthands.include?(entry[:type])
38
+ end
39
+
40
+ render json: { data: available }
41
+ end
42
+
43
+ protected
44
+
45
+ def model_class
46
+ Spree::PaymentMethod
47
+ end
48
+
49
+ def serializer_class
50
+ Spree.api.admin_payment_method_serializer
51
+ end
52
+
53
+ # Explicit allowlist per the v3 convention — flat params, no
54
+ # reach into the global `Spree::PermittedAttributes` registry
55
+ # (which is the legacy Rails admin's surface). `type` and
56
+ # `preferences` are added by `SubclassedResource` on top.
57
+ def permitted_params
58
+ params.permit(:name, :description, :active, :storefront_visible, :auto_capture, :position, metadata: {}, preferences: {})
59
+ end
60
+
61
+ private
62
+
63
+ # New payment methods get scoped to the current store automatically.
64
+ def build_subclassed_resource(klass, attrs)
65
+ resource = klass.new(attrs)
66
+ resource.stores = [current_store] if resource.stores.empty?
67
+ resource
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,179 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin CRUD for `Spree::PriceList`, plus the lifecycle transitions
6
+ # (`activate` / `deactivate`) and the spreadsheet's data feed
7
+ # (`prices`).
8
+ #
9
+ # Everything writable on the list — name, schedule, match policy,
10
+ # product membership (`product_ids: [...]`), nested rules
11
+ # (`rules: [...]`), and individual price overrides
12
+ # (`prices: [...]`) — flows through the regular PATCH payload, so
13
+ # the SPA saves the entire editor in one round-trip. No separate
14
+ # add_products / remove_products / add_rule / bulk_update_prices
15
+ # endpoints.
16
+ #
17
+ # Scoped under the `products` API-key scope — price lists are a
18
+ # product/pricing concern; we don't introduce a separate
19
+ # `read_price_lists` scope.
20
+ class PriceListsController < ResourceController
21
+ scoped_resource :products
22
+
23
+ # The base ResourceController limits `set_resource` to
24
+ # `show/update/destroy`. We need it on the custom member
25
+ # actions below too, so swap in our own filter — Rails keys
26
+ # before_actions by method name, so this would otherwise
27
+ # *replace* the parent's narrower filter and break the standard
28
+ # actions. Wrapping it under a different name keeps both.
29
+ before_action :load_member_resource, only: [:activate, :deactivate, :prices]
30
+
31
+ # GET /api/v3/admin/price_lists/price_rule_types
32
+ #
33
+ # Returns `[{ type, label, description, preference_schema }]`
34
+ # for every registered subclass in `Spree.pricing.rules`. The
35
+ # SPA uses this to build the "Add rule" picker + render a
36
+ # generic preferences form per subclass. Rules themselves are
37
+ # not a separate REST resource — they ride along on the price
38
+ # list's PATCH body via `rules: [...]`.
39
+ def price_rule_types
40
+ authorize! :read, Spree::PriceRule
41
+ render json: { data: Spree::PriceRule.subclasses_with_preference_schema }
42
+ end
43
+
44
+ # PATCH /api/v3/admin/price_lists/:id/activate
45
+ #
46
+ # State transition: draft|inactive → active (or → scheduled when
47
+ # `starts_at` is in the future). Mirrors the old Rails admin's
48
+ # "Activate" button which automatically scheduled future lists.
49
+ def activate
50
+ authorize! :update, @resource
51
+ event = @resource.starts_at.present? && @resource.starts_at.future? ? :schedule : :activate
52
+
53
+ if @resource.send(event)
54
+ render json: serialize_resource(@resource)
55
+ else
56
+ render_validation_error(@resource.errors)
57
+ end
58
+ end
59
+
60
+ # PATCH /api/v3/admin/price_lists/:id/deactivate
61
+ def deactivate
62
+ authorize! :update, @resource
63
+
64
+ if @resource.deactivate
65
+ render json: serialize_resource(@resource)
66
+ else
67
+ render_validation_error(@resource.errors)
68
+ end
69
+ end
70
+
71
+ # GET /api/v3/admin/price_lists/:id/prices
72
+ #
73
+ # The spreadsheet editor's data source. Returns every Price row
74
+ # in this list (filtered by `?currency=`), eager-loading
75
+ # `variant.product` + option values so each cell can render
76
+ # product name, variant options and SKU without N+1.
77
+ def prices
78
+ authorize! :read, @resource
79
+ currency = params[:currency].presence || current_store.default_currency
80
+ prices = @resource.prices
81
+ .includes(variant: [:product, { option_values: :option_type }])
82
+ .where(currency: currency)
83
+ .joins(variant: :product)
84
+ .order(Arel.sql("#{Spree::Product.table_name}.name ASC"))
85
+ .order(Arel.sql("#{Spree::Variant.table_name}.position ASC"))
86
+
87
+ render json: {
88
+ data: prices.map { |p| serialize_price(p) },
89
+ meta: { currency: currency, count: prices.size }
90
+ }
91
+ end
92
+
93
+ protected
94
+
95
+ def model_class
96
+ Spree::PriceList
97
+ end
98
+
99
+ def serializer_class
100
+ Spree.api.admin_price_list_serializer
101
+ end
102
+
103
+ def scope
104
+ super.for_store(current_store)
105
+ end
106
+
107
+ def permitted_params
108
+ attrs = normalize_params(
109
+ params.permit(
110
+ :name, :description, :position,
111
+ :starts_at, :ends_at, :match_policy,
112
+ product_ids: [],
113
+ rules: [:id, :type, { preferences: {} }],
114
+ prices: [:id, :variant_id, :currency, :amount, :compare_at_amount]
115
+ )
116
+ )
117
+ reject_foreign_membership(attrs)
118
+ end
119
+
120
+ # The PriceList model setters (`product_ids=`, `prices=`) resolve
121
+ # member ids with no store scoping, so a list in this store could
122
+ # otherwise be populated with another store's products/variants.
123
+ # Drop any id that isn't in the current store before assignment.
124
+ def reject_foreign_membership(attrs)
125
+ if attrs[:product_ids].present?
126
+ store_product_ids = current_store.products.where(id: attrs[:product_ids]).pluck(:id).map(&:to_s).to_set
127
+ attrs[:product_ids] = Array(attrs[:product_ids]).select { |id| store_product_ids.include?(id.to_s) }
128
+ end
129
+
130
+ if attrs[:prices].present?
131
+ incoming = Array(attrs[:prices])
132
+ store_variant_ids = current_store.variants.where(id: incoming.map { |r| r[:variant_id] }.compact).
133
+ pluck(:id).map(&:to_s).to_set
134
+ attrs[:prices] = incoming.select do |row|
135
+ row[:variant_id].blank? || store_variant_ids.include?(row[:variant_id].to_s)
136
+ end
137
+ end
138
+
139
+ attrs
140
+ end
141
+
142
+ private
143
+
144
+ # Loads the record without the action-derived authorization
145
+ # `set_resource` runs (which would check `:activate` /
146
+ # `:deactivate` / `:prices` — actions that abilities don't
147
+ # grant). The per-action methods below explicitly call
148
+ # `authorize!` with the standard action that ability rules
149
+ # actually mention (`:update` / `:read`).
150
+ def load_member_resource
151
+ @resource = find_resource
152
+ end
153
+
154
+ # Hand-rolled flat shape for the spreadsheet — keeps the payload
155
+ # narrow (no nested variant/product/option_value objects) and
156
+ # avoids paying for the admin Price serializer when we only need
157
+ # ~6 fields per row. The grouping the UI does (rows → product
158
+ # header) is driven entirely off `product_id`/`product_name`.
159
+ def serialize_price(price)
160
+ variant = price.variant
161
+ product = variant.product
162
+ {
163
+ id: price.prefixed_id,
164
+ variant_id: variant.prefixed_id,
165
+ product_id: product.prefixed_id,
166
+ product_name: product.name,
167
+ variant_label: variant.options_text.presence,
168
+ sku: variant.sku,
169
+ currency: price.currency,
170
+ amount: price.amount&.to_s,
171
+ compare_at_amount: price.compare_at_amount&.to_s
172
+ }
173
+ end
174
+
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,157 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Admin CRUD for `Spree::Price`. Covers both base prices
6
+ # (`price_list_id: nil`) and price-list overrides under one
7
+ # resource, filtered via Ransack predicates on the index.
8
+ class PricesController < ResourceController
9
+ include Spree::Api::V3::BulkOperations
10
+
11
+ scoped_resource :products
12
+
13
+ before_action :require_ids!, only: [:bulk_destroy]
14
+ before_action :require_prices!, only: [:bulk_upsert]
15
+
16
+ # Bulk-upserts prices on the unique-key triple
17
+ # `(variant_id, currency, price_list_id)`.
18
+ #
19
+ # @return [void]
20
+ def bulk_upsert
21
+ authorize! :create, Spree::Price
22
+ authorize! :update, Spree::Price
23
+
24
+ rows = Array(params[:prices]).map { |row| decode_price_row(row) }
25
+ invalid = rows.each_with_index.filter_map do |row, idx|
26
+ missing = %i[variant_id currency].reject { |k| row[k].present? }
27
+ { index: idx, missing: missing } if missing.any?
28
+ end
29
+ if invalid.any?
30
+ return render_error(
31
+ code: 'invalid_prices',
32
+ message: 'Each row must include variant_id and currency.',
33
+ status: :unprocessable_content,
34
+ details: { rows: invalid }
35
+ )
36
+ end
37
+
38
+ foreign = foreign_rows(rows)
39
+ if foreign.any?
40
+ return render_error(
41
+ code: 'invalid_prices',
42
+ message: 'Each row must reference a variant and price list in the current store.',
43
+ status: :unprocessable_content,
44
+ details: { rows: foreign }
45
+ )
46
+ end
47
+
48
+ result = Spree::Prices::BulkUpsert.call(rows: rows)
49
+ render json: result.value
50
+ end
51
+
52
+ # Soft-deletes the listed prices.
53
+ #
54
+ # @return [void]
55
+ def bulk_destroy
56
+ authorize! :destroy, Spree::Price
57
+
58
+ destroy_scope = scope.where(id: decode_ids(params[:ids]))
59
+ destroyed = destroy_scope.count(&:destroy)
60
+
61
+ render json: { price_count: destroyed }
62
+ end
63
+
64
+ protected
65
+
66
+ def model_class
67
+ Spree::Price
68
+ end
69
+
70
+ def serializer_class
71
+ Spree.api.admin_price_serializer
72
+ end
73
+
74
+ def collection_includes
75
+ {
76
+ variant: [
77
+ :tax_category,
78
+ :prices,
79
+ product: :tax_category,
80
+ option_values: :option_type,
81
+ stock_items: [:stock_location, :active_stock_reservations]
82
+ ]
83
+ }
84
+ end
85
+
86
+ # Disabled: Ransack's default `result(distinct: true)` makes
87
+ # Postgres reject `sort=variant_product_name` because the order
88
+ # column isn't in the DISTINCT select list. The store scope
89
+ # already guarantees one Price row per result.
90
+ def collection_distinct?
91
+ false
92
+ end
93
+
94
+ def permitted_params
95
+ normalize_params(
96
+ params.permit(:variant_id, :currency, :amount, :compare_at_amount, :price_list_id)
97
+ )
98
+ end
99
+
100
+ private
101
+
102
+ def bulk_record_count_key
103
+ :price_count
104
+ end
105
+
106
+ def require_prices!
107
+ return if params.key?(:prices)
108
+
109
+ render_error(
110
+ code: 'missing_prices',
111
+ message: 'prices is required (send an empty array to no-op).',
112
+ status: :unprocessable_content
113
+ )
114
+ end
115
+
116
+ def decode_price_row(row)
117
+ row = row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row.to_h
118
+ row = row.with_indifferent_access
119
+
120
+ {
121
+ id: decode_id(row[:id]),
122
+ variant_id: decode_id(row[:variant_id]),
123
+ price_list_id: row.key?(:price_list_id) ? decode_id(row[:price_list_id]) : nil,
124
+ currency: row[:currency],
125
+ amount: row[:amount],
126
+ compare_at_amount: row[:compare_at_amount]
127
+ }.compact
128
+ end
129
+
130
+ def decode_id(value)
131
+ return nil if value.blank?
132
+
133
+ Spree::PrefixedId.prefixed_id?(value) ? Spree::PrefixedId.decode_prefixed_id(value) : value
134
+ end
135
+
136
+ # Rejects rows whose variant or price list belongs to another store.
137
+ # `Spree::Prices::BulkUpsert` writes rows keyed on the raw variant_id
138
+ # with no ownership check, so the store boundary is enforced here.
139
+ def foreign_rows(rows)
140
+ variant_ids = rows.map { |r| r[:variant_id] }.compact.uniq
141
+ price_list_ids = rows.map { |r| r[:price_list_id] }.compact.uniq
142
+
143
+ store_variant_ids = current_store.variants.where(id: variant_ids).pluck(:id).map(&:to_s).to_set
144
+ store_price_list_ids = Spree::PriceList.for_store(current_store).where(id: price_list_ids).pluck(:id).map(&:to_s).to_set
145
+
146
+ rows.each_with_index.filter_map do |row, idx|
147
+ variant_ok = store_variant_ids.include?(row[:variant_id].to_s)
148
+ price_list_ok = row[:price_list_id].blank? || store_price_list_ids.include?(row[:price_list_id].to_s)
149
+
150
+ { index: idx } unless variant_ok && price_list_ok
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Products
6
+ class VariantsController < ResourceController
7
+ scoped_resource :products
8
+
9
+ protected
10
+
11
+ def model_class
12
+ Spree::Variant
13
+ end
14
+
15
+ def serializer_class
16
+ Spree.api.admin_variant_serializer
17
+ end
18
+
19
+ def set_parent
20
+ @parent = current_store.products.find_by_prefix_id!(params[:product_id])
21
+ authorize!(:show, @parent)
22
+ end
23
+
24
+ def parent_association
25
+ :variants_including_master
26
+ end
27
+
28
+ def scope_includes
29
+ [:prices, stock_items: :stock_location]
30
+ end
31
+
32
+ def permitted_params
33
+ params.permit(
34
+ :sku, :barcode, :price, :compare_at_price,
35
+ :cost_price, :cost_currency,
36
+ :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
37
+ :track_inventory, :tax_category_id, :position,
38
+ options: [:name, :value],
39
+ prices: [:amount, :compare_at_amount, :currency],
40
+ stock_items: [:stock_location_id, :count_on_hand, :backorderable]
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end