spree_core 6.0.0.rc7 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
- data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/spree/ability.rb +9 -0
- data/app/models/spree/invitation.rb +11 -2
- data/app/models/spree/maintenance_task.rb +221 -0
- data/app/models/spree/maintenance_task_run.rb +78 -0
- data/app/models/spree/order_routing/strategy/rules.rb +8 -48
- data/app/models/spree/seller_transfer.rb +51 -0
- data/app/models/spree/stock/coordinator.rb +37 -17
- data/app/models/spree/stock/packer.rb +19 -9
- data/app/models/spree/stock/prioritizer.rb +30 -15
- data/app/models/spree/store.rb +35 -2
- data/app/models/spree/upgrade_record.rb +62 -0
- data/app/services/spree/carts/update.rb +2 -1
- data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
- data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
- data/app/services/spree/maintenance_tasks/start.rb +127 -0
- data/app/services/spree/seeds/all.rb +22 -0
- data/app/services/spree/stock_reservations/reserve.rb +73 -25
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/config/locales/en.yml +1 -0
- data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
- data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
- data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/configuration.rb +6 -0
- data/lib/spree/core/engine.rb +10 -1
- data/lib/spree/core/permissions/default_catalog.rb +7 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +17 -1
- data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrade.rb +189 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +30 -10
- data/lib/tasks/maintenance_tasks.rake +42 -0
- data/lib/tasks/upgrade.rake +81 -60
- metadata +28 -6
- data/lib/tasks/capture_methods_migration.rake +0 -30
- data/lib/tasks/order_market_backfill.rake +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2407d5fb2d24d9dd893a8c9dae11cfd8aba5a4b1d621a82938a2cb7009581936
|
|
4
|
+
data.tar.gz: f3384773e3442f383e979f60d58b7b06672a72065cd8d117a114ef49827fda5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebc83d9db9ebc13afea53ae0b184789f32a81121dcd9608461d93fb7e2e62b020cef774e69de3404fc0d23adb62df13f770281eacd6fd4671e5c0f5328ecae5b
|
|
7
|
+
data.tar.gz: 99ecc2439e9a73c1f805898c35e48079cd573b947110951c1a8349b26934e7ea8023b55a53cfdf1b3af94a5013290c52e5a097e4e5dff61d1249af8ca3327fb7
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
# Executes one Spree::MaintenanceTaskRun on Active Job Continuations
|
|
4
|
+
# (docs/plans/6.0-maintenance-tasks.md).
|
|
5
|
+
#
|
|
6
|
+
# Three things can stop a run mid-collection, and all three resolve to the
|
|
7
|
+
# same checkpoint: the worker is shutting down (Continuations re-queue the
|
|
8
|
+
# job), the execution has used its time slice (the runner re-queues
|
|
9
|
+
# itself so no single task holds a worker), or an operator asked to
|
|
10
|
+
# cancel (the Cancel service writes the request, the runner observes it
|
|
11
|
+
# here). The run row's cursor is written before each checkpoint, so
|
|
12
|
+
# whichever path fires, the next execution restarts from the same record.
|
|
13
|
+
class RunJob < ::Spree::BaseJob
|
|
14
|
+
include ActiveJob::Continuable
|
|
15
|
+
|
|
16
|
+
queue_as Spree.queues.maintenance_tasks
|
|
17
|
+
|
|
18
|
+
# A run that fails is recorded as failed; it must not be replayed by the
|
|
19
|
+
# backend behind the operator's back. Running it again is an explicit
|
|
20
|
+
# action.
|
|
21
|
+
#
|
|
22
|
+
# `inline` is a run executing in the caller's own process — the
|
|
23
|
+
# upgrade walk and the command line. It holds no worker, so it is not
|
|
24
|
+
# sliced: handing the remainder to a worker would let the caller report
|
|
25
|
+
# a step done that has not finished, on a deploy where no worker runs.
|
|
26
|
+
def perform(run_id, inline: false)
|
|
27
|
+
# Outside the steps on purpose — runs on every execution, resumes
|
|
28
|
+
# included.
|
|
29
|
+
@run = Spree::MaintenanceTaskRun.find_by(id: run_id)
|
|
30
|
+
return if @run.nil?
|
|
31
|
+
# `cancelling` counts as active: a job re-queued after its worker
|
|
32
|
+
# stopped is the only execution that will ever answer it.
|
|
33
|
+
return unless @run.active?
|
|
34
|
+
|
|
35
|
+
@task_class = @run.task_class
|
|
36
|
+
return unfindable_task if @task_class.nil?
|
|
37
|
+
|
|
38
|
+
@inline = inline
|
|
39
|
+
@execution_started_at = Time.current
|
|
40
|
+
@time_running_before = @run.time_running.to_f
|
|
41
|
+
prepare
|
|
42
|
+
return if @halted
|
|
43
|
+
|
|
44
|
+
check_operator_request
|
|
45
|
+
return if @halted
|
|
46
|
+
return refuse_duplicate if duplicate_run?
|
|
47
|
+
|
|
48
|
+
step :start
|
|
49
|
+
# Seeded from the row rather than from the continuation: the row
|
|
50
|
+
# survives a dropped job, which a re-queued execution then picks up
|
|
51
|
+
# with no continuation at all.
|
|
52
|
+
step :process_collection, start: @run.cursor
|
|
53
|
+
step :finish
|
|
54
|
+
rescue SignalException => e
|
|
55
|
+
# Ctrl-C or a deploy stopping an inline run. No job was queued for it,
|
|
56
|
+
# so nothing would ever come back to a run left `running`, and it
|
|
57
|
+
# would block every later run of its task.
|
|
58
|
+
record_error(e) if @inline && run&.reload&.active?
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :run, :task
|
|
65
|
+
|
|
66
|
+
def start
|
|
67
|
+
return if run.running?
|
|
68
|
+
|
|
69
|
+
started = write_status('running', from: %w[enqueued interrupted],
|
|
70
|
+
started_at: run.started_at || Time.current,
|
|
71
|
+
job_id: job_id,
|
|
72
|
+
tick_total: run.tick_total || safe_count)
|
|
73
|
+
# An operator cancelled the run after the check above.
|
|
74
|
+
return check_operator_request unless started
|
|
75
|
+
|
|
76
|
+
publish('started')
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
record_error(e)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Outside the steps, so an error here would never reach the rescues
|
|
82
|
+
# below: a run whose arguments no longer fit its task (a deploy removed
|
|
83
|
+
# a parameter) would stay queued forever, or be resumed by Continuations
|
|
84
|
+
# without end. Recorded as a failure instead.
|
|
85
|
+
def prepare
|
|
86
|
+
@task = @task_class.new(@run.arguments.presence || {})
|
|
87
|
+
@task.run = @run
|
|
88
|
+
resume_after_time_slice
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
record_error(e)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One run per task is checked when a run is created, but two requests
|
|
94
|
+
# can both pass that check before either is saved. The older run wins,
|
|
95
|
+
# so the outcome does not depend on which worker picks a job up first.
|
|
96
|
+
def duplicate_run?
|
|
97
|
+
return false unless run.enqueued?
|
|
98
|
+
|
|
99
|
+
run.class.active.where(task_name: run.task_name).where(run.class.arel_table[:id].lt(run.id)).exists?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def refuse_duplicate
|
|
103
|
+
close_run('cancelled', 'Spree::MaintenanceTasks::AlreadyRunning',
|
|
104
|
+
"#{run.task_api_type} was already running, so this run did not start")
|
|
105
|
+
publish('cancelled')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Continuations skip `start` once it has completed, so an execution
|
|
109
|
+
# picking up after its time slice would otherwise keep reporting
|
|
110
|
+
# `interrupted` while it works — and the Cancel service would treat it as
|
|
111
|
+
# idle. Conditional, so a cancel written while the job sat
|
|
112
|
+
# in the queue is not overwritten.
|
|
113
|
+
def resume_after_time_slice
|
|
114
|
+
return unless run.interrupted?
|
|
115
|
+
|
|
116
|
+
run.class.where(id: run.id, status: 'interrupted').update_all(status: 'running', updated_at: Time.current)
|
|
117
|
+
run.reload
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The collection walk. Batches are fetched by cursor rather than by
|
|
121
|
+
# offset so a resumed execution reads nothing it has already processed.
|
|
122
|
+
def process_collection(step)
|
|
123
|
+
return if @halted
|
|
124
|
+
|
|
125
|
+
if task.no_collection?
|
|
126
|
+
process_single
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
loop do
|
|
131
|
+
break if @halted
|
|
132
|
+
|
|
133
|
+
items = next_batch(step.cursor)
|
|
134
|
+
break if items.empty?
|
|
135
|
+
|
|
136
|
+
process_batch(items)
|
|
137
|
+
break if @halted
|
|
138
|
+
|
|
139
|
+
# One cursor value, the last id seen, written to the row and to the
|
|
140
|
+
# continuation. Persisted before the checkpoint — an interruption
|
|
141
|
+
# between these two lines must not lose the batch already applied.
|
|
142
|
+
cursor = items.last.id.to_s
|
|
143
|
+
persist_progress(cursor: cursor, count: items.size)
|
|
144
|
+
step.set!(cursor)
|
|
145
|
+
|
|
146
|
+
check_operator_request
|
|
147
|
+
check_time_slice(step)
|
|
148
|
+
end
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
# Not discard_on: Continuations resume errors raised after progress,
|
|
151
|
+
# so a discard handler would never see them and the run would loop.
|
|
152
|
+
record_error(e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The callback runs before the row is written so a task can summarise in
|
|
156
|
+
# `after_complete` — tallying there and persisting first would silently
|
|
157
|
+
# drop whatever it recorded.
|
|
158
|
+
def finish
|
|
159
|
+
return if @halted
|
|
160
|
+
|
|
161
|
+
task.after_complete
|
|
162
|
+
return check_operator_request unless write_status('succeeded', from: 'running',
|
|
163
|
+
ended_at: Time.current,
|
|
164
|
+
tallies: merged_tallies)
|
|
165
|
+
|
|
166
|
+
publish('succeeded')
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
record_error(e)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A no_collection task is one unit of work: no cursor, no partial
|
|
172
|
+
# progress, and an error fails the whole run.
|
|
173
|
+
def process_single
|
|
174
|
+
task.process
|
|
175
|
+
persist_progress(cursor: nil, count: 1)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
record_error(e)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def process_batch(items)
|
|
181
|
+
items.each { |item| task.process(item) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Cursor-paged rather than find_each: the cursor has to survive between
|
|
185
|
+
# executions, which an in-memory batch enumerator cannot do.
|
|
186
|
+
def next_batch(cursor)
|
|
187
|
+
scope = task.collection
|
|
188
|
+
scope = scope.where(scope.arel_table[scope.primary_key].gt(cursor)) if cursor.present?
|
|
189
|
+
scope.limit(task.class.batch_size).to_a
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# update_columns rather than update!: progress is written on every batch
|
|
193
|
+
# and carries no validation of its own.
|
|
194
|
+
def persist_progress(cursor:, count:)
|
|
195
|
+
run.update_columns(
|
|
196
|
+
cursor: cursor,
|
|
197
|
+
tick_count: run.tick_count + count,
|
|
198
|
+
tallies: merged_tallies,
|
|
199
|
+
time_running: accumulated_time,
|
|
200
|
+
updated_at: Time.current
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Cancel is a request written to the row by the Cancel service; the
|
|
205
|
+
# runner is the only writer of the resulting state, so a killed worker
|
|
206
|
+
# can never leave `cancelled` on a run that is still moving.
|
|
207
|
+
#
|
|
208
|
+
# Read once per batch rather than on a timer: a primary-key read costs
|
|
209
|
+
# nothing beside the batch of writes it follows, and a time-based
|
|
210
|
+
# throttle would make a fast task ignore a cancel altogether — the
|
|
211
|
+
# batches would all land inside one interval.
|
|
212
|
+
def check_operator_request
|
|
213
|
+
requested = run.class.where(id: run.id).pick(:status)
|
|
214
|
+
return unless requested.in?(%w[cancelling cancelled])
|
|
215
|
+
|
|
216
|
+
write_status('cancelled', from: requested, ended_at: Time.current, time_running: accumulated_time)
|
|
217
|
+
publish('cancelled')
|
|
218
|
+
@halted = true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The control services write to the same row this job holds in memory,
|
|
222
|
+
# so the write only lands while the row is still in the status the
|
|
223
|
+
# runner expects. Otherwise an operator's cancel, written
|
|
224
|
+
# between the runner's read and its write, would be overwritten.
|
|
225
|
+
#
|
|
226
|
+
# @return [Boolean] whether the row was in an expected status
|
|
227
|
+
def write_status(status, from:, **attributes)
|
|
228
|
+
written = run.class.where(id: run.id, status: from).
|
|
229
|
+
update_all(status: status, updated_at: Time.current, **attributes)
|
|
230
|
+
run.reload
|
|
231
|
+
written.positive?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def check_time_slice(step)
|
|
235
|
+
return if @inline
|
|
236
|
+
return if Time.current - @execution_started_at < Spree::MaintenanceTask::MAX_RUN_TIME
|
|
237
|
+
|
|
238
|
+
return check_operator_request unless write_status('interrupted', from: 'running',
|
|
239
|
+
time_running: accumulated_time)
|
|
240
|
+
|
|
241
|
+
publish('interrupted')
|
|
242
|
+
# Raises Continuation::Interrupt, which inherits Exception and is
|
|
243
|
+
# never caught by the rescues above.
|
|
244
|
+
interrupt!(reason: :run_time_limit)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Recording a failure must never fail. This is the last thing standing
|
|
248
|
+
# between a crashed task and a run stuck in `running` forever, so it
|
|
249
|
+
# writes columns directly rather than going through validations that
|
|
250
|
+
# could reject the very state that describes the problem.
|
|
251
|
+
def record_error(error)
|
|
252
|
+
run.reload
|
|
253
|
+
run.update_columns(
|
|
254
|
+
status: 'errored',
|
|
255
|
+
ended_at: Time.current,
|
|
256
|
+
time_running: accumulated_time,
|
|
257
|
+
tallies: merged_tallies,
|
|
258
|
+
error_class: error.class.name,
|
|
259
|
+
error_message: error.message.to_s.truncate(1000),
|
|
260
|
+
error_backtrace: cleaned_backtrace(error),
|
|
261
|
+
updated_at: Time.current
|
|
262
|
+
)
|
|
263
|
+
publish('errored')
|
|
264
|
+
Rails.error.report(error, handled: true)
|
|
265
|
+
@halted = true
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def cleaned_backtrace(error)
|
|
269
|
+
return nil if error.backtrace.blank?
|
|
270
|
+
|
|
271
|
+
Rails.backtrace_cleaner.clean(error.backtrace).first(50).join("\n")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The task was removed from the registry between enqueue and execution —
|
|
275
|
+
# a deploy that dropped it. Recorded rather than raised: retrying cannot
|
|
276
|
+
# bring the class back.
|
|
277
|
+
def unfindable_task
|
|
278
|
+
close_run('errored', 'Spree::MaintenanceTasks::TaskNotRegistered',
|
|
279
|
+
"#{run.task_name} is not a registered maintenance task")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Ends a run that never started work, so there is nothing to tally.
|
|
283
|
+
def close_run(status, error_class, error_message)
|
|
284
|
+
run.update_columns(status: status, ended_at: Time.current, error_class: error_class,
|
|
285
|
+
error_message: error_message, updated_at: Time.current)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Summed, not replaced: a task instance counts only what the current
|
|
289
|
+
# execution did, so an interrupted run that merged would report its last
|
|
290
|
+
# slice as the whole total. Baselined at the first checkpoint of each
|
|
291
|
+
# execution so repeated writes within one execution stay accurate.
|
|
292
|
+
def merged_tallies
|
|
293
|
+
@tally_baseline ||= (run.tallies || {}).dup
|
|
294
|
+
return @tally_baseline.dup if task.nil?
|
|
295
|
+
|
|
296
|
+
task.tallies.each_with_object(@tally_baseline.dup) do |(key, value), result|
|
|
297
|
+
result[key] = result.fetch(key, 0) + value
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# From the total this execution started with, never the row's current
|
|
302
|
+
# value: progress rewrites the row every batch, so adding elapsed time
|
|
303
|
+
# to it would count the same seconds again on each write.
|
|
304
|
+
def accumulated_time
|
|
305
|
+
return run.time_running if @execution_started_at.nil?
|
|
306
|
+
|
|
307
|
+
@time_running_before + (Time.current - @execution_started_at)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def safe_count
|
|
311
|
+
task.count
|
|
312
|
+
rescue StandardError
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def publish(event)
|
|
317
|
+
run.publish_event("maintenance_task_run.#{event}")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module SellerTransfers
|
|
3
|
-
# Hands every seller holding an unconfirmed earning to a job of
|
|
3
|
+
# Hands every seller holding an unconfirmed earning or reversal to a job of
|
|
4
|
+
# their own.
|
|
4
5
|
#
|
|
5
6
|
# The transfer level's counterpart to {Spree::SellerPayouts::SweepDueJob},
|
|
6
7
|
# and deliberately a separate schedule rather than something the settlement
|
|
@@ -39,17 +40,18 @@ module Spree
|
|
|
39
40
|
# @param store [Spree::Store]
|
|
40
41
|
# @return [ActiveRecord::Relation<Spree::Seller>]
|
|
41
42
|
def sellers_awaiting_provider(store)
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
transfers = Spree::SellerTransfer.for_store(store)
|
|
44
|
+
sending = store.sellers.approved.where(id: transfers.awaiting_provider.select(:seller_id))
|
|
44
45
|
|
|
45
46
|
# A seller the provider will not accept yet keeps their earnings
|
|
46
47
|
# unconfirmed, and the job would return at once. Skipped rather than
|
|
47
48
|
# enqueued, because a marketplace's half-onboarded sellers are a
|
|
48
49
|
# standing population and this runs often — and nothing is lost by it:
|
|
49
50
|
# finishing onboarding runs the job for them directly.
|
|
50
|
-
|
|
51
|
+
sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
# Money owed back is pursued whatever the seller's standing.
|
|
54
|
+
sending.or(store.sellers.where(id: transfers.reversals_awaiting_provider.select(:seller_id)))
|
|
53
55
|
end
|
|
54
56
|
end
|
|
55
57
|
end
|
|
@@ -14,6 +14,9 @@ module Spree
|
|
|
14
14
|
# seller who was payable all along would never see a refused earning tried
|
|
15
15
|
# again, since nothing about them ever changes to prompt it.
|
|
16
16
|
#
|
|
17
|
+
# Reversals are retried the same way: a provider that pulls money back can
|
|
18
|
+
# refuse too, and one cannot be pulled back before its earning was sent.
|
|
19
|
+
#
|
|
17
20
|
# It takes the retryable rows — never sent, or refused outright. A transfer
|
|
18
21
|
# whose outcome the provider could not report is left alone: asking again
|
|
19
22
|
# is precisely how the same money moves twice, and only a person can say
|
|
@@ -24,30 +27,47 @@ module Spree
|
|
|
24
27
|
# @param seller_id [Integer]
|
|
25
28
|
def perform(seller_id)
|
|
26
29
|
seller = Spree::Seller.find_by(id: seller_id)
|
|
27
|
-
return if seller.nil?
|
|
30
|
+
return if seller.nil?
|
|
28
31
|
|
|
29
32
|
Spree::Current.store = seller.store
|
|
33
|
+
send_earnings(seller) if seller.payouts_enabled?
|
|
34
|
+
|
|
35
|
+
# Never through `transfer!`: a reversal's amount is negative, and the
|
|
36
|
+
# amount sent is its absolute value, so it would pay the seller the
|
|
37
|
+
# money it exists to take back. Through the provider that made the
|
|
38
|
+
# earning, which is not necessarily the store's current one. And for
|
|
39
|
+
# every seller, payable or not — a seller suspended after a wave of
|
|
40
|
+
# refunds is exactly the one whose money must come back.
|
|
41
|
+
seller.seller_transfers.reversals_awaiting_provider.includes(:reversed_from, :order).find_each do |reversal|
|
|
42
|
+
execute(reversal) { reversal.payout_provider_instance.reverse!(reversal) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Before the reversals, so a reversal waiting on its earning finds it sent.
|
|
49
|
+
def send_earnings(seller)
|
|
30
50
|
provider = seller.store.payout_provider_instance
|
|
31
51
|
|
|
32
|
-
# Earnings only — see the scope. A reversal is retryable the same way
|
|
33
|
-
# when its own provider call is refused, but sending one through
|
|
34
|
-
# `transfer!` would pay the seller the amount it exists to take back:
|
|
35
|
-
# the row is negative and the amount sent is its absolute value.
|
|
36
52
|
seller.seller_transfers.awaiting_provider.find_each do |transfer|
|
|
37
|
-
provider.transfer!(transfer)
|
|
38
|
-
rescue Spree::Core::AmbiguousGatewayError => e
|
|
39
|
-
# The money may already have moved, so this row must not be offered
|
|
40
|
-
# again: left retryable, the next run would send it a second time,
|
|
41
|
-
# and an idempotency key stops that only while the provider still
|
|
42
|
-
# holds its record.
|
|
43
|
-
transfer.update!(status: 'unresolved')
|
|
44
|
-
Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
|
|
45
|
-
rescue StandardError => e
|
|
46
|
-
# A refusal — the money did not move. One seller's stuck earning must
|
|
47
|
-
# not stop the rest, and the row stays retryable for the next run.
|
|
48
|
-
Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
|
|
53
|
+
execute(transfer) { provider.transfer!(transfer) }
|
|
49
54
|
end
|
|
50
55
|
end
|
|
56
|
+
|
|
57
|
+
def execute(transfer)
|
|
58
|
+
yield
|
|
59
|
+
rescue Spree::Core::AmbiguousGatewayError => e
|
|
60
|
+
# The money may already have moved, so this row must not be offered
|
|
61
|
+
# again: left retryable, the next run would move it a second time,
|
|
62
|
+
# and an idempotency key stops that only while the provider still
|
|
63
|
+
# holds its record.
|
|
64
|
+
transfer.update!(status: 'unresolved')
|
|
65
|
+
Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
# A refusal — the money did not move. One stuck row must not stop the
|
|
68
|
+
# rest, and it stays retryable for the next run.
|
|
69
|
+
Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
|
|
70
|
+
end
|
|
51
71
|
end
|
|
52
72
|
end
|
|
53
73
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# 5.6 → 6.0: market is mandatory on orders in Spree 6, and orders placed
|
|
5
|
+
# before markets existed carry NULLs. Each order takes its own store's
|
|
6
|
+
# default market, so the walk is per order rather than one UPDATE per
|
|
7
|
+
# store — that is what gives the run a cursor to resume from and a
|
|
8
|
+
# progress bar to watch on a catalog-sized table.
|
|
9
|
+
#
|
|
10
|
+
# Orders whose store has no default market are counted and left alone;
|
|
11
|
+
# there is nothing to assign, and failing the run would strand every
|
|
12
|
+
# other store's orders behind them.
|
|
13
|
+
class BackfillOrderMarkets < Spree::MaintenanceTask
|
|
14
|
+
supports_dry_run
|
|
15
|
+
collection_batch_size 500
|
|
16
|
+
|
|
17
|
+
def collection
|
|
18
|
+
Spree::Order.unscoped.where(market_id: nil).order(:id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def process(order)
|
|
22
|
+
market_id = default_market_id_for(order.store_id)
|
|
23
|
+
|
|
24
|
+
if market_id.nil?
|
|
25
|
+
tally(:skipped_no_default_market)
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return tally(:would_update) if dry_run?
|
|
30
|
+
|
|
31
|
+
order.update_columns(market_id: market_id, updated_at: Time.current)
|
|
32
|
+
tally(:updated)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# One lookup per store rather than per order: a backfill of this size
|
|
38
|
+
# walks the same handful of stores over and over.
|
|
39
|
+
def default_market_id_for(store_id)
|
|
40
|
+
@default_market_ids ||= {}
|
|
41
|
+
return @default_market_ids[store_id] if @default_market_ids.key?(store_id)
|
|
42
|
+
|
|
43
|
+
@default_market_ids[store_id] = Spree::Store.find_by(id: store_id)&.default_market&.id
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# 5.6 → 6.0 conversion of the per-payment-method auto_capture boolean onto
|
|
5
|
+
# the capture_method vocabulary (docs/plans/6.0-store-scoped-configuration.md).
|
|
6
|
+
#
|
|
7
|
+
# A method that captured on authorization charges at checkout. One that
|
|
8
|
+
# did not only recorded "not at checkout" — the old column could not say
|
|
9
|
+
# whether dispatch or staff was meant to take the money, and the store's
|
|
10
|
+
# own setting is what decided. Leaving those rows empty is what makes them
|
|
11
|
+
# keep inheriting it, so only the true case is written here, and the
|
|
12
|
+
# collection is the true case alone: any other scope would never reach
|
|
13
|
+
# zero remaining.
|
|
14
|
+
class CaptureMethods < Spree::MaintenanceTask
|
|
15
|
+
supports_dry_run
|
|
16
|
+
collection_batch_size 5_000
|
|
17
|
+
|
|
18
|
+
def collection
|
|
19
|
+
Spree::PaymentMethod.unscoped.where(capture_method: nil, auto_capture: true).order(:id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def process(payment_method)
|
|
23
|
+
return tally(:would_convert) if dry_run?
|
|
24
|
+
|
|
25
|
+
payment_method.update_columns(capture_method: 'checkout', updated_at: Time.current)
|
|
26
|
+
tally(:converted)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rows the conversion deliberately leaves alone, reported once so an
|
|
30
|
+
# operator can see they were considered rather than missed.
|
|
31
|
+
def after_complete
|
|
32
|
+
return if dry_run?
|
|
33
|
+
|
|
34
|
+
inheriting = Spree::PaymentMethod.unscoped.where(capture_method: nil, auto_capture: false).count
|
|
35
|
+
tally(:left_inheriting_store_setting, inheriting) if inheriting.positive?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# Creates a delivery for every fulfillment that carries a 5.6 tracking
|
|
5
|
+
# number, moving carrier tracking onto its own record.
|
|
6
|
+
class Deliveries < RakeStep
|
|
7
|
+
runs_rake_task 'spree:migrate_deliveries'
|
|
8
|
+
|
|
9
|
+
attribute :batch_size, :integer, default: 1_000
|
|
10
|
+
|
|
11
|
+
passes_to_environment :batch_size, as: 'BATCH_SIZE'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# Copies the coupon code a placed order was bought with onto the order
|
|
5
|
+
# itself.
|
|
6
|
+
class OrderCouponCodes < RakeStep
|
|
7
|
+
runs_rake_task 'spree:backfill_order_coupon_codes'
|
|
8
|
+
|
|
9
|
+
attribute :batch_size, :integer, default: 500
|
|
10
|
+
|
|
11
|
+
passes_to_environment :batch_size, as: 'BATCH_SIZE'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# Rewrites Option Value promotion rules so they reference option values
|
|
5
|
+
# directly rather than the join rows they used to point at.
|
|
6
|
+
class PromotionOptionValueRules < RakeStep
|
|
7
|
+
runs_rake_task 'spree:upgrade:migrate_promotion_option_value_rules'
|
|
8
|
+
|
|
9
|
+
attribute :skip_failed_rows, :boolean, default: false
|
|
10
|
+
|
|
11
|
+
passes_to_environment :skip_failed_rows, as: 'SKIP_FAILED_ROWS'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# Base for the one-pass upgrade steps that ask the operator for parameters
|
|
5
|
+
# (docs/plans/6.0-maintenance-tasks.md). The rake body stays the
|
|
6
|
+
# implementation; this class only maps its parameters onto the environment
|
|
7
|
+
# variables the body reads. A step without parameters runs through
|
|
8
|
+
# `UpgradeStep` and needs no class at all.
|
|
9
|
+
class RakeStep < Spree::MaintenanceTask
|
|
10
|
+
ENVIRONMENT_LOCK = Mutex.new
|
|
11
|
+
|
|
12
|
+
no_collection
|
|
13
|
+
|
|
14
|
+
class_attribute :rake_task_name, instance_writer: false
|
|
15
|
+
class_attribute :environment_parameters, instance_writer: false, default: {}.freeze
|
|
16
|
+
|
|
17
|
+
# @param name [String] the rake task this step runs
|
|
18
|
+
def self.runs_rake_task(name)
|
|
19
|
+
self.rake_task_name = name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Declares a parameter the rake body reads from the environment.
|
|
23
|
+
#
|
|
24
|
+
# These bodies were written for a shell, so their answers arrive as
|
|
25
|
+
# environment variables. Mapping them to attributes records the answer
|
|
26
|
+
# on the run, and lets `ARGS` supply it when the step is run on its own.
|
|
27
|
+
#
|
|
28
|
+
# @param attribute_name [Symbol]
|
|
29
|
+
# @param as [String] the environment variable the body reads
|
|
30
|
+
def self.passes_to_environment(attribute_name, as:)
|
|
31
|
+
self.environment_parameters = environment_parameters.merge(attribute_name.to_sym => as).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The declared parameters an operator exported before running the
|
|
35
|
+
# upgrade walk, so the walk passes them on rather than letting each
|
|
36
|
+
# attribute's default overwrite them.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash{String => String}]
|
|
39
|
+
def self.arguments_from_environment
|
|
40
|
+
environment_parameters.each_with_object({}) do |(attribute_name, variable), arguments|
|
|
41
|
+
arguments[attribute_name.to_s] = ENV[variable] if ENV.key?(variable)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def process
|
|
46
|
+
with_environment { invoke_rake_task(self.class.rake_task_name) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Restored afterwards so one run never leaks its answers into the next.
|
|
52
|
+
# ENV is shared by the whole process, so two steps running at once in a
|
|
53
|
+
# threaded worker take turns rather than reading each other's answers.
|
|
54
|
+
def with_environment(&block)
|
|
55
|
+
return yield if self.class.environment_parameters.empty?
|
|
56
|
+
|
|
57
|
+
ENVIRONMENT_LOCK.synchronize { with_variables_set(&block) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_variables_set
|
|
61
|
+
keys = self.class.environment_parameters.values
|
|
62
|
+
previous = ENV.to_h.slice(*keys)
|
|
63
|
+
|
|
64
|
+
self.class.environment_parameters.each do |attribute_name, variable|
|
|
65
|
+
ENV[variable] = environment_value(public_send(attribute_name))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
keys&.each { |key| ENV[key] = previous[key] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def environment_value(value)
|
|
74
|
+
case value
|
|
75
|
+
when true then 'true'
|
|
76
|
+
when false, nil then nil
|
|
77
|
+
else value.to_s.presence
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
module Upgrade
|
|
4
|
+
# Moves return authorizations onto the 6.0 Return and Exchange models.
|
|
5
|
+
#
|
|
6
|
+
# Resumable by construction: the legacy number is copied onto the new
|
|
7
|
+
# record and uniquely indexed there, so what is still outstanding is a
|
|
8
|
+
# query rather than tracked state.
|
|
9
|
+
class Returns < RakeStep
|
|
10
|
+
runs_rake_task 'spree:upgrade:migrate_returns'
|
|
11
|
+
|
|
12
|
+
attribute :skip_failed_rows, :boolean, default: false
|
|
13
|
+
attribute :batch_size, :integer, default: 500
|
|
14
|
+
|
|
15
|
+
passes_to_environment :skip_failed_rows, as: 'SKIP_FAILED_ROWS'
|
|
16
|
+
passes_to_environment :batch_size, as: 'BATCH_SIZE'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|