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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_release.yml +2 -0
  3. data/.github/workflows/test.yml +107 -0
  4. data/.gitignore +23 -0
  5. data/.rspec +3 -0
  6. data/CHANGELOG.md +21 -0
  7. data/CONTRIBUTING.md +20 -0
  8. data/Gemfile +27 -0
  9. data/LICENSE.md +9 -0
  10. data/README.md +115 -0
  11. data/Rakefile +23 -0
  12. data/app/controllers/spree/admin/loyalty_accounts_controller.rb +36 -0
  13. data/app/controllers/spree/admin/loyalty_transactions_controller.rb +13 -0
  14. data/app/controllers/spree_loyalty/loyalty_account_controller.rb +45 -0
  15. data/app/models/spree_loyalty/account.rb +13 -0
  16. data/app/models/spree_loyalty/transaction.rb +41 -0
  17. data/app/services/spree_loyalty/accrual.rb +38 -0
  18. data/app/services/spree_loyalty/redemption.rb +61 -0
  19. data/app/subscribers/spree_loyalty/order_completed_subscriber.rb +19 -0
  20. data/app/views/spree/admin/loyalty_accounts/index.html.erb +37 -0
  21. data/app/views/spree/admin/loyalty_transactions/index.html.erb +5 -0
  22. data/config/initializers/spree.rb +15 -0
  23. data/config/initializers/spree_admin_loyalty_navigation.rb +20 -0
  24. data/config/initializers/spree_admin_loyalty_tables.rb +96 -0
  25. data/config/locales/en.yml +5 -0
  26. data/config/routes.rb +23 -0
  27. data/db/migrate/20260820000001_create_spree_loyalty_accounts.rb +18 -0
  28. data/db/migrate/20260820000002_create_spree_loyalty_transactions.rb +38 -0
  29. data/lib/generators/spree_loyalty/install/install_generator.rb +20 -0
  30. data/lib/spree_loyalty/configuration.rb +19 -0
  31. data/lib/spree_loyalty/engine.rb +34 -0
  32. data/lib/spree_loyalty/factories.rb +14 -0
  33. data/lib/spree_loyalty/version.rb +7 -0
  34. data/lib/spree_loyalty.rb +12 -0
  35. data/spree_loyalty.gemspec +42 -0
  36. 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,2 @@
1
+ bump:
2
+ file: "lib/spree_loyalty/version.rb"
@@ -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
@@ -0,0 +1,3 @@
1
+ --color
2
+ -r spec_helper
3
+ -f documentation
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
+ [![Gem Version](https://img.shields.io/gem/v/spree_loyalty.svg)](https://rubygems.org/gems/spree_loyalty)
4
+ [![GitHub Release](https://img.shields.io/github/v/release/amitkssolanki/spree_loyalty)](https://github.com/amitkssolanki/spree_loyalty/releases)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ <% content_for :page_title do %>
2
+ Loyalty Transactions
3
+ <% end %>
4
+
5
+ <%= render_table @collection, :loyalty_transactions %>
@@ -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
@@ -0,0 +1,5 @@
1
+ en:
2
+ spree_loyalty:
3
+ account_id: "Account ID"
4
+ points: "Points"
5
+ adjust: "Adjust"
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,7 @@
1
+ module SpreeLoyalty
2
+ VERSION = '0.1.0'.freeze
3
+
4
+ def gem_version
5
+ Gem::Version.new(VERSION)
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ require 'spree'
2
+ require 'spree_loyalty/engine'
3
+ require 'spree_loyalty/version'
4
+ require 'spree_loyalty/configuration'
5
+
6
+ module SpreeLoyalty
7
+ mattr_accessor :queue
8
+
9
+ def self.queue
10
+ @@queue ||= Spree.queues.default
11
+ end
12
+ 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: []