spree_square 0.1.2 → 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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +26 -0
- data/app/controllers/spree/admin/square_tax_rates_controller.rb +28 -0
- data/app/models/spree_square/tax_category_mapping.rb +24 -0
- data/app/models/spree_square/tax_mapping.rb +24 -0
- data/app/services/spree_square/catalog_importer.rb +12 -9
- data/app/services/spree_square/catalog_object_mapper.rb +158 -0
- data/app/views/spree/admin/square_tax_rates/index.html.erb +5 -0
- data/config/initializers/spree_admin_square_navigation.rb +16 -0
- data/config/initializers/spree_admin_square_tables.rb +48 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260820180001_create_spree_square_tax_mappings.rb +22 -0
- data/db/migrate/20260820180002_create_spree_square_tax_category_mappings.rb +23 -0
- data/lib/spree_square/version.rb +1 -1
- data/lib/tasks/spree_square.rake +82 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de9d79909bb37dc35374d1b512f5e015f2e29f37b6aafe9948ee9cc5eb870c2a
|
|
4
|
+
data.tar.gz: aa8065c6c9c5fd613f97f1fcf5ba827bb5a4f6083dfd1259ed723b0214fe40ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6eab08d920ac2cee2dd52da0d728294733393b4b7c2f48ebe3c4f38f7a1b5f9f8e469569125d7843b58e38f6ea097aba71937da4f88a854fc1ec7a2c043b75a5
|
|
7
|
+
data.tar.gz: f56a193327ddf7ed32f34cfd31e1a47ca2f5a3d91506797d9278bcb0acfdb7f3fa5e1cb1a3889aa63a1baa1877cd151e6b15249cfb959a88b287003d141db21f
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
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
|
+
|
|
37
|
+
## 0.1.3
|
|
38
|
+
|
|
39
|
+
- New demo menu content: a "Pizzas" category with 5 standalone pizzas plus a
|
|
40
|
+
Build-Your-Own Half & Half Pizza (two stacked SINGLE-select modifier lists,
|
|
41
|
+
"Left Half"/"Right Half", sharing the same 5 toppings) — the concrete
|
|
42
|
+
real-world case for two independent modifier lists stacked on one item.
|
|
43
|
+
Also 2 new combo items (Burger Combo, Family Feast Combo), joining the
|
|
44
|
+
existing Lunch Combo. Demo menu is now 45 items across 8 categories (up
|
|
45
|
+
from 37/7).
|
|
46
|
+
|
|
5
47
|
## 0.1.2
|
|
6
48
|
|
|
7
49
|
- Two new demo menu items (a build-your-own bowl with stacked modifier lists, a combo meal with
|
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
|
|
3
|
-
# Square (with related objects — images, referenced categories —
|
|
4
|
-
# via `include_related_objects`) and upserts them into Spree via
|
|
5
|
-
# CatalogObjectMapper. Categories
|
|
6
|
-
# items so an item can resolve its `category_id` /
|
|
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,
|
|
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
|
|
@@ -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
|
data/lib/spree_square/version.rb
CHANGED
data/lib/tasks/spree_square.rake
CHANGED
|
@@ -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.
|
|
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
|