usage_credits 0.5.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +25 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +51 -158
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -37
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. 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