spree_loyalty 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.
- checksums.yaml +7 -0
- data/.gem_release.yml +2 -0
- data/.github/workflows/test.yml +107 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +21 -0
- data/CONTRIBUTING.md +20 -0
- data/Gemfile +27 -0
- data/LICENSE.md +9 -0
- data/README.md +115 -0
- data/Rakefile +23 -0
- data/app/controllers/spree/admin/loyalty_accounts_controller.rb +36 -0
- data/app/controllers/spree/admin/loyalty_transactions_controller.rb +13 -0
- data/app/controllers/spree_loyalty/loyalty_account_controller.rb +45 -0
- data/app/models/spree_loyalty/account.rb +13 -0
- data/app/models/spree_loyalty/transaction.rb +41 -0
- data/app/services/spree_loyalty/accrual.rb +38 -0
- data/app/services/spree_loyalty/redemption.rb +61 -0
- data/app/subscribers/spree_loyalty/order_completed_subscriber.rb +19 -0
- data/app/views/spree/admin/loyalty_accounts/index.html.erb +37 -0
- data/app/views/spree/admin/loyalty_transactions/index.html.erb +5 -0
- data/config/initializers/spree.rb +15 -0
- data/config/initializers/spree_admin_loyalty_navigation.rb +20 -0
- data/config/initializers/spree_admin_loyalty_tables.rb +96 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +23 -0
- data/db/migrate/20260820000001_create_spree_loyalty_accounts.rb +18 -0
- data/db/migrate/20260820000002_create_spree_loyalty_transactions.rb +38 -0
- data/lib/generators/spree_loyalty/install/install_generator.rb +20 -0
- data/lib/spree_loyalty/configuration.rb +19 -0
- data/lib/spree_loyalty/engine.rb +34 -0
- data/lib/spree_loyalty/factories.rb +14 -0
- data/lib/spree_loyalty/version.rb +7 -0
- data/lib/spree_loyalty.rb +12 -0
- data/spree_loyalty.gemspec +42 -0
- metadata +137 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d15acfb30891827b6a98ad18bdf142a27719d9788723c6d16d355874b8e5a31f
|
|
4
|
+
data.tar.gz: c5a3add030ba18043fe115f06590de368765f96afb8565c5e625b2a6ceb7b04a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6d7cb518d47c367dfb39561866b2919ed8c9d14e3c4cd6dcad8ea8add75ecba7a261b3516ba2a1a17953988b49b4312aab48b34f75fe0b463b4d6e9864a8a456
|
|
7
|
+
data.tar.gz: 2447f287e26f39a7907596558aa8ed178ecbc36f0a620857a710d99d8b1519eb16fdd59d63ca2a350ae69ed1f998683ced71ff56d4a8c39d345add8617c224da
|
data/.gem_release.yml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test-postgres:
|
|
17
|
+
name: "PostgreSQL"
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
services:
|
|
20
|
+
postgres:
|
|
21
|
+
image: postgres:16
|
|
22
|
+
env:
|
|
23
|
+
POSTGRES_USER: postgres
|
|
24
|
+
POSTGRES_PASSWORD: postgres
|
|
25
|
+
ports:
|
|
26
|
+
- 5432:5432
|
|
27
|
+
options: >-
|
|
28
|
+
--health-cmd pg_isready
|
|
29
|
+
--health-interval 10s
|
|
30
|
+
--health-timeout 5s
|
|
31
|
+
--health-retries 5
|
|
32
|
+
env:
|
|
33
|
+
DB: postgres
|
|
34
|
+
DB_HOST: localhost
|
|
35
|
+
DB_USERNAME: postgres
|
|
36
|
+
DB_PASSWORD: postgres
|
|
37
|
+
BUNDLE_JOBS: 4
|
|
38
|
+
BUNDLE_RETRY: 3
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
|
|
42
|
+
- uses: ruby/setup-ruby@v1
|
|
43
|
+
with:
|
|
44
|
+
ruby-version: '3.3'
|
|
45
|
+
bundler-cache: true
|
|
46
|
+
|
|
47
|
+
- name: Install libvips
|
|
48
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
49
|
+
|
|
50
|
+
- name: Create test app
|
|
51
|
+
run: bundle exec rake test_app
|
|
52
|
+
|
|
53
|
+
- name: Run tests
|
|
54
|
+
run: bundle exec rspec --format documentation
|
|
55
|
+
|
|
56
|
+
test-mysql:
|
|
57
|
+
name: "MySQL"
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
services:
|
|
60
|
+
mysql:
|
|
61
|
+
image: mysql:8.0
|
|
62
|
+
env:
|
|
63
|
+
MYSQL_ROOT_PASSWORD: password
|
|
64
|
+
ports:
|
|
65
|
+
- 3306:3306
|
|
66
|
+
options: >-
|
|
67
|
+
--health-cmd="mysqladmin ping"
|
|
68
|
+
--health-interval 10s
|
|
69
|
+
--health-timeout 5s
|
|
70
|
+
--health-retries 5
|
|
71
|
+
env:
|
|
72
|
+
DB: mysql
|
|
73
|
+
DB_HOST: 127.0.0.1
|
|
74
|
+
DB_USERNAME: root
|
|
75
|
+
DB_PASSWORD: password
|
|
76
|
+
BUNDLE_JOBS: 4
|
|
77
|
+
BUNDLE_RETRY: 3
|
|
78
|
+
steps:
|
|
79
|
+
- uses: actions/checkout@v4
|
|
80
|
+
|
|
81
|
+
- uses: ruby/setup-ruby@v1
|
|
82
|
+
with:
|
|
83
|
+
ruby-version: '3.3'
|
|
84
|
+
bundler-cache: true
|
|
85
|
+
|
|
86
|
+
- name: Install libvips
|
|
87
|
+
run: sudo apt-get update && sudo apt-get install -y libvips-dev
|
|
88
|
+
|
|
89
|
+
- name: Create test app
|
|
90
|
+
run: bundle exec rake test_app
|
|
91
|
+
|
|
92
|
+
- name: Run tests
|
|
93
|
+
run: bundle exec rspec --format documentation
|
|
94
|
+
|
|
95
|
+
brakeman:
|
|
96
|
+
name: Brakeman
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v4
|
|
100
|
+
|
|
101
|
+
- uses: ruby/setup-ruby@v1
|
|
102
|
+
with:
|
|
103
|
+
ruby-version: '3.3'
|
|
104
|
+
bundler-cache: true
|
|
105
|
+
|
|
106
|
+
- name: Run Brakeman
|
|
107
|
+
run: bundle exec brakeman --exit-on-warn --exit-on-error
|
data/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
\#*
|
|
2
|
+
*~
|
|
3
|
+
.#*
|
|
4
|
+
.DS_Store
|
|
5
|
+
.idea
|
|
6
|
+
.localeapp/locales
|
|
7
|
+
.project
|
|
8
|
+
.vscode
|
|
9
|
+
coverage
|
|
10
|
+
default
|
|
11
|
+
Gemfile.lock
|
|
12
|
+
tmp
|
|
13
|
+
nbproject
|
|
14
|
+
pkg
|
|
15
|
+
*.sw?
|
|
16
|
+
spec/dummy
|
|
17
|
+
.rvmrc
|
|
18
|
+
.sass-cache
|
|
19
|
+
public/spree
|
|
20
|
+
.ruby-version
|
|
21
|
+
.ruby-gemset
|
|
22
|
+
*.gem
|
|
23
|
+
*/*.gem
|
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- First release: points-based loyalty program for Spree Commerce, POS-agnostic by design.
|
|
8
|
+
- Accrual on `order.completed` for logged-in customers, based on `item_total` (not the order
|
|
9
|
+
total — delivery fees excluded).
|
|
10
|
+
- Redemption grants real `Spree::StoreCredit`, spendable through the store's existing Store
|
|
11
|
+
Credit payment method — no new checkout code.
|
|
12
|
+
- Full ledger (`SpreeLoyalty::Transaction`), not just a running balance.
|
|
13
|
+
- Admin: read-only Loyalty Accounts / Loyalty Transactions pages, plus a manual points-adjustment
|
|
14
|
+
action for service recovery.
|
|
15
|
+
- Small Store API surface (`GET`/`POST /api/v3/store/loyalty_account`) for storefront
|
|
16
|
+
integration — verified end to end against a real Next.js storefront (balance display,
|
|
17
|
+
redemption form, order-confirmation earn message).
|
|
18
|
+
- Fixed a real bug found during that end-to-end verification: `Spree::StoreCredit#amount` is a
|
|
19
|
+
decimal column, serialized as a JSON string over the API, not a number — the storefront
|
|
20
|
+
reference implementation's naive `.toFixed()` call crashed on it despite the redemption having
|
|
21
|
+
already succeeded server-side. Fixed by coercing with `Number(...)` before formatting.
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for considering a contribution to `spree_loyalty`.
|
|
4
|
+
|
|
5
|
+
## Getting set up
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle install
|
|
9
|
+
bundle exec rake test_app # generates spec/dummy, the Rails app specs run against
|
|
10
|
+
bundle exec rspec
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Making a change
|
|
14
|
+
|
|
15
|
+
1. Open an issue first for anything beyond a small fix, so the approach can be discussed before
|
|
16
|
+
you put time into it.
|
|
17
|
+
2. Add or update specs alongside any behavior change — `bundle exec rspec` should stay green.
|
|
18
|
+
3. Keep decorators as a last resort (see the main README's customization pattern order); prefer
|
|
19
|
+
Spree's Events/Subscribers or Dependencies mechanisms where they fit.
|
|
20
|
+
4. Open a pull request describing what changed and why.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
source 'https://rubygems.org'
|
|
2
|
+
|
|
3
|
+
gem 'rails-controller-testing'
|
|
4
|
+
|
|
5
|
+
# Pinned to the released 5.6.x line (matching spree_host), same rationale as
|
|
6
|
+
# spree_square/spree_doordash/spree_menu_chat's identical Gemfile comment.
|
|
7
|
+
spree_opts = if ENV['SPREE_PATH']
|
|
8
|
+
{ 'path': ENV['SPREE_PATH'] }
|
|
9
|
+
else
|
|
10
|
+
'~> 5.6.0'
|
|
11
|
+
end
|
|
12
|
+
gem 'spree', spree_opts
|
|
13
|
+
gem 'spree_admin', spree_opts
|
|
14
|
+
|
|
15
|
+
gem 'spree_dev_tools', '>= 0.6.0.rc1'
|
|
16
|
+
|
|
17
|
+
if ENV['DB'] == 'mysql'
|
|
18
|
+
gem 'mysql2'
|
|
19
|
+
elsif ENV['DB'] == 'postgres'
|
|
20
|
+
gem 'pg'
|
|
21
|
+
else
|
|
22
|
+
gem 'sqlite3'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gem 'propshaft'
|
|
26
|
+
|
|
27
|
+
gemspec
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amit Solanki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Spree Loyalty
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/spree_loyalty)
|
|
4
|
+
[](https://github.com/amitkssolanki/spree_loyalty/releases)
|
|
5
|
+
[](LICENSE.md)
|
|
6
|
+
|
|
7
|
+
A POS-agnostic points loyalty program for [Spree Commerce](https://spreecommerce.org), an open
|
|
8
|
+
source e-commerce platform built with Ruby on Rails. Customers earn points on completed orders
|
|
9
|
+
and redeem them for real `Spree::StoreCredit` — spendable immediately through the store's
|
|
10
|
+
existing Store Credit payment method, with no new checkout/payment code required.
|
|
11
|
+
|
|
12
|
+
## Why POS-agnostic?
|
|
13
|
+
|
|
14
|
+
Most restaurant/retail loyalty platforms (Punchh, Square Loyalty, and similar) issue points as a
|
|
15
|
+
program tied to one POS or one brand — a point earned under one system generally isn't
|
|
16
|
+
redeemable under another, even across brands owned by the same company (Yum! Brands keeps Taco
|
|
17
|
+
Bell Rewards, KFC's program, and Pizza Hut Rewards entirely separate, for example). This
|
|
18
|
+
extension takes the opposite approach on purpose: loyalty lives entirely in Spree, independent of
|
|
19
|
+
whatever payment gateway or point-of-sale system a store also happens to use.
|
|
20
|
+
|
|
21
|
+
## What this does
|
|
22
|
+
|
|
23
|
+
- **Points accrue automatically on order completion** — `SpreeLoyalty::OrderCompletedSubscriber`
|
|
24
|
+
reacts to Spree's own `order.completed` event; no coupling to any specific payment or POS
|
|
25
|
+
integration.
|
|
26
|
+
- **Only logged-in customers earn points.** Guest checkouts don't accrue — loyalty requires an
|
|
27
|
+
account, the same way most real loyalty programs identify a customer.
|
|
28
|
+
- **Points earn on `item_total`, not the order total** — delivery/shipping fees are excluded from
|
|
29
|
+
the earn calculation by design.
|
|
30
|
+
- **Redemption grants real `Spree::StoreCredit`**, not a bespoke discount/coupon mechanism —
|
|
31
|
+
reuses Spree's own mature, already-checkout-integrated store credit system instead of building
|
|
32
|
+
a parallel one.
|
|
33
|
+
- **A full ledger**, not just a running balance — every earn, redemption, and manual adjustment is
|
|
34
|
+
a `SpreeLoyalty::Transaction` row, so a balance is always reconstructable and auditable.
|
|
35
|
+
- **Admin visibility** — read-only Loyalty Accounts and Loyalty Transactions pages, plus a manual
|
|
36
|
+
points-adjustment action for service recovery (comping or correcting a balance).
|
|
37
|
+
- **A small Store API surface** for the storefront — `GET /api/v3/store/loyalty_account` and
|
|
38
|
+
`POST /api/v3/store/loyalty_account/redeem`, authenticated the same way any other customer
|
|
39
|
+
Store API endpoint is.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
1. Add this extension to your Gemfile with this line:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
bundle add spree_loyalty
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
2. Run the install generator
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
bundle exec rails g spree_loyalty:install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. Restart your server
|
|
56
|
+
|
|
57
|
+
If your server was running, restart it so that it can find the assets properly.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
`SpreeLoyalty::Config` (a `Spree::Preferences::Configuration`, settable the usual Spree way —
|
|
62
|
+
e.g. in a `config/initializers/spree.rb` block):
|
|
63
|
+
|
|
64
|
+
| Preference | Default | Meaning |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `points_per_dollar` | `1` | Points earned per whole dollar of `order.item_total`. |
|
|
67
|
+
| `point_value_cents` | `1` | Cents of store credit granted per point redeemed (default: 100 points = $1.00). |
|
|
68
|
+
| `minimum_redemption_points` | `100` | Smallest redemption a customer can make in one request. |
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
SpreeLoyalty::Config.points_per_dollar = 2
|
|
72
|
+
SpreeLoyalty::Config.minimum_redemption_points = 50
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How redemption reaches checkout
|
|
76
|
+
|
|
77
|
+
Redeeming points calls `Spree::StoreCredit.create!(user:, store:, amount:, category: "Loyalty
|
|
78
|
+
Rewards", ...)` directly — the same model Spree's own gift card system uses. The store's existing
|
|
79
|
+
`Spree::PaymentMethod::StoreCredit` payment method (present in any standard Spree install) then
|
|
80
|
+
picks it up automatically at checkout. No new payment gateway, no new checkout step, no
|
|
81
|
+
storefront changes beyond a page to let the customer trigger the redemption.
|
|
82
|
+
|
|
83
|
+
## Storefront integration
|
|
84
|
+
|
|
85
|
+
The two Store API endpoints this extension adds are enough to build a full "My Rewards" account
|
|
86
|
+
page: fetch the balance/config with a `GET`, submit a redemption with a `POST`. See this
|
|
87
|
+
extension's own reference implementation in the companion `spree_storefront_web` Next.js app
|
|
88
|
+
(`/account/rewards`) for the full pattern — a balance display, a redemption form, and an
|
|
89
|
+
order-confirmation "You earned N points on this order!" message computed from `item_total *
|
|
90
|
+
points_per_dollar` (client-side, using the same math the real accrual runs server-side, since the
|
|
91
|
+
subscriber that actually credits the account runs asynchronously and may not have finished by the
|
|
92
|
+
time the confirmation page renders).
|
|
93
|
+
|
|
94
|
+
## Data model
|
|
95
|
+
|
|
96
|
+
- `SpreeLoyalty::Account` — one per `[user, store]`, `points_balance` and
|
|
97
|
+
`lifetime_points_earned`.
|
|
98
|
+
- `SpreeLoyalty::Transaction` — the ledger. `kind` is `earned`, `redeemed`, or `adjusted`; `source`
|
|
99
|
+
is a polymorphic reference to the originating `Spree::Order` (earn) or `Spree::StoreCredit`
|
|
100
|
+
(redemption), `nil` for a manual admin adjustment.
|
|
101
|
+
|
|
102
|
+
## Releasing a new version
|
|
103
|
+
|
|
104
|
+
```shell
|
|
105
|
+
bundle exec gem bump -p -t
|
|
106
|
+
bundle exec gem release
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
|
|
110
|
+
|
|
111
|
+
## Contributing
|
|
112
|
+
|
|
113
|
+
If you'd like to contribute, please take a look at the
|
|
114
|
+
[instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
|
|
115
|
+
pull request.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'bundler'
|
|
2
|
+
Bundler::GemHelper.install_tasks
|
|
3
|
+
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
|
+
require 'spree/testing_support/extension_rake'
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new
|
|
8
|
+
|
|
9
|
+
task :default do
|
|
10
|
+
if Dir['spec/dummy'].empty?
|
|
11
|
+
Rake::Task[:test_app].invoke
|
|
12
|
+
Dir.chdir('../../')
|
|
13
|
+
end
|
|
14
|
+
Rake::Task[:spec].invoke
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'Generates a dummy app for testing'
|
|
18
|
+
task :test_app do
|
|
19
|
+
ENV['LIB_NAME'] = 'spree_loyalty'
|
|
20
|
+
Rake::Task['extension:test_app'].execute(
|
|
21
|
+
install_admin: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Read-only support/diagnostic view — no create/edit/destroy for the
|
|
4
|
+
# account itself (see config/routes.rb: only: [:index]). The one real
|
|
5
|
+
# write action is #adjust_points, a deliberately narrow escape hatch
|
|
6
|
+
# for staff comping/correcting a balance, kept separate from normal
|
|
7
|
+
# CRUD so it always goes through SpreeLoyalty::Redemption-adjacent
|
|
8
|
+
# ledger bookkeeping rather than a raw balance edit.
|
|
9
|
+
class LoyaltyAccountsController < ResourceController
|
|
10
|
+
def model_class
|
|
11
|
+
SpreeLoyalty::Account
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def adjust_points
|
|
15
|
+
account = SpreeLoyalty::Account.find(params[:account_id])
|
|
16
|
+
points = params[:points].to_i
|
|
17
|
+
note = params[:note].presence
|
|
18
|
+
|
|
19
|
+
if points.zero?
|
|
20
|
+
flash[:error] = 'Enter a non-zero number of points to adjust.'
|
|
21
|
+
else
|
|
22
|
+
SpreeLoyalty::Account.transaction do
|
|
23
|
+
account.transactions.create!(points: points, kind: SpreeLoyalty::Transaction::ADJUSTED, note: note)
|
|
24
|
+
account.update!(
|
|
25
|
+
points_balance: account.points_balance + points,
|
|
26
|
+
lifetime_points_earned: account.lifetime_points_earned + [points, 0].max
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
flash[:success] = "Adjusted #{account.user.email}'s balance by #{points} points."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
redirect_to admin_loyalty_accounts_path
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
# Read-only ledger view — the audit trail behind every account's
|
|
4
|
+
# balance. No create/edit/destroy (config/routes.rb: only: [:index]);
|
|
5
|
+
# adjustments are made via LoyaltyAccountsController#adjust_points,
|
|
6
|
+
# which itself only ever creates rows here, never edits or removes one.
|
|
7
|
+
class LoyaltyTransactionsController < ResourceController
|
|
8
|
+
def model_class
|
|
9
|
+
SpreeLoyalty::Transaction
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
# Small custom Store API surface (mirrors Spree::Api::V3::Store::WishlistsController's
|
|
3
|
+
# own require_authentication!/current_user shape — a loyalty account is a
|
|
4
|
+
# user-scoped resource the same way a wishlist is). The storefront never
|
|
5
|
+
# touches SpreeLoyalty::Account/Transaction directly, only through these
|
|
6
|
+
# two actions.
|
|
7
|
+
class LoyaltyAccountController < Spree::Api::V3::Store::BaseController
|
|
8
|
+
prepend_before_action :require_authentication!
|
|
9
|
+
|
|
10
|
+
# GET /api/v3/store/loyalty_account
|
|
11
|
+
def show
|
|
12
|
+
account = find_or_initialize_account
|
|
13
|
+
render json: account_json(account)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# POST /api/v3/store/loyalty_account/redeem
|
|
17
|
+
def redeem
|
|
18
|
+
account = find_or_initialize_account
|
|
19
|
+
account.save! if account.new_record?
|
|
20
|
+
|
|
21
|
+
store_credit = SpreeLoyalty::Redemption.call(account: account, points: params[:points])
|
|
22
|
+
render json: account_json(account.reload).merge(
|
|
23
|
+
store_credit: { amount: store_credit.amount, currency: store_credit.currency }
|
|
24
|
+
)
|
|
25
|
+
rescue SpreeLoyalty::Redemption::Error => e
|
|
26
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def find_or_initialize_account
|
|
32
|
+
SpreeLoyalty::Account.find_or_initialize_by(user: current_user, store: current_store)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def account_json(account)
|
|
36
|
+
{
|
|
37
|
+
points_balance: account.points_balance,
|
|
38
|
+
lifetime_points_earned: account.lifetime_points_earned,
|
|
39
|
+
minimum_redemption_points: SpreeLoyalty::Config.minimum_redemption_points,
|
|
40
|
+
point_value_cents: SpreeLoyalty::Config.point_value_cents,
|
|
41
|
+
points_per_dollar: SpreeLoyalty::Config.points_per_dollar
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
class Account < Spree.base_class
|
|
3
|
+
self.table_name = 'spree_loyalty_accounts'
|
|
4
|
+
|
|
5
|
+
belongs_to :user, class_name: "::#{Spree.user_class}"
|
|
6
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
7
|
+
|
|
8
|
+
has_many :transactions, class_name: 'SpreeLoyalty::Transaction', foreign_key: :account_id, dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :user_id, uniqueness: { scope: :store_id }
|
|
11
|
+
validates :points_balance, :lifetime_points_earned, numericality: { greater_than_or_equal_to: 0 }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
class Transaction < Spree.base_class
|
|
3
|
+
self.table_name = 'spree_loyalty_transactions'
|
|
4
|
+
|
|
5
|
+
EARNED = 'earned'.freeze
|
|
6
|
+
REDEEMED = 'redeemed'.freeze
|
|
7
|
+
ADJUSTED = 'adjusted'.freeze
|
|
8
|
+
KINDS = [EARNED, REDEEMED, ADJUSTED].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :account, class_name: 'SpreeLoyalty::Account'
|
|
11
|
+
belongs_to :source, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
validates :kind, inclusion: { in: KINDS }
|
|
14
|
+
validates :points, numericality: { other_than: 0 }
|
|
15
|
+
validate :earned_points_are_positive
|
|
16
|
+
validate :redeemed_or_adjusted_points_are_negative_or_positive_appropriately
|
|
17
|
+
|
|
18
|
+
scope :earned, -> { where(kind: EARNED) }
|
|
19
|
+
scope :redeemed, -> { where(kind: REDEEMED) }
|
|
20
|
+
scope :adjusted, -> { where(kind: ADJUSTED) }
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def earned_points_are_positive
|
|
25
|
+
return unless kind == EARNED
|
|
26
|
+
return if points.to_i.positive?
|
|
27
|
+
|
|
28
|
+
errors.add(:points, 'must be positive for an earned transaction')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Redemptions are always a deduction (negative); manual adjustments can
|
|
32
|
+
# go either way (a comp is positive, a correction can be negative), so
|
|
33
|
+
# only the sign of a redemption is actually constrained here.
|
|
34
|
+
def redeemed_or_adjusted_points_are_negative_or_positive_appropriately
|
|
35
|
+
return unless kind == REDEEMED
|
|
36
|
+
return if points.to_i.negative?
|
|
37
|
+
|
|
38
|
+
errors.add(:points, 'must be negative for a redeemed transaction')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
# Awards loyalty points for a completed order. Guest checkouts (no
|
|
3
|
+
# order.user) don't earn — loyalty requires an account, same reasoning
|
|
4
|
+
# Square Loyalty itself uses (it identifies customers by phone number to
|
|
5
|
+
# accrue points; an anonymous sale earns nothing there either). No
|
|
6
|
+
# retroactive guest-to-account linking in this version — a guest who
|
|
7
|
+
# creates an account later doesn't get past orders' points backfilled.
|
|
8
|
+
#
|
|
9
|
+
# Points accrue on order.item_total, not order.total — a restaurant
|
|
10
|
+
# loyalty program crediting points for the DoorDash delivery fee would be
|
|
11
|
+
# an unstated, arguably wrong default; this makes the choice explicit.
|
|
12
|
+
class Accrual
|
|
13
|
+
def self.call(...) = new.call(...)
|
|
14
|
+
|
|
15
|
+
def call(order)
|
|
16
|
+
return if order.user.blank?
|
|
17
|
+
|
|
18
|
+
points = (order.item_total * SpreeLoyalty::Config.points_per_dollar).floor
|
|
19
|
+
return unless points.positive?
|
|
20
|
+
|
|
21
|
+
account = SpreeLoyalty::Account.find_or_create_by!(user: order.user, store: order.store)
|
|
22
|
+
|
|
23
|
+
SpreeLoyalty::Account.transaction do
|
|
24
|
+
account.transactions.create!(
|
|
25
|
+
points: points,
|
|
26
|
+
kind: SpreeLoyalty::Transaction::EARNED,
|
|
27
|
+
source: order
|
|
28
|
+
)
|
|
29
|
+
account.update!(
|
|
30
|
+
points_balance: account.points_balance + points,
|
|
31
|
+
lifetime_points_earned: account.lifetime_points_earned + points
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
account
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
# Converts points into a real Spree::StoreCredit grant, spendable
|
|
3
|
+
# immediately through the store's existing Store Credit payment method —
|
|
4
|
+
# deliberately no bespoke discount/coupon engine. Returns the created
|
|
5
|
+
# Spree::StoreCredit on success; raises SpreeLoyalty::Redemption::Error
|
|
6
|
+
# (with a message meant to be shown to the customer) on failure.
|
|
7
|
+
class Redemption
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Lazily created once per store on first redemption, not seeded — a
|
|
11
|
+
# store that never runs loyalty never gets an unused category cluttering
|
|
12
|
+
# its Store Credit admin.
|
|
13
|
+
CATEGORY_NAME = 'Loyalty Rewards'.freeze
|
|
14
|
+
|
|
15
|
+
def self.call(...) = new.call(...)
|
|
16
|
+
|
|
17
|
+
def call(account:, points:)
|
|
18
|
+
points = points.to_i
|
|
19
|
+
validate!(account, points)
|
|
20
|
+
|
|
21
|
+
dollars = points_to_dollars(points)
|
|
22
|
+
|
|
23
|
+
SpreeLoyalty::Account.transaction do
|
|
24
|
+
store_credit = Spree::StoreCredit.create!(
|
|
25
|
+
user: account.user,
|
|
26
|
+
store: account.store,
|
|
27
|
+
amount: dollars,
|
|
28
|
+
currency: account.store.default_currency,
|
|
29
|
+
category: loyalty_category,
|
|
30
|
+
memo: "Redeemed #{points} loyalty points"
|
|
31
|
+
)
|
|
32
|
+
account.transactions.create!(
|
|
33
|
+
points: -points,
|
|
34
|
+
kind: SpreeLoyalty::Transaction::REDEEMED,
|
|
35
|
+
source: store_credit
|
|
36
|
+
)
|
|
37
|
+
account.update!(points_balance: account.points_balance - points)
|
|
38
|
+
|
|
39
|
+
store_credit
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate!(account, points)
|
|
46
|
+
minimum = SpreeLoyalty::Config.minimum_redemption_points
|
|
47
|
+
|
|
48
|
+
raise Error, 'Enter a number of points to redeem' unless points.positive?
|
|
49
|
+
raise Error, "You need at least #{minimum} points to redeem" if points < minimum
|
|
50
|
+
raise Error, "You only have #{account.points_balance} points" if points > account.points_balance
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def points_to_dollars(points)
|
|
54
|
+
(points * SpreeLoyalty::Config.point_value_cents) / 100.0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def loyalty_category
|
|
58
|
+
Spree::StoreCreditCategory.find_or_create_by!(name: CATEGORY_NAME)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
# Events & Subscribers is the preferred pattern for this kind of side
|
|
3
|
+
# effect (per this app's own CLAUDE.md conventions) — react to
|
|
4
|
+
# order.completed without touching Spree::Order itself. Mirrors
|
|
5
|
+
# SpreeSquare::OrderCompletedSubscriber / SpreeDoordash's own subscriber
|
|
6
|
+
# shape exactly; fully independent of both — Spree's Events system fires
|
|
7
|
+
# every subscriber for the same event, confirmed live this session
|
|
8
|
+
# watching all three fire for the same real order.
|
|
9
|
+
class OrderCompletedSubscriber < Spree::Subscriber
|
|
10
|
+
subscribes_to 'order.completed'
|
|
11
|
+
|
|
12
|
+
def handle(event)
|
|
13
|
+
order = Spree::Order.find_by_prefix_id(event.payload['id'])
|
|
14
|
+
return unless order
|
|
15
|
+
|
|
16
|
+
SpreeLoyalty::Accrual.call(order)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<% content_for :page_title do %>
|
|
2
|
+
Loyalty Accounts
|
|
3
|
+
<% end %>
|
|
4
|
+
|
|
5
|
+
<%= render_table @collection, :loyalty_accounts %>
|
|
6
|
+
|
|
7
|
+
<div class="card mt-3">
|
|
8
|
+
<div class="card-header">
|
|
9
|
+
<h5 class="mb-0">Adjust a balance</h5>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="card-body">
|
|
12
|
+
<p class="text-muted">
|
|
13
|
+
Comp or correct a customer's points balance (e.g. service recovery). Positive numbers add
|
|
14
|
+
points, negative numbers deduct — both are logged in Loyalty Transactions as an
|
|
15
|
+
<code>adjusted</code> entry.
|
|
16
|
+
</p>
|
|
17
|
+
<%= form_tag spree.adjust_points_admin_loyalty_accounts_path, method: :post do %>
|
|
18
|
+
<div class="row g-2 align-items-end">
|
|
19
|
+
<div class="col-auto">
|
|
20
|
+
<%= label_tag :account_id, 'Account ID' %>
|
|
21
|
+
<%= number_field_tag :account_id, nil, class: 'form-control', required: true %>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="col-auto">
|
|
24
|
+
<%= label_tag :points, 'Points (+/-)' %>
|
|
25
|
+
<%= number_field_tag :points, nil, class: 'form-control', required: true %>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="col-auto">
|
|
28
|
+
<%= label_tag :note, 'Note' %>
|
|
29
|
+
<%= text_field_tag :note, nil, class: 'form-control' %>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="col-auto">
|
|
32
|
+
<%= button_tag 'Adjust', type: :submit, class: 'btn btn-primary' %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Registers this extension's event subscriber with Spree's event system.
|
|
2
|
+
#
|
|
3
|
+
# Spree::Subscriber's own docstring says subscribers are "automatically
|
|
4
|
+
# registered during Rails initialization" — that's not what actually
|
|
5
|
+
# happens in spree_core 5.6.1: Spree::Events.register_subscribers! only
|
|
6
|
+
# ever iterates the explicit Spree.subscribers array (see
|
|
7
|
+
# spree_core/lib/spree/events.rb), there is no Zeitwerk-descendant scan.
|
|
8
|
+
# Without this file, SpreeLoyalty::OrderCompletedSubscriber is a real,
|
|
9
|
+
# loadable class but never actually wired to the 'order.completed' event —
|
|
10
|
+
# spree_square and spree_doordash both hit this live before adding their
|
|
11
|
+
# own identical registration file; mirroring it here from the start rather
|
|
12
|
+
# than rediscovering the same bug a third time.
|
|
13
|
+
Rails.application.config.after_initialize do
|
|
14
|
+
Spree.subscribers << SpreeLoyalty::OrderCompletedSubscriber
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Rails.application.config.after_initialize do
|
|
2
|
+
# Positions 73-74 — right after spree_menu_chat's own entries (71-72), so
|
|
3
|
+
# every extension's nav items sit together without colliding. (spree_square:
|
|
4
|
+
# 65-67, spree_doordash: 68-70, spree_menu_chat: 71-72.)
|
|
5
|
+
Spree.admin.navigation.sidebar.add :loyalty_accounts,
|
|
6
|
+
label: 'Loyalty Accounts',
|
|
7
|
+
url: :admin_loyalty_accounts_path,
|
|
8
|
+
icon: 'award',
|
|
9
|
+
position: 73,
|
|
10
|
+
active: -> { controller_name == 'loyalty_accounts' },
|
|
11
|
+
if: -> { can?(:manage, SpreeLoyalty::Account) }
|
|
12
|
+
|
|
13
|
+
Spree.admin.navigation.sidebar.add :loyalty_transactions,
|
|
14
|
+
label: 'Loyalty Transactions',
|
|
15
|
+
url: :admin_loyalty_transactions_path,
|
|
16
|
+
icon: 'list-details',
|
|
17
|
+
position: 74,
|
|
18
|
+
active: -> { controller_name == 'loyalty_transactions' },
|
|
19
|
+
if: -> { can?(:manage, SpreeLoyalty::Transaction) }
|
|
20
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Rails.application.config.after_initialize do
|
|
2
|
+
# new_resource: false — read-only support/diagnostic tables (no
|
|
3
|
+
# `:new`/`:create` route; only: [:index] in config/routes.rb). Without
|
|
4
|
+
# this the empty-state partial builds a `new_object_url` link
|
|
5
|
+
# unconditionally and 500s the moment the table is ever empty — the exact
|
|
6
|
+
# bug spree_square and spree_doordash each hit live on their first real
|
|
7
|
+
# Postgres run and fixed in their own spree_admin_*_tables.rb. Copying
|
|
8
|
+
# their already-fixed pattern here instead of rediscovering it a third
|
|
9
|
+
# time.
|
|
10
|
+
Spree.admin.tables.register(:loyalty_accounts, model_class: SpreeLoyalty::Account,
|
|
11
|
+
search_param: :user_email_cont, new_resource: false)
|
|
12
|
+
|
|
13
|
+
Spree.admin.tables.loyalty_accounts.add :id,
|
|
14
|
+
label: :id,
|
|
15
|
+
type: :string,
|
|
16
|
+
sortable: true,
|
|
17
|
+
filterable: false,
|
|
18
|
+
default: true,
|
|
19
|
+
position: 5
|
|
20
|
+
|
|
21
|
+
Spree.admin.tables.loyalty_accounts.add :user_email,
|
|
22
|
+
label: :customer,
|
|
23
|
+
type: :string,
|
|
24
|
+
sortable: false,
|
|
25
|
+
filterable: false,
|
|
26
|
+
default: true,
|
|
27
|
+
position: 10,
|
|
28
|
+
method: ->(account) { account.user&.email }
|
|
29
|
+
|
|
30
|
+
Spree.admin.tables.loyalty_accounts.add :points_balance,
|
|
31
|
+
label: :points_balance,
|
|
32
|
+
type: :string,
|
|
33
|
+
sortable: true,
|
|
34
|
+
filterable: false,
|
|
35
|
+
default: true,
|
|
36
|
+
position: 20
|
|
37
|
+
|
|
38
|
+
Spree.admin.tables.loyalty_accounts.add :lifetime_points_earned,
|
|
39
|
+
label: :lifetime_points_earned,
|
|
40
|
+
type: :string,
|
|
41
|
+
sortable: true,
|
|
42
|
+
filterable: false,
|
|
43
|
+
default: true,
|
|
44
|
+
position: 30
|
|
45
|
+
|
|
46
|
+
Spree.admin.tables.loyalty_accounts.add :created_at,
|
|
47
|
+
label: :member_since,
|
|
48
|
+
type: :datetime,
|
|
49
|
+
sortable: true,
|
|
50
|
+
filterable: false,
|
|
51
|
+
default: true,
|
|
52
|
+
position: 40
|
|
53
|
+
|
|
54
|
+
Spree.admin.tables.register(:loyalty_transactions, model_class: SpreeLoyalty::Transaction,
|
|
55
|
+
search_param: :kind_cont, new_resource: false)
|
|
56
|
+
|
|
57
|
+
Spree.admin.tables.loyalty_transactions.add :account_id,
|
|
58
|
+
label: :account_id,
|
|
59
|
+
type: :string,
|
|
60
|
+
sortable: true,
|
|
61
|
+
filterable: true,
|
|
62
|
+
default: true,
|
|
63
|
+
position: 10
|
|
64
|
+
|
|
65
|
+
Spree.admin.tables.loyalty_transactions.add :kind,
|
|
66
|
+
label: :kind,
|
|
67
|
+
type: :string,
|
|
68
|
+
sortable: true,
|
|
69
|
+
filterable: true,
|
|
70
|
+
default: true,
|
|
71
|
+
position: 20
|
|
72
|
+
|
|
73
|
+
Spree.admin.tables.loyalty_transactions.add :points,
|
|
74
|
+
label: :points,
|
|
75
|
+
type: :string,
|
|
76
|
+
sortable: true,
|
|
77
|
+
filterable: false,
|
|
78
|
+
default: true,
|
|
79
|
+
position: 30
|
|
80
|
+
|
|
81
|
+
Spree.admin.tables.loyalty_transactions.add :note,
|
|
82
|
+
label: :note,
|
|
83
|
+
type: :string,
|
|
84
|
+
sortable: false,
|
|
85
|
+
filterable: false,
|
|
86
|
+
default: true,
|
|
87
|
+
position: 40
|
|
88
|
+
|
|
89
|
+
Spree.admin.tables.loyalty_transactions.add :created_at,
|
|
90
|
+
label: :created_at,
|
|
91
|
+
type: :datetime,
|
|
92
|
+
sortable: true,
|
|
93
|
+
filterable: false,
|
|
94
|
+
default: true,
|
|
95
|
+
position: 50
|
|
96
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Spree::Core::Engine.add_routes do
|
|
2
|
+
namespace :admin do
|
|
3
|
+
resources :loyalty_accounts, only: [:index] do
|
|
4
|
+
collection do
|
|
5
|
+
post :adjust_points
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
resources :loyalty_transactions, only: [:index]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Small custom Store API surface (same "extend spree_api with just what
|
|
12
|
+
# the storefront needs" pattern spree_square already uses for modifier
|
|
13
|
+
# lists) — the storefront never touches SpreeLoyalty::Account/Transaction
|
|
14
|
+
# directly, only through these two endpoints.
|
|
15
|
+
namespace :api, defaults: { format: 'json' } do
|
|
16
|
+
namespace :v3 do
|
|
17
|
+
namespace :store do
|
|
18
|
+
get 'loyalty_account', to: '/spree_loyalty/loyalty_account#show'
|
|
19
|
+
post 'loyalty_account/redeem', to: '/spree_loyalty/loyalty_account#redeem'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateSpreeLoyaltyAccounts < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_loyalty_accounts do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: { to_table: Spree.user_class.table_name }
|
|
5
|
+
t.references :store, null: false, foreign_key: { to_table: :spree_stores }
|
|
6
|
+
|
|
7
|
+
t.integer :points_balance, null: false, default: 0
|
|
8
|
+
t.integer :lifetime_points_earned, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# One account per user per store — accrual/redemption both
|
|
14
|
+
# find_or_create_by! on [user, store], so this is the constraint that
|
|
15
|
+
# actually keeps that safe under concurrent requests.
|
|
16
|
+
add_index :spree_loyalty_accounts, %i[user_id store_id], unique: true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class CreateSpreeLoyaltyTransactions < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_loyalty_transactions do |t|
|
|
4
|
+
t.references :account, null: false, foreign_key: { to_table: :spree_loyalty_accounts }
|
|
5
|
+
|
|
6
|
+
# Signed — positive for an earn, negative for a redemption or a
|
|
7
|
+
# manual deduction. The running Account#points_balance is a cache of
|
|
8
|
+
# summing this column; this table is the real source of truth (same
|
|
9
|
+
# "keep a real audit trail, don't just mutate a running total"
|
|
10
|
+
# precedent as SpreeSquare::WebhookEvent/OrderMapping).
|
|
11
|
+
t.integer :points, null: false
|
|
12
|
+
|
|
13
|
+
# 'earned' | 'redeemed' | 'adjusted' — a plain string, not a Rails
|
|
14
|
+
# enum backed by an integer column: this table is read directly in
|
|
15
|
+
# the admin ledger view and in support queries, and a string reads
|
|
16
|
+
# there without a lookup table, same rationale already used for
|
|
17
|
+
# SpreeSquare::OrderMapping#last_status and
|
|
18
|
+
# SpreeDoordash::DeliveryMapping#last_status.
|
|
19
|
+
t.string :kind, null: false
|
|
20
|
+
|
|
21
|
+
# Polymorphic source of the transaction — Spree::Order for a real
|
|
22
|
+
# earn event, nil for a manual admin adjustment (kind: 'adjusted').
|
|
23
|
+
# Redemptions ('redeemed') point at the Spree::StoreCredit they
|
|
24
|
+
# granted, so the ledger and the spendable balance are directly
|
|
25
|
+
# traceable to each other.
|
|
26
|
+
t.references :source, polymorphic: true, null: true
|
|
27
|
+
|
|
28
|
+
# Free-text note — populated for admin-initiated adjustments (why a
|
|
29
|
+
# staff member comped points), blank for order-driven earn/redeem
|
|
30
|
+
# rows where the source association already explains itself.
|
|
31
|
+
t.text :note
|
|
32
|
+
|
|
33
|
+
t.timestamps
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
add_index :spree_loyalty_transactions, :kind
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
module Generators
|
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
|
4
|
+
class_option :migrate, type: :boolean, default: true
|
|
5
|
+
|
|
6
|
+
def add_migrations
|
|
7
|
+
run 'bundle exec rake railties:install:migrations FROM=spree_loyalty'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run_migrations
|
|
11
|
+
run_migrations = options[:migrate] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
|
|
12
|
+
if run_migrations
|
|
13
|
+
run 'bin/rails db:migrate'
|
|
14
|
+
else
|
|
15
|
+
puts 'Skipping rails db:migrate, don\'t forget to run it!'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
class Configuration < Spree::Preferences::Configuration
|
|
3
|
+
# Points earned per whole dollar of an order's item_total (delivery
|
|
4
|
+
# fees excluded — see Accrual for why order.item_total, not
|
|
5
|
+
# order.total). 1 point per $1 is the plain, easy-to-explain default;
|
|
6
|
+
# store owners tune it from here without a code change.
|
|
7
|
+
preference :points_per_dollar, :integer, default: 1
|
|
8
|
+
|
|
9
|
+
# Cents of Spree::StoreCredit granted per point redeemed. Default
|
|
10
|
+
# 1 -> 100 points = $1.00, i.e. the same 1-point-per-cent-of-a-dollar
|
|
11
|
+
# rate as the earn side, so "100 points" reads the same as "$1" in both
|
|
12
|
+
# directions without the store owner having to do math.
|
|
13
|
+
preference :point_value_cents, :integer, default: 1
|
|
14
|
+
|
|
15
|
+
# Floor on a single redemption — prevents a $0.01 store-credit grant
|
|
16
|
+
# (and the matching tiny StoreCredit row) for a 1-point redemption.
|
|
17
|
+
preference :minimum_redemption_points, :integer, default: 100
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module SpreeLoyalty
|
|
2
|
+
class Engine < Rails::Engine
|
|
3
|
+
require 'spree/core'
|
|
4
|
+
isolate_namespace Spree
|
|
5
|
+
engine_name 'spree_loyalty'
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer 'spree_loyalty.environment', before: :load_config_initializers do |_app|
|
|
12
|
+
SpreeLoyalty::Config = SpreeLoyalty::Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# No external API, no encrypted credentials — unlike spree_square/
|
|
16
|
+
# spree_doordash this extension never talks outbound, so it has no
|
|
17
|
+
# equivalent of their ACTIVE_RECORD_ENCRYPTION_* initializer to run
|
|
18
|
+
# before Active Record's own encryption-configuration initializer.
|
|
19
|
+
|
|
20
|
+
# Force-loads decorator files the same way spree_square/spree_doordash's
|
|
21
|
+
# engines do — Zeitwerk's lazy autoloading never triggers a decorator
|
|
22
|
+
# file's `prepend` line on its own (nothing references the exact
|
|
23
|
+
# `Spree::XyzDecorator` constant name), so each engine has to eager-load
|
|
24
|
+
# its own app/ tree explicitly. See spree_square/lib/spree_square/engine.rb
|
|
25
|
+
# for the full rationale.
|
|
26
|
+
def self.activate
|
|
27
|
+
Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
|
|
28
|
+
Rails.application.config.cache_classes ? require(c) : load(c)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :loyalty_account, class: 'SpreeLoyalty::Account' do
|
|
3
|
+
user
|
|
4
|
+
store
|
|
5
|
+
points_balance { 0 }
|
|
6
|
+
lifetime_points_earned { 0 }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
factory :loyalty_transaction, class: 'SpreeLoyalty::Transaction' do
|
|
10
|
+
association :account, factory: :loyalty_account
|
|
11
|
+
points { 10 }
|
|
12
|
+
kind { SpreeLoyalty::Transaction::EARNED }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
lib = File.expand_path('../lib/', __FILE__)
|
|
3
|
+
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
|
|
4
|
+
|
|
5
|
+
require 'spree_loyalty/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |s|
|
|
8
|
+
s.platform = Gem::Platform::RUBY
|
|
9
|
+
s.name = 'spree_loyalty'
|
|
10
|
+
s.version = SpreeLoyalty::VERSION
|
|
11
|
+
s.summary = 'Spree Commerce Points-Based Loyalty Program'
|
|
12
|
+
s.description = 'A POS-agnostic points loyalty program for Spree Commerce: customers earn points ' \
|
|
13
|
+
'on completed orders and redeem them for real Spree::StoreCredit, spendable through ' \
|
|
14
|
+
'the store\'s existing Store Credit payment method — no new checkout code needed. ' \
|
|
15
|
+
'Independent of any point-of-sale or payment provider.'
|
|
16
|
+
s.required_ruby_version = '>= 3.2'
|
|
17
|
+
|
|
18
|
+
s.author = 'Amit Solanki'
|
|
19
|
+
s.email = 'amitkssolanki@gmail.com'
|
|
20
|
+
s.homepage = 'https://github.com/amitkssolanki/spree_loyalty'
|
|
21
|
+
s.license = 'MIT'
|
|
22
|
+
|
|
23
|
+
s.metadata = {
|
|
24
|
+
'homepage_uri' => s.homepage,
|
|
25
|
+
'source_code_uri' => s.homepage,
|
|
26
|
+
'changelog_uri' => "#{s.homepage}/blob/main/CHANGELOG.md",
|
|
27
|
+
'bug_tracker_uri' => "#{s.homepage}/issues"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
s.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
31
|
+
f.start_with?('spec/') && !f.start_with?('spec/fixtures')
|
|
32
|
+
end
|
|
33
|
+
s.require_path = 'lib'
|
|
34
|
+
s.requirements << 'none'
|
|
35
|
+
|
|
36
|
+
spree_version = '>= 5.4.0.beta'
|
|
37
|
+
s.add_dependency 'spree', spree_version
|
|
38
|
+
s.add_dependency 'spree_admin', spree_version
|
|
39
|
+
|
|
40
|
+
s.add_development_dependency 'spree_dev_tools'
|
|
41
|
+
s.add_development_dependency 'gem-release'
|
|
42
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: spree_loyalty
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Amit Solanki
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: spree
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 5.4.0.beta
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 5.4.0.beta
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: spree_admin
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 5.4.0.beta
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 5.4.0.beta
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: spree_dev_tools
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: gem-release
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
description: 'A POS-agnostic points loyalty program for Spree Commerce: customers
|
|
69
|
+
earn points on completed orders and redeem them for real Spree::StoreCredit, spendable
|
|
70
|
+
through the store''s existing Store Credit payment method — no new checkout code
|
|
71
|
+
needed. Independent of any point-of-sale or payment provider.'
|
|
72
|
+
email: amitkssolanki@gmail.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- ".gem_release.yml"
|
|
78
|
+
- ".github/workflows/test.yml"
|
|
79
|
+
- ".gitignore"
|
|
80
|
+
- ".rspec"
|
|
81
|
+
- CHANGELOG.md
|
|
82
|
+
- CONTRIBUTING.md
|
|
83
|
+
- Gemfile
|
|
84
|
+
- LICENSE.md
|
|
85
|
+
- README.md
|
|
86
|
+
- Rakefile
|
|
87
|
+
- app/controllers/spree/admin/loyalty_accounts_controller.rb
|
|
88
|
+
- app/controllers/spree/admin/loyalty_transactions_controller.rb
|
|
89
|
+
- app/controllers/spree_loyalty/loyalty_account_controller.rb
|
|
90
|
+
- app/models/spree_loyalty/account.rb
|
|
91
|
+
- app/models/spree_loyalty/transaction.rb
|
|
92
|
+
- app/services/spree_loyalty/accrual.rb
|
|
93
|
+
- app/services/spree_loyalty/redemption.rb
|
|
94
|
+
- app/subscribers/spree_loyalty/order_completed_subscriber.rb
|
|
95
|
+
- app/views/spree/admin/loyalty_accounts/index.html.erb
|
|
96
|
+
- app/views/spree/admin/loyalty_transactions/index.html.erb
|
|
97
|
+
- config/initializers/spree.rb
|
|
98
|
+
- config/initializers/spree_admin_loyalty_navigation.rb
|
|
99
|
+
- config/initializers/spree_admin_loyalty_tables.rb
|
|
100
|
+
- config/locales/en.yml
|
|
101
|
+
- config/routes.rb
|
|
102
|
+
- db/migrate/20260820000001_create_spree_loyalty_accounts.rb
|
|
103
|
+
- db/migrate/20260820000002_create_spree_loyalty_transactions.rb
|
|
104
|
+
- lib/generators/spree_loyalty/install/install_generator.rb
|
|
105
|
+
- lib/spree_loyalty.rb
|
|
106
|
+
- lib/spree_loyalty/configuration.rb
|
|
107
|
+
- lib/spree_loyalty/engine.rb
|
|
108
|
+
- lib/spree_loyalty/factories.rb
|
|
109
|
+
- lib/spree_loyalty/version.rb
|
|
110
|
+
- spree_loyalty.gemspec
|
|
111
|
+
homepage: https://github.com/amitkssolanki/spree_loyalty
|
|
112
|
+
licenses:
|
|
113
|
+
- MIT
|
|
114
|
+
metadata:
|
|
115
|
+
homepage_uri: https://github.com/amitkssolanki/spree_loyalty
|
|
116
|
+
source_code_uri: https://github.com/amitkssolanki/spree_loyalty
|
|
117
|
+
changelog_uri: https://github.com/amitkssolanki/spree_loyalty/blob/main/CHANGELOG.md
|
|
118
|
+
bug_tracker_uri: https://github.com/amitkssolanki/spree_loyalty/issues
|
|
119
|
+
rdoc_options: []
|
|
120
|
+
require_paths:
|
|
121
|
+
- lib
|
|
122
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
123
|
+
requirements:
|
|
124
|
+
- - ">="
|
|
125
|
+
- !ruby/object:Gem::Version
|
|
126
|
+
version: '3.2'
|
|
127
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '0'
|
|
132
|
+
requirements:
|
|
133
|
+
- none
|
|
134
|
+
rubygems_version: 3.6.9
|
|
135
|
+
specification_version: 4
|
|
136
|
+
summary: Spree Commerce Points-Based Loyalty Program
|
|
137
|
+
test_files: []
|