Compare commits

...

126 Commits

Author SHA1 Message Date
dependabot[bot]
4e0f5225b4 Bump mini-css-extract-plugin from 2.10.1 to 2.10.2
Bumps [mini-css-extract-plugin](https://github.com/webpack/mini-css-extract-plugin) from 2.10.1 to 2.10.2.
- [Release notes](https://github.com/webpack/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack/mini-css-extract-plugin/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/mini-css-extract-plugin/compare/v2.10.1...v2.10.2)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-version: 2.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 09:32:58 +00:00
Gaetan Craig-Riou
783ac990bc Merge pull request #14132 from openfoodfoundation/dependabot/bundler/rack-2.2.23
Bump rack from 2.2.22 to 2.2.23
2026-04-03 09:33:02 +11:00
dependabot[bot]
a4cc2f17dc Bump rack from 2.2.22 to 2.2.23
Bumps [rack](https://github.com/rack/rack) from 2.2.22 to 2.2.23.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v2.2.22...v2.2.23)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 2.2.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 20:02:57 +00:00
Rachel Arnould
c19241ddd9 Merge pull request #14118 from dacook/linked-variants-14088
Prevent creating a linked variant from a linked variant
2026-04-02 17:44:34 +02:00
Maikel
eee9f61c38 Merge pull request #14129 from openfoodfoundation/dependabot/bundler/aws-sdk-s3-1.217.0
Bump aws-sdk-s3 from 1.215.0 to 1.217.0
2026-04-02 11:50:42 +11:00
Maikel
7f711d746f Merge pull request #14126 from dacook/dependabot-cooldown
Dependabot cooldown
2026-04-02 11:49:55 +11:00
dependabot[bot]
732234f1c0 Bump aws-sdk-s3 from 1.215.0 to 1.217.0
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.215.0 to 1.217.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.217.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 11:11:34 +00:00
Maikel
450fe4ada1 Merge pull request #14119 from mkllnk/replace-whenever
Replace whenever with sidekiq scheduler
2026-04-01 15:37:33 +11:00
Maikel Linke
bcecbf9a0f Require rake dependency to run it within jobs 2026-04-01 15:18:49 +11:00
Maikel
2e6e4b665f Merge pull request #14122 from openfoodfoundation/dependabot/bundler/view_component-4.5.0
Bump view_component from 4.1.1 to 4.5.0
2026-04-01 10:31:41 +11:00
David Cook
e255bcc082 Formatting
Compacted and adjusted comments to make it a bit easier to read.
2026-04-01 10:31:34 +11:00
David Cook
51b4dc64cc Add cooldown for turbo_power
Ensure it's treated the same as other gems and packages.
2026-04-01 10:17:40 +11:00
Maikel
77b6bc15e7 Merge pull request #14121 from openfoodfoundation/dependabot/bundler/devise-i18n-1.16.0
Bump devise-i18n from 1.15.0 to 1.16.0
2026-04-01 10:11:37 +11:00
Ahmed Ejaz
9b145da898 Merge pull request #14040 from chahmedejaz/task/13797-improve-performance-of-products-page
Fix Admin Bulk Products screen performance issue
2026-04-01 00:37:40 +05:00
dependabot[bot]
00d600911d Bump view_component from 4.1.1 to 4.5.0
Bumps [view_component](https://github.com/viewcomponent/view_component) from 4.1.1 to 4.5.0.
- [Release notes](https://github.com/viewcomponent/view_component/releases)
- [Changelog](https://github.com/ViewComponent/view_component/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/viewcomponent/view_component/compare/v4.1.1...v4.5.0)

---
updated-dependencies:
- dependency-name: view_component
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 09:55:00 +00:00
dependabot[bot]
10d6dd73f2 Bump devise-i18n from 1.15.0 to 1.16.0
Bumps [devise-i18n](https://github.com/devise-i18n/devise-i18n) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/devise-i18n/devise-i18n/releases)
- [Changelog](https://github.com/devise-i18n/devise-i18n/blob/main/CHANGELOG.md)
- [Commits](https://github.com/devise-i18n/devise-i18n/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: devise-i18n
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 09:41:05 +00:00
Maikel Linke
c74624cd57 Remove unused gem whenever 2026-03-31 14:58:57 +11:00
Maikel Linke
60edcada2c Remove whenever config 2026-03-31 14:56:41 +11:00
Maikel Linke
b61f6ab444 Schedule all jobs with Sidekiq 2026-03-31 14:53:26 +11:00
David Cook
ccc38367f3 Prevent creating a linked variant from a linked variant
It's just too confusing.
2026-03-31 14:34:17 +11:00
Maikel Linke
80a12db191 Move database clean from cron to Sidekiq scheduler
After moving the remaining tasks from schedule.rb to sidekiq.yml, we can
remove whenever and won't rely on cron any more. That will simplify the
setup and migration to a new server.
2026-03-31 12:34:47 +11:00
Maikel
5beed6f028 Merge pull request #14117 from openfoodfoundation/dependabot/bundler/whenever-1.1.2
Bump whenever from 1.1.0 to 1.1.2
2026-03-31 10:27:56 +11:00
Ahmed Ejaz
0a65322594 rename ajax_search_spec 2026-03-31 04:05:06 +05:00
Ahmed Ejaz
b7f154d289 revert back the bin/setup 2026-03-31 03:49:35 +05:00
Maikel
edb8a03436 Merge pull request #14116 from openfoodfoundation/dependabot/bundler/active_storage_validations-3.0.4
Bump active_storage_validations from 3.0.3 to 3.0.4
2026-03-31 09:35:28 +11:00
Ahmed Ejaz
3ee338fa8d Add ajax search controller 2026-03-31 01:54:02 +05:00
dependabot[bot]
e3da27ca12 Bump whenever from 1.1.0 to 1.1.2
Bumps [whenever](https://github.com/javan/whenever) from 1.1.0 to 1.1.2.
- [Release notes](https://github.com/javan/whenever/releases)
- [Changelog](https://github.com/javan/whenever/blob/main/CHANGELOG.md)
- [Commits](https://github.com/javan/whenever/compare/v1.1.0...v1.1.2)

---
updated-dependencies:
- dependency-name: whenever
  dependency-version: 1.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 10:17:45 +00:00
dependabot[bot]
4c8e6d8260 Bump active_storage_validations from 3.0.3 to 3.0.4
Bumps [active_storage_validations](https://github.com/igorkasyanchuk/active_storage_validations) from 3.0.3 to 3.0.4.
- [Release notes](https://github.com/igorkasyanchuk/active_storage_validations/releases)
- [Changelog](https://github.com/igorkasyanchuk/active_storage_validations/blob/master/CHANGES.md)
- [Commits](https://github.com/igorkasyanchuk/active_storage_validations/compare/3.0.03...3.0.4)

---
updated-dependencies:
- dependency-name: active_storage_validations
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 10:16:06 +00:00
Maikel
de28083007 Merge pull request #14112 from openfoodfoundation/dependabot/bundler/bootsnap-1.23.0
Bump bootsnap from 1.22.0 to 1.23.0
2026-03-30 11:59:05 +11:00
Gaetan Craig-Riou
01bfd72387 Merge pull request #14115 from openfoodfoundation/dependabot/npm_and_yarn/trix-2.1.18
Bump trix from 2.1.17 to 2.1.18
2026-03-30 09:57:33 +11:00
Gaetan Craig-Riou
69d9c52a53 Merge pull request #14111 from openfoodfoundation/dependabot/bundler/pg-1.6.3
Bump pg from 1.6.2 to 1.6.3
2026-03-30 09:44:59 +11:00
dependabot[bot]
5371361a74 Bump trix from 2.1.17 to 2.1.18
Bumps [trix](https://github.com/basecamp/trix) from 2.1.17 to 2.1.18.
- [Release notes](https://github.com/basecamp/trix/releases)
- [Commits](https://github.com/basecamp/trix/compare/v2.1.17...v2.1.18)

---
updated-dependencies:
- dependency-name: trix
  dependency-version: 2.1.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 18:49:52 +00:00
dependabot[bot]
b7c628dc2a Bump bootsnap from 1.22.0 to 1.23.0
Bumps [bootsnap](https://github.com/rails/bootsnap) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/rails/bootsnap/releases)
- [Changelog](https://github.com/rails/bootsnap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/bootsnap/compare/v1.22.0...v1.23.0)

---
updated-dependencies:
- dependency-name: bootsnap
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 09:34:18 +00:00
dependabot[bot]
5bef61aa2e Bump pg from 1.6.2 to 1.6.3
Bumps [pg](https://github.com/ged/ruby-pg) from 1.6.2 to 1.6.3.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.6.2...v1.6.3)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 09:33:08 +00:00
Maikel
79c346acb1 Merge pull request #14109 from openfoodfoundation/dependabot/npm_and_yarn/node-forge-1.4.0
Bump node-forge from 1.3.3 to 1.4.0
2026-03-27 13:56:43 +11:00
Gaetan Craig-Riou
e87159426e Update all locales with the latest Transifex translations 2026-03-27 13:49:26 +11:00
dependabot[bot]
ca10ae2f5c Bump node-forge from 1.3.3 to 1.4.0
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 00:12:35 +00:00
Maikel
423e8a2cff Merge pull request #14104 from openfoodfoundation/dependabot/npm_and_yarn/picomatch-2.3.2
Bump picomatch from 2.3.1 to 2.3.2
2026-03-27 10:29:10 +11:00
dependabot[bot]
191df4ecf7 Bump picomatch from 2.3.1 to 2.3.2
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 23:17:51 +00:00
Maikel
c274c19e96 Merge pull request #14106 from mkllnk/taler-fix
Fix specs after taler gem update
2026-03-27 10:15:59 +11:00
Maikel
0e7e09bcfe Merge pull request #14103 from openfoodfoundation/dependabot/bundler/webmock-3.26.2
Bump webmock from 3.26.1 to 3.26.2
2026-03-27 10:06:00 +11:00
Maikel
96c2dff744 Merge pull request #14102 from openfoodfoundation/dependabot/bundler/undercover-0.8.4
Bump undercover from 0.8.3 to 0.8.4
2026-03-27 10:05:23 +11:00
Gaetan Craig-Riou
01cff7a618 Merge pull request #14060 from mvanhorn/add-order-id-to-webhook
Add order number to Payment webhook payload
2026-03-27 09:29:10 +11:00
Maikel Linke
ecca47f96d Fix specs after taler gem update 2026-03-26 13:28:16 +11:00
Ahmed Ejaz
4a66984ec4 Merge pull request #14084 from chahmedejaz/bugfix/14081-delete-user
Fix authorization for removing enterprise managers for non-admins
2026-03-26 05:10:36 +05:00
Ahmed Ejaz
ac716150eb Merge pull request #14091 from chahmedejaz/bugfix/14015-products-search-timeout-on-subscriptions
Fix Products search timing out when creating a new subscription
2026-03-25 20:49:45 +05:00
Ahmed Ejaz
2fe28d1707 Merge pull request #14101 from mkllnk/taler-currency
Use real currency for Taler payments unless using demo backend
2026-03-25 20:44:44 +05:00
Rachel Arnould
dcf3ab74b8 Merge pull request #13962 from mkllnk/taler-credit
Credit customers via Taler
2026-03-25 15:24:06 +01:00
dependabot[bot]
ef56df09a1 Bump webmock from 3.26.1 to 3.26.2
Bumps [webmock](https://github.com/bblimke/webmock) from 3.26.1 to 3.26.2.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.26.1...v3.26.2)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 09:38:33 +00:00
dependabot[bot]
3d116d0027 Bump undercover from 0.8.3 to 0.8.4
Bumps [undercover](https://github.com/grodowski/undercover) from 0.8.3 to 0.8.4.
- [Release notes](https://github.com/grodowski/undercover/releases)
- [Changelog](https://github.com/grodowski/undercover/blob/master/CHANGELOG.md)
- [Commits](https://github.com/grodowski/undercover/compare/v0.8.3...v0.8.4)

---
updated-dependencies:
- dependency-name: undercover
  dependency-version: 0.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 09:37:16 +00:00
Maikel Linke
fe0c6a4deb Use real currency for Taler payments unless using demo backend. 2026-03-25 15:09:43 +11:00
Gaetan Craig-Riou
1e6de5e251 Merge pull request #14096 from openfoodfoundation/dependabot/npm_and_yarn/babel/preset-env-7.29.2
Bump @babel/preset-env from 7.29.0 to 7.29.2
2026-03-25 11:08:44 +11:00
Gaetan Craig-Riou
af2299c666 Merge pull request #14098 from openfoodfoundation/dependabot/bundler/bugsnag-6.29.0
Bump bugsnag from 6.28.0 to 6.29.0
2026-03-25 11:08:25 +11:00
Gaetan Craig-Riou
b37111f007 Merge pull request #14097 from openfoodfoundation/dependabot/bundler/puffing-billy-4.0.4
Bump puffing-billy from 4.0.2 to 4.0.4
2026-03-25 10:52:26 +11:00
Gaetan Craig-Riou
043a8a84f3 Merge pull request #14095 from openfoodfoundation/dependabot/npm_and_yarn/babel/runtime-7.29.2
Bump @babel/runtime from 7.28.6 to 7.29.2
2026-03-25 10:48:50 +11:00
Ahmed Ejaz
8ba0ab6b5a Update specs according to new remote search function on products page 2026-03-25 02:01:36 +05:00
Ahmed Ejaz
044f6131da fix aria_label translations 2026-03-25 01:30:06 +05:00
Ahmed Ejaz
062fcd317c Add searchable dropdowns for producers, categories, and tax categories in products_v3 2026-03-25 01:30:06 +05:00
dependabot[bot]
a2fad2cab3 Bump bugsnag from 6.28.0 to 6.29.0
Bumps [bugsnag](https://github.com/bugsnag/bugsnag-ruby) from 6.28.0 to 6.29.0.
- [Release notes](https://github.com/bugsnag/bugsnag-ruby/releases)
- [Changelog](https://github.com/bugsnag/bugsnag-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bugsnag/bugsnag-ruby/compare/v6.28.0...v6.29.0)

---
updated-dependencies:
- dependency-name: bugsnag
  dependency-version: 6.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 09:34:24 +00:00
dependabot[bot]
5ab1ce751b Bump puffing-billy from 4.0.2 to 4.0.4
Bumps [puffing-billy](https://github.com/oesmith/puffing-billy) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/oesmith/puffing-billy/releases)
- [Changelog](https://github.com/oesmith/puffing-billy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/oesmith/puffing-billy/commits)

---
updated-dependencies:
- dependency-name: puffing-billy
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 09:32:08 +00:00
dependabot[bot]
1a2b5ffc3a Bump @babel/preset-env from 7.29.0 to 7.29.2
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.29.0 to 7.29.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.2/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 09:31:58 +00:00
dependabot[bot]
080c4f7cb5 Bump @babel/runtime from 7.28.6 to 7.29.2
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.28.6 to 7.29.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.2/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-version: 7.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 09:31:42 +00:00
Maikel
9d389e22d3 Merge pull request #14087 from openfoodfoundation/dependabot/bundler/haml-7.2.0
Bump haml from 6.3.0 to 7.2.0
2026-03-24 11:34:28 +11:00
Ahmed Ejaz
fc123b38b4 Add comment for outgroing exchange variants 2026-03-24 03:46:17 +05:00
Gaetan Craig-Riou
6c4ae1d2c1 Fix spec 2026-03-24 09:40:28 +11:00
Gaetan Craig-Riou
eff1ed4a5e Upgrade haml-lint 2026-03-24 09:40:28 +11:00
dependabot[bot]
7ea2b126f2 Bump haml from 6.3.0 to 7.2.0
Bumps [haml](https://github.com/haml/haml) from 6.3.0 to 7.2.0.
- [Release notes](https://github.com/haml/haml/releases)
- [Changelog](https://github.com/haml/haml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/haml/haml/compare/v6.3.0...v7.2.0)

---
updated-dependencies:
- dependency-name: haml
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-24 09:40:28 +11:00
Gaetan Craig-Riou
bfca6248ae Merge pull request #14086 from openfoodfoundation/dependabot/bundler/private_address_check-0.6.0
Bump private_address_check from 0.5.0 to 0.6.0
2026-03-24 08:45:55 +11:00
Ahmed Ejaz
1ff665a33a Refactor permitted producer IDs and outgoing exchange variant IDs queries for improved performance 2026-03-24 02:24:40 +05:00
dependabot[bot]
8250029eb7 Bump private_address_check from 0.5.0 to 0.6.0
Bumps [private_address_check](https://github.com/jtdowney/private_address_check) from 0.5.0 to 0.6.0.
- [Commits](https://github.com/jtdowney/private_address_check/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: private_address_check
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 10:02:55 +00:00
David Cook
5e92fa9a17 Merge pull request #14078 from rioug/fix-flacky-order-cylcle-spec
[Spec] Fix flaky order cycle spec
2026-03-23 12:24:05 +11:00
Maikel
d23ad9c8ad Merge pull request #14079 from openfoodfoundation/dependabot/bundler/i18n-tasks-1.1.2
Bump i18n-tasks from 1.0.15 to 1.1.2
2026-03-23 11:27:59 +11:00
David Cook
d80249da2d Merge pull request #14064 from openfoodfoundation/dependabot/bundler/taler-0.3.0
Bump taler from 0.2.0 to 0.3.0
2026-03-23 10:52:08 +11:00
Gaetan Craig-Riou
d6c69fdc2c Fix code typo
Co-authored-by: Maikel <maikel@email.org.au>
2026-03-23 10:48:27 +11:00
Ahmed Ejaz
715a8f421a 14081: fix permission issue for deleting manager 2026-03-21 03:38:38 +05:00
Rachel Arnould
06d6db5a07 Merge pull request #14075 from gbathree/13688-fix-button-font-consistency
Fix: unify font-family across all .button elements
2026-03-20 11:03:51 +01:00
Rachel Arnould
3f81883bc7 Merge pull request #14061 from mvanhorn/fix/enterprise-user-inline-error-style
Fix inline error style in Add Manager dialog
2026-03-20 11:03:32 +01:00
Rachel Arnould
27be0f6fd1 Merge pull request #13912 from dacook/sourced-variant1-13887
Create linked variants
2026-03-20 10:59:46 +01:00
dependabot[bot]
8880f83d09 Bump i18n-tasks from 1.0.15 to 1.1.2
Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.15 to 1.1.2.
- [Release notes](https://github.com/glebm/i18n-tasks/releases)
- [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md)
- [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.15...v1.1.2)

---
updated-dependencies:
- dependency-name: i18n-tasks
  dependency-version: 1.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 09:33:07 +00:00
Maikel Linke
23a4ca5933 Add additional Taler token requests to specs 2026-03-20 15:52:41 +11:00
Gaetan Craig-Riou
4dc44c6156 Fix code syntax 2026-03-20 15:49:41 +11:00
Gaetan Craig-Riou
8defb2f4c8 Improve EnterpriseFee loading
Request is only send if there isn't another currently running, and also
ensure that filtered enterprise fees are loaded only when not other
request is running.
2026-03-20 14:38:49 +11:00
dependabot[bot]
067349f742 Bump taler from 0.2.0 to 0.3.0
Bumps [taler](https://github.com/openfoodfoundation/taler-ruby) from 0.2.0 to 0.3.0.
- [Changelog](https://github.com/openfoodfoundation/taler-ruby/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openfoodfoundation/taler-ruby/compare/v0.2.0...v0.3.0)

---
updated-dependencies:
- dependency-name: taler
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 01:24:38 +00:00
Matt Van Horn
aa3fa59a32 fix(spec): include order number in webhook_service_spec expected data
The webhook_payload now includes :number in the order slice, but
webhook_service_spec still expected the order hash without it. Since
hash_including only matches at the top level, the nested order hash
comparison was strict and failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:05:49 -07:00
Greg Austic
032953e7d6 Fix: unify font-family across all .button elements
<button> elements don't inherit font-family from parent by default
in all browsers, causing a visible font mismatch between the
link-based buttons (Back To Store, Back To Website, Cancel Order)
and the button-tag-based Save Changes button on the order
confirmation page.

Add `font-family: inherit` to the base `.button, button` rule so
all button elements use the inherited page font (Roboto). Remove the
now-redundant `font-family: $body-font` from the `.primary` rule.

Fixes #13688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:22:59 -04:00
Matt Van Horn
1878a39188 Fix inline error style in Add Manager dialog to match product list
Add .field class to the email row in the user invitation modal so
the .formError styles (icon, color, font-size) defined in forms.scss
apply consistently with the product list inline errors.

Fixes #13993
2026-03-18 21:46:21 -07:00
Matt Van Horn
544f62dbc5 feat(payments): add order number to webhook payload
Include the order number in the webhook payload so consumers can
identify which order a payment notification belongs to.

Fixes #13858
2026-03-18 00:12:23 -07:00
David Cook
8e6f1c4e99 Show display name 2026-03-18 14:30:27 +11:00
David Cook
2004934399 Register only necessary elements
This should be more efficient.

Best viewed with whitespace ignored.
2026-03-18 14:30:23 +11:00
David Cook
827ba1990d Ensure changes are tracked on newly added variant
I considered a few ways to do this. Cloned products are done with MutationObserver but it doesn't quite sit right with me. A dedicated controller for newly added rows would provide a good general solution. But do we want yet another controller? I'm not sure. This works and is pretty simple (although it requires a quick loop over _every_ form element.. let's see if we can avoid that.)
2026-03-18 14:03:11 +11:00
Maikel Linke
9961578fc1 Don't offer to void a refund 2026-03-16 12:56:38 +11:00
Maikel Linke
53c2ef53d5 Call #credit with right arguments 2026-03-16 12:56:22 +11:00
Maikel Linke
7619062ad2 Revert "Move payment action logic to payment"
This reverts commit fdd22bc097.

And it adds the now needed `can_credit?` method to Taler.
It's just a duplicate.
2026-03-11 11:32:08 +11:00
David Cook
18fb1cfa74 Rename variant 'owner' to 'hub'
As discussed by team, and using same nomenclature as VariantOverride.
2026-03-11 11:09:13 +11:00
David Cook
e9ce2df5a9 Rename 'source variant' to linked variant (in most places)
There are two types of linked variant associations: source and target, so we need to keep the name there.
But when cloning a variant and retaining a link as source,  we will prefer the general term 'linked variant'. Hopefully this name works well.
2026-03-11 11:09:13 +11:00
David Cook
c165ade4ba Avoid unnecessary save
Actually, the variant factory is still adding an extra save. We should refactor Variant to avoid that.. but the afternoon slump has got me.
2026-03-11 11:09:12 +11:00
David Cook
7e8b3694be Move class instance variable to helper 2026-03-11 11:09:12 +11:00
David Cook
6ee715419a ORder by ID to ensure deterministic results 2026-03-11 11:09:12 +11:00
David Cook
7da6adfe4f Use reference command
For uniformity

Co-authored-by: Maikel <maikel@email.org.au>
2026-03-11 11:09:12 +11:00
David Cook
05c31db46a Remove touch
I think I was just following convention from existing relationships. Perhaps you could argue that a variant is affected by the links added/removed.. but we never look at updated_at so really there's no point at all.
2026-03-11 11:09:12 +11:00
David Cook
666e872ac8 Revert uninentional schema change
I guess my local db is slightly out of sync.
2026-03-11 11:09:12 +11:00
David Cook
de6eb9e281 Avoid useless updated_at column
These records won't be updatable, but it might still be worth tracking when they were created.
2026-03-11 11:09:12 +11:00
David Cook
299ada1220 Refactor: move variant duplication to model
I tried to avoid it but rubocop made me move it. I think maybe it will need to go into a concern or service class later, but hopefully it's ok here for now.
2026-03-11 11:09:12 +11:00
David Cook
5757f086ec Set owner enterprise when creating source variant 2026-03-11 11:09:12 +11:00
David Cook
8955ffe126 AddOwnerToSpreeVariants [migration]
Should existing variants be migrated to have an owner (copied from supplier)? No, because you can change supplier. This concept needs work.
2026-03-11 11:09:09 +11:00
David Cook
b26152cf0e Only show option when you have permission
Preload the allow list once in the controller. This controller was initially set up to avoid instance variables, and pass variables explicitly to the template. That's a good principle, but in practice we have a growing list of variables passed down the chain to multiple partials which is getting cumbersome. I think instance variables have their place after all.
2026-03-11 11:08:50 +11:00
David Cook
78db179ff3 haml-lint:disable ViewLength
TIL we have linting on haml.

I couldn't think of a better way to handle this but would be glad to receive feedback.
2026-03-11 11:08:50 +11:00
David Cook
5fc6d25a69 Display presence of variant link in UI
It's quite ugly. But we will be iterating on this later.
2026-03-11 11:08:50 +11:00
David Cook
b877540f5f Create sourced variant link on clone 2026-03-11 11:08:50 +11:00
copilot-swe-agent[bot]
04c0adf960 Fix source_variants and target_variants associations in Variant model
Co-authored-by: dacook <4188088+dacook@users.noreply.github.com>

Thanks co-pilot for sending me in the right direction.
Would this be neater as a has_and_belongs_to_many? Maybe but I will try to keep moving.
2026-03-11 11:08:50 +11:00
David Cook
1c89e9979e CreateVariantLinks [migration]
Tried using the rails generator, but as usual it was a waste of time becuase it doesn't handle unusual cases.

I found more good guidance from that stackoverflow post:
> why are you worrying about your indexes? Build your app!

Something's not right in the model, see next commit.
2026-03-11 11:08:47 +11:00
David Cook
eba2fbcc30 Create source variants 2026-03-11 11:07:08 +11:00
David Cook
940aa57daf Set up permissions for creating source variants 2026-03-11 11:07:08 +11:00
David Cook
766bedb773 Label this feature as 'beta'
The permission is effectively the feature toggle. Users can choose to use it, but shouldn't expect it all to work perfectly yet.
When it's considered full featured, we just need to update the translation. Hm... I hope that's not too painful.🤞
2026-03-11 11:07:08 +11:00
David Cook
6fe2357ca0 Add enterprise permission create_sourced_variants 2026-03-11 11:07:08 +11:00
David Cook
bd01b5f113 Add 'create sourced variant' option in variant actions menu
For now, we will only be able to create sourced variant from variants that are visible to us (variants that we manage)

    In a later commit I will hide the option if you can't use it
2026-03-11 11:07:08 +11:00
David Cook
e565243ce4 Remove unnecessary before action
Each context has different setup and needs to load the page after setup.
2026-03-11 11:07:08 +11:00
David Cook
0f3b299544 Clean up spec
There's no "COPY OF" product in the spec setup, so we don't need to check that it's not there. (unless maybe we added that to the product factory, but it seems unlikely).

Also we can use helper method.
2026-03-11 11:07:08 +11:00
David Cook
1332051a6e Move specs to relevant file
These tests are about browsing products, not performing actions.

Well, ok there's one about updating, which should probably go in the update file. But hey this is better than before.
And admittedly the "Actions" file covers three different things, not just the actions menu. shrug.
2026-03-11 11:07:07 +11:00
Maikel Linke
fb2dfed6bf Pass amount to payment mailer
We now include the refunded amount in the email to the customer. When
voiding a payment, it's the full amount of the payment. When giving back
credit then it's only the money that has been paid too much.
2026-03-11 10:58:30 +11:00
Maikel Linke
cf53ac1990 Add credit action to Taler 2026-03-11 10:58:30 +11:00
Maikel Linke
fdd22bc097 Move payment action logic to payment
At closer inspection, almost all logic around which payment actions to
display involves only the state of the payment. So I moved the logic
there.

We now have one list of all possible actions supported by the UX. Then
payment methods can declare a list of supported actions. If that's
conditional, they can implement the conditions themselves. The payment
model itself then still filters the actions based on its state.
2026-03-11 10:58:30 +11:00
Maikel Linke
956c4a27c2 Remove unnecessary guard clause
Previously, payment actions that were listed without an associated
`can_?` method were interpreted as supported. All payment methods are
implementing all `can_?` methods for listed actions though. And I think
that new payment methods should explicitely implement all `can_?`
methods instead of relying on this hidden logic.
2026-03-11 10:58:30 +11:00
Maikel Linke
2b32f6b909 Make payment method source of truth of supported actions 2026-03-11 10:58:30 +11:00
Maikel Linke
303b91af5e Move list of payment actions from card to gateway
We currently ask the credit card first which payment actions like "void"
it supports. But all the logic is not card specifc. It depends on the
payment method which actions it supports.

And instead of having two different classes potentially being the source
of truth for actions, I prefer leaving that responsibility with exactly
one class, the payment method.

I'll move the `can_?` methods next.
2026-03-11 10:58:30 +11:00
Maikel Linke
ce96b58800 Remove useless setting of payment amount
It gets overridden later anyway.
2026-03-11 10:58:25 +11:00
77 changed files with 1776 additions and 522 deletions

6
.env
View File

@@ -21,12 +21,6 @@ CHECKOUT_ZONE="Australia"
# Find currency codes at http://en.wikipedia.org/wiki/ISO_4217.
CURRENCY="AUD"
# The whenever gem can set the `MAILTO` variable for our cron jobs.
# You can define an email address to notify if any job outputs something.
# But you need a working mail server setup so that the message is delivered.
# See: config/schedule.rb
# SCHEDULE_NOTIFICATIONS="admin@example.com"
# Mail settings
MAIL_HOST="example.com"
MAIL_DOMAIN="example.com"

View File

@@ -9,32 +9,30 @@ multi-ecosystem-groups:
turbo_power:
schedule:
interval: "daily"
cooldown:
default-days: 7
updates:
# turbo_power: ensure gem and package are updated together
- package-ecosystem: "bundler"
directory: "/"
patterns: ["turbo_power"]
multi-ecosystem-group: "turbo_power"
# Only specific requirements are specified in Gemfile, so don't touch it.
versioning-strategy: lockfile-only
- package-ecosystem: "npm"
directory: "/"
patterns: ["turbo_power"]
multi-ecosystem-group: "turbo_power"
# Only specific requirements are specified in package.json, so don't touch it.
versioning-strategy: lockfile-only
# All others
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 7
# Only specific requirements are specified in Gemfile, so don't touch it.
# Only specific requirements are specified in Gemfile, so don't let Dependabot touch it.
versioning-strategy: lockfile-only
- package-ecosystem: "npm"
@@ -43,6 +41,5 @@ updates:
interval: "daily"
cooldown:
default-days: 7
# Only specific requirements are specified in package.json, so don't touch it.
# Only specific requirements are specified in package.json, so don't let Dependabot touch it.
versioning-strategy: lockfile-only

View File

@@ -118,8 +118,6 @@ gem 'immigrant'
gem 'roo' # read spreadsheets
gem 'spreadsheet_architect' # write spreadsheets
gem 'whenever', require: false
gem 'coffee-rails', '~> 5.0.0'
gem 'angular_rails_csrf'

View File

@@ -112,7 +112,7 @@ GEM
rails-html-sanitizer (~> 1.6)
active_model_serializers (0.8.4)
activemodel (>= 3.0)
active_storage_validations (3.0.3)
active_storage_validations (3.0.4)
activejob (>= 6.1.4)
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
@@ -167,7 +167,7 @@ GEM
zeitwerk (>= 2.4, < 3.0)
acts_as_list (1.0.4)
activerecord (>= 4.2)
addressable (2.8.8)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
aes_key_wrap (1.1.0)
afm (1.0.0)
@@ -185,8 +185,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1227.0)
aws-sdk-core (3.243.0)
aws-partitions (1.1233.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -194,11 +194,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.215.0)
aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -210,9 +210,9 @@ GEM
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.22.0)
bootsnap (1.23.0)
msgpack (~> 1.2)
bugsnag (6.28.0)
bugsnag (6.29.0)
concurrent-ruby (~> 1.0)
builder (3.3.0)
bullet (8.1.0)
@@ -245,7 +245,6 @@ GEM
cgi (0.5.1)
childprocess (5.0.0)
choice (0.2.0)
chronic (0.10.2)
coderay (1.1.3)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
@@ -290,8 +289,8 @@ GEM
warden (~> 1.2.3)
devise-encryptable (0.2.0)
devise (>= 2.1.0)
devise-i18n (1.15.0)
devise (>= 4.9.0)
devise-i18n (1.16.0)
devise (>= 5.0.0)
rails-i18n
diff-lcs (1.6.2)
digest (3.2.1)
@@ -385,11 +384,11 @@ GEM
good_migrations (0.3.1)
activerecord (>= 3.1)
railties (>= 3.1)
haml (6.3.0)
haml (7.2.0)
temple (>= 0.8.2)
thor
tilt
haml_lint (0.68.0)
haml_lint (0.72.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@@ -402,18 +401,19 @@ GEM
highline (3.1.2)
reline
htmlentities (4.4.2)
http_parser.rb (0.8.0)
http_parser.rb (0.8.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
i18n-js (3.9.2)
i18n (>= 0.6.6)
i18n-tasks (1.0.15)
i18n-tasks (1.1.2)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
highline (>= 2.0.0)
highline (>= 3.0.0)
i18n
parser (>= 3.2.2.1)
prism
rails-i18n
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
@@ -514,7 +514,7 @@ GEM
money (6.16.0)
i18n (>= 0.6.4, <= 2)
msgpack (1.8.0)
multi_json (1.17.0)
multi_json (1.19.1)
multi_xml (0.6.0)
mutex_m (0.3.0)
net-http (0.9.1)
@@ -590,12 +590,12 @@ GEM
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.6.2)
pg (1.6.3)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
private_address_check (0.5.0)
private_address_check (0.6.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -603,9 +603,10 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (7.0.2)
puffing-billy (4.0.2)
public_suffix (7.0.5)
puffing-billy (4.0.4)
addressable (~> 2.5)
cgi
em-http-request (~> 1.1, >= 1.1.0)
em-synchrony
eventmachine (~> 1.2)
@@ -619,7 +620,7 @@ GEM
railties (>= 4.2)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.22)
rack (2.2.23)
rack-mini-profiler (2.3.4)
rack (>= 1.2.0)
rack-oauth2 (2.3.0)
@@ -896,14 +897,14 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
taler (0.2.0)
taler (0.3.0)
temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
thor (1.5.0)
thread-local (1.1.0)
tilt (2.7.0)
timeout (0.6.0)
timeout (0.6.1)
tsort (0.2.0)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
@@ -914,7 +915,7 @@ GEM
turbo-rails (>= 1.3.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
undercover (0.8.3)
undercover (0.8.4)
base64
bigdecimal
imagen (>= 0.2.0)
@@ -936,9 +937,9 @@ GEM
validates_lengths_from_database (0.8.0)
activerecord (>= 4)
vcr (6.4.0)
view_component (4.1.1)
actionview (>= 7.1.0, < 8.2)
activesupport (>= 7.1.0, < 8.2)
view_component (4.5.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
concurrent-ruby (~> 1)
view_component_reflex (3.1.14.pre9)
rails (>= 5.2, < 8.0)
@@ -957,7 +958,7 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -966,8 +967,6 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.1.0)
chronic (>= 0.6.3)
xml-simple (1.1.8)
xpath (3.2.0)
nokogiri (~> 1.8)
@@ -1125,7 +1124,6 @@ DEPENDENCIES
web!
web-console
webmock
whenever
wicked_pdf!
wkhtmltopdf-binary!

View File

@@ -3,7 +3,15 @@ angular.module('admin.orderCycles').controller 'AdminOrderCycleIncomingCtrl', ($
$scope.view = 'incoming'
# NB: weirdly at this next line $scope.order_cycle.id comes out undefined so we use $scope.order_cycle_id instead
$scope.enterprise_fees = EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true)
$scope.enterprise_fees = null
$scope.enterprise_fees = EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true) unless EnterpriseFee.loading
# We want to make sure to load the filtered EnterpriseFee when any previous request is finished
# otherwise the enterprise_fees migh get overriden by non filtered ones.
$scope.$watch(( -> EnterpriseFee.loading), (isLoading) =>
$scope.enterprise_fees ||= EnterpriseFee.index(order_cycle_id: $scope.order_cycle_id, per_item: true) unless isLoading
)
$scope.exchangeTotalVariants = (exchange) ->
return unless $scope.enterprises? && $scope.enterprises[exchange.enterprise_id]?

View File

@@ -14,10 +14,14 @@ angular.module('admin.orderCycles').factory('EnterpriseFee', ($resource) ->
EnterpriseFee: EnterpriseFee
enterprise_fees: {}
loaded: false
loading: false
index: (params={}) ->
return if @loading == true
@loading = true
EnterpriseFee.index params, (data) =>
@enterprise_fees = data
@loading = false
@loaded = true
forEnterprise: (enterprise_id) ->

View File

@@ -6,6 +6,7 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
'manage_products'
'edit_profile'
'create_variant_overrides'
'create_linked_variants'
]
constructor: ->
@@ -30,3 +31,4 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
when "manage_products" then t('js.services.manage_products')
when "edit_profile" then t('js.services.edit_profile')
when "create_variant_overrides" then t('js.services.add_products_to_inventory')
when "create_linked_variants" then t('js.services.create_linked_variants')

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Admin
class AjaxSearchController < Spree::Admin::BaseController
def producers
query = OpenFoodNetwork::Permissions.new(spree_current_user)
.managed_product_enterprises.is_primary_producer.by_name
render json: build_search_response(query)
end
def categories
query = Spree::Taxon.all
render json: build_search_response(query)
end
def tax_categories
query = Spree::TaxCategory.all
render json: build_search_response(query)
end
private
def build_search_response(query)
page = (params[:page] || 1).to_i
per_page = 30
filtered_query = apply_search_filter(query)
total_count = filtered_query.size
items = paginated_items(filtered_query, page, per_page)
results = format_results(items)
{ results: results, pagination: { more: (page * per_page) < total_count } }
end
def apply_search_filter(query)
search_term = params[:q]
return query if search_term.blank?
escaped_search_term = ActiveRecord::Base.sanitize_sql_like(search_term)
pattern = "%#{escaped_search_term}%"
query.where('name ILIKE ?', pattern)
end
def paginated_items(query, page, per_page)
query.order(:name).offset((page - 1) * per_page).limit(per_page).pluck(:name, :id)
end
def format_results(items)
items.map { |label, value| { value:, label: } }
end
end
end

View File

@@ -107,6 +107,33 @@ module Admin
end
end
# Clone a variant, retaining a link to the "source"
def create_linked_variant
linked_variant = Spree::Variant.find(params[:variant_id])
product_index = params[:product_index]
authorize! :create_linked_variant, linked_variant
status = :ok
begin
variant = linked_variant.create_linked_variant(spree_current_user)
flash.now[:success] = t('.success')
variant_index = "-#{variant.id}"
rescue ActiveRecord::RecordInvalid
flash.now[:error] = variant.errors.full_messages.to_sentence
status = :unprocessable_entity
variant_index = "-1" # Create a unique-enough index
end
respond_with do |format|
format.turbo_stream {
locals = { linked_variant:, variant:, product_index:, variant_index:,
producer_options:, category_options: categories, tax_category_options: }
render :create_linked_variant, status:, locals:
}
end
end
def index_url(params)
"/admin/products?#{params.to_query}" # todo: fix routing so this can be automaticly generated
end

View File

@@ -52,7 +52,7 @@ module Spree
# (we can't use respond_override because Spree no longer uses respond_with)
def fire
event = params[:e]
return unless event && @payment.payment_source
return unless event
# capture_and_complete_order will complete the order, so we want to try to redeem VINE
# voucher first and exit if it fails

View File

@@ -47,5 +47,20 @@ module Admin
def variant_tag_enabled?(user)
feature?(:variant_tag, user) || feature?(:variant_tag, *user.enterprises)
end
def allowed_source_producers
@allowed_source_producers ||= OpenFoodNetwork::Permissions.new(spree_current_user)
.enterprises_granting_linked_variants
end
# Query only name of the model to avoid loading the whole record
def selected_option(id, model)
return [] unless id
name = model.where(id: id).pick(:name)
return [] unless name
[[name, id]]
end
end
end

15
app/jobs/rake_job.rb Normal file
View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require "rake"
# Executes a rake task
class RakeJob < ApplicationJob
def perform(task_string)
Rails.application.load_tasks if Rake::Task.tasks.empty?
Rake.application.invoke_task(task_string)
ensure
name, _args = Rake.application.parse_task_string(task_string)
Rake::Task[name].reenable
end
end

View File

@@ -22,10 +22,10 @@ class PaymentMailer < ApplicationMailer
end
end
def refund_available(payment, taler_order_status_url)
def refund_available(amount, payment, taler_order_status_url)
@order = payment.order
@shop = @order.distributor.name
@amount = payment.display_amount
@amount = amount
@taler_order_status_url = taler_order_status_url
I18n.with_locale valid_locale(@order.user) do

View File

@@ -197,12 +197,16 @@ module Spree
can [:admin, :index, :destroy], :oidc_setting
can [:admin, :create], Voucher
can [:admin, :destroy], EnterpriseRole do |enterprise_role|
enterprise_role.enterprise.owner_id == user.id
end
end
def add_product_management_abilities(user)
# Enterprise User can only access products that they are a supplier for
can [:create], Spree::Product
# An enterperprise user can change a product if they are supplier of at least
# An enterprise user can change a product if they are supplier of at least
# one of the product's associated variants
can [:admin, :read, :index, :update,
:seo, :group_buy_options,
@@ -214,7 +218,24 @@ module Spree
)
end
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone], :products_v3
# An enterprise user can clone if they have been granted permission to the source variant.
# Technically I'd call this permission clone_linked_variant, but it would be less confusing to
# use the same name as everywhere else.
can [:create_linked_variant], Spree::Variant do |variant|
OpenFoodNetwork::Permissions.new(user).
enterprises_granting_linked_variants.include? variant.supplier
end
can [
:admin,
:index,
:bulk_update,
:destroy,
:destroy_variant,
:clone,
:create_linked_variant
], :products_v3
can [:admin, :producers, :categories, :tax_categories], :ajax_search
can [:create], Spree::Variant
can [:admin, :index, :read, :edit,

View File

@@ -63,35 +63,6 @@ module Spree
"XXXX-XXXX-XXXX-#{last_digits}"
end
def actions
%w{capture_and_complete_order void credit resend_authorization_email}
end
def can_resend_authorization_email?(payment)
payment.requires_authorization?
end
# Indicates whether its possible to capture the payment
def can_capture_and_complete_order?(payment)
return false if payment.requires_authorization?
payment.pending? || payment.checkout?
end
# Indicates whether its possible to void the payment.
def can_void?(payment)
!payment.void?
end
# Indicates whether its possible to credit the payment. Note that most gateways require that the
# payment be settled first which generally happens within 12-24 hours of the transaction.
def can_credit?(payment)
return false unless payment.completed?
return false unless payment.order.payment_state == 'credit_owed'
payment.credit_allowed.positive?
end
# Allows us to use a gateway_payment_profile_id to store Stripe Tokens
def has_payment_profile?
gateway_customer_profile_id.present? || gateway_payment_profile_id.present?

View File

@@ -13,6 +13,35 @@ module Spree
preference :server, :string, default: 'live'
preference :test_mode, :boolean, default: false
def actions
%w{capture_and_complete_order void credit resend_authorization_email}
end
# Indicates whether its possible to capture the payment
def can_capture_and_complete_order?(payment)
return false if payment.requires_authorization?
payment.pending? || payment.checkout?
end
# Indicates whether its possible to void the payment.
def can_void?(payment)
!payment.void?
end
# Indicates whether its possible to credit the payment. Note that most gateways require that the
# payment be settled first which generally happens within 12-24 hours of the transaction.
def can_credit?(payment)
return false unless payment.completed?
return false unless payment.order.payment_state == 'credit_owed'
payment.credit_allowed.positive?
end
def can_resend_authorization_email?(payment)
payment.requires_authorization?
end
def payment_source_class
CreditCard
end

View File

@@ -152,11 +152,10 @@ module Spree
end
def actions
return [] unless payment_source.respond_to?(:actions)
return [] unless payment_method.respond_to?(:actions)
payment_source.actions.select do |action|
!payment_source.respond_to?("can_#{action}?") ||
payment_source.__send__("can_#{action}?", self)
payment_method.actions.select do |action|
payment_method.__send__("can_#{action}?", self)
end
end
@@ -166,11 +165,6 @@ module Spree
PaymentMailer.authorize_payment(self).deliver_later
end
def payment_source
res = source.is_a?(Payment) ? source.source : source
res || payment_method
end
def ensure_correct_adjustment
revoke_adjustment_eligibility if ['failed', 'invalid', 'void'].include?(state)
return if adjustment.try(:finalized?)

View File

@@ -18,15 +18,27 @@ module Spree
# - backend_url: https://backend.demo.taler.net/instances/sandbox
# - api_key: sandbox
class Taler < PaymentMethod
# Demo backend instances will use the KUDOS currency.
DEMO_PREFIX = "https://backend.demo.taler.net/instances"
preference :backend_url, :string
preference :api_key, :password
def actions
%w{void}
%w[credit void]
end
def can_void?(payment)
payment.state == "completed"
# The source can be another payment. Then this is an offset payment
# like a credit record. We can't void a refund.
payment.source == self && payment.state == "completed"
end
def can_credit?(payment)
return false unless payment.completed?
return false unless payment.order.payment_state == 'credit_owed'
payment.credit_allowed.positive?
end
# Name of the view to display during checkout
@@ -68,6 +80,23 @@ module Spree
ActiveMerchant::Billing::Response.new(success, message)
end
def credit(money, response_code, gateway_options)
amount = money / 100 # called with cents
payment = gateway_options[:payment]
taler_order = taler_order(id: response_code)
status = taler_order.fetch("order_status")
raise "Unsupported action" if status != "paid"
taler_amount = "KUDOS:#{amount}"
taler_order.refund(refund: taler_amount, reason: "credit")
spree_money = Spree::Money.new(amount, currency: payment.currency).to_s
PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later
ActiveMerchant::Billing::Response.new(true, "Refund initiated")
end
def void(response_code, gateway_options)
payment = gateway_options[:payment]
taler_order = taler_order(id: response_code)
@@ -82,7 +111,8 @@ module Spree
amount = taler_order.fetch("contract_terms")["amount"]
taler_order.refund(refund: amount, reason: "void")
PaymentMailer.refund_available(payment, taler_order.status_url).deliver_later
spree_money = payment.money.to_s
PaymentMailer.refund_available(spree_money, payment, taler_order.status_url).deliver_later
ActiveMerchant::Billing::Response.new(true, "Refund initiated")
end
@@ -96,7 +126,7 @@ module Spree
def create_taler_order(payment)
# We are ignoring currency for now so that we can test with the
# current demo backend only working with the KUDOS currency.
taler_amount = "KUDOS:#{payment.amount}"
taler_amount = "#{currency(payment)}:#{payment.amount}"
urls = Rails.application.routes.url_helpers
fulfillment_url = urls.payment_gateways_confirm_taler_url(payment_id: payment.id)
taler_order.create(
@@ -113,6 +143,12 @@ module Spree
id:,
)
end
def currency(payment)
return "KUDOS" if preferred_backend_url.starts_with?(DEMO_PREFIX)
payment.order.currency
end
end
end
end

View File

@@ -40,6 +40,7 @@ module Spree
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', optional: false
belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true, optional: false
belongs_to :supplier, class_name: 'Enterprise', optional: false, touch: true
belongs_to :hub, class_name: 'Enterprise', optional: true
delegate :name, :name=, :description, :description=, :meta_keywords, to: :product
@@ -72,6 +73,15 @@ module Spree
has_many :semantic_links, as: :subject, dependent: :delete_all
has_many :supplier_properties, through: :supplier, source: :properties
# Linked variants: I may have one or many sources.
has_many :variant_links_as_target, class_name: 'VariantLink', foreign_key: :target_variant_id,
dependent: :delete_all, inverse_of: :target_variant
has_many :source_variants, through: :variant_links_as_target, source: :source_variant
# I may also have one more many targets.
has_many :variant_links_as_source, class_name: 'VariantLink', foreign_key: :source_variant_id,
dependent: :delete_all, inverse_of: :source_variant
has_many :target_variants, through: :variant_links_as_source, source: :target_variant
localize_number :price, :weight
validates_lengths_from_database
@@ -263,6 +273,24 @@ module Spree
@on_hand_desired = ActiveModel::Type::Integer.new.cast(val)
end
# Clone this variant, retaining a 'source' link to it
def create_linked_variant(user)
# Hub owner is my enterprise which has permission to create variant sourced from that supplier
hub_id = EnterpriseRelationship.permitted_by(supplier).permitting(user.enterprises)
.with_permission(:create_linked_variants)
.pick(:child_id)
dup.tap do |variant|
variant.price = price
variant.source_variants = [self]
variant.stock_items << Spree::StockItem.new(variant:)
variant.hub_id = hub_id
variant.on_demand = on_demand
variant.on_hand = on_hand
variant.save!
end
end
private
def check_currency

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
class VariantLink < ApplicationRecord
belongs_to :source_variant, class_name: 'Spree::Variant'
belongs_to :target_variant, class_name: 'Spree::Variant'
end

View File

@@ -14,7 +14,6 @@ module Checkout
apply_strong_parameters
set_pickup_address
set_address_details
set_payment_amount
set_existing_card
@order_params
@@ -58,12 +57,6 @@ module Checkout
end
end
def set_payment_amount
return unless @order_params[:payments_attributes]
@order_params[:payments_attributes].first[:amount] = order.outstanding_balance.amount
end
def set_existing_card
return unless existing_card_selected?

View File

@@ -13,7 +13,7 @@ module Payments
payment: @payment.slice(:updated_at, :amount, :state),
enterprise: @enterprise.slice(:abn, :acn, :name)
.merge(address: @enterprise.address.slice(:address1, :address2, :city, :zipcode)),
order: @order.slice(:total, :currency).merge(line_items: line_items)
order: @order.slice(:number, :total, :currency).merge(line_items: line_items)
}.with_indifferent_access
end
@@ -31,6 +31,7 @@ module Payments
def self.test_order
order = Spree::Order.new(
number: "R555555555",
total: 0.00,
currency: "AUD",
)

View File

@@ -9,14 +9,22 @@
- if producer_options.many?
.producers
= label_tag :producer_id, t('.producers.label')
= select_tag :producer_id, options_for_select(producer_options, producer_id),
include_blank: t('.all_producers'), class: "fullwidth",
data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_producers')}
= render(SearchableDropdownComponent.new(name: :producer_id,
aria_label: t('.producers.label'),
options: selected_option(producer_id, Enterprise),
selected_option: producer_id,
remote_url: admin_ajax_search_producers_url,
include_blank: t('.all_producers'),
placeholder_value: t('.search_for_producers')))
.categories
= label_tag :category_id, t('.categories.label')
= select_tag :category_id, options_for_select(category_options, category_id),
include_blank: t('.all_categories'), class: "fullwidth",
data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_categories')}
= render(SearchableDropdownComponent.new(name: :category_id,
aria_label: t('.categories.label'),
options: selected_option(category_id, Spree::Taxon),
selected_option: category_id,
remote_url: admin_ajax_search_categories_url,
include_blank: t('.all_categories'),
placeholder_value: t('.search_for_categories')))
-if variant_tag_enabled?(spree_current_user)
.tags
= label_tag :tags_name_in, t('.tags.label')

View File

@@ -11,9 +11,10 @@
-# Filter out variant a user has not permission to update, but keep variant with no supplier
- next if variant.supplier.present? && !allowed_producers.include?(variant.supplier)
= form.fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |variant_form|
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper", 'data-new-record': variant.new_record? ? "true" : false }
= render partial: 'variant_row', locals: { variant:, f: variant_form, category_options:, tax_category_options:, producer_options: }
= render partial: 'variant_row', locals: { variant:, f: variant_form, product_index:, category_options:, tax_category_options:, producer_options: }
= form.fields_for("products][#{product_index}][variants_attributes][NEW_RECORD", prepare_new_variant(product, producer_options)) do |new_variant_form|
%template{ 'data-nested-form-target': "template" }

View File

@@ -1,7 +1,10 @@
-# locals: (variant:, f:, category_options:, tax_category_options:, producer_options:)
-# haml-lint:disable ViewLength (This file is big, but doesn't make sense to split up at this point)
-# locals: (variant:, f:, product_index: nil, category_options:, tax_category_options:, producer_options:)
- method_on_demand, method_on_hand = variant.new_record? ? [:on_demand_desired, :on_hand_desired ]: [:on_demand, :on_hand]
%td.col-image
-# empty
- variant.source_variants.each do |source_variant|
= content_tag(:span, "🔗", title: t('admin.products_page.variant_row.sourced_from', source_name: source_variant.display_name, source_id: source_variant.id, hub_name: variant.hub&.name))
%td.col-name.field.naked_inputs
= f.hidden_field :id
= f.text_field :display_name, 'aria-label': t('admin.products_page.columns.name'), placeholder: variant.product.name
@@ -56,27 +59,28 @@
= render(SearchableDropdownComponent.new(form: f,
name: :supplier_id,
aria_label: t('.producer_field_name'),
options: producer_options,
options: variant.supplier_id ? [[variant.supplier.name, variant.supplier_id]] : [],
selected_option: variant.supplier_id,
include_blank: t('admin.products_v3.filters.select_producer'),
remote_url: admin_ajax_search_producers_url,
placeholder_value: t('admin.products_v3.filters.select_producer')))
= error_message_on variant, :supplier
%td.col-category.field.naked_inputs
= render(SearchableDropdownComponent.new(form: f,
name: :primary_taxon_id,
options: category_options,
options: variant.primary_taxon_id ? [[variant.primary_taxon.name, variant.primary_taxon_id]] : [],
selected_option: variant.primary_taxon_id,
aria_label: t('.category_field_name'),
include_blank: t('admin.products_v3.filters.select_category'),
remote_url: admin_ajax_search_categories_url,
placeholder_value: t('admin.products_v3.filters.select_category')))
= error_message_on variant, :primary_taxon
%td.col-tax_category.field.naked_inputs
= render(SearchableDropdownComponent.new(form: f,
name: :tax_category_id,
options: tax_category_options,
options: variant.tax_category_id ? [[variant.tax_category.name, variant.tax_category_id]] : [],
selected_option: variant.tax_category_id,
include_blank: t('.none_tax_category'),
aria_label: t('.tax_category_field_name'),
include_blank: t('.none_tax_category'),
remote_url: admin_ajax_search_tax_categories_url,
placeholder_value: t('.search_for_tax_categories')))
= error_message_on variant, :tax_category
- if variant_tag_enabled?(spree_current_user)
@@ -88,6 +92,10 @@
= render(VerticalEllipsisMenuComponent.new) do
- if variant.persisted?
= link_to t('admin.products_page.actions.edit'), edit_admin_product_variant_path(variant.product, variant)
- if variant.source_variants.empty? && allowed_source_producers.include?(variant.supplier)
= link_to t('admin.products_page.actions.create_linked_variant'), admin_create_linked_variant_path(variant_id: variant.id, product_index:), 'data-turbo-method': :post
- if variant.product.variants.size > 1
%a{ "data-controller": "modal-link", "data-action": "click->modal-link#setModalDataSetOnConfirm click->modal-link#open",
"data-modal-link-target-value": "variant-delete-modal", "class": "delete",

View File

@@ -0,0 +1,16 @@
-# locals: (variant:, linked_variant:, product_index:, variant_index:, producer_options:, category_options:, tax_category_options:)
-# Pre-render the form, because you can't do it inside turbo stream block
- variant_row = nil
- fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |f|
- variant_row = render(partial: 'variant_row', formats: :html,
locals: { f:,
variant:,
producer_options:,
category_options:,
tax_category_options:})
= turbo_stream.after dom_id(linked_variant) do
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper slide-in",'data-variant-bulk-form-outlet': "#products-form"}
= variant_row
= turbo_stream.append "flashes" do
= render(partial: 'admin/shared/flashes', locals: { flashes: flash })

View File

@@ -6,7 +6,7 @@
%p= t ".description"
%fieldset.no-border-top.no-border-bottom
.row
.row.field
= f.label :email, t(:email)
= f.email_field :email, placeholder: t('.eg_email_address'), data: { controller: "select-user" }, inputmode: "email", autocomplete: "off"
= f.error_message_on :email

View File

@@ -44,12 +44,17 @@ export default class BulkFormController extends Controller {
}
// Register any new elements (may be called by another controller after dynamically adding fields)
registerElements() {
const registeredElements = Object.values(this.recordElements).flat();
// Select only elements that haven't been registered yet
const newElements = Array.from(this.form.elements).filter(
(n) => !registeredElements.includes(n),
);
// May be called with array of elements to register, otherwise finds all un-registered elements.
registerElements(eventOrElements = null) {
let newElements;
if (Array.isArray(eventOrElements)) {
newElements = eventOrElements;
} else {
const registeredElements = Object.values(this.recordElements).flat();
// Select only elements that haven't been registered yet
newElements = Array.from(this.form.elements).filter((n) => !registeredElements.includes(n));
}
this.#registerElements(newElements);
}

View File

@@ -83,6 +83,9 @@ export default class extends Controller {
}
#addRemoteOptions(options) {
// by default, for dropdown_input plugin, it's true. Otherwise for multi-select it's false
// it should always be true so to invoke the onDropdownOpen to fetch options
options.shouldOpen = true;
this.openedByClick = false;
options.firstUrl = (query) => {
@@ -91,12 +94,9 @@ export default class extends Controller {
options.load = this.#fetchOptions.bind(this);
options.onFocus = function () {
this.control.load("", () => {});
}.bind(this);
options.onDropdownOpen = function () {
this.openedByClick = true;
this.control.load("", () => {});
}.bind(this);
options.onType = function () {

View File

@@ -4,6 +4,8 @@ import OptionValueNamer from "js/services/option_value_namer";
// Dynamically update related variant fields
//
export default class VariantController extends Controller {
static outlets = ["bulk-form"];
connect() {
// idea: create a helper that includes a nice getter/setter for Rails model attr values, just pass it the attribute name.
// It could automatically find (and cache a ref to) each dom element and get/set the values.
@@ -40,6 +42,12 @@ export default class VariantController extends Controller {
// on display_as changed; update unit_to_display
// TODO: optimise to avoid unnecessary OptionValueNamer calc
this.displayAs.addEventListener("input", this.#updateUnitDisplay.bind(this), { passive: true });
// Register with bulk products form to listen for changes. Used when dynamically appending variants.
if (this.hasBulkFormOutlet) {
const formElements = this.element.querySelectorAll("input, select, textarea, button");
this.bulkFormOutlet.registerElements(formElements);
}
}
disconnect() {

View File

@@ -54,6 +54,7 @@
.button, button {
@include border-radius(0.5em);
font-family: inherit;
outline: none;
&.x-small {
@@ -65,7 +66,6 @@
}
.button.primary, button.primary {
font-family: $body-font;
background: $orange-450;
color: white;
}

View File

@@ -7,6 +7,15 @@ redis_connection_settings = {
Sidekiq.configure_server do |config|
config.redis = redis_connection_settings
config.on(:startup) do
# Load schedule file similar to sidekiq/cli.rb loading the main config.
path = File.expand_path("../sidekiq_scheduler.yml", __dir__)
erb = ERB.new(File.read(path), trim_mode: "-")
Sidekiq.schedule =
YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true)
SidekiqScheduler::Scheduler.instance.reload_schedule!
end
end
Sidekiq.configure_client do |config|

View File

@@ -717,8 +717,11 @@ en:
delete: Delete
remove: Remove
preview: Preview
create_linked_variant: Create linked variant
image:
edit: Edit
variant_row:
sourced_from: "Sourced from: %{source_name} (%{source_id}); Hub: %{hub_name}"
product_preview:
product_preview: Product preview
shop_tab: Shop
@@ -1098,6 +1101,8 @@ en:
clone:
success: Successfully cloned the product
error: Unable to clone the product
create_linked_variant:
success: "Successfully created linked variant"
tag_rules:
rules_per_tag:
one: "%{tag} has 1 rule"
@@ -3901,6 +3906,7 @@ en:
manage_products: "manage products"
edit_profile: "edit profile"
add_products_to_inventory: "add products to inventory"
create_linked_variants: "create linked variants [BETA]"
resources:
could_not_delete_customer: 'Could not delete customer'
product_import:

View File

@@ -287,7 +287,7 @@ en_CA:
customer_instructions: "Customer instructions"
additional_information: "Additional information"
connect_app:
url: "https://n8n.openfoodnetwork.org/webhook/foodjustice/connect-enterprise"
url: "https://n8n.openfoodnetwork.org/webhook-test/foodjustice/connect-enterprise"
devise:
passwords:
spree_user:
@@ -668,8 +668,11 @@ en_CA:
delete: Delete
remove: Remove
preview: Preview
create_linked_variant: Create linked variant
image:
edit: Edit
variant_row:
sourced_from: "Sourced from: %{source_name} (%{source_id}); Hub: %{hub_name}"
product_preview:
product_preview: Product preview
shop_tab: Shop
@@ -811,6 +814,7 @@ en_CA:
bill_address: "Billing Address"
ship_address: "Shipping Address"
balance: "Balance"
credit: "Available Credit"
update_address_success: "Address updated successfully."
update_address_error: "Sorry! Please input all of the required fields!"
edit_bill_address: "Edit Billing Address"
@@ -825,12 +829,16 @@ en_CA:
guest_label: "Guest checkout"
credit_owed: "Credit Owed"
balance_due: "Balance Due"
id: Id
destroy:
has_associated_subscriptions: "Delete failed: This customer has active subscriptions. Cancel them first."
customer_account_transaction:
index:
available_credit: "Available credit: %{available_credit}"
transaction_date: Transaction Date
description: Description
amount: Amount
created_by: Created by
running_balance: Running balance
column_preferences:
bulk_update:
@@ -1020,6 +1028,8 @@ en_CA:
clone:
success: Successfully cloned the product
error: Unable to clone the product
create_linked_variant:
success: "Successfully created linked variant"
tag_rules:
rules_per_tag:
one: "%{tag} has 1 rule"
@@ -1789,6 +1799,11 @@ en_CA:
images: "Images"
contact: "Contact"
web: "Web Resources"
stimulus_pagination:
navigation: Pagination
page: "Page %{number}"
previous: Previous page
next: Next page
enterprise_issues:
create_new: Create New
resend_email: Resend Email
@@ -2455,6 +2470,7 @@ en_CA:
order_total: Total order
order_payment: "Paying via:"
no_payment_required: "No payment required"
credit_used: "Credit used: %{amount}"
customer_credit: Credit
order_billing_address: Billing address
order_delivery_on: Delivery on
@@ -3439,6 +3455,7 @@ en_CA:
no_orders_found: "No Orders Found"
order_information: "Order Information"
new_payment: "New Payment"
credit_customer: Credit customer
create_or_update_invoice: "Create or Update Invoice"
date_completed: "Date Completed"
amount: "Amount"
@@ -3749,6 +3766,7 @@ en_CA:
manage_products: "manage products"
edit_profile: "edit profile"
add_products_to_inventory: "add products to inventory"
create_linked_variants: "create linked variants [BETA]"
resources:
could_not_delete_customer: 'Could not delete customer'
product_import:
@@ -4029,6 +4047,7 @@ en_CA:
items_cannot_be_shipped: "Items cannot be shipped"
gateway_config_unavailable: "Gateway config unavailable"
gateway_error: "Payment failed"
internal_payment_not_voidable: Payment not voidable
more: "More"
new_adjustment: "New adjustment"
new_tax_category: "New Tax Category"
@@ -4523,6 +4542,7 @@ en_CA:
paypalexpress: "PayPal Express"
stripesca: "Stripe SCA"
taler: "Taler"
customercredit: "Customer Credit"
payments:
source_forms:
stripe:
@@ -4530,6 +4550,7 @@ en_CA:
submitting_payment: Submitting payment...
paypal:
no_payment_via_admin_backend: Paypal payments cannot be captured in the backoffice.
customer_credit_successful: Customer has been successfully credited!
products:
image_upload_error: "Please upload the image in JPG, PNG, GIF, SVG or WEBP format."
image_not_processable: "Image attachment is not a valid image."
@@ -4845,6 +4866,7 @@ en_CA:
orders: Orders
cards: Credit Cards
transactions: Transactions
customer_account_transactions: Customer Transactions
settings: Account Settings
unconfirmed_email: "Pending email confirmation for: %{unconfirmed_email}. Your email address will be updated once the new email is confirmed."
orders:
@@ -4855,6 +4877,9 @@ en_CA:
authorisation_required: Authorisation Required
authorise: Authorize
customer_account_transactions:
title: Customer Transactions
credit_available: "Credit available: %{credit}"
transaction_date: Transaction Date
description: Description
amount: Amount
running_balance: Running balance
@@ -4997,3 +5022,22 @@ en_CA:
invisible_captcha:
sentence_for_humans: "Please leave empty"
timestamp_error_message: "Please try again after 5 seconds."
api_customer_credit: "API credit: %{description}"
credit_payment_method:
name: Customer credit
description: Allow customer to pay with credit
success: Payment with credit was sucessful
void_success: Credit void was sucessful
order_payment_description: "Customer credit: Payment for order: %{order_number}"
order_void_description: "Customer credit: Refund for order: %{order_number}"
errors:
customer_not_found: Customer not found
missing_payment: Missing payment
credit_payment_method_missing: Credit payment method is missing
no_credit_available: No credit available
not_enough_credit_available: Not enough credit available
orders:
customer_credit_service:
no_credit_owed: No credit owed
credit_payment_method_missing: Customer credit payment method is missing, please check configuration
refund_sucessful: Refund successful!

View File

@@ -1432,7 +1432,7 @@ en_GB:
legend: "Users"
email_confirmation_notice_html: "Email confirmation is pending. We've sent a confirmation email to %{email}."
resend: Resend
contact: "Contact"
contact: "Notifications"
manager: "Manager"
owner: 'Owner'
contact_tip: "The manager who will receive enterprise emails for orders and notifications. Must have a confirmed email adress."

View File

@@ -669,8 +669,11 @@ fr:
delete: Supprimer
remove: Supprimer
preview: Prévisualisation
create_linked_variant: Créer une variante liée
image:
edit: Modifier
variant_row:
sourced_from: "Source : %{source_name} ( %{source_id} ) ; Hub : %{hub_name}"
product_preview:
product_preview: Prévisualisation du produit
shop_tab: Boutique
@@ -1028,6 +1031,8 @@ fr:
clone:
success: Le produit a bien été dupliqué
error: Impossible de dupliquer le produit
create_linked_variant:
success: "Variante liée créée avec succès"
tag_rules:
rules_per_tag:
one: "%{tag} comporte une règle"
@@ -3799,6 +3804,7 @@ fr:
manage_products: "modifier les produits"
edit_profile: "modifier le profil"
add_products_to_inventory: "ajouter les produits au catalogue boutique"
create_linked_variants: "créer des variantes liées [BÊTA]"
resources:
could_not_delete_customer: 'L''acheteur n''a pas pu être supprimé'
product_import:

View File

@@ -662,8 +662,11 @@ fr_BE:
delete: Supprimer
remove: Supprimer
preview: Aperçu
create_linked_variant: Créer une variante liée
image:
edit: Modifier
variant_row:
sourced_from: "Source : %{source_name} (%{source_id}); Hub : %{hub_name}"
product_preview:
product_preview: Aperçu du produit
shop_tab: Comptoir
@@ -1021,6 +1024,8 @@ fr_BE:
clone:
success: Le produit a bien été dupliqué
error: Impossible de dupliquer le produit
create_linked_variant:
success: "Variante liée créée avec succès"
tag_rules:
rules_per_tag:
one: "%{tag} comporte 1 règle"
@@ -3774,6 +3779,7 @@ fr_BE:
manage_products: "modifier les produits"
edit_profile: "modifier le profil"
add_products_to_inventory: "ajouter les produits au catalogue comptoir"
create_linked_variants: "créer des variantes liées [BÊTA]"
resources:
could_not_delete_customer: 'L''acheteur·euse n''a pas pu être supprimé'
product_import:

View File

@@ -274,6 +274,10 @@ fr_CA:
no_default_card: "Pas de carte de paiement par défaut pour cet acheteur"
shipping_method:
not_available_to_shop: "n'est pas disponible pour %{shop}"
user_invitation:
attributes:
email:
is_already_manager: est déjà gestionnaire!
card_details: "Détalis de la carte"
card_type: "Type de carte"
card_type_is: "Type de carte"
@@ -666,8 +670,11 @@ fr_CA:
delete: Supprimer
remove: Supprimer
preview: Prévisualisation
create_linked_variant: Créer une variante liée
image:
edit: Modifier
variant_row:
sourced_from: "Source : %{source_name} (%{source_id}); Hub : %{hub_name}"
product_preview:
product_preview: Prévisualisation du produit
shop_tab: Boutique
@@ -809,6 +816,7 @@ fr_CA:
bill_address: "Adresse de facturation"
ship_address: "Adresse de livraison"
balance: "Solde"
credit: "Crédit disponible"
update_address_success: "Adresse mise à jour avec succès."
update_address_error: "Oups! Veuillez remplir tous les champs obligatoires!"
edit_bill_address: "Modifier l'adresse de facturation"
@@ -823,12 +831,16 @@ fr_CA:
guest_label: "Commande en mode invite"
credit_owed: "Crédit dû"
balance_due: "Solde dû"
id: Id
destroy:
has_associated_subscriptions: "La suppression a planté : cet acheteur a des abonnements actifs. Veuillez d'abord les annuler."
customer_account_transaction:
index:
available_credit: "Crédit disponible : %{available_credit}"
transaction_date: Date de la transaction
description: Description
amount: Montant
created_by: Créé par
running_balance: Solde courant
column_preferences:
bulk_update:
@@ -1020,6 +1032,8 @@ fr_CA:
clone:
success: Le produit a bien été dupliqué
error: Impossible de dupliquer le produit
create_linked_variant:
success: "Variante liée créée avec succès"
tag_rules:
rules_per_tag:
one: "%{tag} a 1 règle"
@@ -1426,10 +1440,12 @@ fr_CA:
show_hide_payment: 'Afficher ou Montrer les méthodes de paiement lors de la finalisation de commande'
show_hide_order_cycles: 'Afficher ou Masquer les cycles de vente de ma boutique'
users:
description: Les utilisateurs autorisés à gérer cette entreprise.
legend: "Utilisateurs"
email_confirmation_notice_html: "L'email de confirmation n'a pas encore été validé. Il a été envoyé à %{email}."
resend: Renvoyer
contact: "Contact"
manager: "Gestionnaire"
owner: 'Manager principal'
contact_tip: "Le manager qui recevra les emails de confirmation de commande et autres notifications de l'entreprise. Il doit avoir confirmé son adresse email pour pouvoir être sélectionné."
owner_tip: Manager principal de cette entreprise.
@@ -1440,6 +1456,8 @@ fr_CA:
invite_manager: "Inviter un manager"
email_confirmed: "Email confirmé"
email_not_confirmed: "Email non confirmé"
set_as_contact: "Configurer %{email} comme contact"
set_as_owner: "Définir %{email} comme propriétaire"
vouchers:
legend: Bon de réduction
voucher_code: Code promo
@@ -1788,6 +1806,11 @@ fr_CA:
images: "Images"
contact: "Contact"
web: "Liens web"
stimulus_pagination:
navigation: Pagination
page: "Page %{number}"
previous: Page précédente
next: Page suivante
enterprise_issues:
create_new: Créer Nouveau
resend_email: Renvoyer l'email
@@ -2018,7 +2041,10 @@ fr_CA:
user_invitations:
new:
back: Retour
description: "Invitez un utilisateur à s'inscrire et à devenir gestionnaire de cette entreprise."
eg_email_address: 'ex : l''adresse e-mail d''un utilisateur nouveau ou existant'
email: Email
invite_new_user: Inviter un nouvel utilisateur
invite: Inviter
vouchers:
new:
@@ -2454,6 +2480,7 @@ fr_CA:
order_total: Total commande
order_payment: "Payer via:"
no_payment_required: "Pas de paiement requis"
credit_used: "Crédit utilisé : %{amount}"
customer_credit: Crédit
order_billing_address: Adresse de facturation
order_delivery_on: Livraison prévue
@@ -3441,6 +3468,7 @@ fr_CA:
no_orders_found: "Aucune commande trouvée"
order_information: "Info commande"
new_payment: "Nouveau paiement"
credit_customer: Client à crédit
create_or_update_invoice: "Créer ou mettre à jour la facture"
date_completed: "Date d'opération"
amount: "Montant"
@@ -3762,6 +3790,7 @@ fr_CA:
manage_products: "Gérer les produits"
edit_profile: "modifier le profil"
add_products_to_inventory: "ajouter les produits au catalogue boutique"
create_linked_variants: "créer des variantes liées [BÊTA]"
resources:
could_not_delete_customer: 'L''acheteur n''a pas pu être supprimé'
product_import:
@@ -4076,6 +4105,7 @@ fr_CA:
items_cannot_be_shipped: "Les produits ne peuvent pas être envoyés"
gateway_config_unavailable: "Configuration de la passerelle indisponible"
gateway_error: "Le paiement a échoué"
internal_payment_not_voidable: Paiement non annulable
more: "Plus"
new_adjustment: "Nouvel ajustement"
new_tax_category: "Nouvelle catégorie de taxe"
@@ -4570,6 +4600,7 @@ fr_CA:
paypalexpress: "PayPal Express"
stripesca: "Stripe SCA"
taler: "Taler"
customercredit: "Crédit client"
payments:
source_forms:
stripe:
@@ -4577,6 +4608,7 @@ fr_CA:
submitting_payment: Envoi du paiement...
paypal:
no_payment_via_admin_backend: 'Il n''est pas encore possible de payer avec Paypal via l''administration. '
customer_credit_successful: Le client a été crédité avec succès!
products:
image_upload_error: "Veuillez utiliser une image au format JPG, PNG, GIF, SVG ou WEBP format."
image_not_processable: "L'image n'est pas valide"
@@ -4893,6 +4925,7 @@ fr_CA:
orders: Commandes
cards: Cartes bancaires
transactions: Achats
customer_account_transactions: Transactions des clients
settings: Paramètres du Compte
unconfirmed_email: "Attente de validation pour l'email: %{unconfirmed_email}. Votre adresse email sera mise à jour quand le nouvel email aura été confirmé."
orders:
@@ -4903,6 +4936,9 @@ fr_CA:
authorisation_required: Autorisation nécessaire
authorise: Autorise
customer_account_transactions:
title: Transactions des clients
credit_available: "Crédit disponible : %{credit}"
transaction_date: Date de la transaction
description: Description
amount: Montant
running_balance: Solde courant
@@ -5058,3 +5094,22 @@ fr_CA:
invisible_captcha:
sentence_for_humans: "Merci de laisser ce champ libre"
timestamp_error_message: "S'il vous plaît réessayez après 5 secondes."
api_customer_credit: "Crédit API : %{description}"
credit_payment_method:
name: Crédit client
description: Autoriser le client à payer par crédit
success: Le paiement par crédit a été effectué avec succès.
void_success: L'annulation du crédit a réussi.
order_payment_description: "Crédit client : Paiement de la commande :%{order_number}"
order_void_description: "Crédit client : Remboursement pour la commande :%{order_number}"
errors:
customer_not_found: Client introuvable
missing_payment: Paiement manquant
credit_payment_method_missing: Le mode de paiement par crédit est manquant.
no_credit_available: Aucune carte de paiement autorisée disponible
not_enough_credit_available: Crédit disponible insuffisant
orders:
customer_credit_service:
no_credit_owed: Aucun crédit dû
credit_payment_method_missing: Le mode de paiement par crédit client est manquant. Veuillez vérifier la configuration.
refund_sucessful: Remboursement effectué avec succès !

View File

@@ -1298,10 +1298,10 @@ hu:
add_new_button: '+ Új alapértelmezett szabály hozzáadása'
no_tags_yet: Ehhez a vállalkozáshoz még nem tartozik címke
add_new_tag: '+ Új címke hozzáadása'
show_hide_variants: 'Változatok megjelenítése vagy elrejtése a kirakatomban'
show_hide_variants: 'Termékváltozatok megjelenítése vagy elrejtése a kínálatomban'
show_hide_shipping: 'Áruátadási módok megjelenítése vagy elrejtése fizetéskor'
show_hide_payment: 'Fizetési módok megjelenítése vagy elrejtése a pénztárnál'
show_hide_order_cycles: 'Rendelési ciklusok megjelenítése vagy elrejtése az online kirakatban'
show_hide_payment: 'Fizetési módok megjelenítése vagy elrejtése rendelés leadásakor'
show_hide_order_cycles: 'Rendelési ciklusok megjelenítése vagy elrejtése a kínálatomban'
users:
legend: "Felhasználók"
email_confirmation_notice_html: "Az email megerősítés függőben van. Megerősítő emailt küldtünk a következő címre: %{email}."
@@ -1878,6 +1878,7 @@ hu:
new:
back: Vissza
description: "Hívj meg egy felhasználót, hogy regisztráljon és a vállalkozás menedzsere legyen."
eg_email_address: írd be egy új vagy meglévő felhasználó e-mail címét
email: Email
invite_new_user: Új felhasználó meghívása
invite: Meghívás
@@ -4776,16 +4777,16 @@ hu:
default_placeholder: Adj hozzá egy címkét
tag_rule_form:
tag_rules:
shipping_method_tagged_top: "Áruátadási módok felcímkézve"
shipping_method_tagged_bottom: "vannak:"
payment_method_tagged_top: "Fizetési módok felcímkézve"
payment_method_tagged_bottom: "vannak:"
order_cycle_tagged_top: "Rendelési ciklusok felcímkézve"
order_cycle_tagged_bottom: "vannak:"
inventory_tagged_top: "Leltár változatok felcímkézve"
inventory_tagged_bottom: "vannak:"
variant_tagged_top: "Változat felcímkézve"
variant_tagged_bottom: "vannak:"
shipping_method_tagged_top: "Ezen címkéjű áruátadási módok:"
shipping_method_tagged_bottom: "hatása:"
payment_method_tagged_top: "Ezen címkéjű fizetési módok:"
payment_method_tagged_bottom: "hatása:"
order_cycle_tagged_top: "Ezen címkéjű rendelési ciklusok:"
order_cycle_tagged_bottom: "hatása:"
inventory_tagged_top: "Ezen címkéjű termékváltozatok:"
inventory_tagged_bottom: "hatása:"
variant_tagged_top: "Ezen címkéjű termékváltozatok:"
variant_tagged_bottom: "hatása:"
visible: LÁTHATÓ
not_visible: NEM LÁTHATÓ
tag_rule_group_form:

View File

@@ -82,6 +82,14 @@ Openfoodnetwork::Application.routes.draw do
delete 'products_v3/:id', to: 'products_v3#destroy', as: 'product_destroy'
delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant'
post 'clone/:id', to: 'products_v3#clone', as: 'clone_product'
post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant'
scope :ajax_search, as: :ajax_search, controller: :ajax_search do
get :producers
get :categories
get :tax_categories
end
resources :product_preview, only: [:show]
resources :variant_overrides do

View File

@@ -1,24 +0,0 @@
# Force manual loading of rails application to get all env variables from dotenv-rails when running whenever cmd
require File.expand_path('../environment', __FILE__)
require 'whenever'
require 'yaml'
# Learn more: http://github.com/javan/whenever
env "MAILTO", ENV["SCHEDULE_NOTIFICATIONS"] if ENV["SCHEDULE_NOTIFICATIONS"]
# If we use -e with a file containing specs, rspec interprets it and filters out our examples
job_type :run_file, "cd :path; :environment_variable=:environment bundle exec script/rails runner :task :output"
every 1.month, at: '4:30am' do
rake 'ofn:data:remove_transient_data'
end
every 1.day, at: '2:45am' do
rake 'db2fog:clean' if ENV['S3_BACKUPS_BUCKET']
end
every 4.hours do
rake 'db2fog:backup' if ENV['S3_BACKUPS_BUCKET']
end

View File

@@ -7,15 +7,8 @@
- default
- mailers
:scheduler:
:schedule:
HeartbeatJob:
every: ["5m", first_in: "0s"]
SubscriptionPlacementJob:
every: "5m"
SubscriptionConfirmJob:
every: "5m"
TriggerOrderCyclesToOpenJob:
every: "5m"
OrderCycleClosingJob:
every: "5m"
# This config is loaded by sidekiq before dotenv is loading our server config.
# Therefore we load the schedule later. See:
#
# - config/initializers/sidekiq.rb
# - config/sidekiq_scheduler.yml

View File

@@ -0,0 +1,36 @@
# Configure sidekiq-scheduler to run jobs.
#
# - https://github.com/sidekiq-scheduler/sidekiq-scheduler
#
# > Note that every and interval count from when the Sidekiq process (re)starts.
# > So every: '48h' will never run if the Sidekiq process is restarted daily,
# > for example. You can do every: ['48h', first_in: '0s'] to make the job run
# > immediately after a restart, and then have the worker check when it was
# > last run.
#
# Therefore, we use `cron` for jobs that should run at certain times like backups.
HeartbeatJob:
every: ["5m", first_in: "0s"]
SubscriptionPlacementJob:
every: "5m"
SubscriptionConfirmJob:
every: "5m"
TriggerOrderCyclesToOpenJob:
every: "5m"
OrderCycleClosingJob:
every: "5m"
backup:
class: "RakeJob"
args: ["db2fog:backup"]
cron: "0 */4 * * *" # every 4 hours
enabled: <%= ENV.fetch("S3_BACKUPS_BUCKET", false) && true %>
backup_clean:
class: "RakeJob"
args: ["db2fog:clean"]
cron: "45 2 * * *" # every day at 2:45am
enabled: <%= ENV.fetch("S3_BACKUPS_BUCKET", false) && true %>
ofn_clean:
class: "RakeJob"
args: ["ofn:data:remove_transient_data"]
cron: "30 4 1 * *" # every month on the first at 4:30am

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateVariantLinks < ActiveRecord::Migration[7.1]
def change
# Create a join table to join two variants. One is the source of the other.
# Primary key index ensures uniqueness and assists querying. target_variant_id is the most
# likely subject and so is first in the index.
# An additional index for source_variant is also included because it may be helpful
# (https://stackoverflow.com/questions/10790518/best-sql-indexes-for-join-table).
create_table :variant_links, primary_key: [:target_variant_id, :source_variant_id] do |t|
t.integer :source_variant_id, null: false, index: true
t.integer :target_variant_id, null: false
t.datetime :created_at, null: false
end
add_foreign_key :variant_links, :spree_variants, column: :source_variant_id
add_foreign_key :variant_links, :spree_variants, column: :target_variant_id
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddHubToSpreeVariants < ActiveRecord::Migration[7.1]
def change
add_reference :spree_variants, :hub, foreign_key: { to_table: :enterprises }
end
end

View File

@@ -1009,6 +1009,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
t.bigint "supplier_id"
t.float "variant_unit_scale"
t.string "variant_unit_name", limit: 255
t.bigint "hub_id"
t.index ["hub_id"], name: "index_spree_variants_on_hub_id"
t.index ["primary_taxon_id"], name: "index_spree_variants_on_primary_taxon_id"
t.index ["product_id"], name: "index_variants_on_product_id"
t.index ["shipping_category_id"], name: "index_spree_variants_on_shipping_category_id"
@@ -1113,6 +1115,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
t.datetime "updated_at", precision: nil, null: false
end
create_table "variant_links", primary_key: ["target_variant_id", "source_variant_id"], force: :cascade do |t|
t.integer "source_variant_id", null: false
t.integer "target_variant_id", null: false
t.datetime "created_at", null: false
t.index ["source_variant_id"], name: "index_variant_links_on_source_variant_id"
end
create_table "variant_overrides", id: :serial, force: :cascade do |t|
t.integer "variant_id", null: false
t.integer "hub_id", null: false
@@ -1262,6 +1271,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
add_foreign_key "spree_tax_rates", "spree_zones", column: "zone_id", name: "spree_tax_rates_zone_id_fk"
add_foreign_key "spree_users", "spree_addresses", column: "bill_address_id", name: "spree_users_bill_address_id_fk"
add_foreign_key "spree_users", "spree_addresses", column: "ship_address_id", name: "spree_users_ship_address_id_fk"
add_foreign_key "spree_variants", "enterprises", column: "hub_id"
add_foreign_key "spree_variants", "enterprises", column: "supplier_id"
add_foreign_key "spree_variants", "spree_products", column: "product_id", name: "spree_variants_product_id_fk"
add_foreign_key "spree_variants", "spree_shipping_categories", column: "shipping_category_id"
@@ -1278,6 +1288,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
add_foreign_key "subscriptions", "spree_payment_methods", column: "payment_method_id", name: "subscriptions_payment_method_id_fk"
add_foreign_key "subscriptions", "spree_shipping_methods", column: "shipping_method_id", name: "subscriptions_shipping_method_id_fk"
add_foreign_key "tag_rules", "enterprises"
add_foreign_key "variant_links", "spree_variants", column: "source_variant_id"
add_foreign_key "variant_links", "spree_variants", column: "target_variant_id"
add_foreign_key "variant_overrides", "enterprises", column: "hub_id", name: "variant_overrides_hub_id_fk"
add_foreign_key "variant_overrides", "spree_variants", column: "variant_id", name: "variant_overrides_variant_id_fk"
add_foreign_key "vouchers", "enterprises"

View File

@@ -30,17 +30,19 @@ module OrderManagement
other_permitted_producer_ids = EnterpriseRelationship.joins(:parent)
.permitting(distributor.id).with_permission(:add_to_order_cycle)
.merge(Enterprise.is_primary_producer)
.pluck(:parent_id)
.select(:parent_id)
# Append to the potentially gigantic array instead of using union, which creates a new array
# The db IN statement won't care if there's a duplicate.
other_permitted_producer_ids << distributor.id
Enterprise.where(id: distributor.id)
.select(:id)
.or(Enterprise.where(id: other_permitted_producer_ids))
end
def self.outgoing_exchange_variant_ids(distributor)
ExchangeVariant.select("DISTINCT exchange_variants.variant_id").joins(:exchange)
# DISTINCT is not required here since this subquery is used within an IN clause,
# where duplicate values do not impact the result.
ExchangeVariant.joins(:exchange)
.where(exchanges: { incoming: false, receiver_id: distributor.id })
.pluck(:variant_id)
.select(:variant_id)
end
end
end

View File

@@ -86,6 +86,10 @@ module OpenFoodNetwork
managed_and_related_enterprises_granting :manage_products
end
def enterprises_granting_linked_variants
related_enterprises_granting :create_linked_variants
end
def manages_one_enterprise?
@user.enterprises.length == 1
end

View File

@@ -1,54 +1,5 @@
---
http_interactions:
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
body:
encoding: UTF-8
string: '{"order":{"amount":"KUDOS:10.0","summary":"Open Food Network order","fulfillment_url":"http://test.host/payment_gateways/taler/61"},"create_token":false}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Thu, 22 Jan 2026 04:43:32 GMT
Content-Type:
- application/json
Content-Length:
- '42'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |-
{
"order_id": "2026.022-0284X4GE8WKMJ"
}
recorded_at: Thu, 22 Jan 2026 04:43:33 GMT
- request:
method: get
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.022-0284X4GE8WKMJ
@@ -103,4 +54,108 @@ http_interactions:
}
}
recorded_at: Thu, 22 Jan 2026 04:43:34 GMT
recorded_with: VCR 6.3.1
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/token
body:
encoding: UTF-8
string: '{"scope":"write"}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Fri, 20 Mar 2026 04:31:47 GMT
Content-Type:
- application/json
Content-Length:
- '258'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: ASCII-8BIT
string: |-
{
"access_token": "secret-token:J38S28NEJ6T07H1WP60F3T6PPWQYNKMR251TEZEX3CXP3SH54210",
"token": "secret-token:J38S28NEJ6T07H1WP60F3T6PPWQYNKMR251TEZEX3CXP3SH54210",
"scope": "write",
"refreshable": false,
"expiration": {
"t_s": 1774067507
}
}
recorded_at: Fri, 20 Mar 2026 04:31:48 GMT
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/orders
body:
encoding: UTF-8
string: '{"order":{"amount":"KUDOS:10.0","summary":"Open Food Network order","fulfillment_url":"http://test.host/payment_gateways/taler/198"},"create_token":false}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Fri, 20 Mar 2026 04:31:48 GMT
Content-Type:
- application/json
Content-Length:
- '42'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |-
{
"order_id": "2026.079-0189PJNWMX6JA"
}
recorded_at: Fri, 20 Mar 2026 04:31:48 GMT
recorded_with: VCR 6.4.0

View File

@@ -47,6 +47,61 @@ http_interactions:
"detail": "taler-order-id:12345"
}
recorded_at: Sat, 24 Jan 2026 00:51:31 GMT
- request:
method: post
uri: https://backend.demo.taler.net/instances/sandbox/private/token
body:
encoding: UTF-8
string: '{"scope":"write"}'
headers:
Authorization:
- "<HIDDEN-AUTHORIZATION-HEADER>"
Accept:
- application/json
User-Agent:
- Taler Ruby
Content-Type:
- application/json
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
response:
status:
code: 200
message: OK
headers:
Server:
- nginx/1.26.3
Date:
- Fri, 20 Mar 2026 04:52:23 GMT
Content-Type:
- application/json
Content-Length:
- '258'
Connection:
- keep-alive
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- "*"
Cache-Control:
- no-store
Via:
- 1.1 Caddy
Strict-Transport-Security:
- max-age=63072000; includeSubDomains; preload
body:
encoding: ASCII-8BIT
string: |-
{
"access_token": "secret-token:176N5XTVVSR98FE6V4QR2Y35HKS61ZW5CK1BC7YEZYHX9M41N5GG",
"token": "secret-token:176N5XTVVSR98FE6V4QR2Y35HKS61ZW5CK1BC7YEZYHX9M41N5GG",
"scope": "write",
"refreshable": false,
"expiration": {
"t_s": 1774068743
}
}
recorded_at: Fri, 20 Mar 2026 04:52:23 GMT
- request:
method: get
uri: https://backend.demo.taler.net/instances/sandbox/private/orders/2026.020-03R3ETNZZ0DVA
@@ -70,7 +125,7 @@ http_interactions:
Server:
- nginx/1.26.3
Date:
- Sat, 24 Jan 2026 00:55:33 GMT
- Fri, 20 Mar 2026 04:52:24 GMT
Content-Type:
- application/json
Content-Length:
@@ -205,5 +260,5 @@ http_interactions:
"refund_details": [],
"order_status_url": "https://backend.demo.taler.net/instances/sandbox/orders/2026.020-03R3ETNZZ0DVA"
}
recorded_at: Sat, 24 Jan 2026 00:55:32 GMT
recorded_with: VCR 6.3.1
recorded_at: Fri, 20 Mar 2026 04:52:24 GMT
recorded_with: VCR 6.4.0

View File

@@ -166,7 +166,6 @@ describe("TomSelectController", () => {
expect(settings.searchField).toBe("label");
expect(settings.load).toEqual(expect.any(Function));
expect(settings.firstUrl).toEqual(expect.any(Function));
expect(settings.onFocus).toEqual(expect.any(Function));
});
it("fetches page 1 on focus", async () => {

View File

@@ -16,3 +16,4 @@ describe "enterprise relationships", ->
expect(EnterpriseRelationships.permission_presentation("manage_products")).toEqual "manage products"
expect(EnterpriseRelationships.permission_presentation("edit_profile")).toEqual "edit profile"
expect(EnterpriseRelationships.permission_presentation("create_variant_overrides")).toEqual "add products to inventory"
expect(EnterpriseRelationships.permission_presentation("create_linked_variants")).toEqual "create linked variants [BETA]"

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
require "tasks/data/remove_transient_data"
RSpec.describe RakeJob do
let(:task_string) { "ofn:data:remove_transient_data" }
it "calls the removal service" do
expect(RemoveTransientData).to receive(:new).and_call_original
RakeJob.perform_now(task_string)
end
it "can be called several times" do
expect(RemoveTransientData).to receive(:new).twice.and_call_original
RakeJob.perform_now(task_string)
RakeJob.perform_now(task_string)
end
end

View File

@@ -56,7 +56,7 @@ RSpec.describe PaymentMailer do
payment = build(:payment)
payment.order.distributor = build(:enterprise, name: "Carrot Castle")
link = "https://taler.example.com/order/1"
mail = PaymentMailer.refund_available(payment, link)
mail = PaymentMailer.refund_available(payment.money.to_s, payment, link)
expect(mail.subject).to eq "Refund from Carrot Castle"
expect(mail.body).to include "Your payment of $45.75 to Carrot Castle is being refunded."

View File

@@ -364,6 +364,19 @@ RSpec.describe Spree::Ability do
for: p2.variants.first)
end
describe "create_linked_variant" do
it "should not be able to create linked variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
end
it "should be able to create linked variant when granted permission" do
create(:enterprise_relationship, parent: s_related, child: s1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
end
end
it "should not be able to access admin actions on orders" do
is_expected.not_to have_ability([:admin], for: Spree::Order)
end
@@ -729,6 +742,19 @@ RSpec.describe Spree::Ability do
it "can request permitted enterprise fees for an order cycle" do
is_expected.to have_ability([:for_order_cycle], for: EnterpriseFee)
end
describe "create_linked_variant" do
it "should not be able to create linked variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
end
it "should be able to create linked variant when granted permission" do
create(:enterprise_relationship, parent: s_related, child: d1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
end
end
end
context 'Order Cycle co-ordinator, distributor enterprise manager' do
@@ -804,6 +830,19 @@ RSpec.describe Spree::Ability do
it "has the ability to manage vouchers" do
is_expected.to have_ability([:admin, :create], for: Voucher)
end
describe "create_linked_variant for own enterprise" do
it "should not be able to create own sourced variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p1.variants.first)
end
it "should be able to create own sourced variant when granted self permission" do
create(:enterprise_relationship, parent: s1, child: s1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p1.variants.first)
end
end
end
context 'enterprise owner' do

View File

@@ -21,53 +21,6 @@ RSpec.describe Spree::CreditCard do
let(:credit_card) { described_class.new }
context "#can_capture?" do
it "should be true if payment is pending" do
payment = build_stubbed(:payment, created_at: Time.zone.now)
allow(payment).to receive(:pending?) { true }
expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy
end
it "should be true if payment is checkout" do
payment = build_stubbed(:payment, created_at: Time.zone.now)
allow(payment).to receive_messages pending?: false,
checkout?: true
expect(credit_card.can_capture_and_complete_order?(payment)).to be_truthy
end
end
context "#can_void?" do
it "should be true if payment is not void" do
payment = build_stubbed(:payment)
allow(payment).to receive(:void?) { false }
expect(credit_card.can_void?(payment)).to be_truthy
end
end
context "#can_credit?" do
it "should be false if payment is not completed" do
payment = build_stubbed(:payment)
allow(payment).to receive(:completed?) { false }
expect(credit_card.can_credit?(payment)).to be_falsy
end
it "should be false when order payment_state is not 'credit_owed'" do
payment = build_stubbed(:payment,
order: create(:order, payment_state: 'paid'))
allow(payment).to receive(:completed?) { true }
expect(credit_card.can_credit?(payment)).to be_falsy
end
it "should be false when credit_allowed is zero" do
payment = build_stubbed(:payment,
order: create(:order, payment_state: 'credit_owed'))
allow(payment).to receive_messages completed?: true,
credit_allowed: 0
expect(credit_card.can_credit?(payment)).to be_falsy
end
end
context "#valid?" do
it "should validate presence of number" do
credit_card.attributes = valid_credit_card_attributes.except(:number)

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true
RSpec.describe Spree::Gateway do
subject(:gateway) { test_gateway.new }
let(:test_gateway) do
Class.new(Spree::Gateway) do
def provider_class
@@ -15,13 +16,58 @@ RSpec.describe Spree::Gateway do
it "passes through all arguments on a method_missing call" do
expect(Rails.env).to receive(:local?).and_return(false)
gateway = test_gateway.new
expect(gateway.provider).to receive(:imaginary_method).with('foo')
gateway.imaginary_method('foo')
end
it "raises an error in test env" do
gateway = test_gateway.new
expect { gateway.imaginary_method('foo') }.to raise_error StandardError
end
describe "#can_capture?" do
it "should be true if payment is pending" do
payment = build_stubbed(:payment, created_at: Time.zone.now)
allow(payment).to receive(:pending?) { true }
expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy
end
it "should be true if payment is checkout" do
payment = build_stubbed(:payment, created_at: Time.zone.now)
allow(payment).to receive_messages pending?: false,
checkout?: true
expect(gateway.can_capture_and_complete_order?(payment)).to be_truthy
end
end
describe "#can_void?" do
it "should be true if payment is not void" do
payment = build_stubbed(:payment)
allow(payment).to receive(:void?) { false }
expect(gateway.can_void?(payment)).to be_truthy
end
end
describe "#can_credit?" do
it "should be false if payment is not completed" do
payment = build_stubbed(:payment)
allow(payment).to receive(:completed?) { false }
expect(gateway.can_credit?(payment)).to be_falsy
end
it "should be false when order payment_state is not 'credit_owed'" do
payment = build_stubbed(:payment,
order: create(:order, payment_state: 'paid'))
allow(payment).to receive(:completed?) { true }
expect(gateway.can_credit?(payment)).to be_falsy
end
it "should be false when credit_allowed is zero" do
payment = build_stubbed(:payment,
order: create(:order, payment_state: 'credit_owed'))
allow(payment).to receive_messages completed?: true,
credit_allowed: 0
expect(gateway.can_credit?(payment)).to be_falsy
end
end
end

View File

@@ -10,16 +10,38 @@ RSpec.describe Spree::PaymentMethod::Taler do
)
}
let(:backend_url) { "https://backend.demo.taler.net/instances/sandbox" }
let(:token_url) { "#{backend_url}/private/token" }
describe "#external_payment_url", vcr: true do
it "creates an order reference and retrieves a URL to pay at" do
describe "#external_payment_url" do
it "creates an order reference and retrieves a URL to pay at", vcr: true do
order = create(:order_ready_for_confirmation, payment_method: taler)
url = subject.external_payment_url(order:)
expect(url).to eq "#{backend_url}/orders/2026.022-0284X4GE8WKMJ"
expect(url).to start_with "#{backend_url}/orders/"
expect(url).to match "orders/20...[0-9A-Z-]{17}$"
payment = order.payments.last.reload
expect(payment.response_code).to match "2026.022-0284X4GE8WKMJ"
expect(payment.response_code).to match "20...[0-9A-Z-]{17}$"
end
it "creates the Taler order with the right currency" do
order = create(:order_ready_for_confirmation, payment_method: taler)
backend_url = "https://taler.example.com"
token_url = "https://taler.example.com/private/token"
order_url = "https://taler.example.com/private/orders"
taler = Spree::PaymentMethod::Taler.new(
preferred_backend_url: "https://taler.example.com",
preferred_api_key: "sandbox",
)
stub_request(:post, token_url).to_return(body: { token: "1234" }.to_json)
stub_request(:post, order_url)
.with(body: /"amount":"AUD:10.0"/)
.to_return(body: { order_id: "one" }.to_json)
url = taler.external_payment_url(order:)
expect(url).to eq "#{backend_url}/orders/one"
end
end
@@ -29,6 +51,10 @@ RSpec.describe Spree::PaymentMethod::Taler do
let(:payment) { build(:payment, response_code: "taler-order-7") }
let(:order_url) { "#{backend_url}/private/orders/taler-order-7" }
before do
stub_request(:post, token_url).to_return(body: { token: "12345" }.to_json)
end
it "returns an ActiveMerchant response" do
order_status = "paid"
stub_request(:get, order_url).to_return(body: { order_status: }.to_json)
@@ -50,6 +76,50 @@ RSpec.describe Spree::PaymentMethod::Taler do
end
end
describe "#credit" do
let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" }
let(:refund_endpoint) { "#{order_endpoint}/refund" }
let(:taler_refund_uri) {
"taler://refund/backend.demo.taler.net/instances/sandbox/taler-order-8/"
}
before do
stub_request(:post, token_url).to_return(body: { token: "12345" }.to_json)
end
it "starts the refund process" do
order_status = { order_status: "paid" }
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
stub_request(:post, refund_endpoint).to_return(body: { taler_refund_uri: }.to_json)
order = create(:completed_order_with_totals)
order.payments.create(
amount: order.total, state: :completed,
payment_method: taler,
response_code: "taler-order-8",
)
expect {
response = taler.credit(100, "taler-order-8", { payment: order.payments[0] })
expect(response.success?).to eq true
}.to enqueue_mail(PaymentMailer, :refund_available)
end
it "raises an error if payment hasn't been taken yet" do
order_status = { order_status: "claimed" }
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
order = create(:completed_order_with_totals)
order.payments.create(
amount: order.total, state: :completed,
payment_method: taler,
response_code: "taler-order-8",
)
expect {
taler.credit(100, "taler-order-8", { payment: order.payments[0] })
}.to raise_error StandardError, "Unsupported action"
end
end
describe "#void" do
let(:order_endpoint) { "#{backend_url}/private/orders/taler-order-8" }
let(:refund_endpoint) { "#{order_endpoint}/refund" }
@@ -57,6 +127,10 @@ RSpec.describe Spree::PaymentMethod::Taler do
"taler://refund/backend.demo.taler.net/instances/sandbox/taler-order-8/"
}
before do
stub_request(:post, token_url).to_return(body: { token: "12345" }.to_json)
end
it "starts the refund process" do
order_status = {
order_status: "paid",

View File

@@ -855,7 +855,8 @@ RSpec.describe Spree::Payment do
describe "available actions" do
context "for most gateways" do
let(:payment) { build_stubbed(:payment, source: build_stubbed(:credit_card)) }
let(:payment) { build_stubbed(:payment, payment_method:) }
let(:payment_method) { Spree::Gateway::StripeSCA.new }
it "can capture and void" do
expect(payment.actions).to match_array %w(capture_and_complete_order void)

View File

@@ -8,6 +8,7 @@ RSpec.describe Spree::Variant do
it { is_expected.to have_many :semantic_links }
it { is_expected.to belong_to(:product).required }
it { is_expected.to belong_to(:supplier).required }
it { is_expected.to belong_to(:hub).optional }
it { is_expected.to have_many(:inventory_units) }
it { is_expected.to have_many(:line_items) }
it { is_expected.to have_many(:stock_items) }
@@ -20,6 +21,9 @@ RSpec.describe Spree::Variant do
it { is_expected.to have_many(:inventory_items) }
it { is_expected.to have_many(:supplier_properties).through(:supplier) }
it { is_expected.to have_many(:source_variants).through(:variant_links_as_target) }
it { is_expected.to have_many(:target_variants).through(:variant_links_as_source) }
describe "shipping category" do
it "sets a shipping category if none provided" do
variant = build(:variant, shipping_category: nil)
@@ -1001,4 +1005,30 @@ RSpec.describe Spree::Variant do
expect(variant.unit_presentation).to eq "My display"
end
end
describe "#create_linked_variant" do
let(:user) { create(:user, enterprises: [enterprise]) }
let(:supplier) { variant.supplier }
let(:enterprise) { create(:enterprise) }
context "with create_linked_variants permissions on supplier" do
let!(:enterprise_relationship) {
create(:enterprise_relationship,
parent: supplier,
child: enterprise,
permissions_list: [:create_linked_variants])
}
let(:variant) { create(:variant, price: 10.95, on_demand: false, on_hand: 5) }
it "clones the variant, retaining a link to the source" do
linked_variant = variant.create_linked_variant(user)
expect(linked_variant.source_variants).to eq [variant]
expect(linked_variant.hub).to eq enterprise
expect(linked_variant.price).to eq 10.95
expect(linked_variant.on_demand).to eq false
expect(linked_variant.on_hand).to eq 5
end
end
end
end

View File

@@ -0,0 +1,285 @@
# frozen_string_literal: true
RSpec.describe "/admin/ajax_search" do
include AuthenticationHelper
let(:admin_user) { create(:admin_user) }
let(:regular_user) { create(:user) }
describe "GET /admin/ajax_search/producers" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_producers_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_producers_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:producer1) { create(:supplier_enterprise, name: "Apple Farm") }
let!(:producer2) { create(:supplier_enterprise, name: "Berry Farm") }
let!(:producer3) { create(:supplier_enterprise, name: "Cherry Orchard") }
let!(:distributor) { create(:distributor_enterprise, name: "Distributor") }
it "returns producers sorted alphabetically by name" do
get admin_ajax_search_producers_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm',
'Cherry Orchard'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters producers by search query" do
get admin_ajax_search_producers_path, params: { q: "berry" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Berry Farm'])
expect(json_response["results"].pluck("value")).to eq([producer2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_producers_path, params: { q: "BERRY" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Berry Farm'])
end
it "filters with partial matches" do
get admin_ajax_search_producers_path, params: { q: "Farm" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm'])
end
it "excludes non-producer enterprises" do
get admin_ajax_search_producers_path
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).not_to include('Distributor')
end
context "with more than 30 producers" do
before do
create_list(:supplier_enterprise, 35) do |enterprise, i|
enterprise.update!(name: "Producer #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_producers_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_producers_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
context "when user has enterprise permissions" do
let!(:my_producer) { create(:supplier_enterprise, name: "My Producer") }
let!(:other_producer) { create(:supplier_enterprise, name: "Other Producer") }
let(:user_with_producer) { create(:user, enterprises: [my_producer]) }
before { login_as user_with_producer }
it "returns only managed producers" do
get admin_ajax_search_producers_path
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['My Producer'])
expect(json_response["results"].pluck("label")).not_to include('Other Producer')
end
end
end
describe "GET /admin/ajax_search/categories" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_categories_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_categories_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:category1) { create(:taxon, name: "Vegetables") }
let!(:category2) { create(:taxon, name: "Fruits") }
let!(:category3) { create(:taxon, name: "Dairy") }
it "returns categories sorted alphabetically by name" do
get admin_ajax_search_categories_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Dairy', 'Fruits', 'Vegetables'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters categories by search query" do
get admin_ajax_search_categories_path, params: { q: "fruit" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Fruits'])
expect(json_response["results"].pluck("value")).to eq([category2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_categories_path, params: { q: "VEGETABLES" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Vegetables'])
end
it "filters with partial matches" do
get admin_ajax_search_categories_path, params: { q: "ege" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Vegetables'])
end
context "with more than 30 categories" do
before do
create_list(:taxon, 35) do |taxon, i|
taxon.update!(name: "Category #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_categories_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_categories_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
end
describe "GET /admin/ajax_search/tax_categories" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_tax_categories_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_tax_categories_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:tax_cat1) { create(:tax_category, name: "GST") }
let!(:tax_cat2) { create(:tax_category, name: "VAT") }
let!(:tax_cat3) { create(:tax_category, name: "No Tax") }
it "returns tax categories sorted alphabetically by name" do
get admin_ajax_search_tax_categories_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['GST', 'No Tax', 'VAT'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters tax categories by search query" do
get admin_ajax_search_tax_categories_path, params: { q: "vat" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['VAT'])
expect(json_response["results"].pluck("value")).to eq([tax_cat2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_tax_categories_path, params: { q: "GST" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['GST'])
end
it "filters with partial matches" do
get admin_ajax_search_tax_categories_path, params: { q: "tax" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['No Tax'])
end
context "with more than 30 tax categories" do
before do
create_list(:tax_category, 35) do |tax_cat, i|
tax_cat.update!(name: "Tax Category #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_tax_categories_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_tax_categories_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
end
end

View File

@@ -59,4 +59,71 @@ RSpec.describe "Admin::ProductsV3" do
expect(response).to redirect_to('/unauthorized')
end
end
describe "POST /admin/products/create_linked_variant" do
let(:enterprise) { create(:supplier_enterprise) }
let(:user) { create(:user, enterprises: [enterprise]) }
let(:supplier) { create(:supplier_enterprise) }
let(:variant) { create(:variant, display_name: "Original variant", supplier: supplier) }
before do
sign_in user
end
it "checks for permission" do
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to redirect_to('/unauthorized')
}.not_to change { variant.product.variants.count }
end
context "With create_linked_variants permissions on supplier" do
let!(:enterprise_relationship) {
create(:enterprise_relationship,
parent: supplier,
child: enterprise,
permissions_list: [:create_linked_variants])
}
it "clones the variant, retaining link as source" do
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to have_http_status(:ok)
expect(response.body).to match "Original variant" # cloned variant name
}.to change { variant.product.variants.count }.by(1)
new_variant = variant.product.variants.order(:id).last
# The new variant is a target of the original. It is a "sourced" variant.
expect(variant.target_variants.first).to eq new_variant
# The new variant's source is the original
expect(new_variant.source_variants.first).to eq variant
end
context "and I'm also owner of another enterprise" do
let!(:enterprise2) { create(:enterprise) }
let(:user) { create(:user, enterprises: [enterprise, enterprise2]) }
it "clones the variant, owned by my enterprise that has permission" do
enterprise2.owner = user
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to have_http_status(:ok)
}.to change { variant.product.variants.count }.by(1)
# The new variant is owned by my enterprise that has permission, not the other one
new_variant = variant.product.variants.order(:id).last
expect(new_variant.hub).to eq enterprise
end
end
end
end
end

View File

@@ -157,8 +157,6 @@ RSpec.describe Spree::Admin::PaymentsController do
context "with no payment source" do
it "redirect to payments page" do
allow(payment).to receive(:payment_source).and_return(nil)
put(
"/admin/orders/#{order.number}/payments/#{order.payments.first.id}/fire?e=void",
params: {},

View File

@@ -42,6 +42,7 @@ RSpec.describe Payments::WebhookPayload do
}
},
order: {
number: order.number,
total: order.total,
currency: order.currency,
line_items: line_items
@@ -72,6 +73,7 @@ RSpec.describe Payments::WebhookPayload do
}
},
order: {
number: "R555555555",
total: 0.00,
currency: "AUD",
line_items: [

View File

@@ -56,6 +56,7 @@ RSpec.describe Payments::WebhookService do
}
},
order: {
number: order.number,
total: order.total,
currency: order.currency,
line_items: line_items

View File

@@ -19,12 +19,25 @@ module TomSelectHelper
tomselect_wrapper.find(:css, '.ts-dropdown div.create').click
end
# Searches for and selects an option in a TomSelect dropdown with search functionality.
# @param value [String] The text to search for and select from the dropdown
# @param options [Hash] Configuration options
# @option options [String] :from The name/id of the select field
# @option options [Boolean] :remote_search If true, waits for search loading after interactions
#
# @example
# tomselect_search_and_select("Apple", from: "fruit_selector")
# tomselect_search_and_select("California", from: "state", remote_search: true)
def tomselect_search_and_select(value, options)
tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper")
tomselect_wrapper.find(".ts-control").click
expect_tomselect_loading_completion(tomselect_wrapper, options)
# Use send_keys as setting the value directly doesn't trigger the search
tomselect_wrapper.find(".ts-dropdown input.dropdown-input").send_keys(value)
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active", text: value).click
expect_tomselect_loading_completion(tomselect_wrapper, options)
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option", text: value).click
end
def tomselect_select(value, options)
@@ -64,4 +77,52 @@ module TomSelectHelper
end
end
end
# Validates both available options and selected options in a TomSelect dropdown.
# @param from [String] The name/id of the select field
# @param existing_options [Array<String>] List of options that should be available in the dropdown
# @param selected_options [Array<String>] List of options that should currently be selected
#
# @example
# expect_tomselect_existing_with_selected_options(
# from: "category_selector",
# existing_options: ["Fruit", "Vegetables", "Dairy"],
# selected_options: ["Fruit"]
# )
def expect_tomselect_existing_with_selected_options(from:, existing_options:, selected_options:)
tomselect_wrapper = page.find_field(from).sibling(".ts-wrapper")
tomselect_control = tomselect_wrapper.find('.ts-control')
tomselect_control.click # open the dropdown (would work for remote vs non-remote dropdowns)
# validate existing options are present in the dropdown
within(tomselect_wrapper) do
existing_options.each do |option|
expect(page).to have_css(
".ts-dropdown .ts-dropdown-content .option",
text: option
)
end
end
# validate selected options are selected in the dropdown
within(tomselect_wrapper) do
selected_options.each do |option|
expect(page).to have_css(
"div[data-ts-item]",
text: option
)
end
end
# close the dropdown by clicking on the already selected option
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active").click
end
def expect_tomselect_loading_completion(tomselect_wrapper, options)
return unless options[:remote_search]
expect(tomselect_wrapper).to have_css(".spinner")
expect(tomselect_wrapper).not_to have_css(".spinner")
end
end

View File

@@ -47,18 +47,23 @@ create(:enterprise)
uncheck 'to manage products'
check 'to edit profile'
check 'to add products to inventory'
check 'to create linked variants'
select2_select 'Two', from: 'enterprise_relationship_child_id'
click_button 'Create'
# Wait for row to appear since have_relationship doesn't wait
expect(page).to have_selector 'tr', count: 2
# Permissions appear.. in a different order for some reason.
expect_relationship_with_permissions e1, e2,
['to add to order cycle',
'to add products to inventory', 'to edit profile']
'to create linked variants [BETA]',
'to add products to inventory',
'to edit profile']
er = EnterpriseRelationship.where(parent_id: e1, child_id: e2).first
expect(er).to be_present
expect(er.permissions.map(&:name)).to match_array ['add_to_order_cycle', 'edit_profile',
'create_variant_overrides']
'create_variant_overrides',
'create_linked_variants']
end
it "attempting to create a relationship with invalid data" do

View File

@@ -885,6 +885,47 @@ RSpec.describe '
end
end
end
describe "removing enterprise managers" do
let(:existing_user) { create(:user) }
before do
distributor1.users << existing_user
login_as logged_in_user
visit edit_admin_enterprise_path(distributor1)
scroll_to(:bottom)
within ".side_menu" do
find(:link, "Users").trigger("click")
end
end
context "as the enterprise owner" do
let(:logged_in_user) { distributor1.owner }
it 'removes the manager as enterprise owner' do
expect(page).to have_content existing_user.email
within "#manager-#{existing_user.id}" do
accept_confirm do
page.find("a.icon-trash").click
end
end
expect(page).not_to have_content existing_user.email
end
end
context "as the enterprise manager" do
let(:logged_in_user) { existing_user }
it "is unable delete any other manager" do
expect(page).to have_content existing_user.email
within('.edit_enterprise') do
expect(page).not_to have_selector('a.icon-trash')
end
end
end
end
end
context "changing package" do

View File

@@ -25,15 +25,17 @@ RSpec.describe "Admin -> Order -> Payments" do
login_as distributor.owner
end
it "allows to refund a Taler payment" do
it "allows to void a Taler payment" do
order_status = {
order_status: "paid",
contract_terms: {
amount: "KUDOS:2",
}
}
token_endpoint = "https://taler.example.com/private/token"
order_endpoint = "https://taler.example.com/private/orders/taler-id-1"
refund_endpoint = "https://taler.example.com/private/orders/taler-id-1/refund"
stub_request(:post, token_endpoint).to_return(body: { token: "abc" }.to_json)
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
stub_request(:post, refund_endpoint).to_return(body: "{}")
@@ -49,4 +51,36 @@ RSpec.describe "Admin -> Order -> Payments" do
expect(page).not_to have_link "Void"
end
end
it "allows to credit a Taler payment" do
order_status = {
order_status: "paid",
contract_terms: {
amount: "KUDOS:2",
}
}
token_endpoint = "https://taler.example.com/private/token"
order_endpoint = "https://taler.example.com/private/orders/taler-id-1"
refund_endpoint = "https://taler.example.com/private/orders/taler-id-1/refund"
stub_request(:post, token_endpoint).to_return(body: { token: "abc" }.to_json)
stub_request(:get, order_endpoint).to_return(body: order_status.to_json)
stub_request(:post, refund_endpoint).to_return(body: "{}")
visit spree.admin_order_payments_path(order.number)
within row_containing("Taler") do
expect(page).to have_text "COMPLETED"
expect(page).to have_link "Credit"
click_link class: "icon-credit"
expect(page).to have_text "COMPLETED"
expect(page).not_to have_link "Credit"
end
# Our payment system creates a new payment to show the credit.
within row_containing("$-9.75") do
expect(page).not_to have_link "Void"
end
end
end

View File

@@ -2,7 +2,7 @@
require "system_helper"
RSpec.describe 'As an enterprise user, I can manage my products' do
RSpec.describe 'As an enterprise user, I can perform actions on the products screen' do
include AdminHelper
include WebHelper
include AuthenticationHelper
@@ -15,22 +15,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
login_as user
end
let(:producer_search_selector) { 'input[placeholder="Select producer"]' }
let(:categories_search_selector) { 'input[placeholder="Select category"]' }
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
describe "with no products" do
before { visit admin_products_url }
it "can see the new product page" do
expect(page).to have_content "Bulk Edit Products"
expect(page).to have_text "No products found"
# displays buttons to add products with the correct links
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
expect(page).to have_link(class: "button", text: "Import multiple products",
href: admin_product_import_path)
end
end
describe "column selector" do
let!(:product) { create(:simple_product) }
@@ -105,8 +89,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
describe "columns"
describe "Changing producers, category and tax category" do
let!(:variant_a1) {
product_a.variants.first.tap{ |v|
@@ -116,54 +98,7 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
}
let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") }
context "when they are under 11" do
before do
create_list(:supplier_enterprise, 9, users: [user])
create_list(:tax_category, 9)
create_list(:taxon, 2)
visit admin_products_url
end
it "should not display search input, change the producers, category and tax category" do
producer_to_select = random_producer(variant_a1)
category_to_select = random_category(variant_a1)
tax_category_to_select = random_tax_category
within row_containing_name(variant_a1.display_name) do
validate_tomselect_without_search!(
page, "Producer",
producer_search_selector
)
tomselect_select(producer_to_select, from: "Producer")
end
within row_containing_name(variant_a1.display_name) do
validate_tomselect_without_search!(
page, "Category",
categories_search_selector
)
tomselect_select(category_to_select, from: "Category")
validate_tomselect_without_search!(
page, "Tax Category",
tax_categories_search_selector
)
tomselect_select(tax_category_to_select, from: "Tax Category")
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
variant_a1.reload
expect(variant_a1.supplier.name).to eq(producer_to_select)
expect(variant_a1.primary_taxon.name).to eq(category_to_select)
expect(variant_a1.tax_category.name).to eq(tax_category_to_select)
end
end
context "when they are over 11" do
context "when there are products" do
before do
create_list(:supplier_enterprise, 11, users: [user])
create_list(:tax_category, 11)
@@ -181,9 +116,13 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
tax_category_to_select = random_tax_category
within row_containing_name(variant_a1.display_name) do
tomselect_search_and_select(producer_to_select, from: "Producer")
tomselect_search_and_select(category_to_select, from: "Category")
tomselect_search_and_select(tax_category_to_select, from: "Tax Category")
tomselect_search_and_select(producer_to_select, from: "Producer", remote_search: true)
tomselect_search_and_select(category_to_select, from: "Category", remote_search: true)
tomselect_search_and_select(
tax_category_to_select,
from: "Tax Category",
remote_search: true
)
end
click_button "Save changes"
@@ -260,24 +199,24 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
describe "Cloning product" do
it "shows the cloned product on page when clicked on the cloned option" do
# TODO, variant supplier missing, needs to be copied from variant and not product
within "table.products" do
# Gather input values, because page.content doesn't include them.
input_content = page.find_all('input[type=text]').map(&:value).join
# Products does not include the cloned product.
expect(input_content).not_to match /COPY OF Apples/
end
click_product_clone "Apples"
expect(page).to have_content "Successfully cloned the product"
within "table.products" do
# Gather input values, because page.content doesn't include them.
input_content = page.find_all('input[type=text]').map(&:value).join
# Product list includes the cloned product.
expect(all_input_values).to match /COPY OF Apples/
# Products include the cloned product.
expect(input_content).to match /COPY OF Apples/
# And I can perform actions on the new product
within row_containing_name "COPY OF Apples" do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Edit"
expect(page).to have_link "Clone"
# expect(page).to have_link "Delete" # it's not a proper link :/
fill_in "Name", with: "My copy of Apples"
end
click_button "Save changes"
end
end
end
@@ -298,6 +237,98 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
describe "Create linked variant" do
let!(:variant) { create(:variant, display_name: "My box", supplier: producer) }
let!(:linked_variant) {
variant.create_linked_variant(user).tap{ |v| v.update! display_name: "My linked variant" }
}
let!(:other_producer) { create(:supplier_enterprise) }
let!(:other_variant) {
create(:variant, display_name: "My friends box", supplier: other_producer)
}
let!(:enterprise_relationship) {
# Other producer grants me access to manage their variant
create(:enterprise_relationship, parent: other_producer, child: producer,
permissions_list: [:manage_products])
}
context "with create_linked_variants permission for my, and other's variants" do
it "creates a linked variant" do
create(:enterprise_relationship, parent: producer, child: producer,
permissions_list: [:create_linked_variants])
enterprise_relationship.permissions.create! name: :create_linked_variants
visit admin_products_url
# Check my own variant
within row_containing_name("My box") do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Create linked variant"
end
close_action_menu
# Check my own linked variant
within row_containing_name("My linked variant") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
close_action_menu
# Create linked variant sourced from my friend
within row_containing_name("My friends box") do
page.find(".vertical-ellipsis-menu").click
click_link "Create linked variant"
end
expect(page).to have_content "Successfully created linked variant"
within "table.products" do
# There are now two copies
expect(all_input_values).to match /My friends box.*My friends box/
# One of them is designated as a linked variant
expect(page).to have_content "🔗"
last_box = page.all(row_containing_name("My friends box")).last
# Close action menu (shouldn't need this, it should close itself)
last_box.click
# And I can perform actions on the new product
within last_box do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Edit"
# expect(page).to have_link "Clone" # tofix: menu is partially obscured
# expect(page).to have_link "Delete" # it's not a proper link
fill_in "Name", with: "My copy of Apples"
end
click_button "Save changes"
# initially obscured by the previous message, then disappears before capybara sees it.
# expect(page).to have_content "Changes saved"
end
end
end
context "without create_linked_variants permission" do
it "does not show the option in the menu" do
visit admin_products_url
within row_containing_name("My box") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
within row_containing_name("My friends box") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
end
end
end
describe "delete" do
let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") }
let(:delete_option_selector) { "a[data-controller='modal-link'].delete" }
@@ -527,90 +558,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
context "as an enterprise manager" do
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
let!(:product_supplied_permitted) {
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
}
let(:product_supplied_inactive) {
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
}
let!(:supplier_permitted_relationship) do
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
permissions_list: [:manage_products])
end
before do
enterprise_user = create(:user)
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
login_as enterprise_user
end
it "shows only products that I supply" do
visit spree.admin_products_path
# displays permitted product list only
expect(page).to have_selector row_containing_name(product_supplied.name)
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
end
it "shows only suppliers that I manage or have permission to" do
visit spree.admin_products_path
within row_containing_placeholder(product_supplied.name) do
expect(page).to have_select(
'_products_0_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_managed1.name
)
end
within row_containing_placeholder(product_supplied_permitted.name) do
expect(page).to have_select(
'_products_1_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_permitted.name
)
end
end
it "shows inactive products that I supply" do
product_supplied_inactive
visit spree.admin_products_path
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
end
it "allows me to update a product" do
visit spree.admin_products_path
within row_containing_name(product_supplied.name) do
fill_in "Name", with: "Pommes"
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
expect(page).to have_selector row_containing_name("Pommes")
end
end
def open_action_menu
page.find(".vertical-ellipsis-menu").click
end

View File

@@ -86,14 +86,14 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
find('button[aria-label="On Hand"]').click
find('input[id$="_price"]').fill_in with: "11.1"
select supplier.name, from: 'Producer'
select taxon.name, from: 'Category'
if stock == "on_hand"
find('input[id$="_on_hand_desired"]').fill_in with: "66"
elsif stock == "on_demand"
find('input[id$="_on_demand_desired"]').check
end
tomselect_select supplier.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect(page).to have_content "1 product modified."

View File

@@ -2,13 +2,13 @@
require "system_helper"
RSpec.describe 'As an enterprise user, I can manage my products' do
RSpec.describe 'As an enterprise user, I can browse my products' do
include AdminHelper
include WebHelper
include AuthenticationHelper
include FileHelper
let(:producer) { create(:supplier_enterprise) }
let(:producer) { create(:supplier_enterprise, name: "My Enterprise") }
let(:user) { create(:user, enterprises: [producer]) }
before do
@@ -19,15 +19,25 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
let(:categories_search_selector) { 'input[placeholder="Search for categories"]' }
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
describe "with no products" do
before { visit admin_products_url }
it "can see the new product page" do
expect(page).to have_content "Bulk Edit Products"
expect(page).to have_text "No products found"
# displays buttons to add products with the correct links
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
expect(page).to have_link(class: "button", text: "Import multiple products",
href: admin_product_import_path)
end
end
describe "listing" do
let!(:p1) { create(:product, name: "Product1") }
let!(:p2) { create(:product, name: "Product2") }
before do
visit admin_products_url
end
it "displays a list of products" do
visit admin_products_path
within ".products" do
# displays table header
expect(page).to have_selector "th", text: "Name"
@@ -96,13 +106,19 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
visit spree.admin_products_path
within row_containing_name "Variant1" do
expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"],
selected: "Producer A"
expect_tomselect_existing_with_selected_options(
from: 'Producer',
existing_options: ["Producer A", "Producer B"],
selected_options: ["Producer A"]
)
end
within row_containing_name "Variant2a" do
expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"],
selected: "Producer B"
expect_tomselect_existing_with_selected_options(
from: 'Producer',
existing_options: ["Producer A", "Producer B"],
selected_options: ["Producer B"]
)
end
end
end
@@ -129,6 +145,34 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
expect(page).to have_select "variant_unit_with_scale", selected: "Items"
expect(page).to have_field "variant_unit_name", with: "packet"
end
context "with sourced variant" do
let(:source_producer) { create(:supplier_enterprise) }
let(:p3) { create(:product, name: "Product3", supplier_id: source_producer.id) }
let!(:v3_source) { p3.variants.first }
let!(:v3_sourced) {
create(:variant, display_name: "Variant3-sourced", product: p3, supplier: source_producer,
hub: producer)
}
let!(:enterprise_relationship) {
# Other producer grants me access to manage their variant
create(:enterprise_relationship, parent: source_producer, child: producer,
permissions_list: [:manage_products])
}
before do
v3_sourced.source_variants << v3_source
visit admin_products_url
end
it "shows sourced variant with indicator" do
within row_containing_name("Variant3-sourced") do
expect(page).to have_selector 'span[title*="Sourced from: "]'
expect(page).to have_selector 'span[title*="Hub: My Enterprise"]'
end
end
end
end
describe "sorting" do
@@ -463,4 +507,85 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
end
context "as an enterprise manager" do
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
let!(:product_supplied_permitted) {
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
}
let(:product_supplied_inactive) {
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
}
let!(:supplier_permitted_relationship) do
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
permissions_list: [:manage_products])
end
before do
enterprise_user = create(:user)
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
login_as enterprise_user
end
it "shows only products that I supply" do
visit spree.admin_products_path
# displays permitted product list only
expect(page).to have_selector row_containing_name(product_supplied.name)
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
end
it "shows only suppliers that I manage or have permission to" do
visit spree.admin_products_path
existing_options = [supplier_managed1.name, supplier_managed2.name, supplier_permitted.name]
within row_containing_placeholder(product_supplied.name) do
expect_tomselect_existing_with_selected_options(
existing_options:,
from: '_products_0_variants_attributes_0_supplier_id',
selected_options: [supplier_managed1.name]
)
end
within row_containing_placeholder(product_supplied_permitted.name) do
expect_tomselect_existing_with_selected_options(
existing_options:,
from: '_products_1_variants_attributes_0_supplier_id',
selected_options: [supplier_permitted.name]
)
end
end
it "shows inactive products that I supply" do
product_supplied_inactive
visit spree.admin_products_path
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
end
it "allows me to update a product" do
visit spree.admin_products_path
within row_containing_name(product_supplied.name) do
fill_in "Name", with: "Pommes"
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
expect(page).to have_selector row_containing_name("Pommes")
end
end
end

View File

@@ -350,8 +350,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do
click_on "On Hand" # activate popout
fill_in "On Hand", with: "3"
select producer.name, from: 'Producer'
select taxon.name, from: 'Category'
tomselect_select producer.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect {
@@ -586,8 +586,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do
fill_in "Name", with: "Nice box"
fill_in "SKU", with: "APL-02"
select producer.name, from: 'Producer'
select taxon.name, from: 'Category'
tomselect_select producer.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect {

View File

@@ -370,6 +370,7 @@ RSpec.describe "As a consumer, I want to checkout my order" do
Spree::PaymentMethod::Taler.create!(
name: "Taler",
environment: "test",
preferred_backend_url: "https://taler.example.com/",
distributors: [distributor]
)
end

View File

@@ -17,7 +17,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do
end
let(:spree_current_user) { build(:enterprise_user) }
it "shows the producer filter when there are options" do
it "shows the producer filter with the default option initially" do
allow(view).to receive_messages locals.merge(
producer_options: [
["Ada's Apples", 1],
@@ -27,9 +27,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do
is_expected.to have_content "Producers"
is_expected.to have_select "producer_id", options: [
"All producers",
"Ada's Apples",
"Ben's Bananas",
"All producers"
], selected: nil
end

View File

@@ -6,13 +6,13 @@ RSpec.describe "registration/steps/_details.html.haml" do
it "uses Google Maps when it is enabled" do
allow(view).to receive_messages(using_google_maps?: true)
is_expected.to match /<ui-gmap-google-map center='map.center' zoom='map.zoom'>/
is_expected.to match /<ui-gmap-google-map center="map.center" zoom="map.zoom">/
end
it "uses OpenStreetMap when it is enabled" do
ContentConfig.open_street_map_enabled = true
allow(view).to receive_messages(using_google_maps?: false)
is_expected.to match /<div class='map-container--registration' id='open-street-map'>/
is_expected.to match /<div class="map-container--registration" id="open-street-map">/
end
end

View File

@@ -816,9 +816,9 @@
"@babel/helper-plugin-utils" "^7.28.6"
"@babel/preset-env@^7.28.5":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.0.tgz#c55db400c515a303662faaefd2d87e796efa08d0"
integrity sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==
version "7.29.2"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.2.tgz#5a173f22c7d8df362af1c9fe31facd320de4a86c"
integrity sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==
dependencies:
"@babel/compat-data" "^7.29.0"
"@babel/helper-compilation-targets" "^7.28.6"
@@ -900,15 +900,10 @@
"@babel/types" "^7.4.4"
esutils "^2.0.2"
"@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
"@babel/runtime@^7.28.4":
version "7.28.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b"
integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==
"@babel/runtime@^7.12.5", "@babel/runtime@^7.28.4", "@babel/runtime@^7.8.4":
version "7.29.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==
"@babel/template@^7.28.6":
version "7.28.6"
@@ -5156,9 +5151,9 @@ mimic-fn@^2.1.0:
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
mini-css-extract-plugin@^2.9.4:
version "2.10.1"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.1.tgz#a7f0bb890f4e1ce6dfc124bd1e6d6fcd3b359844"
integrity sha512-k7G3Y5QOegl380tXmZ68foBRRjE9Ljavx835ObdvmZjQ639izvZD8CS7BkWw1qKPPzHsGL/JDhl0uyU1zc2rJw==
version "2.10.2"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz#5c85ec9450c05d26e32531b465a15a08c3a57253"
integrity sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==
dependencies:
schema-utils "^4.0.0"
tapable "^2.2.1"
@@ -5275,9 +5270,9 @@ node-addon-api@^7.0.0:
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
node-forge@^1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751"
integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==
version "1.4.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2"
integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==
node-int64@^0.4.0:
version "0.4.0"
@@ -5482,14 +5477,14 @@ picocolors@1.1.1, picocolors@^1.1.1:
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
version "2.3.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pify@^2.3.0:
version "2.3.0"
@@ -7116,9 +7111,9 @@ tr46@^5.1.0:
punycode "^2.3.1"
trix@*:
version "2.1.17"
resolved "https://registry.yarnpkg.com/trix/-/trix-2.1.17.tgz#a47c4ee1925a7abb26aee5c094ec7ef9fe49575a"
integrity sha512-nkHg7VgIItGVx1CFA645dDlAoCgah+9gv80Yc+97aS8jkZmO5K4MxkSqmU9t/C8upqZB8uhfJs90epoDfYz/6Q==
version "2.1.18"
resolved "https://registry.yarnpkg.com/trix/-/trix-2.1.18.tgz#d87a5be64c10ecdab64f5af47f941b5318c08225"
integrity sha512-DWOdTsz3n9PO3YBc1R6pGh9MG1cXys/2+rouc/qsISncjc2MBew2UOW8nXh3NjUOjobKsXCIPR6LB02abg2EYg==
dependencies:
dompurify "^3.2.5"