spree_square 0.1.3 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75fa1684fc6a84f266e00c694aefe4437388c4fab672e9fc71a161381ea8aacb
4
- data.tar.gz: 9e6b870b0ec92897f5b345833ac5eb28b6c18a2428db39cca5b3cb0c343de327
3
+ metadata.gz: de9d79909bb37dc35374d1b512f5e015f2e29f37b6aafe9948ee9cc5eb870c2a
4
+ data.tar.gz: aa8065c6c9c5fd613f97f1fcf5ba827bb5a4f6083dfd1259ed723b0214fe40ef
5
5
  SHA512:
6
- metadata.gz: 0e7c9d2be77ed4a34c6296c84683c17af0b46d2faec6aeb8d863811bcb9f56d887cd00ea9fdffb048524d1ddff723805d2206a7b67af92be4e684b09c279b0d3
7
- data.tar.gz: 8ec827290263e9574bab4003ab5e5e1e0d4a72adfa99552ba90021eaa8f5d334f2110c635bfc194627f3bc714b16a048475642497f9a4d8f21a5aa2c59274053
6
+ metadata.gz: 6eab08d920ac2cee2dd52da0d728294733393b4b7c2f48ebe3c4f38f7a1b5f9f8e469569125d7843b58e38f6ea097aba71937da4f88a854fc1ec7a2c043b75a5
7
+ data.tar.gz: f56a193327ddf7ed32f34cfd31e1a47ca2f5a3d91506797d9278bcb0acfdb7f3fa5e1cb1a3889aa63a1baa1877cd151e6b15249cfb959a88b287003d141db21f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.2.0
6
+
7
+ - **Sales tax, sourced from Square's own catalog tax config.** Square's Catalog API has no concept
8
+ of jurisdiction, so this is two pieces working together: a new rake task
9
+ (`spree_square:ensure_tax_zone`) sets up a Spree::Zone matching the store's own StockLocation
10
+ state (re-derived live, not hardcoded), and the catalog importer now pulls Square's `TAX`
11
+ catalog objects (`CatalogImporter`/`CatalogObjectMapper#map_tax`) plus each item's `tax_ids`
12
+ (`#resolve_tax_category`), mirroring them into `Spree::TaxRate`/`Spree::TaxCategory` — Spree's
13
+ own built-in tax-calculation engine does the actual per-order math, unchanged. An item carrying
14
+ more than one Square tax at once gets its own composite `Spree::TaxCategory` with one
15
+ `Spree::TaxRate` per constituent tax (`SpreeSquare::TaxMapping`/`TaxCategoryMapping`), rather
16
+ than forcing a single flat rate — correct for the general case, though this project's own
17
+ catalog only ever needs one. Disabling a tax in Square soft-deletes every `Spree::TaxRate` it
18
+ backs; re-enabling restores them. Reuses the existing `catalog.version.updated` webhook — no new
19
+ subscription needed.
20
+ - New rake task `spree_square:setup_demo_tax` — creates a real "Sales Tax" object in Square
21
+ Sandbox, batch-attaches it to every item via `update_item_taxes`, and (Spree-side, since Square
22
+ has no concept of a delivery fee at all) applies the same tax category to every shipping method.
23
+ - New read-only admin page, "Square Tax Rates" (`/admin/square_tax_rates`), mirroring the existing
24
+ Square Orders/Webhooks pages.
25
+ - Fixed a real bug found while building this: `Spree::TaxRate`'s `has_one :calculator, dependent:
26
+ destroy` performs a REAL (hard) destroy even when the owning TaxRate is only soft-deleted —
27
+ acts_as_paranoid only intercepts the TaxRate's own row, not its dependent-destroy callback
28
+ chain. Re-enabling a previously-disabled tax rebuilds the lost calculator explicitly; without
29
+ this, the very next order recalculation against that rate raised
30
+ (`Spree::TaxRate#calculator` was blank).
31
+ - Documented, not fixed here (pre-existing, discovered during manual verification): a store's
32
+ connected OAuth credential (`SpreeSquare::Credential`) may not carry `ITEMS_WRITE` if it was
33
+ authorized before this or `seed_demo_menu`'s scope needs existed — both item-write rake tasks
34
+ need it. Reconnecting via the admin's OAuth flow picks up the current scope list; until then,
35
+ `setup_demo_tax` explicitly bypasses the connected credential in favor of `SQUARE_ACCESS_TOKEN`.
36
+
5
37
  ## 0.1.3
6
38
 
7
39
  - New demo menu content: a "Pizzas" category with 5 standalone pizzas plus a
data/README.md CHANGED
@@ -70,6 +70,32 @@ The original single-tenant path: generate a token yourself from the Developer Da
70
70
  store that hasn't connected via OAuth — convenient for local development, but not something
71
71
  Square allows for real multi-merchant production use.
72
72
 
73
+ ## Sales tax
74
+
75
+ Tax rates are configured in Square (the same place you manage the menu), not in Spree's admin —
76
+ this extension keeps `Spree::TaxRate`/`Spree::TaxCategory` in sync with whatever `TAX` catalog
77
+ objects and item `tax_ids` you set up in Square. **Square's Catalog API has no concept of
78
+ jurisdiction**, so one one-time Spree-side step is still required:
79
+
80
+ ```bash
81
+ bin/rails spree_square:ensure_tax_zone
82
+ ```
83
+
84
+ This creates a `Spree::Zone` matching your store's own `Spree::StockLocation` state (re-derived
85
+ live — re-run it if that address ever changes). From there, create a `TAX` object in Square and
86
+ attach it to your taxable items (via the Square dashboard, or the Catalog API's
87
+ `update_item_taxes`) — the next catalog sync (webhook or `spree_square:import_catalog`) picks it
88
+ up automatically. `spree_square:setup_demo_tax` does both of the Square-side steps for you against
89
+ a fresh Sandbox catalog, for a working demo without leaving the terminal.
90
+
91
+ Delivery/shipping fees are entirely outside Square's model — assign a `tax_category` to your
92
+ `Spree::ShippingMethod` records directly (`setup_demo_tax` does this too) if you want them taxed
93
+ the same way.
94
+
95
+ An item carrying more than one Square tax at once (rare — most stores have exactly one) gets its
96
+ own composite `Spree::TaxCategory` combining all of them; Spree's own tax-calculation engine sums
97
+ every rate that shares an item's category, so no special handling is needed at checkout time.
98
+
73
99
  ## Developing
74
100
 
75
101
  1. Create a dummy app
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only support/diagnostic view — no create/edit/destroy, this is
4
+ # visibility into what Square's own tax config has synced into Spree,
5
+ # not a place to change it (edits happen in Square's own dashboard).
6
+ # Mirrors SquareOrderMappingsController exactly.
7
+ class SquareTaxRatesController < ResourceController
8
+ def model_class
9
+ Spree::TaxRate
10
+ end
11
+
12
+ private
13
+
14
+ # `scope`, not `collection` — collection is ResourceController's full
15
+ # ransack+pagination pipeline (search_collection.result...pagy), and
16
+ # overriding it directly skips building @search, which is exactly
17
+ # what `render_table`'s search_form_for needs. `scope` is the one
18
+ # documented extension point for narrowing the base query before
19
+ # ransack/pagination run. Scoped here so this page only ever shows
20
+ # rates this extension actually created (via
21
+ # SpreeSquare::TaxCategoryMapping), not any rate a store admin might
22
+ # separately hand-create in the regular Spree tax-rates admin page.
23
+ def scope
24
+ Spree::TaxRate.where(id: SpreeSquare::TaxCategoryMapping.select(:tax_rate_id))
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ module SpreeSquare
2
+ # Join row: "in this Spree::TaxCategory, this Square tax is represented by
3
+ # this Spree::TaxRate." A composite category (an item carrying more than
4
+ # one Square tax at once) has one row per constituent tax. See
5
+ # CatalogObjectMapper#resolve_tax_category for how this is used to
6
+ # find-or-create the right category for an item's exact set of Square tax
7
+ # ids, and TaxMapping for why this can't just be a belongs_to on
8
+ # TaxMapping itself (one Square tax can back rates in more than one
9
+ # category).
10
+ class TaxCategoryMapping < Spree.base_class
11
+ self.table_name = 'spree_square_tax_category_mappings'
12
+
13
+ # `with_deleted` (mirrors Spree::TaxRate's own belongs_to :tax_category)
14
+ # — without it, a soft-deleted (disabled-in-Square) rate becomes
15
+ # unreachable through this association, which would break re-enabling
16
+ # it later: CatalogObjectMapper#sync_enabled_state! needs to find and
17
+ # restore the very row it previously destroyed.
18
+ belongs_to :tax_category, class_name: 'Spree::TaxCategory'
19
+ belongs_to :tax_mapping, class_name: 'SpreeSquare::TaxMapping'
20
+ belongs_to :tax_rate, -> { with_deleted }, class_name: 'Spree::TaxRate'
21
+
22
+ validates :tax_mapping_id, uniqueness: { scope: :tax_category_id }
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ module SpreeSquare
2
+ # Mirrors one Square CatalogTax object — a plain flat percentage + an
3
+ # inclusion type, category-agnostic. Square's Catalog API has no concept
4
+ # of jurisdiction/zone at all, so this row never points at a
5
+ # Spree::TaxRate directly: which Spree::TaxCategory (and therefore which
6
+ # Spree::TaxRate) a given Square tax participates in depends on which
7
+ # *combination* of Square taxes each item carries — see
8
+ # TaxCategoryMapping and CatalogObjectMapper#resolve_tax_category.
9
+ #
10
+ # `square_version` is Square's optimistic-concurrency token — same
11
+ # stale-check pattern as CatalogMapping, guards against out-of-order or
12
+ # duplicate webhook delivery re-processing older data over newer.
13
+ class TaxMapping < Spree.base_class
14
+ self.table_name = 'spree_square_tax_mappings'
15
+
16
+ has_many :tax_category_mappings, class_name: 'SpreeSquare::TaxCategoryMapping', dependent: :destroy
17
+
18
+ validates :square_tax_id, presence: true, uniqueness: true
19
+
20
+ def stale?(incoming_version)
21
+ square_version.present? && incoming_version.present? && incoming_version <= square_version
22
+ end
23
+ end
24
+ end
@@ -1,12 +1,12 @@
1
1
  module SpreeSquare
2
- # Full catalog import: pulls every ITEM + CATEGORY + MODIFIER_LIST from
3
- # Square (with related objects — images, referenced categories — inlined
4
- # via `include_related_objects`) and upserts them into Spree via
5
- # CatalogObjectMapper. Categories and modifier lists are imported before
6
- # items so an item can resolve its `category_id` / `modifier_list_info` to
7
- # an already-mapped record.
2
+ # Full catalog import: pulls every ITEM + CATEGORY + MODIFIER_LIST + TAX
3
+ # from Square (with related objects — images, referenced categories —
4
+ # inlined via `include_related_objects`) and upserts them into Spree via
5
+ # CatalogObjectMapper. Categories, modifier lists, and taxes are imported
6
+ # before items so an item can resolve its `category_id` /
7
+ # `modifier_list_info` / `tax_ids` to an already-mapped record.
8
8
  class CatalogImporter
9
- Result = Struct.new(:categories_count, :modifier_lists_count, :items_count, keyword_init: true)
9
+ Result = Struct.new(:categories_count, :modifier_lists_count, :taxes_count, :items_count, keyword_init: true)
10
10
 
11
11
  def self.call = new.call
12
12
 
@@ -17,14 +17,17 @@ module SpreeSquare
17
17
  by_type = objects.group_by(&:type)
18
18
  categories = by_type.fetch('CATEGORY', [])
19
19
  modifier_lists = by_type.fetch('MODIFIER_LIST', [])
20
+ taxes = by_type.fetch('TAX', [])
20
21
  items = by_type.fetch('ITEM', [])
21
22
 
22
23
  mapper = CatalogObjectMapper.new(related_objects_by_id: related_by_id)
23
24
  categories.each { |category| mapper.map_category(category) }
24
25
  modifier_lists.each { |list| mapper.map_modifier_list(list) }
26
+ taxes.each { |tax| mapper.map_tax(tax) }
25
27
  items.each { |item| mapper.map_item(item) }
26
28
 
27
- Result.new(categories_count: categories.size, modifier_lists_count: modifier_lists.size, items_count: items.size)
29
+ Result.new(categories_count: categories.size, modifier_lists_count: modifier_lists.size,
30
+ taxes_count: taxes.size, items_count: items.size)
28
31
  end
29
32
 
30
33
  private
@@ -39,7 +42,7 @@ module SpreeSquare
39
42
 
40
43
  loop do
41
44
  response = client.catalog.search(
42
- object_types: %w[ITEM CATEGORY MODIFIER_LIST],
45
+ object_types: %w[ITEM CATEGORY MODIFIER_LIST TAX],
43
46
  include_related_objects: true,
44
47
  cursor: cursor
45
48
  )
@@ -75,6 +75,46 @@ module SpreeSquare
75
75
  modifier
76
76
  end
77
77
 
78
+ # Upserts a Square CatalogTax into SpreeSquare::TaxMapping — a pure
79
+ # mirror (percentage/inclusion/enabled), category-agnostic. Which
80
+ # Spree::TaxCategory (and therefore Spree::TaxRate) this tax actually
81
+ # backs is resolved per-item in map_item, since that depends on which
82
+ # *combination* of taxes each item carries — see resolve_tax_category.
83
+ def map_tax(square_object)
84
+ data = square_object.tax_data
85
+ mapping = SpreeSquare::TaxMapping.find_or_initialize_by(square_tax_id: square_object.id)
86
+ return mapping if mapping.persisted? && mapping.stale?(square_object.version)
87
+
88
+ was_enabled = mapping.persisted? ? mapping.enabled : nil
89
+ mapping.name = data.name.presence || 'Square Tax'
90
+ mapping.percentage = data.percentage.presence&.to_d || 0
91
+ mapping.included_in_price = (data.inclusion_type == 'INCLUSIVE')
92
+ mapping.enabled = data.enabled != false
93
+ mapping.square_version = square_object.version
94
+ mapping.last_synced_at = Time.current
95
+ mapping.save!
96
+
97
+ # Enable/disable first — a re-enable rebuilds the rate's calculator
98
+ # (see sync_enabled_state!'s own comment for why that's needed), which
99
+ # the amount/inclusion sync below depends on being present.
100
+ sync_enabled_state!(mapping, was_enabled)
101
+
102
+ # Already-materialized (and currently live) rates mirror any
103
+ # percentage/inclusion/name change immediately — Square is the source
104
+ # of truth here, these rows are never hand-edited in Spree. A rate
105
+ # that's still disabled is skipped: no live TaxRate exists for it to
106
+ # update, and the next enable will rebuild it fresh from this mapping
107
+ # anyway.
108
+ mapping.tax_category_mappings.includes(:tax_rate).each do |tcm|
109
+ rate = tcm.tax_rate
110
+ next unless rate && !rate.paranoia_destroyed?
111
+
112
+ rate.update!(amount: mapping.percentage / 100.0, included_in_price: mapping.included_in_price, name: mapping.name)
113
+ end
114
+
115
+ mapping
116
+ end
117
+
78
118
  def map_item(square_object)
79
119
  data = square_object.item_data
80
120
  mapping = SpreeSquare::CatalogMapping.find_or_initialize_by(
@@ -91,6 +131,7 @@ module SpreeSquare
91
131
  )
92
132
  product.name = data.name.presence || 'Untitled item'
93
133
  product.description = data.description
134
+ product.tax_category = resolve_tax_category(data)
94
135
  product.save!
95
136
 
96
137
  publish!(product)
@@ -222,5 +263,122 @@ module SpreeSquare
222
263
  base = name.presence || 'item'
223
264
  "#{base.parameterize}-#{square_id.downcase}"
224
265
  end
266
+
267
+ # Enable/disable is the one thing that can't just be "update the row" —
268
+ # a disabled Square tax must stop applying everywhere it appears, and a
269
+ # re-enabled one must resume applying with no re-import of the items
270
+ # that reference it. Spree::TaxRate is acts_as_paranoid, so soft-delete/
271
+ # restore is exactly "stop/resume applying" — Spree's own
272
+ # TaxRate.potential_rates_for_zone already excludes deleted rows via its
273
+ # default scope, no other code needs to know about this.
274
+ def sync_enabled_state!(mapping, was_enabled)
275
+ return if was_enabled == mapping.enabled
276
+
277
+ mapping.tax_category_mappings.each do |tcm|
278
+ rate = tcm.tax_rate
279
+ next unless rate
280
+
281
+ if mapping.enabled
282
+ next unless rate.paranoia_destroyed?
283
+
284
+ rate.restore(recursive: true)
285
+ # Spree::TaxRate's `has_one :calculator, dependent: :destroy` is a
286
+ # REAL (hard) destroy even when the owning TaxRate is only
287
+ # soft-deleted — acts_as_paranoid intercepts the TaxRate's own row,
288
+ # not its dependent-destroy callbacks. So a restored rate has no
289
+ # calculator left to restore; rebuild it, or `TaxRate.adjust`'s
290
+ # `delegate :compute, to: :calculator` raises on the next order
291
+ # recalculation.
292
+ rate.calculator_type = 'Spree::Calculator::DefaultTax' if rate.calculator.nil?
293
+ rate.save!
294
+ elsif !rate.paranoia_destroyed?
295
+ rate.destroy
296
+ end
297
+ end
298
+ end
299
+
300
+ # Resolves an item's exact set of Square tax_ids to the Spree::TaxCategory
301
+ # that represents that combination — creating it (and the underlying
302
+ # Spree::TaxRate(s), one per constituent Square tax) the first time this
303
+ # exact combination is seen. See TaxCategoryMapping for why a composite
304
+ # category can need more than one row here.
305
+ def resolve_tax_category(item_data)
306
+ square_tax_ids = Array(item_data.tax_ids)
307
+ return non_taxable_category if square_tax_ids.empty?
308
+
309
+ tax_mappings = SpreeSquare::TaxMapping.where(square_tax_id: square_tax_ids).to_a
310
+ # Item references a tax id this store hasn't synced yet (import order,
311
+ # or the tax was deleted in Square) — fall back to non-taxable rather
312
+ # than guessing; a later re-import (taxes always run before items)
313
+ # will resolve it correctly once the tax itself is known.
314
+ return non_taxable_category if tax_mappings.empty?
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)
320
+ end
321
+
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
336
+ end
337
+ end
338
+
339
+ def create_composite_tax_category!(tax_mappings)
340
+ 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
+
344
+ tax_mappings.each do |tm|
345
+ next if SpreeSquare::TaxCategoryMapping.exists?(tax_category_id: category.id, tax_mapping_id: tm.id)
346
+
347
+ rate = Spree::TaxRate.create!(
348
+ zone: zone,
349
+ tax_category: category,
350
+ name: tm.name,
351
+ amount: (tm.percentage || 0) / 100.0,
352
+ included_in_price: tm.included_in_price,
353
+ calculator_type: 'Spree::Calculator::DefaultTax'
354
+ )
355
+ SpreeSquare::TaxCategoryMapping.create!(tax_category: category, tax_mapping: tm, tax_rate: rate)
356
+ end
357
+
358
+ category
359
+ end
360
+
361
+ def non_taxable_category
362
+ @non_taxable_category ||= Spree::TaxCategory.find_by(name: 'Non-taxable') ||
363
+ Spree::TaxCategory.create!(name: 'Non-taxable')
364
+ end
365
+
366
+ # The store's own tax Zone, set up once via `spree_square:ensure_tax_zone`
367
+ # (Square's Catalog API has no jurisdiction concept to sync — this is
368
+ # entirely Spree-side). Raises rather than silently skipping: importing
369
+ # a real tax with no zone to attach it to is a setup error, not a
370
+ # recoverable no-op.
371
+ def tax_zone!
372
+ @tax_zone ||= begin
373
+ state = Spree::StockLocation.find_by(default: true)&.state
374
+ zone = state && Spree::Zone.where(kind: 'state')
375
+ .joins(:zone_members)
376
+ .where(spree_zone_members: { zoneable_type: 'Spree::State', zoneable_id: state.id })
377
+ .first
378
+ raise "No tax Zone found for the store's state — run `bin/rails spree_square:ensure_tax_zone` first." if zone.blank?
379
+
380
+ zone
381
+ end
382
+ end
225
383
  end
226
384
  end
@@ -0,0 +1,5 @@
1
+ <% content_for :page_title do %>
2
+ Square Tax Rates
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :square_tax_rates %>
@@ -22,4 +22,20 @@ Rails.application.config.after_initialize do
22
22
  position: 67,
23
23
  active: -> { controller_name == 'square_oauth' },
24
24
  if: -> { can?(:manage, SpreeSquare::Credential) }
25
+
26
+ # Position 75 — confirmed free at the time this was added by checking
27
+ # every nav initializer across this project: spree_square itself (65-67,
28
+ # above), spree_doordash (68-70), spree_menu_chat (71-72), spree_loyalty
29
+ # (73-74), plus spree_host's own host-local
30
+ # admin_square_modifier_lists_navigation.rb (73 — a pre-existing
31
+ # collision with spree_loyalty's 73, not this extension's to fix).
32
+ # Confirm the actual current highest position the same way before adding
33
+ # another nav item anywhere in this project.
34
+ Spree.admin.navigation.sidebar.add :square_tax_rates,
35
+ label: 'Square Tax Rates',
36
+ url: :admin_square_tax_rates_path,
37
+ icon: 'percentage',
38
+ position: 75,
39
+ active: -> { controller_name == 'square_tax_rates' },
40
+ if: -> { can?(:manage, Spree::TaxRate) }
25
41
  end
@@ -92,4 +92,52 @@ Rails.application.config.after_initialize do
92
92
  filterable: false,
93
93
  default: true,
94
94
  position: 50
95
+
96
+ # new_resource: false — read-only, same reason as every table above (no
97
+ # :new/:create route; only: [:index] in config/routes.rb).
98
+ Spree.admin.tables.register(:square_tax_rates, model_class: Spree::TaxRate,
99
+ search_param: :name_cont, new_resource: false)
100
+
101
+ Spree.admin.tables.square_tax_rates.add :name,
102
+ label: :name,
103
+ type: :string,
104
+ sortable: true,
105
+ filterable: true,
106
+ default: true,
107
+ position: 10
108
+
109
+ Spree.admin.tables.square_tax_rates.add :amount_percentage,
110
+ label: :rate,
111
+ type: :string,
112
+ sortable: false,
113
+ filterable: false,
114
+ default: true,
115
+ position: 20,
116
+ method: ->(rate) { "#{rate.amount_percentage}%" }
117
+
118
+ Spree.admin.tables.square_tax_rates.add :zone,
119
+ label: :zone,
120
+ type: :string,
121
+ sortable: false,
122
+ filterable: false,
123
+ default: true,
124
+ position: 30,
125
+ method: ->(rate) { rate.zone&.name }
126
+
127
+ Spree.admin.tables.square_tax_rates.add :tax_category,
128
+ label: :tax_category,
129
+ type: :string,
130
+ sortable: false,
131
+ filterable: false,
132
+ default: true,
133
+ position: 40,
134
+ method: ->(rate) { rate.tax_category&.name }
135
+
136
+ Spree.admin.tables.square_tax_rates.add :updated_at,
137
+ label: :last_synced,
138
+ type: :datetime,
139
+ sortable: true,
140
+ filterable: false,
141
+ default: true,
142
+ position: 50
95
143
  end
data/config/routes.rb CHANGED
@@ -13,6 +13,7 @@ Spree::Core::Engine.add_routes do
13
13
  namespace :admin do
14
14
  resources :square_order_mappings, only: [:index]
15
15
  resources :square_webhook_events, only: [:index]
16
+ resources :square_tax_rates, only: [:index]
16
17
 
17
18
  # Self-service OAuth connection (see Spree::Admin::SquareOauthController).
18
19
  # Explicit named routes rather than `resource :square_oauth` — the
@@ -0,0 +1,22 @@
1
+ class CreateSpreeSquareTaxMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_square_tax_mappings do |t|
4
+ t.string :square_tax_id, null: false
5
+ t.string :name
6
+ # Mirrors what Square itself stores: a plain decimal percentage
7
+ # (e.g. 8.0, not 0.08) — kept alongside the derived Spree::TaxRate(s)
8
+ # so re-imports can detect a no-op without re-deriving from a rate.
9
+ t.decimal :percentage, precision: 8, scale: 5
10
+ t.boolean :included_in_price, default: false, null: false
11
+ t.boolean :enabled, default: true, null: false
12
+ # Square's optimistic-concurrency token — same stale-check pattern as
13
+ # CatalogMapping, guards against out-of-order webhook delivery.
14
+ t.bigint :square_version
15
+ t.datetime :last_synced_at
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_square_tax_mappings, :square_tax_id, unique: true
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ class CreateSpreeSquareTaxCategoryMappings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # A single Square tax can back tax rates in more than one Spree tax
4
+ # category — e.g. item A carries only "Sales Tax", item B carries
5
+ # "Sales Tax" + "Bottle Tax" together, so "Sales Tax" needs its own
6
+ # Spree::TaxRate under each of the two resulting composite categories.
7
+ # This join records, for one (tax_category, square tax) pair, which
8
+ # Spree::TaxRate row represents it — the lookup CatalogObjectMapper
9
+ # uses to find-or-create the right composite Spree::TaxCategory for an
10
+ # item's exact set of Square tax ids, without any schema change to
11
+ # core's spree_tax_categories/spree_tax_rates tables.
12
+ create_table :spree_square_tax_category_mappings do |t|
13
+ t.references :tax_category, null: false, foreign_key: { to_table: :spree_tax_categories }
14
+ t.references :tax_mapping, null: false, foreign_key: { to_table: :spree_square_tax_mappings }
15
+ t.references :tax_rate, null: false, foreign_key: { to_table: :spree_tax_rates }
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :spree_square_tax_category_mappings, %i[tax_category_id tax_mapping_id],
21
+ unique: true, name: 'index_square_tax_category_mappings_on_category_and_tax'
22
+ end
23
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeSquare
2
- VERSION = '0.1.3'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
 
4
4
  def gem_version
5
5
  Gem::Version.new(VERSION)
@@ -74,4 +74,86 @@ namespace :spree_square do
74
74
  puts '---'
75
75
  puts 'Set SQUARE_WEBHOOK_SIGNATURE_KEY in .env to the value above, then recreate the web container.'
76
76
  end
77
+
78
+ desc "Phase 8 M1: ensure a state-scoped tax Zone exists for the store's own StockLocation state"
79
+ task ensure_tax_zone: :environment do
80
+ stock_location = Spree::StockLocation.find_by(default: true)
81
+ abort 'No default Spree::StockLocation found.' if stock_location.blank?
82
+
83
+ state = stock_location.state
84
+ abort "StockLocation ##{stock_location.id} (#{stock_location.name}) has no state set — set one before running this task." if state.blank?
85
+
86
+ # Named/keyed off the state itself (not hardcoded "OH") and re-derived
87
+ # from StockLocation on every run — if that address ever moves to a
88
+ # different state, re-running this task points the zone at the new one
89
+ # instead of silently leaving a stale zone behind.
90
+ zone = Spree::Zone.find_or_initialize_by(name: "#{state.abbr} Sales Tax")
91
+ zone.kind = 'state'
92
+ zone.description = "Sales tax zone for #{state.name} — tracks Spree::StockLocation's own state; see spree_square:ensure_tax_zone." if zone.has_attribute?(:description)
93
+ zone.save!
94
+ zone.state_ids = [state.id]
95
+
96
+ puts "Zone ##{zone.id} '#{zone.name}' now contains exactly: #{zone.states.pluck(:abbr).join(', ')}"
97
+ end
98
+
99
+ desc 'Phase 8 M4: create a real 8% Sales Tax object in Square Sandbox, attach it to every item, and tax delivery fees to match'
100
+ task setup_demo_tax: :environment do
101
+ # credential: nil forces the SQUARE_ACCESS_TOKEN env fallback instead of
102
+ # this store's connected OAuth credential — the OAuth connection was
103
+ # authorized without ITEMS_WRITE (confirmed live: its scopes list is
104
+ # MERCHANT_PROFILE_READ/ITEMS_READ/INVENTORY_*/ORDERS_*/PAYMENTS_*, no
105
+ # ITEMS_WRITE), the same scope seed_demo_menu.rake's item-creation
106
+ # calls need. A one-time catalog-admin task like this one reasonably
107
+ # uses the broader sandbox token rather than re-running the OAuth
108
+ # consent flow just to add a scope.
109
+ client = SpreeSquare::Client.new(credential: nil)
110
+
111
+ puts 'Creating "Sales Tax" (8.0%) catalog object in Square...'
112
+ response = client.catalog.batch_upsert(
113
+ idempotency_key: SecureRandom.uuid,
114
+ batches: [{
115
+ objects: [{
116
+ type: 'TAX',
117
+ id: '#tax-sales-tax',
118
+ tax_data: {
119
+ name: 'Sales Tax',
120
+ calculation_phase: 'TAX_SUBTOTAL_PHASE',
121
+ inclusion_type: 'ADDITIVE',
122
+ percentage: '8.0',
123
+ applies_to_custom_amounts: true,
124
+ enabled: true
125
+ }
126
+ }]
127
+ }]
128
+ )
129
+ tax_id = response.id_mappings.find { |m| m.client_object_id == '#tax-sales-tax' }&.object_id_
130
+ abort 'Square did not return an id for the new tax object.' if tax_id.blank?
131
+ puts "Created tax #{tax_id}"
132
+
133
+ item_ids = client.catalog.search(object_types: ['ITEM']).objects.to_a.map(&:id)
134
+ abort 'No items found in the Square catalog — run spree_square:seed_demo_menu first.' if item_ids.empty?
135
+
136
+ puts "Attaching to #{item_ids.size} item(s)..."
137
+ client.catalog.update_item_taxes(item_ids: item_ids, taxes_to_enable: [tax_id])
138
+
139
+ puts 'Waiting for Square search index to catch up...'
140
+ sleep 20
141
+
142
+ puts 'Importing into Spree...'
143
+ result = SpreeSquare::CatalogImporter.call
144
+ puts "Imported #{result.taxes_count} tax(es) and re-synced #{result.items_count} item(s)."
145
+
146
+ tax_mapping = SpreeSquare::TaxMapping.find_by(square_tax_id: tax_id)
147
+ tax_category = tax_mapping&.tax_category_mappings&.first&.tax_category
148
+ if tax_category.blank?
149
+ puts 'Warning: could not resolve the resulting Spree::TaxCategory — did any item actually import with this tax attached?'
150
+ else
151
+ # Square's Catalog API has no concept of a delivery fee at all — this
152
+ # part is Spree-only, applying the same tax category to every
153
+ # shipping method per the confirmed decision (delivery fee taxed the
154
+ # same as food).
155
+ count = Spree::ShippingMethod.update_all(tax_category_id: tax_category.id)
156
+ puts "Set tax_category '#{tax_category.name}' on #{count} shipping method(s): #{Spree::ShippingMethod.pluck(:name).join(', ')}"
157
+ end
158
+ end
77
159
  end
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.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amit Solanki
@@ -121,6 +121,7 @@ files:
121
121
  - app/assets/images/.keep
122
122
  - app/controllers/spree/admin/square_oauth_controller.rb
123
123
  - app/controllers/spree/admin/square_order_mappings_controller.rb
124
+ - app/controllers/spree/admin/square_tax_rates_controller.rb
124
125
  - app/controllers/spree/admin/square_webhook_events_controller.rb
125
126
  - app/controllers/spree_square/webhooks_controller.rb
126
127
  - app/javascript/spree_square/application.js
@@ -141,6 +142,8 @@ files:
141
142
  - app/models/spree_square/modifier_list.rb
142
143
  - app/models/spree_square/order_mapping.rb
143
144
  - app/models/spree_square/product_modifier_list.rb
145
+ - app/models/spree_square/tax_category_mapping.rb
146
+ - app/models/spree_square/tax_mapping.rb
144
147
  - app/models/spree_square/taxon_mapping.rb
145
148
  - app/models/spree_square/webhook_event.rb
146
149
  - app/serializers/spree_square/line_item_serializer.rb
@@ -161,6 +164,7 @@ files:
161
164
  - app/subscribers/spree_square/order_completed_subscriber.rb
162
165
  - app/views/spree/admin/square_oauth/show.html.erb
163
166
  - app/views/spree/admin/square_order_mappings/index.html.erb
167
+ - app/views/spree/admin/square_tax_rates/index.html.erb
164
168
  - app/views/spree/admin/square_webhook_events/index.html.erb
165
169
  - bin/importmap
166
170
  - bin/rails
@@ -181,6 +185,8 @@ files:
181
185
  - db/migrate/20260809160004_create_spree_square_line_item_modifiers.rb
182
186
  - db/migrate/20260809170001_create_spree_square_order_mappings.rb
183
187
  - db/migrate/20260810180001_create_spree_square_credentials.rb
188
+ - db/migrate/20260820180001_create_spree_square_tax_mappings.rb
189
+ - db/migrate/20260820180002_create_spree_square_tax_category_mappings.rb
184
190
  - lib/generators/spree_square/install/install_generator.rb
185
191
  - lib/spree_square.rb
186
192
  - lib/spree_square/configuration.rb