@spree/docs 0.1.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.
- package/README.md +54 -0
- package/dist/api-reference/platform/authentication.md +38 -0
- package/dist/api-reference/store-api/authentication.md +188 -0
- package/dist/api-reference/store-api/errors.md +277 -0
- package/dist/api-reference/store-api/idempotency.md +129 -0
- package/dist/api-reference/store-api/introduction.md +34 -0
- package/dist/api-reference/store-api/localization.md +279 -0
- package/dist/api-reference/store-api/metadata.md +160 -0
- package/dist/api-reference/store-api/monetary-amounts.md +65 -0
- package/dist/api-reference/store-api/querying.md +399 -0
- package/dist/api-reference/store-api/rate-limitting.md +103 -0
- package/dist/api-reference/store-api/relations.md +185 -0
- package/dist/api-reference/storefront/authentication.md +88 -0
- package/dist/api-reference/tutorials/adyen-integration-guide-for-android.md +165 -0
- package/dist/api-reference/tutorials/adyen-integration-guide-for-ios.md +194 -0
- package/dist/api-reference/tutorials/quick-checkout-with-stripe.md +248 -0
- package/dist/api-reference/v2/fetching-multiple-resources.md +26 -0
- package/dist/api-reference/v2/filtering-and-sorting.md +53 -0
- package/dist/api-reference/v2/introduction.md +22 -0
- package/dist/api-reference/v2/pagination.md +37 -0
- package/dist/api-reference/webhooks-events.md +883 -0
- package/dist/developer/admin/admin.md +205 -0
- package/dist/developer/admin/authentication.md +59 -0
- package/dist/developer/admin/components.md +711 -0
- package/dist/developer/admin/custom-css.md +243 -0
- package/dist/developer/admin/custom-javascript.md +116 -0
- package/dist/developer/admin/extending-ui.md +1964 -0
- package/dist/developer/admin/form-builder.md +444 -0
- package/dist/developer/admin/helper-methods.md +531 -0
- package/dist/developer/admin/navigation.md +805 -0
- package/dist/developer/admin/tables.md +491 -0
- package/dist/developer/advanced/adding_spree_to_rails_app.md +106 -0
- package/dist/developer/cli/quickstart.md +137 -0
- package/dist/developer/contributing/creating-an-extension.md +258 -0
- package/dist/developer/contributing/developing-spree.md +339 -0
- package/dist/developer/contributing/quickstart.md +32 -0
- package/dist/developer/contributing/updating-extensions.md +67 -0
- package/dist/developer/core-concepts/addresses.md +265 -0
- package/dist/developer/core-concepts/adjustments.md +107 -0
- package/dist/developer/core-concepts/architecture.md +177 -0
- package/dist/developer/core-concepts/calculators.md +323 -0
- package/dist/developer/core-concepts/customers.md +230 -0
- package/dist/developer/core-concepts/events.md +624 -0
- package/dist/developer/core-concepts/imports-exports.md +698 -0
- package/dist/developer/core-concepts/inventory.md +191 -0
- package/dist/developer/core-concepts/markets.md +250 -0
- package/dist/developer/core-concepts/media.md +167 -0
- package/dist/developer/core-concepts/metafields.md +187 -0
- package/dist/developer/core-concepts/orders.md +328 -0
- package/dist/developer/core-concepts/payments.md +710 -0
- package/dist/developer/core-concepts/pricing.md +163 -0
- package/dist/developer/core-concepts/products.md +360 -0
- package/dist/developer/core-concepts/promotions.md +322 -0
- package/dist/developer/core-concepts/reports.md +206 -0
- package/dist/developer/core-concepts/search-filtering.md +237 -0
- package/dist/developer/core-concepts/shipments.md +212 -0
- package/dist/developer/core-concepts/slugs.md +111 -0
- package/dist/developer/core-concepts/staff-roles.md +123 -0
- package/dist/developer/core-concepts/store-credits-gift-cards.md +317 -0
- package/dist/developer/core-concepts/stores.md +117 -0
- package/dist/developer/core-concepts/taxes.md +135 -0
- package/dist/developer/core-concepts/translations.md +120 -0
- package/dist/developer/core-concepts/users.md +299 -0
- package/dist/developer/core-concepts/webhooks.md +378 -0
- package/dist/developer/create-spree-app/quickstart.md +158 -0
- package/dist/developer/customization/api.md +93 -0
- package/dist/developer/customization/authentication.md +88 -0
- package/dist/developer/customization/checkout.md +204 -0
- package/dist/developer/customization/configuration.md +55 -0
- package/dist/developer/customization/decorators.md +523 -0
- package/dist/developer/customization/dependencies.md +232 -0
- package/dist/developer/customization/emails.md +21 -0
- package/dist/developer/customization/extensions.md +92 -0
- package/dist/developer/customization/metadata.md +236 -0
- package/dist/developer/customization/model-preferences.md +130 -0
- package/dist/developer/customization/permissions.md +265 -0
- package/dist/developer/customization/quickstart.md +229 -0
- package/dist/developer/customization/routes.md +24 -0
- package/dist/developer/customization/v4/admin-panel.md +78 -0
- package/dist/developer/customization/v4/authentication.md +210 -0
- package/dist/developer/customization/v4/checkout.md +212 -0
- package/dist/developer/customization/v4/deface.md +251 -0
- package/dist/developer/customization/v4/images.md +86 -0
- package/dist/developer/customization/v4/storefront.md +450 -0
- package/dist/developer/deployment/assets.md +87 -0
- package/dist/developer/deployment/aws.md +335 -0
- package/dist/developer/deployment/caching.md +27 -0
- package/dist/developer/deployment/cdn.md +39 -0
- package/dist/developer/deployment/database.md +155 -0
- package/dist/developer/deployment/docker.md +128 -0
- package/dist/developer/deployment/emails.md +77 -0
- package/dist/developer/deployment/environment_variables.md +111 -0
- package/dist/developer/deployment/heroku.md +51 -0
- package/dist/developer/deployment/render.md +95 -0
- package/dist/developer/getting-started/quickstart.md +82 -0
- package/dist/developer/how-to/custom-payment-method.md +374 -0
- package/dist/developer/how-to/custom-promotion.md +373 -0
- package/dist/developer/how-to/custom-report.md +387 -0
- package/dist/developer/how-to/custom-search-provider.md +230 -0
- package/dist/developer/multi-store/quickstart.md +71 -0
- package/dist/developer/multi-store/setup.md +38 -0
- package/dist/developer/multi-tenant/configuration.md +41 -0
- package/dist/developer/multi-tenant/core-concepts.md +75 -0
- package/dist/developer/multi-tenant/installation.md +96 -0
- package/dist/developer/multi-tenant/quickstart.md +20 -0
- package/dist/developer/multi-vendor/installation.md +45 -0
- package/dist/developer/multi-vendor/quickstart.md +17 -0
- package/dist/developer/sdk/admin/quickstart.md +22 -0
- package/dist/developer/sdk/authentication.md +89 -0
- package/dist/developer/sdk/configuration.md +225 -0
- package/dist/developer/sdk/quickstart.md +82 -0
- package/dist/developer/sdk/store/account.md +67 -0
- package/dist/developer/sdk/store/cart-checkout.md +140 -0
- package/dist/developer/sdk/store/markets.md +151 -0
- package/dist/developer/sdk/store/payments.md +96 -0
- package/dist/developer/sdk/store/products.md +149 -0
- package/dist/developer/sdk/store/wishlists.md +52 -0
- package/dist/developer/security/pci_compliance.md +15 -0
- package/dist/developer/security/security_policy.md +68 -0
- package/dist/developer/storefront/blocks.md +285 -0
- package/dist/developer/storefront/custom-css.md +260 -0
- package/dist/developer/storefront/custom-javascript.md +166 -0
- package/dist/developer/storefront/helper-methods.md +1288 -0
- package/dist/developer/storefront/links.md +298 -0
- package/dist/developer/storefront/nextjs/architecture.md +150 -0
- package/dist/developer/storefront/nextjs/customization.md +141 -0
- package/dist/developer/storefront/nextjs/deployment.md +180 -0
- package/dist/developer/storefront/nextjs/quickstart.md +92 -0
- package/dist/developer/storefront/nextjs/spree-next-package.md +314 -0
- package/dist/developer/storefront/pages.md +163 -0
- package/dist/developer/storefront/sections.md +569 -0
- package/dist/developer/storefront/storefront.md +56 -0
- package/dist/developer/storefront/themes.md +161 -0
- package/dist/developer/tutorial/admin.md +134 -0
- package/dist/developer/tutorial/extending-models.md +380 -0
- package/dist/developer/tutorial/file-uploads.md +121 -0
- package/dist/developer/tutorial/introduction.md +33 -0
- package/dist/developer/tutorial/model.md +41 -0
- package/dist/developer/tutorial/page-builder.md +487 -0
- package/dist/developer/tutorial/rich-text.md +73 -0
- package/dist/developer/tutorial/seo.md +332 -0
- package/dist/developer/tutorial/storefront.md +352 -0
- package/dist/developer/tutorial/testing.md +558 -0
- package/dist/developer/upgrades/2.0-to-2.1.md +46 -0
- package/dist/developer/upgrades/2.1-to-2.2.md +59 -0
- package/dist/developer/upgrades/2.2-to-2.3.md +44 -0
- package/dist/developer/upgrades/2.3-to-2.4.md +42 -0
- package/dist/developer/upgrades/3.0-to-3.1.md +47 -0
- package/dist/developer/upgrades/3.1-to-3.2.md +34 -0
- package/dist/developer/upgrades/3.2-to-3.3.md +70 -0
- package/dist/developer/upgrades/3.3-to-3.4.md +36 -0
- package/dist/developer/upgrades/3.4-to-3.5.md +44 -0
- package/dist/developer/upgrades/3.5-to-3.6.md +40 -0
- package/dist/developer/upgrades/3.6-to-3.7.md +62 -0
- package/dist/developer/upgrades/3.7-to-4.0.md +152 -0
- package/dist/developer/upgrades/4.0-to-4.1.md +92 -0
- package/dist/developer/upgrades/4.1-to-4.2.md +109 -0
- package/dist/developer/upgrades/4.10-to-5.0.md +129 -0
- package/dist/developer/upgrades/4.2-to-4.3.md +100 -0
- package/dist/developer/upgrades/4.3-to-4.4.md +125 -0
- package/dist/developer/upgrades/4.4-to-4.5.md +94 -0
- package/dist/developer/upgrades/4.5-to-4.6.md +119 -0
- package/dist/developer/upgrades/4.6-to-4.7.md +39 -0
- package/dist/developer/upgrades/4.8-to-4.9.md +24 -0
- package/dist/developer/upgrades/4.9-to-4.10.md +24 -0
- package/dist/developer/upgrades/4.x-to-4.8.md +52 -0
- package/dist/developer/upgrades/5.0-to-5.1.md +28 -0
- package/dist/developer/upgrades/5.1-to-5.2.md +127 -0
- package/dist/developer/upgrades/5.2-to-5.3.md +338 -0
- package/dist/developer/upgrades/5.3-to-5.4.md +248 -0
- package/dist/developer/upgrades/quickstart.md +36 -0
- package/dist/integrations/analytics/google-analytics.md +64 -0
- package/dist/integrations/analytics/google-tag-manager.md +78 -0
- package/dist/integrations/integrations.md +39 -0
- package/dist/integrations/marketing/klaviyo.md +99 -0
- package/dist/integrations/payments/adyen.md +90 -0
- package/dist/integrations/payments/paypal.md +41 -0
- package/dist/integrations/payments/razorpay.md +45 -0
- package/dist/integrations/payments/stripe.md +109 -0
- package/dist/integrations/search/meilisearch.md +236 -0
- package/dist/integrations/sso-mfa-social-login/admin-dashboard.md +57 -0
- package/dist/integrations/sso-mfa-social-login/storefront.md +56 -0
- package/package.json +27 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a Custom Report
|
|
3
|
+
description: Step-by-step guide to creating custom reports with data queries, line item formatters, and CSV export.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Spree's reporting system is designed for extension. Each report is a pair of classes — a **Report** that defines the data query and a **ReportLineItem** that formats each row — registered in `Spree.reports` so it appears in the admin UI.
|
|
9
|
+
|
|
10
|
+
This guide walks you through building a custom report from scratch, including advanced patterns for SQL aggregations and multi-vendor support.
|
|
11
|
+
|
|
12
|
+
Before starting, make sure you understand [how the reporting system works](/developer/core-concepts/reports).
|
|
13
|
+
|
|
14
|
+
## Creating a Custom Report
|
|
15
|
+
|
|
16
|
+
### Step 1: Create the Report Class
|
|
17
|
+
|
|
18
|
+
Create a new report class inheriting from `Spree::Report`. The key method to implement is `line_items_scope`, which returns an `ActiveRecord::Relation` defining the records in your report:
|
|
19
|
+
|
|
20
|
+
```ruby app/models/spree/reports/customer_orders.rb
|
|
21
|
+
module Spree
|
|
22
|
+
module Reports
|
|
23
|
+
class CustomerOrders < Spree::Report
|
|
24
|
+
# Define the scope of records to include in the report
|
|
25
|
+
def line_items_scope
|
|
26
|
+
store.orders.complete.where(
|
|
27
|
+
completed_at: date_from..date_to,
|
|
28
|
+
currency: currency
|
|
29
|
+
).includes(:user, :bill_address)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `line_items_scope` method has access to:
|
|
37
|
+
|
|
38
|
+
| Helper | Description |
|
|
39
|
+
|--------|-------------|
|
|
40
|
+
| `store` | The current store |
|
|
41
|
+
| `date_from` | Report start date |
|
|
42
|
+
| `date_to` | Report end date |
|
|
43
|
+
| `currency` | Report currency |
|
|
44
|
+
| `vendor` | Vendor (if multi-vendor is enabled) |
|
|
45
|
+
|
|
46
|
+
### Step 2: Create the Line Item Class
|
|
47
|
+
|
|
48
|
+
Create a corresponding line item class that transforms each record into report columns. The class name must match the report class name (e.g., `Reports::CustomerOrders` → `ReportLineItems::CustomerOrders`):
|
|
49
|
+
|
|
50
|
+
```ruby app/models/spree/report_line_items/customer_orders.rb
|
|
51
|
+
module Spree
|
|
52
|
+
module ReportLineItems
|
|
53
|
+
class CustomerOrders < Spree::ReportLineItem
|
|
54
|
+
# Define attributes that will become columns
|
|
55
|
+
attribute :order_number, :string
|
|
56
|
+
attribute :completed_at, :string
|
|
57
|
+
attribute :customer_email, :string
|
|
58
|
+
attribute :customer_name, :string
|
|
59
|
+
attribute :item_count, :integer
|
|
60
|
+
attribute :total, :string
|
|
61
|
+
|
|
62
|
+
# Map record fields to report columns
|
|
63
|
+
def order_number
|
|
64
|
+
record.number
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def completed_at
|
|
68
|
+
record.completed_at.strftime('%Y-%m-%d %H:%M')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def customer_email
|
|
72
|
+
record.email
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def customer_name
|
|
76
|
+
record.bill_address&.full_name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def item_count
|
|
80
|
+
record.line_items.sum(:quantity)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def total
|
|
84
|
+
Spree::Money.new(record.total, currency: currency)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Important notes:**
|
|
92
|
+
- Use `attribute` to define columns with their types — these become CSV headers
|
|
93
|
+
- Each attribute needs a corresponding method that extracts/formats data from `record`
|
|
94
|
+
- `record` is a single item from `line_items_scope`
|
|
95
|
+
- Use `Spree::Money` for currency formatting
|
|
96
|
+
- `currency` and `store` are delegated from the report
|
|
97
|
+
|
|
98
|
+
#### Available Base Class Methods
|
|
99
|
+
|
|
100
|
+
`Spree::ReportLineItem` provides:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Returns column headers for display
|
|
104
|
+
self.headers
|
|
105
|
+
# => [{ name: :order_number, label: "Order Number" }, ...]
|
|
106
|
+
|
|
107
|
+
# Returns column names for CSV header row
|
|
108
|
+
self.csv_headers
|
|
109
|
+
# => ["order_number", "completed_at", "customer_email", ...]
|
|
110
|
+
|
|
111
|
+
# Converts line item to CSV row array
|
|
112
|
+
to_csv
|
|
113
|
+
# => ["R123456", "2025-01-15 14:30", "john@example.com", ...]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Step 3: Register the Report
|
|
117
|
+
|
|
118
|
+
Add your report to the registry in an initializer:
|
|
119
|
+
|
|
120
|
+
```ruby config/initializers/spree.rb
|
|
121
|
+
Rails.application.config.after_initialize do
|
|
122
|
+
Spree.reports << Spree::Reports::CustomerOrders
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Step 4: Add Translations
|
|
127
|
+
|
|
128
|
+
Add the report name and column header translations:
|
|
129
|
+
|
|
130
|
+
```yaml config/locales/en.yml
|
|
131
|
+
en:
|
|
132
|
+
spree:
|
|
133
|
+
report_names:
|
|
134
|
+
customer_orders: Customer Orders
|
|
135
|
+
order_number: Order Number
|
|
136
|
+
completed_at: Completed At
|
|
137
|
+
customer_email: Customer Email
|
|
138
|
+
customer_name: Customer Name
|
|
139
|
+
item_count: Item Count
|
|
140
|
+
total: Total
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
After restarting your application, the new report will be available in **Admin > Reports**.
|
|
144
|
+
|
|
145
|
+
## Advanced Patterns
|
|
146
|
+
|
|
147
|
+
### Complex Queries with Aggregations
|
|
148
|
+
|
|
149
|
+
For reports that aggregate data across records, use SQL directly in `line_items_scope`:
|
|
150
|
+
|
|
151
|
+
```ruby app/models/spree/reports/revenue_by_category.rb
|
|
152
|
+
module Spree
|
|
153
|
+
module Reports
|
|
154
|
+
class RevenueByCategory < Spree::Report
|
|
155
|
+
def line_items_scope
|
|
156
|
+
line_items_sql = Spree::LineItem
|
|
157
|
+
.joins(:order)
|
|
158
|
+
.where(
|
|
159
|
+
spree_orders: {
|
|
160
|
+
completed_at: date_from..date_to,
|
|
161
|
+
currency: currency
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
.select(
|
|
165
|
+
"spree_line_items.variant_id",
|
|
166
|
+
"SUM(spree_line_items.quantity) as quantity",
|
|
167
|
+
"SUM(spree_line_items.pre_tax_amount) as revenue"
|
|
168
|
+
)
|
|
169
|
+
.group(:variant_id)
|
|
170
|
+
.to_sql
|
|
171
|
+
|
|
172
|
+
store.taxons
|
|
173
|
+
.where(depth: 1) # Top-level categories
|
|
174
|
+
.joins("LEFT JOIN spree_products_taxons ON spree_products_taxons.taxon_id = spree_taxons.id")
|
|
175
|
+
.joins("LEFT JOIN spree_variants ON spree_variants.product_id = spree_products_taxons.product_id")
|
|
176
|
+
.joins("LEFT JOIN (#{line_items_sql}) AS line_items ON line_items.variant_id = spree_variants.id")
|
|
177
|
+
.select(
|
|
178
|
+
"spree_taxons.*",
|
|
179
|
+
"COALESCE(SUM(line_items.quantity), 0) AS total_quantity",
|
|
180
|
+
"COALESCE(SUM(line_items.revenue), 0.0) AS total_revenue"
|
|
181
|
+
)
|
|
182
|
+
.group("spree_taxons.id")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
When using aggregated queries, the `record` in your line item class will have virtual attributes (like `total_quantity`, `total_revenue`) available as methods.
|
|
190
|
+
|
|
191
|
+
### Custom Summary Section
|
|
192
|
+
|
|
193
|
+
Override `summary` to provide aggregate metrics alongside the line items:
|
|
194
|
+
|
|
195
|
+
```ruby app/models/spree/reports/customer_orders.rb
|
|
196
|
+
module Spree
|
|
197
|
+
module Reports
|
|
198
|
+
class CustomerOrders < Spree::Report
|
|
199
|
+
def summary
|
|
200
|
+
{
|
|
201
|
+
total_orders: line_items_scope.count,
|
|
202
|
+
total_revenue: line_items_scope.sum(:total),
|
|
203
|
+
average_order_value: line_items_scope.average(:total)
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Multi-Vendor Support
|
|
212
|
+
|
|
213
|
+
If you're using Spree Multi-Vendor, filter by vendor when one is selected:
|
|
214
|
+
|
|
215
|
+
```ruby app/models/spree/reports/vendor_sales.rb
|
|
216
|
+
module Spree
|
|
217
|
+
module Reports
|
|
218
|
+
class VendorSales < Spree::Report
|
|
219
|
+
def line_items_scope
|
|
220
|
+
scope = store.line_items.where(
|
|
221
|
+
order: Spree::Order.complete.where(
|
|
222
|
+
completed_at: date_from..date_to,
|
|
223
|
+
currency: currency
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Filter by vendor if one is selected
|
|
228
|
+
scope = scope.where(vendor_id: vendor.id) if defined?(vendor) && vendor.present?
|
|
229
|
+
|
|
230
|
+
scope
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Testing Custom Reports
|
|
238
|
+
|
|
239
|
+
### Testing the Report Class
|
|
240
|
+
|
|
241
|
+
Test that your report's `line_items_scope` returns the correct records:
|
|
242
|
+
|
|
243
|
+
```ruby spec/models/spree/reports/customer_orders_spec.rb
|
|
244
|
+
require 'rails_helper'
|
|
245
|
+
|
|
246
|
+
RSpec.describe Spree::Reports::CustomerOrders, type: :model do
|
|
247
|
+
let(:store) { create(:store) }
|
|
248
|
+
let(:user) { create(:admin_user) }
|
|
249
|
+
|
|
250
|
+
subject(:report) do
|
|
251
|
+
described_class.new(
|
|
252
|
+
store: store,
|
|
253
|
+
user: user,
|
|
254
|
+
currency: 'USD',
|
|
255
|
+
date_from: 1.month.ago,
|
|
256
|
+
date_to: Time.current
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
describe '#line_items_scope' do
|
|
261
|
+
let!(:completed_order) do
|
|
262
|
+
create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 1.week.ago)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
let!(:incomplete_order) do
|
|
266
|
+
create(:order, store: store, currency: 'USD', state: 'cart')
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
let!(:other_currency_order) do
|
|
270
|
+
create(:completed_order_with_totals, store: store, currency: 'EUR', completed_at: 1.week.ago)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
let!(:old_order) do
|
|
274
|
+
create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 2.months.ago)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'returns only completed orders within the date range and currency' do
|
|
278
|
+
scope = report.line_items_scope
|
|
279
|
+
|
|
280
|
+
expect(scope).to include(completed_order)
|
|
281
|
+
expect(scope).not_to include(incomplete_order)
|
|
282
|
+
expect(scope).not_to include(other_currency_order)
|
|
283
|
+
expect(scope).not_to include(old_order)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
describe '#line_items' do
|
|
288
|
+
let!(:order) do
|
|
289
|
+
create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 1.week.ago)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it 'returns ReportLineItem objects' do
|
|
293
|
+
items = report.line_items
|
|
294
|
+
expect(items).to all(be_a(Spree::ReportLineItems::CustomerOrders))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it 'respects the limit option' do
|
|
298
|
+
create(:completed_order_with_totals, store: store, currency: 'USD', completed_at: 2.days.ago)
|
|
299
|
+
items = report.line_items(limit: 1)
|
|
300
|
+
expect(items.length).to eq(1)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Testing the ReportLineItem Class
|
|
307
|
+
|
|
308
|
+
Test that your line item correctly formats each record:
|
|
309
|
+
|
|
310
|
+
```ruby spec/models/spree/report_line_items/customer_orders_spec.rb
|
|
311
|
+
require 'rails_helper'
|
|
312
|
+
|
|
313
|
+
RSpec.describe Spree::ReportLineItems::CustomerOrders, type: :model do
|
|
314
|
+
let(:store) { create(:store) }
|
|
315
|
+
let(:user) { create(:admin_user) }
|
|
316
|
+
let(:bill_address) { create(:address) }
|
|
317
|
+
let(:order) do
|
|
318
|
+
create(:completed_order_with_totals,
|
|
319
|
+
store: store,
|
|
320
|
+
currency: 'USD',
|
|
321
|
+
completed_at: 1.week.ago,
|
|
322
|
+
bill_address: bill_address)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
let(:report) do
|
|
326
|
+
Spree::Reports::CustomerOrders.new(
|
|
327
|
+
store: store,
|
|
328
|
+
user: user,
|
|
329
|
+
currency: 'USD',
|
|
330
|
+
date_from: 1.month.ago,
|
|
331
|
+
date_to: Time.current
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
subject(:line_item) { described_class.new(record: order, report: report) }
|
|
336
|
+
|
|
337
|
+
describe '#order_number' do
|
|
338
|
+
it 'returns the order number' do
|
|
339
|
+
expect(line_item.order_number).to eq(order.number)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
describe '#customer_name' do
|
|
344
|
+
it 'returns the billing address full name' do
|
|
345
|
+
expect(line_item.customer_name).to eq(bill_address.full_name)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
context 'when no billing address' do
|
|
349
|
+
let(:bill_address) { nil }
|
|
350
|
+
|
|
351
|
+
it 'returns nil' do
|
|
352
|
+
order.update_column(:bill_address_id, nil)
|
|
353
|
+
order.reload
|
|
354
|
+
expect(line_item.customer_name).to be_nil
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
describe '#total' do
|
|
360
|
+
it 'returns a Spree::Money formatted total' do
|
|
361
|
+
result = line_item.total
|
|
362
|
+
expect(result).to be_a(Spree::Money)
|
|
363
|
+
expect(result.money.to_f).to eq(order.total.to_f)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
describe '#to_csv' do
|
|
368
|
+
it 'returns an array of values for CSV row' do
|
|
369
|
+
csv_row = line_item.to_csv
|
|
370
|
+
expect(csv_row.length).to eq(6)
|
|
371
|
+
expect(csv_row[0]).to eq(order.number)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Key Testing Patterns
|
|
378
|
+
|
|
379
|
+
1. **Test scope filtering** — verify `line_items_scope` returns only records matching date range, currency, and store
|
|
380
|
+
2. **Test attribute formatting** — verify each attribute method returns correctly formatted data
|
|
381
|
+
3. **Test CSV output** — check `headers`, `csv_headers`, and `to_csv` return expected values
|
|
382
|
+
4. **Test edge cases** — handle nil values gracefully (e.g., missing addresses)
|
|
383
|
+
|
|
384
|
+
## Related Documentation
|
|
385
|
+
|
|
386
|
+
- [Reports](/developer/core-concepts/reports) - Report architecture and built-in reports
|
|
387
|
+
- [Events](/developer/core-concepts/events) - How report generation uses the events system
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a Custom Search Provider
|
|
3
|
+
description: Step-by-step guide to building a custom search provider for Spree, integrating external search engines like Typesense, Algolia, or Elasticsearch.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
This guide walks you through building a custom search provider for Spree. By the end, you'll have a fully functional search integration that:
|
|
9
|
+
|
|
10
|
+
- Powers product search, filtering, sorting, and faceted navigation
|
|
11
|
+
- Handles multi-locale and multi-currency indexing automatically
|
|
12
|
+
- Integrates with the Store API without any frontend changes
|
|
13
|
+
- Supports background indexing and bulk reindex
|
|
14
|
+
|
|
15
|
+
Before starting, make sure you understand [how search and filtering works in Spree](/developer/core-concepts/search-filtering).
|
|
16
|
+
|
|
17
|
+
> **INFO:** Spree ships with a built-in [Meilisearch provider](/integrations/search/meilisearch). If Meilisearch fits your needs, you don't need to build a custom provider — just configure it.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Store API Request (locale=de, currency=EUR)
|
|
23
|
+
│
|
|
24
|
+
├─ AR Scope (security + visibility)
|
|
25
|
+
│ store.products.active(currency).accessible_by(ability)
|
|
26
|
+
│
|
|
27
|
+
└─ Search Provider (search + filter + facets)
|
|
28
|
+
├─ Database (default): ILIKE + Ransack + FiltersAggregator
|
|
29
|
+
├─ Meilisearch (built-in): one API call with locale/currency filtering
|
|
30
|
+
└─ Your Provider: implements the same interface
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The controller builds a base ActiveRecord scope for **security and visibility**, then delegates **everything else** to your search provider.
|
|
34
|
+
|
|
35
|
+
## Step 1: Create the Provider Class
|
|
36
|
+
|
|
37
|
+
Create a class that inherits from `Spree::SearchProvider::Base` and implements `search_and_filter`:
|
|
38
|
+
|
|
39
|
+
```ruby app/models/my_app/search_provider/typesense.rb
|
|
40
|
+
module MyApp
|
|
41
|
+
module SearchProvider
|
|
42
|
+
class Typesense < Spree::SearchProvider::Base
|
|
43
|
+
# Enable background indexing jobs
|
|
44
|
+
def self.indexing_required?
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(store)
|
|
49
|
+
super
|
|
50
|
+
require 'typesense'
|
|
51
|
+
rescue LoadError
|
|
52
|
+
raise LoadError, "Add `gem 'typesense'` to your Gemfile"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`indexing_required?` returning `true` tells the `SearchIndexable` concern to enqueue background jobs when products are created, updated, or destroyed.
|
|
60
|
+
|
|
61
|
+
## Step 2: Implement `search_and_filter`
|
|
62
|
+
|
|
63
|
+
This is the core method. It receives a base AR scope (already filtered for security) and must return a `SearchResult`:
|
|
64
|
+
|
|
65
|
+
```ruby app/models/my_app/search_provider/typesense.rb
|
|
66
|
+
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
67
|
+
page = [page.to_i, 1].max
|
|
68
|
+
limit = limit.to_i.clamp(1, 100)
|
|
69
|
+
|
|
70
|
+
# 1. Query your search engine with locale/currency filtering
|
|
71
|
+
results = client.collections[index_name].documents.search({
|
|
72
|
+
q: query || '*',
|
|
73
|
+
query_by: 'name,description,sku,option_values,category_names,tags',
|
|
74
|
+
filter_by: build_filters(filters),
|
|
75
|
+
sort_by: build_sort(sort),
|
|
76
|
+
page: page,
|
|
77
|
+
per_page: limit,
|
|
78
|
+
facet_by: 'in_stock,price,category_ids,option_value_ids'
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# 2. Extract product IDs (documents have composite IDs, extract product_id)
|
|
82
|
+
product_ids = results['hits'].map { |h| h['document']['product_id'] }.uniq
|
|
83
|
+
raw_ids = product_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
|
|
84
|
+
|
|
85
|
+
# 3. Intersect with AR scope (safety net for authorization)
|
|
86
|
+
products = raw_ids.any? ? scope.where(id: raw_ids).reorder(nil) : scope.none
|
|
87
|
+
|
|
88
|
+
# 4. Build Pagy for pagination metadata
|
|
89
|
+
require 'pagy'
|
|
90
|
+
pagy = Pagy::Offset.new(count: results['found'], page: page, limit: limit)
|
|
91
|
+
|
|
92
|
+
# 5. Return a SearchResult
|
|
93
|
+
Spree::SearchProvider::SearchResult.new(
|
|
94
|
+
products: products,
|
|
95
|
+
filters: build_facet_response(results['facet_counts']),
|
|
96
|
+
sort_options: %w[price -price name -name best_selling -available_on].map { |id| { id: id } },
|
|
97
|
+
default_sort: 'manual',
|
|
98
|
+
total_count: results['found'],
|
|
99
|
+
pagy: pagy
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> **WARNING:** Always filter by `locale`, `currency`, `store_ids`, `status='active'`, and `discontinue_on` in your search engine — not just in the AR scope. This ensures pagination counts are accurate. The AR scope is a safety net, not the primary filter.
|
|
105
|
+
|
|
106
|
+
## Step 3: Implement Indexing
|
|
107
|
+
|
|
108
|
+
Products are indexed as **one document per market × locale** combination. The `ProductPresenter` handles this automatically:
|
|
109
|
+
|
|
110
|
+
```ruby app/models/my_app/search_provider/typesense.rb
|
|
111
|
+
def index(product)
|
|
112
|
+
documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
|
|
113
|
+
documents.each do |doc|
|
|
114
|
+
client.collections[index_name].documents.upsert(doc)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def remove(product)
|
|
119
|
+
remove_by_id(product.prefixed_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def remove_by_id(prefixed_id)
|
|
123
|
+
# Delete all locale/currency variants of this product
|
|
124
|
+
client.collections[index_name].documents.delete(
|
|
125
|
+
filter_by: "product_id:=#{prefixed_id}"
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The `ProductPresenter` returns an array of documents. For a store with US (USD/English) and EU (EUR/German+French) markets, one product produces 3 documents — each with flat `name`, `price`, `locale`, `currency` fields.
|
|
131
|
+
|
|
132
|
+
## Step 4: Implement Bulk Reindex
|
|
133
|
+
|
|
134
|
+
Use `ProductPresenter::REQUIRED_PRELOADS` to avoid N+1 queries:
|
|
135
|
+
|
|
136
|
+
```ruby app/models/my_app/search_provider/typesense.rb
|
|
137
|
+
def reindex(scope = nil)
|
|
138
|
+
scope ||= store.products
|
|
139
|
+
ensure_index_settings!
|
|
140
|
+
|
|
141
|
+
scope.reorder(id: :asc)
|
|
142
|
+
.preload(*Spree::SearchProvider::ProductPresenter::REQUIRED_PRELOADS)
|
|
143
|
+
.find_in_batches(batch_size: 500) do |batch|
|
|
144
|
+
documents = batch.flat_map { |p| Spree::SearchProvider::ProductPresenter.new(p, store).call }
|
|
145
|
+
index_batch(documents)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def index_batch(documents)
|
|
150
|
+
client.collections[index_name].documents.import(documents, action: 'upsert')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def ensure_index_settings!
|
|
154
|
+
# Create/update your collection schema here
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
> **INFO:** Use `flat_map` (not `map`) because `ProductPresenter#call` returns an array of documents per product.
|
|
159
|
+
|
|
160
|
+
## Step 5: Register the Provider
|
|
161
|
+
|
|
162
|
+
```ruby config/initializers/spree.rb
|
|
163
|
+
Spree.search_provider = 'MyApp::SearchProvider::Typesense'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Then reindex:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
rake spree:search:reindex
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Provider Contract Reference
|
|
173
|
+
|
|
174
|
+
| Method | Required | Description |
|
|
175
|
+
|--------|----------|-------------|
|
|
176
|
+
| `search_and_filter(scope:, query:, filters:, sort:, page:, limit:)` | Yes | Search, filter, sort, paginate, and return facets. Must return a `SearchResult`. |
|
|
177
|
+
| `self.indexing_required?` | Yes | Return `true` to enable background indexing jobs. |
|
|
178
|
+
| `index(product)` | Yes | Index a single product. `ProductPresenter#call` returns an array of documents. |
|
|
179
|
+
| `remove(product)` | Yes | Remove all locale/currency variants from the index. |
|
|
180
|
+
| `remove_by_id(prefixed_id)` | Yes | Remove by prefixed product ID (product may already be deleted). |
|
|
181
|
+
| `reindex(scope)` | Yes | Bulk reindex with `ensure_index_settings!` + batch indexing. |
|
|
182
|
+
| `index_batch(documents)` | Yes | Index a batch of pre-serialized documents. |
|
|
183
|
+
| `ensure_index_settings!` | No | Configure index schema. Called by `reindex` and rake task. |
|
|
184
|
+
|
|
185
|
+
### SearchResult
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
Spree::SearchProvider::SearchResult.new(
|
|
189
|
+
products: ar_relation, # ActiveRecord::Relation
|
|
190
|
+
filters: [...], # Array of facet hashes
|
|
191
|
+
sort_options: [{ id: 'price' }, ...], # Array of sort option objects
|
|
192
|
+
default_sort: 'manual', # Default sort string
|
|
193
|
+
total_count: 150, # Total before pagination
|
|
194
|
+
pagy: pagy_object # Pagy::Offset or Pagy::Meilisearch
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### ProductPresenter
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
documents = Spree::SearchProvider::ProductPresenter.new(product, store).call
|
|
202
|
+
# => [
|
|
203
|
+
# { prefixed_id: "prod_abc_en_USD", product_id: "prod_abc", locale: "en",
|
|
204
|
+
# currency: "USD", name: "Blue Shirt", price: 29.99, ... },
|
|
205
|
+
# { prefixed_id: "prod_abc_de_EUR", product_id: "prod_abc", locale: "de",
|
|
206
|
+
# currency: "EUR", name: "Blaues Hemd", price: 27.50, ... }
|
|
207
|
+
# ]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Indexing Lifecycle
|
|
211
|
+
|
|
212
|
+
The `Spree::SearchIndexable` concern on Product provides:
|
|
213
|
+
|
|
214
|
+
| Method | Description |
|
|
215
|
+
|--------|-------------|
|
|
216
|
+
| `product.add_to_search_index` | Index synchronously (inline) |
|
|
217
|
+
| `product.remove_from_search_index` | Remove synchronously (inline) |
|
|
218
|
+
| `product.search_presentation` | Preview the documents that would be indexed |
|
|
219
|
+
| `rake spree:search:reindex` | Bulk reindex all products |
|
|
220
|
+
|
|
221
|
+
Background jobs (`IndexJob`, `RemoveJob`) fire on `after_commit` when `indexing_required?` is `true`.
|
|
222
|
+
|
|
223
|
+
### Important: Prefixed IDs
|
|
224
|
+
|
|
225
|
+
Always use prefixed IDs (`ctg_abc`, `prod_xyz`, `optval_abc`) when indexing. Never use raw database IDs — Spree supports UUID primary keys.
|
|
226
|
+
|
|
227
|
+
## Related Documentation
|
|
228
|
+
|
|
229
|
+
- [Search & Filtering](/developer/core-concepts/search-filtering) — Store API search reference
|
|
230
|
+
- [Meilisearch Integration](/integrations/search/meilisearch) — Built-in Meilisearch provider setup
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Multi-Store Spree Commerce
|
|
3
|
+
sidebarTitle: Quickstart
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
To enable multiple stores, you will need to add the `spree_multi_store` gem to your project via:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bundle add spree_multi_store
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> **INFO:** Spree Multi-Store is licensed under the [AGPL v3 License](https://opensource.org/licenses/AGPL-3.0).
|
|
13
|
+
>
|
|
14
|
+
> If you would like to use it in a commercial application, please [contact us](https://spreecommerce.org/get-started/) to obtain a commercial license.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
## Store resources
|
|
19
|
+
|
|
20
|
+
Each Store can have its own resources. For example, a Store can have its own Products, Taxonomies, Promotions, etc.
|
|
21
|
+
|
|
22
|
+
| Resource | Relationship |
|
|
23
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
24
|
+
| [**Order**](/core-concepts/orders) | One Order belongs to one Store |
|
|
25
|
+
| [**Product**](/core-concepts/products) | One Product can be associated with many Store(s), you can pick and choose in which Store(s) each Product will be available |
|
|
26
|
+
| [**Payment Method**](/core-concepts/payments) | One Payment Method can be associated with many Store(s), you can select in which Stores given Payment Method will be available on Checkout |
|
|
27
|
+
| [**Taxonomy**](/core-concepts/products#taxons-and-taxonomies) | One Taxonomy belongs to one Store |
|
|
28
|
+
| [**Promotion**](/core-concepts/promotions) | One Promotion can be associated with multiple Stores |
|
|
29
|
+
| **Store Credit** | One Store Credit belongs to and can be used in one Store
|
|
30
|
+
|
|
31
|
+
## Current Store
|
|
32
|
+
|
|
33
|
+
Spree will try to determine the current store based on the current URL. If the URL does not match any of the stores in the database, Spree will fall back to the default store.
|
|
34
|
+
|
|
35
|
+
All Spree controllers or any other controllers that include [Spree::Core::ControllerHelpers::Store](https://github.com/spree/spree/blob/main/core/lib/spree/core/controller_helpers/store.rb) have access to the `current_store` method which returns the `Store` matching the current URL.
|
|
36
|
+
|
|
37
|
+
All parts of Spree (API, Admin Panel) have this implemented.
|
|
38
|
+
|
|
39
|
+
> **INFO:** Under the hood `current_store` calls [Spree::Stores::FindCurrent.new(url: url).execute](https://github.com/spree/spree/blob/main/core/app/finders/spree/stores/find_current.rb).
|
|
40
|
+
>
|
|
41
|
+
> This logic can be easily overwritten by setting
|
|
42
|
+
>
|
|
43
|
+
> ```ruby
|
|
44
|
+
Spree::Dependencies.current_store_finder = 'MyStoreFinderClass'
|
|
45
|
+
```
|
|
46
|
+
>
|
|
47
|
+
> in `config/initializers/spree.rb` file
|
|
48
|
+
|
|
49
|
+
## Default Store
|
|
50
|
+
|
|
51
|
+
If the system cannot find any Store that matches the current URL it will fall back to the Default Store.
|
|
52
|
+
|
|
53
|
+
You can set the default Store via Rails console:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Spree::Store.find(2).update(default: true)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To get the default store in your code or rails console type:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Spree::Store.default
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Custom Domains
|
|
66
|
+
|
|
67
|
+
Spree supports managing custom domains for Stores. In the Admin Panel, you can manage custom domains for each Store in the **Settings -> Domains** page.
|
|
68
|
+
|
|
69
|
+
## Setup multi-store application
|
|
70
|
+
|
|
71
|
+
[Follow the setup guide](/developer/multi-store/setup) to get started.
|