spree_square 0.2.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de9d79909bb37dc35374d1b512f5e015f2e29f37b6aafe9948ee9cc5eb870c2a
4
- data.tar.gz: aa8065c6c9c5fd613f97f1fcf5ba827bb5a4f6083dfd1259ed723b0214fe40ef
3
+ metadata.gz: 25d852608d74d0163f8216bfe8daaa44b0d0cff76612deb563aaee475fbf6fc7
4
+ data.tar.gz: d5d1b302b8713ef27a7b89a58490225b2903a3e6f263ba23efc6822983facb90
5
5
  SHA512:
6
- metadata.gz: 6eab08d920ac2cee2dd52da0d728294733393b4b7c2f48ebe3c4f38f7a1b5f9f8e469569125d7843b58e38f6ea097aba71937da4f88a854fc1ec7a2c043b75a5
7
- data.tar.gz: f56a193327ddf7ed32f34cfd31e1a47ca2f5a3d91506797d9278bcb0acfdb7f3fa5e1cb1a3889aa63a1baa1877cd151e6b15249cfb959a88b287003d141db21f
6
+ metadata.gz: c16da8f9c347d08bc694092c3babb2bda2a96c483d1972ce83b24f578893715357f8d2fa36f6a476969b9589abab4b635e5ba73776e373a8e50073c7a3db6c20
7
+ data.tar.gz: 8c6681d1a46fefe6fce793f03d6f6a3a668618f3e958691dd27fbb8b8f25dae061ffebdb38daccbc57d1174d700342e9124307e7dc66916a8c55d837295c3e63
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.2.1
6
+
7
+ Fixes a real bug in `v0.2.0`, found running it against production traffic (not caught by specs —
8
+ sandbox/local dev has no registered webhook to race against): the concurrency fix for the composite
9
+ tax category race (see `v0.2.0`'s own changelog entry) was itself Postgres-only
10
+ (`pg_advisory_xact_lock`), breaking outright on the gem's own SQLite/MySQL-tested dummy app, and
11
+ production actually hit the race it was meant to prevent — this rake task's own `CatalogImporter.call`
12
+ ran concurrently with a real `catalog.version.updated` webhook Square's own `update_item_taxes` call
13
+ fired back mid-run, leaving 44 products correctly on one "Sales Tax" category and 1 stray product on a
14
+ duplicate with its own duplicate rate (same 8% amount either way — no wrong tax charged, but a real
15
+ data duplication). Replaced with `SpreeSquare::TaxCombination`, a real unique index raced via
16
+ `create_or_find_by!` — portable across every adapter this gem supports. `v0.2.0` is yanked; upgrade
17
+ directly to this version.
18
+
5
19
  ## 0.2.0
6
20
 
7
21
  - **Sales tax, sourced from Square's own catalog tax config.** Square's Catalog API has no concept
@@ -0,0 +1,31 @@
1
+ module SpreeSquare
2
+ # One row per distinct combination of Square tax ids an item carries at
3
+ # once, keyed by `signature` (sorted, comma-joined SpreeSquare::TaxMapping
4
+ # ids) — the race-safe find-or-create key `create_or_find_by!` needs. See
5
+ # CatalogObjectMapper#composite_tax_category for why this exists: without
6
+ # a real unique index to race against, two concurrent imports resolving
7
+ # the same combination for the first time could each create their own
8
+ # Spree::TaxCategory.
9
+ class TaxCombination < Spree.base_class
10
+ self.table_name = 'spree_square_tax_combinations'
11
+
12
+ belongs_to :tax_category, class_name: 'Spree::TaxCategory'
13
+
14
+ # Deliberately NO `uniqueness: true` here — that's a validation-layer
15
+ # SELECT-then-INSERT check with exactly the same race this table exists
16
+ # to close. `create_or_find_by!` (see CatalogObjectMapper#
17
+ # composite_tax_category) depends on the real DB-level unique index
18
+ # (the migration's `add_index ... unique: true`) raising
19
+ # ActiveRecord::RecordNotUnique on the losing INSERT, which is the one
20
+ # thing it explicitly rescues — a uniqueness validation raises
21
+ # RecordInvalid instead, which it does not, and defeats the whole
22
+ # point (confirmed live: adding this validation back made the second,
23
+ # non-racing call in this file's own idempotency spec raise instead of
24
+ # transparently finding the row).
25
+ validates :signature, presence: true
26
+
27
+ def self.signature_for(tax_mapping_ids)
28
+ tax_mapping_ids.sort.join(',')
29
+ end
30
+ end
31
+ end
@@ -313,33 +313,47 @@ module SpreeSquare
313
313
  # will resolve it correctly once the tax itself is known.
314
314
  return non_taxable_category if tax_mappings.empty?
315
315
 
316
- existing_category_id = matching_composite_category_id(tax_mappings)
317
- return Spree::TaxCategory.find(existing_category_id) if existing_category_id
318
-
319
- create_composite_tax_category!(tax_mappings)
316
+ category = composite_tax_category(tax_mappings)
317
+ ensure_tax_rates!(category, tax_mappings)
318
+ category
320
319
  end
321
320
 
322
- def matching_composite_category_id(tax_mappings)
323
- target_ids = tax_mappings.map(&:id)
324
- candidates = SpreeSquare::TaxCategoryMapping.where(tax_mapping_id: target_ids)
325
- .group(:tax_category_id)
326
- .having('COUNT(*) = ?', target_ids.size)
327
- .pluck(:tax_category_id)
328
-
329
- # The HAVING above only proves the candidate contains at least
330
- # `target_ids.size` of OUR mappings it doesn't rule out the
331
- # candidate having MORE members than that (a superset, e.g. a
332
- # 3-tax category when the item only carries 2 of those taxes).
333
- # Confirm an exact match before reusing it.
334
- candidates.find do |tax_category_id|
335
- SpreeSquare::TaxCategoryMapping.where(tax_category_id: tax_category_id).count == target_ids.size
321
+ # A real bug found running this against production: the same catalog
322
+ # import can run concurrently more than once (this rake task's own
323
+ # `CatalogImporter.call` racing a real `catalog.version.updated`
324
+ # webhook that Square's own `update_item_taxes` call fires back mid-run
325
+ # sandbox/local dev, with no registered webhook, never exercises
326
+ # this). Two processes each finding "no matching category yet" and
327
+ # independently creating one left two "Sales Tax" categories with
328
+ # duplicate rates. `SpreeSquare::TaxCombination#signature` is a real
329
+ # unique index `create_or_find_by!` races against it exactly the way
330
+ # Rails intends (portable across Postgres/MySQL/SQLite, unlike a
331
+ # database-specific advisory lock): the losing side's block-created
332
+ # Spree::TaxCategory rolls back with it (create_or_find_by! wraps the
333
+ # attempt in its own transaction) and it transparently re-finds what
334
+ # the winner created instead.
335
+ def composite_tax_category(tax_mappings)
336
+ signature = SpreeSquare::TaxCombination.signature_for(tax_mappings.map(&:id))
337
+
338
+ # `create_or_find_by!`'s block runs while *building* the record, before
339
+ # the create attempt that its own race-safety depends on — so it can
340
+ # run more than once even with no real race at all (confirmed live: a
341
+ # second, non-concurrent call in the same process re-runs this block
342
+ # and only then discovers the TaxCombination already exists). The
343
+ # block itself must therefore be safe to run repeatedly — plain
344
+ # `Spree::TaxCategory.create!` isn't, since core validates `name`
345
+ # uniqueness and a second run raises `RecordInvalid` before this
346
+ # method's own race-safety ever gets a chance to matter.
347
+ combination = SpreeSquare::TaxCombination.create_or_find_by!(signature: signature) do |c|
348
+ name = tax_mappings.map(&:name).join(' + ').presence || 'Square Tax'
349
+ c.tax_category = Spree::TaxCategory.find_by(name: name) || Spree::TaxCategory.create!(name: name)
336
350
  end
351
+
352
+ combination.tax_category
337
353
  end
338
354
 
339
- def create_composite_tax_category!(tax_mappings)
355
+ def ensure_tax_rates!(category, tax_mappings)
340
356
  zone = tax_zone!
341
- name = tax_mappings.map(&:name).join(' + ').presence || 'Square Tax'
342
- category = Spree::TaxCategory.find_by(name: name) || Spree::TaxCategory.create!(name: name)
343
357
 
344
358
  tax_mappings.each do |tm|
345
359
  next if SpreeSquare::TaxCategoryMapping.exists?(tax_category_id: category.id, tax_mapping_id: tm.id)
@@ -353,9 +367,14 @@ module SpreeSquare
353
367
  calculator_type: 'Spree::Calculator::DefaultTax'
354
368
  )
355
369
  SpreeSquare::TaxCategoryMapping.create!(tax_category: category, tax_mapping: tm, tax_rate: rate)
370
+ rescue ActiveRecord::RecordNotUnique
371
+ # A concurrent import created this exact (category, tax) row first
372
+ # (same class of race `composite_tax_category` guards against, just
373
+ # a narrower window) — the orphaned rate this attempt already
374
+ # created never got a join row and is safe to drop; the other
375
+ # side's row is what matters.
376
+ rate&.destroy
356
377
  end
357
-
358
- category
359
378
  end
360
379
 
361
380
  def non_taxable_category
@@ -0,0 +1,22 @@
1
+ class CreateSpreeSquareTaxCombinations < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Resolves the race `create_composite_tax_category!` used to have: two
4
+ # concurrent imports (this rake task's own CatalogImporter.call racing a
5
+ # real catalog.version.updated webhook Square fires back mid-run —
6
+ # confirmed live against production, never exercised by sandbox/local
7
+ # dev with no registered webhook) each finding "no matching category
8
+ # yet" and independently creating one. `signature` is a unique,
9
+ # deterministic key for one exact combination of Square tax ids
10
+ # (sorted, joined) — `create_or_find_by!` against it is a portable
11
+ # (Postgres/MySQL/SQLite) race-safe find-or-create, unlike a
12
+ # Postgres-only advisory lock.
13
+ create_table :spree_square_tax_combinations do |t|
14
+ t.string :signature, null: false
15
+ t.references :tax_category, null: false, foreign_key: { to_table: :spree_tax_categories }
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_square_tax_combinations, :signature, unique: true
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeSquare
2
- VERSION = '0.2.0'.freeze
2
+ VERSION = '0.2.1'.freeze
3
3
 
4
4
  def gem_version
5
5
  Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_square
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amit Solanki
@@ -143,6 +143,7 @@ files:
143
143
  - app/models/spree_square/order_mapping.rb
144
144
  - app/models/spree_square/product_modifier_list.rb
145
145
  - app/models/spree_square/tax_category_mapping.rb
146
+ - app/models/spree_square/tax_combination.rb
146
147
  - app/models/spree_square/tax_mapping.rb
147
148
  - app/models/spree_square/taxon_mapping.rb
148
149
  - app/models/spree_square/webhook_event.rb
@@ -187,6 +188,7 @@ files:
187
188
  - db/migrate/20260810180001_create_spree_square_credentials.rb
188
189
  - db/migrate/20260820180001_create_spree_square_tax_mappings.rb
189
190
  - db/migrate/20260820180002_create_spree_square_tax_category_mappings.rb
191
+ - db/migrate/20260820190001_create_spree_square_tax_combinations.rb
190
192
  - lib/generators/spree_square/install/install_generator.rb
191
193
  - lib/spree_square.rb
192
194
  - lib/spree_square/configuration.rb