usage_credits 0.4.0 → 1.0.0
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/CHANGELOG.md +72 -0
- data/README.md +246 -13
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +43 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +55 -152
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +219 -314
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -48
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Upgrades a pre-1.0 usage_credits install to the wallets-backed ledger core.
|
|
4
|
+
#
|
|
5
|
+
# Every DDL step is independently guarded so this migration can resume after
|
|
6
|
+
# an interrupted MySQL deployment. Data-integrity preflights run before the
|
|
7
|
+
# first schema mutation. The migration is intentionally irreversible: restore
|
|
8
|
+
# the required pre-deploy backup to roll back this release.
|
|
9
|
+
#
|
|
10
|
+
# PostgreSQL runs this migration inside one DDL transaction. Locks acquired by
|
|
11
|
+
# the index build, bigint conversions, constraints, foreign keys, and transfer
|
|
12
|
+
# schema are held until the entire migration commits. Rehearse on a production
|
|
13
|
+
# snapshot and schedule a maintenance window for a large/write-heavy ledger.
|
|
14
|
+
# Do not casually add disable_ddl_transaction!: a lower-lock rollout needs an
|
|
15
|
+
# application-specific staged plan for concurrent indexes and later constraint
|
|
16
|
+
# validation, and necessarily exposes a partially upgraded schema between steps.
|
|
17
|
+
class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_version %>
|
|
18
|
+
REQUIRED_TABLES = %i[
|
|
19
|
+
usage_credits_wallets
|
|
20
|
+
usage_credits_transactions
|
|
21
|
+
usage_credits_allocations
|
|
22
|
+
usage_credits_fulfillments
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
OWNER_ASSET_INDEX = "index_usage_credits_wallets_on_owner_and_asset"
|
|
26
|
+
FULFILLMENT_SOURCE_INDEX = "index_usage_credits_fulfillments_on_source"
|
|
27
|
+
TRANSFER_WALLETS_INDEX = "index_usage_credits_transfers_on_wallets_and_asset"
|
|
28
|
+
TRANSACTION_TRANSFER_INDEX = "index_usage_credits_transactions_on_transfer_id"
|
|
29
|
+
PAY_SOURCE_TABLES = {
|
|
30
|
+
"Pay::Charge" => :pay_charges,
|
|
31
|
+
"Pay::Subscription" => :pay_subscriptions
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def up
|
|
35
|
+
ensure_usage_credits_installed!
|
|
36
|
+
ensure_existing_transfer_schema!
|
|
37
|
+
ensure_reserved_index_names!
|
|
38
|
+
ensure_no_duplicate_wallets!
|
|
39
|
+
ensure_no_duplicate_fulfillment_sources!
|
|
40
|
+
ensure_complete_fulfillment_sources!
|
|
41
|
+
ensure_no_orphaned_ledger_references!
|
|
42
|
+
ensure_no_orphaned_pay_sources!
|
|
43
|
+
ensure_ledger_invariants!
|
|
44
|
+
|
|
45
|
+
wallet_key_type = primary_key_type_for(:usage_credits_wallets)
|
|
46
|
+
ensure_ledger_reference_types!(wallet_key_type)
|
|
47
|
+
|
|
48
|
+
unless column_exists?(:usage_credits_wallets, :asset_code)
|
|
49
|
+
add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The wallets core increments this counter on every command and compares
|
|
53
|
+
# it to tell a stale MySQL snapshot from a current one. Without it, the
|
|
54
|
+
# core falls back to a timestamp marker that another connection's
|
|
55
|
+
# `touch(time:)` can repeat. Existing rows start at 0: only changes count.
|
|
56
|
+
unless column_exists?(:usage_credits_wallets, :ledger_revision)
|
|
57
|
+
add_column :usage_credits_wallets, :ledger_revision, :bigint, null: false, default: 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ensure_unique_index!(
|
|
61
|
+
:usage_credits_wallets,
|
|
62
|
+
%i[owner_type owner_id asset_code],
|
|
63
|
+
name: OWNER_ASSET_INDEX
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
ensure_bigint_column!(:usage_credits_wallets, :balance, null: false, default: 0)
|
|
67
|
+
ensure_bigint_column!(:usage_credits_transactions, :amount, null: false)
|
|
68
|
+
ensure_bigint_column!(:usage_credits_allocations, :amount, null: false)
|
|
69
|
+
ensure_bigint_column!(:usage_credits_fulfillments, :credits_last_fulfillment, null: false)
|
|
70
|
+
|
|
71
|
+
ensure_check_constraint!(
|
|
72
|
+
:usage_credits_transactions,
|
|
73
|
+
"amount <> 0",
|
|
74
|
+
name: "check_usage_credits_transactions_amount_nonzero"
|
|
75
|
+
)
|
|
76
|
+
ensure_check_constraint!(
|
|
77
|
+
:usage_credits_allocations,
|
|
78
|
+
"amount > 0",
|
|
79
|
+
name: "check_usage_credits_allocations_amount_positive"
|
|
80
|
+
)
|
|
81
|
+
ensure_check_constraint!(
|
|
82
|
+
:usage_credits_fulfillments,
|
|
83
|
+
"credits_last_fulfillment >= 0",
|
|
84
|
+
name: "check_usage_credits_fulfillments_credits_nonnegative"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
ensure_ledger_foreign_keys!(wallet_key_type)
|
|
88
|
+
ensure_transfers_table!(wallet_key_type)
|
|
89
|
+
ensure_transfer_reference!
|
|
90
|
+
|
|
91
|
+
ensure_unique_index!(
|
|
92
|
+
:usage_credits_fulfillments,
|
|
93
|
+
%i[source_type source_id],
|
|
94
|
+
name: FULFILLMENT_SOURCE_INDEX
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def down
|
|
99
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
100
|
+
"usage_credits 1.0 cannot be downgraded in place. Restore the backup taken before upgrading."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def ensure_usage_credits_installed!
|
|
106
|
+
missing = REQUIRED_TABLES.reject { |table| table_exists?(table) }
|
|
107
|
+
return if missing.empty?
|
|
108
|
+
|
|
109
|
+
if missing == REQUIRED_TABLES
|
|
110
|
+
raise <<~MESSAGE
|
|
111
|
+
No usage_credits tables found. This migration upgrades an existing pre-1.0
|
|
112
|
+
install. For new apps, run `rails generate usage_credits:install` instead.
|
|
113
|
+
MESSAGE
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise <<~MESSAGE
|
|
117
|
+
Cannot upgrade an incomplete usage_credits schema. Missing table(s):
|
|
118
|
+
#{missing.join(', ')}
|
|
119
|
+
|
|
120
|
+
Restore the missing pre-1.0 tables (and their data) from backup before
|
|
121
|
+
retrying. No schema changes have been applied.
|
|
122
|
+
MESSAGE
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ensure_no_duplicate_wallets!
|
|
126
|
+
grouping = %w[owner_type owner_id]
|
|
127
|
+
grouping << "asset_code" if column_exists?(:usage_credits_wallets, :asset_code)
|
|
128
|
+
|
|
129
|
+
duplicates = connection.select_rows(<<~SQL.squish)
|
|
130
|
+
SELECT #{grouping.join(', ')}, COUNT(*)
|
|
131
|
+
FROM usage_credits_wallets
|
|
132
|
+
GROUP BY #{grouping.join(', ')}
|
|
133
|
+
HAVING COUNT(*) > 1
|
|
134
|
+
ORDER BY #{grouping.join(', ')}
|
|
135
|
+
SQL
|
|
136
|
+
|
|
137
|
+
return if duplicates.empty?
|
|
138
|
+
|
|
139
|
+
listed = duplicates.first(10).map do |row|
|
|
140
|
+
owner_type, owner_id, asset_code, count =
|
|
141
|
+
if grouping.include?("asset_code")
|
|
142
|
+
row
|
|
143
|
+
else
|
|
144
|
+
[row[0], row[1], nil, row[2]]
|
|
145
|
+
end
|
|
146
|
+
asset = asset_code ? " / #{asset_code}" : ""
|
|
147
|
+
" - #{owner_type}##{owner_id}#{asset} (#{count} wallets)"
|
|
148
|
+
end
|
|
149
|
+
listed << " ...and #{duplicates.size - 10} more" if duplicates.size > 10
|
|
150
|
+
|
|
151
|
+
raise <<~MESSAGE
|
|
152
|
+
Cannot upgrade: #{duplicates.size} owner(s) have more than one usage_credits wallet:
|
|
153
|
+
|
|
154
|
+
#{listed.join("\n")}
|
|
155
|
+
|
|
156
|
+
These are financially ambiguous and must be reviewed before adding the
|
|
157
|
+
unique owner/asset constraint. For each owner, choose the canonical wallet,
|
|
158
|
+
move its transactions and fulfillments, recompute the cached balance from
|
|
159
|
+
the merged ledger, verify allocations, and only then delete the duplicate.
|
|
160
|
+
|
|
161
|
+
No schema changes have been applied yet. Back up the database, resolve the
|
|
162
|
+
duplicates in a reviewed script, and run `rails db:migrate` again.
|
|
163
|
+
MESSAGE
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ensure_no_duplicate_fulfillment_sources!
|
|
167
|
+
duplicates = connection.select_rows(<<~SQL.squish)
|
|
168
|
+
SELECT source_type, source_id, COUNT(*)
|
|
169
|
+
FROM usage_credits_fulfillments
|
|
170
|
+
WHERE source_type IS NOT NULL AND source_id IS NOT NULL
|
|
171
|
+
GROUP BY source_type, source_id
|
|
172
|
+
HAVING COUNT(*) > 1
|
|
173
|
+
ORDER BY source_type, source_id
|
|
174
|
+
SQL
|
|
175
|
+
|
|
176
|
+
return if duplicates.empty?
|
|
177
|
+
|
|
178
|
+
listed = duplicates.first(10).map { |type, id, count| " - #{type}##{id} (#{count} fulfillments)" }
|
|
179
|
+
listed << " ...and #{duplicates.size - 10} more" if duplicates.size > 10
|
|
180
|
+
|
|
181
|
+
raise <<~MESSAGE
|
|
182
|
+
Cannot upgrade: #{duplicates.size} payment source(s) have duplicate fulfillments:
|
|
183
|
+
|
|
184
|
+
#{listed.join("\n")}
|
|
185
|
+
|
|
186
|
+
One payment source may mint credits only once. Reconcile each source against
|
|
187
|
+
its linked ledger transactions and processor record, retain the correct
|
|
188
|
+
fulfillment, and remove only proven duplicates. No schema changes have been
|
|
189
|
+
applied; run `rails db:migrate` again after reconciliation.
|
|
190
|
+
MESSAGE
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def ensure_complete_fulfillment_sources!
|
|
194
|
+
count = connection.select_value(<<~SQL.squish).to_i
|
|
195
|
+
SELECT COUNT(*)
|
|
196
|
+
FROM usage_credits_fulfillments
|
|
197
|
+
WHERE (source_type IS NULL AND source_id IS NOT NULL)
|
|
198
|
+
OR (source_type IS NOT NULL AND source_id IS NULL)
|
|
199
|
+
SQL
|
|
200
|
+
return if count.zero?
|
|
201
|
+
|
|
202
|
+
raise <<~MESSAGE
|
|
203
|
+
Cannot upgrade: #{count} fulfillment(s) have an incomplete polymorphic
|
|
204
|
+
source (only source_type or source_id is present). Reconcile these rows
|
|
205
|
+
before retrying. No schema changes have been applied.
|
|
206
|
+
MESSAGE
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ensure_no_orphaned_ledger_references!
|
|
210
|
+
checks = [
|
|
211
|
+
[ :usage_credits_transactions, :wallet_id, :usage_credits_wallets ],
|
|
212
|
+
[ :usage_credits_fulfillments, :wallet_id, :usage_credits_wallets ],
|
|
213
|
+
[ :usage_credits_transactions, :fulfillment_id, :usage_credits_fulfillments ],
|
|
214
|
+
[ :usage_credits_allocations, :transaction_id, :usage_credits_transactions ],
|
|
215
|
+
[ :usage_credits_allocations, :source_transaction_id, :usage_credits_transactions ]
|
|
216
|
+
]
|
|
217
|
+
if table_exists?(:usage_credits_transfers)
|
|
218
|
+
checks << [ :usage_credits_transfers, :from_wallet_id, :usage_credits_wallets ]
|
|
219
|
+
checks << [ :usage_credits_transfers, :to_wallet_id, :usage_credits_wallets ]
|
|
220
|
+
end
|
|
221
|
+
if table_exists?(:usage_credits_transfers) && column_exists?(:usage_credits_transactions, :transfer_id)
|
|
222
|
+
checks << [ :usage_credits_transactions, :transfer_id, :usage_credits_transfers ]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
orphaned = checks.filter_map do |child_table, foreign_key, parent_table|
|
|
226
|
+
count = orphan_count(child_table, foreign_key, parent_table)
|
|
227
|
+
" - #{child_table}.#{foreign_key}: #{count}" if count.positive?
|
|
228
|
+
end
|
|
229
|
+
return if orphaned.empty?
|
|
230
|
+
|
|
231
|
+
raise <<~MESSAGE
|
|
232
|
+
Cannot upgrade because orphaned ledger references were found:
|
|
233
|
+
|
|
234
|
+
#{orphaned.join("\n")}
|
|
235
|
+
|
|
236
|
+
Restore or reconcile the missing parent rows before retrying. The upgrade
|
|
237
|
+
adds foreign keys to make this corruption impossible going forward. No
|
|
238
|
+
schema changes have been applied.
|
|
239
|
+
MESSAGE
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def ensure_no_orphaned_pay_sources!
|
|
243
|
+
orphaned = PAY_SOURCE_TABLES.filter_map do |source_type, parent_table|
|
|
244
|
+
source_count = scalar_count(<<~SQL)
|
|
245
|
+
SELECT COUNT(*)
|
|
246
|
+
FROM usage_credits_fulfillments
|
|
247
|
+
WHERE source_type = #{connection.quote(source_type)}
|
|
248
|
+
AND source_id IS NOT NULL
|
|
249
|
+
SQL
|
|
250
|
+
next if source_count.zero?
|
|
251
|
+
|
|
252
|
+
unless table_exists?(parent_table)
|
|
253
|
+
next " - #{source_type}: #{source_count} (missing #{parent_table})"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
count = polymorphic_orphan_count(source_type, parent_table)
|
|
257
|
+
" - #{source_type}: #{count}" if count.positive?
|
|
258
|
+
end
|
|
259
|
+
return if orphaned.empty?
|
|
260
|
+
|
|
261
|
+
raise <<~MESSAGE
|
|
262
|
+
Cannot upgrade because fulfillment payment sources are missing:
|
|
263
|
+
|
|
264
|
+
#{orphaned.join("\n")}
|
|
265
|
+
|
|
266
|
+
Restore or reconcile each Pay charge/subscription before retrying. A
|
|
267
|
+
dangling subscription source could otherwise mint credits without a
|
|
268
|
+
processor record to prove that the customer is still entitled to them.
|
|
269
|
+
No schema changes have been applied.
|
|
270
|
+
MESSAGE
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def ensure_ledger_invariants!
|
|
274
|
+
violations = {
|
|
275
|
+
"zero-amount transactions" => scalar_count(<<~SQL),
|
|
276
|
+
SELECT COUNT(*) FROM usage_credits_transactions WHERE amount = 0
|
|
277
|
+
SQL
|
|
278
|
+
"non-positive allocations" => scalar_count(<<~SQL),
|
|
279
|
+
SELECT COUNT(*) FROM usage_credits_allocations WHERE amount <= 0
|
|
280
|
+
SQL
|
|
281
|
+
"allocations with invalid debit/credit direction or different wallets" => scalar_count(<<~SQL),
|
|
282
|
+
SELECT COUNT(*)
|
|
283
|
+
FROM usage_credits_allocations allocations
|
|
284
|
+
INNER JOIN usage_credits_transactions spends
|
|
285
|
+
ON spends.id = allocations.transaction_id
|
|
286
|
+
INNER JOIN usage_credits_transactions sources
|
|
287
|
+
ON sources.id = allocations.source_transaction_id
|
|
288
|
+
WHERE spends.amount >= 0
|
|
289
|
+
OR sources.amount <= 0
|
|
290
|
+
OR spends.wallet_id <> sources.wallet_id
|
|
291
|
+
SQL
|
|
292
|
+
"over-allocated credit sources" => scalar_count(<<~SQL),
|
|
293
|
+
SELECT COUNT(*)
|
|
294
|
+
FROM (
|
|
295
|
+
SELECT allocations.source_transaction_id
|
|
296
|
+
FROM usage_credits_allocations allocations
|
|
297
|
+
INNER JOIN usage_credits_transactions sources
|
|
298
|
+
ON sources.id = allocations.source_transaction_id
|
|
299
|
+
GROUP BY allocations.source_transaction_id, sources.amount
|
|
300
|
+
HAVING SUM(allocations.amount) > sources.amount
|
|
301
|
+
) ledger_violations
|
|
302
|
+
SQL
|
|
303
|
+
"over-allocated debit transactions" => scalar_count(<<~SQL),
|
|
304
|
+
SELECT COUNT(*)
|
|
305
|
+
FROM (
|
|
306
|
+
SELECT allocations.transaction_id
|
|
307
|
+
FROM usage_credits_allocations allocations
|
|
308
|
+
INNER JOIN usage_credits_transactions spends
|
|
309
|
+
ON spends.id = allocations.transaction_id
|
|
310
|
+
GROUP BY allocations.transaction_id, spends.amount
|
|
311
|
+
HAVING SUM(allocations.amount) > -spends.amount
|
|
312
|
+
) ledger_violations
|
|
313
|
+
SQL
|
|
314
|
+
"negative fulfillment credit snapshots" => scalar_count(<<~SQL)
|
|
315
|
+
SELECT COUNT(*)
|
|
316
|
+
FROM usage_credits_fulfillments
|
|
317
|
+
WHERE credits_last_fulfillment < 0
|
|
318
|
+
SQL
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if table_exists?(:usage_credits_transfers)
|
|
322
|
+
violations["invalid interrupted transfers"] = scalar_count(<<~SQL)
|
|
323
|
+
SELECT COUNT(*)
|
|
324
|
+
FROM usage_credits_transfers
|
|
325
|
+
WHERE amount <= 0 OR from_wallet_id = to_wallet_id
|
|
326
|
+
SQL
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
violations.delete_if { |_description, count| count.zero? }
|
|
330
|
+
return if violations.empty?
|
|
331
|
+
|
|
332
|
+
listed = violations.map { |description, count| " - #{description}: #{count}" }
|
|
333
|
+
raise <<~MESSAGE
|
|
334
|
+
Cannot upgrade because the existing ledger violates wallets accounting invariants:
|
|
335
|
+
|
|
336
|
+
#{listed.join("\n")}
|
|
337
|
+
|
|
338
|
+
Reconcile every listed row against the surrounding transactions and payment
|
|
339
|
+
records before retrying. Automatically coercing these values could create or
|
|
340
|
+
destroy customer credit. No schema changes have been applied.
|
|
341
|
+
MESSAGE
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def scalar_count(sql)
|
|
345
|
+
connection.select_value(sql.squish).to_i
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def orphan_count(child_table, foreign_key, parent_table)
|
|
349
|
+
child = connection.quote_table_name(child_table)
|
|
350
|
+
parent = connection.quote_table_name(parent_table)
|
|
351
|
+
foreign_column = connection.quote_column_name(foreign_key)
|
|
352
|
+
parent_key = connection.quote_column_name(connection.primary_key(parent_table))
|
|
353
|
+
|
|
354
|
+
connection.select_value(<<~SQL.squish).to_i
|
|
355
|
+
SELECT COUNT(*)
|
|
356
|
+
FROM #{child} child
|
|
357
|
+
LEFT JOIN #{parent} parent ON parent.#{parent_key} = child.#{foreign_column}
|
|
358
|
+
WHERE child.#{foreign_column} IS NOT NULL
|
|
359
|
+
AND parent.#{parent_key} IS NULL
|
|
360
|
+
SQL
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def polymorphic_orphan_count(source_type, parent_table)
|
|
364
|
+
fulfillments = connection.quote_table_name(:usage_credits_fulfillments)
|
|
365
|
+
parent = connection.quote_table_name(parent_table)
|
|
366
|
+
source_id = connection.quote_column_name(:source_id)
|
|
367
|
+
parent_key = connection.quote_column_name(connection.primary_key(parent_table))
|
|
368
|
+
|
|
369
|
+
connection.select_value(<<~SQL.squish).to_i
|
|
370
|
+
SELECT COUNT(*)
|
|
371
|
+
FROM #{fulfillments} fulfillments
|
|
372
|
+
LEFT JOIN #{parent} parent ON parent.#{parent_key} = fulfillments.#{source_id}
|
|
373
|
+
WHERE fulfillments.source_type = #{connection.quote(source_type)}
|
|
374
|
+
AND fulfillments.#{source_id} IS NOT NULL
|
|
375
|
+
AND parent.#{parent_key} IS NULL
|
|
376
|
+
SQL
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def ensure_bigint_column!(table, column, **options)
|
|
380
|
+
current = column_for(table, column)
|
|
381
|
+
raise "Cannot upgrade: missing #{table}.#{column}" unless current
|
|
382
|
+
return if current.sql_type.to_s.downcase.include?("bigint")
|
|
383
|
+
|
|
384
|
+
change_column table, column, :bigint, **options
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def ensure_existing_transfer_schema!
|
|
388
|
+
transaction_reference_exists = column_exists?(:usage_credits_transactions, :transfer_id)
|
|
389
|
+
unless table_exists?(:usage_credits_transfers)
|
|
390
|
+
return unless transaction_reference_exists
|
|
391
|
+
|
|
392
|
+
raise <<~MESSAGE.squish
|
|
393
|
+
Cannot resume upgrade: usage_credits_transactions.transfer_id exists but
|
|
394
|
+
usage_credits_transfers does not. Restore or remove the incomplete
|
|
395
|
+
reference before retrying; no new schema changes have been applied.
|
|
396
|
+
MESSAGE
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
required_columns = %w[
|
|
400
|
+
id from_wallet_id to_wallet_id asset_code amount category
|
|
401
|
+
expiration_policy metadata created_at updated_at
|
|
402
|
+
]
|
|
403
|
+
missing = required_columns.reject { |column| column_exists?(:usage_credits_transfers, column) }
|
|
404
|
+
return if missing.empty?
|
|
405
|
+
|
|
406
|
+
raise <<~MESSAGE.squish
|
|
407
|
+
Cannot resume upgrade: usage_credits_transfers is incomplete (missing:
|
|
408
|
+
#{missing.join(', ')}). Repair or remove the interrupted table before
|
|
409
|
+
retrying; no new schema changes have been applied.
|
|
410
|
+
MESSAGE
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def ensure_reserved_index_names!
|
|
414
|
+
expectations = [
|
|
415
|
+
[:usage_credits_wallets, OWNER_ASSET_INDEX, %w[owner_type owner_id asset_code]],
|
|
416
|
+
[:usage_credits_fulfillments, FULFILLMENT_SOURCE_INDEX, %w[source_type source_id]]
|
|
417
|
+
]
|
|
418
|
+
if table_exists?(:usage_credits_transfers)
|
|
419
|
+
expectations << [:usage_credits_transfers, TRANSFER_WALLETS_INDEX, %w[from_wallet_id to_wallet_id asset_code]]
|
|
420
|
+
end
|
|
421
|
+
if column_exists?(:usage_credits_transactions, :transfer_id)
|
|
422
|
+
expectations << [:usage_credits_transactions, TRANSACTION_TRANSFER_INDEX, %w[transfer_id]]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
expectations.each do |table, name, expected_columns|
|
|
426
|
+
index = connection.indexes(table).find { |candidate| candidate.name == name }
|
|
427
|
+
next if index.nil? || index.columns == expected_columns
|
|
428
|
+
|
|
429
|
+
raise <<~MESSAGE.squish
|
|
430
|
+
Cannot upgrade: index #{name} on #{table} is reserved for
|
|
431
|
+
(#{expected_columns.join(', ')}) but currently covers
|
|
432
|
+
(#{index.columns.join(', ')}). Rename or remove the conflicting index
|
|
433
|
+
before retrying; no schema changes have been applied.
|
|
434
|
+
MESSAGE
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def ensure_ledger_reference_types!(wallet_key_type)
|
|
439
|
+
ensure_reference_type!(:usage_credits_transactions, :wallet_id, wallet_key_type)
|
|
440
|
+
ensure_reference_type!(:usage_credits_fulfillments, :wallet_id, wallet_key_type)
|
|
441
|
+
|
|
442
|
+
transaction_key_type = primary_key_type_for(:usage_credits_transactions)
|
|
443
|
+
ensure_reference_type!(:usage_credits_allocations, :transaction_id, transaction_key_type)
|
|
444
|
+
ensure_reference_type!(:usage_credits_allocations, :source_transaction_id, transaction_key_type)
|
|
445
|
+
|
|
446
|
+
fulfillment_key_type = primary_key_type_for(:usage_credits_fulfillments)
|
|
447
|
+
ensure_reference_type!(:usage_credits_transactions, :fulfillment_id, fulfillment_key_type)
|
|
448
|
+
|
|
449
|
+
return unless table_exists?(:usage_credits_transfers)
|
|
450
|
+
|
|
451
|
+
ensure_reference_type!(:usage_credits_transfers, :from_wallet_id, wallet_key_type)
|
|
452
|
+
ensure_reference_type!(:usage_credits_transfers, :to_wallet_id, wallet_key_type)
|
|
453
|
+
if column_exists?(:usage_credits_transactions, :transfer_id)
|
|
454
|
+
ensure_reference_type!(
|
|
455
|
+
:usage_credits_transactions,
|
|
456
|
+
:transfer_id,
|
|
457
|
+
primary_key_type_for(:usage_credits_transfers)
|
|
458
|
+
)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def ensure_ledger_foreign_keys!(wallet_key_type)
|
|
463
|
+
ensure_foreign_key!(:usage_credits_transactions, :usage_credits_wallets, column: :wallet_id)
|
|
464
|
+
ensure_foreign_key!(:usage_credits_fulfillments, :usage_credits_wallets, column: :wallet_id)
|
|
465
|
+
|
|
466
|
+
ensure_foreign_key!(:usage_credits_allocations, :usage_credits_transactions, column: :transaction_id)
|
|
467
|
+
ensure_foreign_key!(:usage_credits_allocations, :usage_credits_transactions, column: :source_transaction_id)
|
|
468
|
+
|
|
469
|
+
ensure_foreign_key!(:usage_credits_transactions, :usage_credits_fulfillments, column: :fulfillment_id)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def ensure_transfers_table!(wallet_key_type)
|
|
473
|
+
unless table_exists?(:usage_credits_transfers)
|
|
474
|
+
create_table :usage_credits_transfers, id: wallet_key_type do |t|
|
|
475
|
+
t.references :from_wallet, null: false, type: wallet_key_type
|
|
476
|
+
t.references :to_wallet, null: false, type: wallet_key_type
|
|
477
|
+
t.string :asset_code, null: false, default: "credits"
|
|
478
|
+
t.bigint :amount, null: false
|
|
479
|
+
t.string :category, null: false, default: "transfer"
|
|
480
|
+
t.string :expiration_policy, null: false, default: "preserve"
|
|
481
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
482
|
+
|
|
483
|
+
t.timestamps
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
ensure_reference_type!(:usage_credits_transfers, :from_wallet_id, wallet_key_type)
|
|
488
|
+
ensure_reference_type!(:usage_credits_transfers, :to_wallet_id, wallet_key_type)
|
|
489
|
+
|
|
490
|
+
ensure_check_constraint!(
|
|
491
|
+
:usage_credits_transfers,
|
|
492
|
+
"amount > 0",
|
|
493
|
+
name: "check_usage_credits_transfers_amount_positive"
|
|
494
|
+
)
|
|
495
|
+
ensure_check_constraint!(
|
|
496
|
+
:usage_credits_transfers,
|
|
497
|
+
"from_wallet_id <> to_wallet_id",
|
|
498
|
+
name: "check_usage_credits_transfers_distinct_wallets"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
ensure_foreign_key!(
|
|
502
|
+
:usage_credits_transfers,
|
|
503
|
+
:usage_credits_wallets,
|
|
504
|
+
column: :from_wallet_id
|
|
505
|
+
)
|
|
506
|
+
ensure_foreign_key!(
|
|
507
|
+
:usage_credits_transfers,
|
|
508
|
+
:usage_credits_wallets,
|
|
509
|
+
column: :to_wallet_id
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
unless index_exists?(:usage_credits_transfers, %i[from_wallet_id to_wallet_id asset_code])
|
|
513
|
+
add_index :usage_credits_transfers,
|
|
514
|
+
%i[from_wallet_id to_wallet_id asset_code],
|
|
515
|
+
name: TRANSFER_WALLETS_INDEX
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def ensure_transfer_reference!
|
|
520
|
+
transfer_key_type = primary_key_type_for(:usage_credits_transfers)
|
|
521
|
+
|
|
522
|
+
unless column_exists?(:usage_credits_transactions, :transfer_id)
|
|
523
|
+
add_column :usage_credits_transactions, :transfer_id, transfer_key_type
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
ensure_reference_type!(:usage_credits_transactions, :transfer_id, transfer_key_type)
|
|
527
|
+
|
|
528
|
+
unless index_exists?(:usage_credits_transactions, :transfer_id)
|
|
529
|
+
add_index :usage_credits_transactions, :transfer_id, name: TRANSACTION_TRANSFER_INDEX
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
ensure_foreign_key!(
|
|
533
|
+
:usage_credits_transactions,
|
|
534
|
+
:usage_credits_transfers,
|
|
535
|
+
column: :transfer_id
|
|
536
|
+
)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def ensure_unique_index!(table, columns, name:)
|
|
540
|
+
expected_columns = columns.map(&:to_s)
|
|
541
|
+
existing = connection.indexes(table).find { |index| index.columns == expected_columns }
|
|
542
|
+
return if existing&.unique
|
|
543
|
+
|
|
544
|
+
remove_index table, name: existing.name if existing
|
|
545
|
+
add_index table, columns, unique: true, name: name
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def ensure_foreign_key!(from_table, to_table, column:)
|
|
549
|
+
return if foreign_key_exists?(from_table, to_table, column: column)
|
|
550
|
+
|
|
551
|
+
add_foreign_key from_table, to_table, column: column
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def ensure_check_constraint!(table, expression, name:)
|
|
555
|
+
return if check_constraint_exists?(table, name: name)
|
|
556
|
+
|
|
557
|
+
add_check_constraint table, expression, name: name
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def ensure_reference_type!(table, column, expected_type)
|
|
561
|
+
actual_type = reference_type_for(column_for(table, column))
|
|
562
|
+
return if actual_type == expected_type
|
|
563
|
+
|
|
564
|
+
raise <<~MESSAGE.squish
|
|
565
|
+
Cannot upgrade: #{table}.#{column} is #{actual_type.inspect}, but the
|
|
566
|
+
referenced primary key is #{expected_type.inspect}. Correct this type
|
|
567
|
+
mismatch before retrying; no automatic cast is safe for ledger keys.
|
|
568
|
+
MESSAGE
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def primary_key_type_for(table)
|
|
572
|
+
primary_key = connection.primary_key(table)
|
|
573
|
+
column = column_for(table, primary_key)
|
|
574
|
+
raise "Cannot determine primary key type for #{table}" unless column
|
|
575
|
+
|
|
576
|
+
reference_type_for(column)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def reference_type_for(column)
|
|
580
|
+
return :uuid if column.type == :uuid
|
|
581
|
+
return :bigint if column.sql_type.to_s.downcase.include?("bigint")
|
|
582
|
+
return :integer if column.type == :integer
|
|
583
|
+
|
|
584
|
+
column.type
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def column_for(table, column)
|
|
588
|
+
connection.columns(table).find { |candidate| candidate.name == column.to_s }
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def json_column_type
|
|
592
|
+
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
|
|
593
|
+
:json
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# MySQL does not allow defaults on JSON columns. Rails' Trilogy adapter
|
|
597
|
+
# talks to MySQL too, under its own name.
|
|
598
|
+
def json_column_default
|
|
599
|
+
return nil if connection.adapter_name.match?(/mysql|trilogy/i)
|
|
600
|
+
{}
|
|
601
|
+
end
|
|
602
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module UsageCredits
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dir)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template "upgrade_usage_credits_to_wallets_core.rb.erb", File.join(db_migrate_path, "upgrade_usage_credits_to_wallets_core.rb")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_post_upgrade_message
|
|
22
|
+
say "\nUsageCredits 1.0 upgrade migration has been generated!", :green
|
|
23
|
+
say "\nThis migration will:"
|
|
24
|
+
say " - Check for duplicate owner wallets first, and abort with instructions if any exist"
|
|
25
|
+
say " - Add 'asset_code' column to wallets (default: 'credits')"
|
|
26
|
+
say " - Add 'ledger_revision' column to wallets (default: 0)"
|
|
27
|
+
say " - Enforce one wallet per owner per asset with a unique index"
|
|
28
|
+
say " - Change integer columns to bigint for larger balance support"
|
|
29
|
+
say " - Create 'usage_credits_transfers' table for wallet transfers"
|
|
30
|
+
say " - Add 'transfer_id' column to transactions"
|
|
31
|
+
say " - Upgrade pre-1.0 installs to the wallets-backed ledger core"
|
|
32
|
+
say "\nEvery step is guarded, so the migration is safe to re-run if interrupted."
|
|
33
|
+
say "\nTo complete the upgrade:"
|
|
34
|
+
say " 1. Review the migration file in db/migrate/"
|
|
35
|
+
say " 2. Back up your database (this migration is not reversible)"
|
|
36
|
+
say " 3. Run 'rails db:migrate'"
|
|
37
|
+
say "\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def migration_version
|
|
43
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|