commerce7-rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d17bdff3aaa4490b2fda29996b277c0947c5dcb6539d4bc02b8d81a63cb50fd0
4
+ data.tar.gz: b16fc3cdc51a00bf3e8563283db63fbaf2bc14d5508831038cb26c155ca3ad6e
5
+ SHA512:
6
+ metadata.gz: 52f6b435f3044adf60b7210a78b1d0b69ccfaa018d126f89432a764875d55a4a370d50c87928a7f7e4024314b3c857a863e6ecc8512ce221c0f281065190ccd6
7
+ data.tar.gz: b7160af777302983bc93d39308bcf63a9f4058c177d5e1ddf5b37be021522fcb69dc5d109cbaf12db86550a45e006c52e18d67c98c8e92cbd18df6e56a4f5b94
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Roberts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # commerce7-rails
2
+
3
+ Rails building blocks for building a [Commerce7](https://www.commerce7.com/) App Store integration: activation/deactivation lifecycle, App Extension staff-JWT auth, safe webhook dispatch, the Commerce7 REST client, and the post-uninstall data purge Commerce7's App Store security review requires.
4
+
5
+ This gem owns the Commerce7-protocol plumbing. Your app owns the business logic: your tenant model's own fields, what a webhook handler actually does, what your App Extension pages render.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.2.0"
12
+ ```
13
+
14
+ ```
15
+ bundle install
16
+ bin/rails generate commerce7:install
17
+ bin/rails db:migrate
18
+ ```
19
+
20
+ The generator creates `db/migrate/*_create_tenants.rb` (skip/adapt it if your app already has a `tenants` table) and `config/initializers/commerce7.rb`.
21
+
22
+ ## Configure
23
+
24
+ ```ruby
25
+ # config/initializers/commerce7.rb
26
+ Commerce7.configure do |c|
27
+ c.tenant_class_name = "Tenant"
28
+
29
+ # -> { [username, password] } — the Basic Auth pair you configured in
30
+ # Commerce7's Developer Center for the Install/Uninstall URLs and the
31
+ # app-level Web Hook.
32
+ c.webhook_credentials = -> {
33
+ [
34
+ Rails.application.credentials.dig(:commerce7, :webhook_username),
35
+ Rails.application.credentials.dig(:commerce7, :webhook_password)
36
+ ]
37
+ }
38
+
39
+ # -> { [app_id, app_secret_key] } — the single app-wide Commerce7 API
40
+ # credential pair (not per-tenant).
41
+ c.app_credentials = -> {
42
+ [
43
+ Rails.application.credentials.dig(:commerce7, :app_id),
44
+ Rails.application.credentials.dig(:commerce7, :app_secret_key)
45
+ ]
46
+ }
47
+
48
+ # ->(event_type:, success:, **kwargs) { ... } — point this at your own
49
+ # audit-log write path so Commerce7-driven events land in the same trail
50
+ # as the rest of your app's.
51
+ c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
52
+ end
53
+ ```
54
+
55
+ Your tenant model includes the lifecycle concern and, since Commerce7's activation POST carries the installing staff member's name/email, encrypts it:
56
+
57
+ ```ruby
58
+ class Tenant < ApplicationRecord
59
+ include Commerce7::TenantConcern
60
+ encrypts :raw_activation_payload
61
+ end
62
+ ```
63
+
64
+ Your app also needs a top-level `Current`, the standard Rails per-request-state convention — `Commerce7::ExtensionController` and `Commerce7::PurgeDeactivatedTenantsJob` both use it:
65
+
66
+ ```ruby
67
+ class Current < ActiveSupport::CurrentAttributes
68
+ attribute :tenant, :staff_user
69
+ end
70
+ ```
71
+
72
+ ## Routes
73
+
74
+ This gem mounts nothing — you declare routes exactly as you would for any in-app controller, just pointing at the gem's classes, so the URLs already registered in Commerce7's Developer Center (Install/Uninstall URLs, an App Extension's iframe src) stay stable and under your control:
75
+
76
+ ```ruby
77
+ namespace :commerce7 do
78
+ post "activate", to: "activations#create", as: :activate
79
+ post "deactivate", to: "deactivations#create", as: :deactivate
80
+ post "webhooks", to: "webhooks#create", as: :webhooks
81
+
82
+ # Your own App Extension pages, subclassing Commerce7::ExtensionController:
83
+ get "dashboard", to: "dashboard#show", as: :dashboard
84
+ end
85
+ ```
86
+
87
+ ## Webhook dispatch
88
+
89
+ `Commerce7::WebhooksController` handles the parsing, Basic Auth, tenant lookup, and audit write. You register what your app actually does for each `(object, action)` pair Commerce7 might send — anything unregistered is a silent no-op, matching Commerce7's own retry-tolerant expectations:
90
+
91
+ ```ruby
92
+ Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
93
+ SyncJob.perform_later(tenant)
94
+ end
95
+
96
+ Commerce7::Webhooks.on("Customer", "Delete") do |tenant, payload, actor|
97
+ Current.tenant = tenant
98
+ ClubMember.find_by(commerce7_customer_id: payload["customerId"])&.destroy
99
+ Current.tenant = nil
100
+ end
101
+ ```
102
+
103
+ Handlers must be **idempotent by construction** (upsert / find-and-destroy) — Commerce7 doesn't expose a delivery/event id to dedupe a redelivery against.
104
+
105
+ ## Activation/deactivation hooks
106
+
107
+ ```ruby
108
+ # Runs once, after a tenant activates (first install or reinstall) — typically
109
+ # a one-time backfill sync, since Web Hooks only fire on future changes.
110
+ Commerce7.on_activate do |tenant, payload|
111
+ SyncJob.perform_later(tenant)
112
+ end
113
+
114
+ Commerce7.on_deactivate do |tenant|
115
+ # optional
116
+ end
117
+ ```
118
+
119
+ ## The 30-day post-uninstall purge
120
+
121
+ Commerce7's security review requires customer data deleted within 30 days of app termination. `Commerce7::TenantConcern` soft-deactivates on uninstall (never hard-deletes, so a reinstall within the window keeps the tenant's data) and exposes a `pending_deletion` scope; `Commerce7::PurgeDeactivatedTenantsJob` hard-deletes anything past that window. Schedule it as a recurring job:
122
+
123
+ ```yaml
124
+ # config/recurring.yml (Solid Queue)
125
+ production:
126
+ commerce7_purge_deactivated_tenants:
127
+ class: Commerce7::PurgeDeactivatedTenantsJob
128
+ queue: default
129
+ schedule: every day at 3am
130
+ ```
131
+
132
+ ## The REST client
133
+
134
+ ```ruby
135
+ client = Commerce7::Client.new(tenant)
136
+ client.each_club_membership { |membership| ... } # paginates automatically
137
+ client.each_customer { |customer| ... }
138
+ client.each_order { |order| ... }
139
+ client.each_order(orderPaidDate: "gte:2026-01-01") { |order| ... } # params pass through as filters
140
+ client.each_product { |product| ... } # variants + per-location inventory inline
141
+ client.each_inventory_location { |location| ... }
142
+ client.fetch_order(order_id)
143
+ ```
144
+
145
+ Handles pagination, the 100 req/min rate limit (retries on 429 using `Retry-After` when present, exponential backoff otherwise), and raises `Commerce7::Client::AuthenticationError` / `RateLimitedError` / `ApiError` as appropriate.
146
+
147
+ `Commerce7::AccountClient` validates the staff JWT Commerce7 passes into an App Extension iframe — used internally by `Commerce7::ExtensionController`, but available directly if you need it.
148
+
149
+ ## Security posture
150
+
151
+ This gem exists so every app built on it starts from a "Yes" on Commerce7's App Store security questionnaire, not a retrofit:
152
+
153
+ - **Server-to-server auth**: `Commerce7::BaseController` requires HTTP Basic Auth (your `webhook_credentials`) on every activation/deactivation/webhook POST, and audits both successful and failed attempts.
154
+ - **App Extension auth**: `Commerce7::ExtensionController` validates the staff JWT Commerce7 passes into every iframe load against Commerce7's own `/account/user` endpoint — a real error page on failure, never a bare status code.
155
+ - **PII**: `raw_activation_payload` (the installing staff member's name/email) is your model's column to encrypt — see Install above.
156
+ - **Data deletion**: built in — soft-deactivate on uninstall, hard-delete after 30 days (`Commerce7::PurgeDeactivatedTenantsJob`).
157
+ - **Webhook auth + idempotency**: Basic Auth on every delivery; handlers are expected to be idempotent by construction, since Commerce7 exposes no delivery id to dedupe against.
158
+ - **Audit trail**: every security-relevant event (server auth success/failure, activation/deactivation, staff extension auth, webhook-driven dispatch, the post-uninstall purge) flows through your configured `audit` hook — user identity, event type, timestamp, success/failure, and origin.
159
+
160
+ ## Development
161
+
162
+ ```
163
+ bundle install
164
+ bundle exec rspec
165
+ bundle exec rubocop
166
+ bundle exec brakeman
167
+ bundle exec bundler-audit --update
168
+ ```
169
+
170
+ Tests run against a minimal dummy Rails app (via [combustion](https://github.com/pat/combustion)) at `spec/dummy`.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Receives Commerce7's activation POST on app install. Per Commerce7's
5
+ # docs, this sends `tenantId` plus the installer's first name, last name,
6
+ # and email — NOT API credentials. That's expected: the App ID/Secret Key
7
+ # is a single app-wide pair (see Commerce7.configuration.app_credentials),
8
+ # not something issued per tenant.
9
+ class ActivationsController < BaseController
10
+ def create
11
+ tenant = Commerce7.configuration.tenant_class.activate!(
12
+ commerce7_tenant_id: params.require(:tenantId),
13
+ payload: activation_payload
14
+ )
15
+
16
+ # See Commerce7.on_activate — typically used to kick off a one-time
17
+ # backfill sync, since Commerce7's Web Hooks only fire on future
18
+ # changes, not a newly (re)installed tenant's pre-existing data.
19
+ Commerce7.run_activate_hooks(tenant, activation_payload)
20
+
21
+ Commerce7.audit!(event_type: "tenant_activated", success: true, commerce7_tenant_id: tenant.commerce7_tenant_id, origin_ip: request.remote_ip)
22
+
23
+ head :ok
24
+ end
25
+
26
+ private
27
+
28
+ def activation_payload
29
+ params.except(:controller, :action).to_unsafe_h
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Base for Commerce7 server-to-server POSTs: the app-wide Install/Uninstall
5
+ # URLs (activation, deactivation) and the app-level Web Hooks feature. Not
6
+ # browser requests, so this skips the CSRF check and doesn't inherit a
7
+ # host app's ApplicationController (e.g. its allow_browser restriction).
8
+ #
9
+ # Auth is HTTP Basic in both cases, per Commerce7's docs: Install/Uninstall
10
+ # URLs support an optional username/password configured in their
11
+ # dashboard's "Advanced" section, and a Web Hook registered in the same
12
+ # Developer Center app version supports the same "Advanced Authentication"
13
+ # — both are a single app-wide credential pair set once, not something
14
+ # each tenant configures (see Commerce7.configuration.webhook_credentials).
15
+ class BaseController < ActionController::Base
16
+ # Explicit rather than relying on a host app's `default_protect_from_forgery`
17
+ # config default — this gem shouldn't depend on that being set for its
18
+ # own controllers' safety. Immediately skipped below since these are
19
+ # server-to-server requests with no session/cookie to forge against.
20
+ protect_from_forgery with: :exception
21
+ skip_before_action :verify_authenticity_token, raise: false
22
+
23
+ before_action :authenticate_commerce7!
24
+
25
+ rescue_from ActionController::ParameterMissing do |error|
26
+ render json: { error: error.message }, status: :bad_request
27
+ end
28
+
29
+ private
30
+
31
+ # Deliberately not authenticate_or_request_with_http_basic: that method's
32
+ # return value is truthy even on failure (it's a bare
33
+ # `response_body = message` assignment under the hood — see
34
+ # ActionController::HttpAuthentication::Basic::ProtectedMethods#authentication_request),
35
+ # so it can't be used as a success/failure signal for auditing. Calling
36
+ # authenticate_with_http_basic directly and handling the 401 ourselves
37
+ # gives an unambiguous boolean instead.
38
+ def authenticate_commerce7!
39
+ return if authenticate_with_http_basic { |username, password| valid_commerce7_credentials?(username, password) }
40
+
41
+ Commerce7.audit!(
42
+ event_type: "commerce7_server_auth",
43
+ success: false,
44
+ commerce7_tenant_id: params[:tenantId],
45
+ origin_ip: request.remote_ip,
46
+ metadata: { path: request.path }
47
+ )
48
+ request_http_basic_authentication
49
+ end
50
+
51
+ def valid_commerce7_credentials?(username, password)
52
+ expected_username, expected_password = Commerce7.configuration.webhook_credentials.call
53
+
54
+ expected_username.present? && expected_password.present? &&
55
+ ActiveSupport::SecurityUtils.secure_compare(username, expected_username) &&
56
+ ActiveSupport::SecurityUtils.secure_compare(password, expected_password)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Receives Commerce7's deactivation POST on app uninstall. Soft-deactivates
5
+ # the tenant (never hard-deletes) so a reinstall can reactivate the same
6
+ # record — see Commerce7::TenantConcern#activate! and
7
+ # Commerce7::PurgeDeactivatedTenantsJob, which enforces the actual
8
+ # post-uninstall deletion after a retention window. A tenantId this app
9
+ # doesn't recognize is a no-op, not an error, since webhook/POST retries
10
+ # are common and shouldn't fail loudly.
11
+ class DeactivationsController < BaseController
12
+ def create
13
+ tenant = Commerce7.configuration.tenant_class.find_by(commerce7_tenant_id: params.require(:tenantId))
14
+ if tenant
15
+ tenant.deactivate!
16
+ Commerce7.run_deactivate_hooks(tenant)
17
+ Commerce7.audit!(event_type: "tenant_deactivated", success: true, commerce7_tenant_id: tenant.commerce7_tenant_id, origin_ip: request.remote_ip)
18
+ end
19
+
20
+ head :ok
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Base for pages embedded as a Commerce7 App Extension (iframe). Commerce7
5
+ # appends `tenantId` and `account` (a staff JWT) to the iframe src URL;
6
+ # this validates that JWT against Commerce7's API and resolves
7
+ # Current.tenant/Current.staff_user before any subclass action runs. A
8
+ # host app is expected to define a top-level `Current < ActiveSupport::
9
+ # CurrentAttributes` with `tenant` and `staff_user` attributes — the same
10
+ # convention Rails apps already reach for to scope per-request state.
11
+ class ExtensionController < ActionController::Base
12
+ # Explicit rather than relying on a host app's `default_protect_from_forgery`
13
+ # config default — this gem shouldn't depend on that being set for its
14
+ # own controllers' safety. A subclass that adds a mutating, browser-
15
+ # submitted action inherits this; Commerce7's staff-JWT re-validation on
16
+ # every request (see authenticate_staff! below) is a second, independent
17
+ # boundary a subclass can lean on if it needs to skip this one (e.g. for
18
+ # a cross-site iframe POST where the session cookie may not travel).
19
+ protect_from_forgery with: :exception
20
+
21
+ before_action :authenticate_staff!
22
+
23
+ # Rails sends X-Frame-Options: SAMEORIGIN by default, which blocks
24
+ # Commerce7's admin panel (a different origin) from framing this page at
25
+ # all. Commerce7 doesn't publish a stable admin origin to scope a
26
+ # replacement CSP frame-ancestors to, so this just drops the blanket
27
+ # deny; a host app that wants a tighter CSP can add its own
28
+ # frame-ancestors directive once that origin is confirmed.
29
+ after_action { response.headers.delete("X-Frame-Options") }
30
+
31
+ rescue_from ActionController::ParameterMissing do |error|
32
+ render plain: error.message, status: :bad_request
33
+ end
34
+
35
+ private
36
+
37
+ def authenticate_staff!
38
+ tenant_id = params.require(:tenantId)
39
+ tenant = Commerce7.configuration.tenant_class.active.find_by(commerce7_tenant_id: tenant_id)
40
+ unless tenant
41
+ audit_auth!(success: false, tenant_id: tenant_id, reason: "unknown_or_deactivated_tenant")
42
+ # Same real error page as a rejected staff token (see below), not a
43
+ # bare status code — an uninstalled-then-still-open tab is the
44
+ # common way staff land here.
45
+ return render "commerce7/extension/unauthorized", status: :forbidden
46
+ end
47
+
48
+ Current.staff_user = Commerce7::AccountClient.new.fetch_user(tenant_id: tenant_id, token: params.require(:account))
49
+ Current.tenant = tenant
50
+ audit_auth!(success: true, tenant_id: tenant_id, actor: Current.staff_user["email"])
51
+ rescue Commerce7::AccountClient::AuthenticationError
52
+ audit_auth!(success: false, tenant_id: tenant_id, reason: "invalid_staff_token")
53
+ # A host app can override this view (same path, its own app/views) to
54
+ # match its own styling; this gem ships a plain-text fallback so
55
+ # staff always see a real error page here, never a bare 401.
56
+ render "commerce7/extension/unauthorized", status: :unauthorized
57
+ end
58
+
59
+ def audit_auth!(success:, tenant_id:, actor: nil, reason: nil)
60
+ Commerce7.audit!(
61
+ event_type: "staff_extension_auth",
62
+ success: success,
63
+ actor: actor,
64
+ commerce7_tenant_id: tenant_id,
65
+ origin_ip: request.remote_ip,
66
+ metadata: reason ? { reason: reason } : {}
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Receives Commerce7's Web Hooks — configured once, app-wide, in the
5
+ # Developer Center's app version under "Step 1. APIs & Webhooks", NOT
6
+ # per-tenant. Per Commerce7's docs (developer.commerce7.com/docs/
7
+ # app-apis-webhooks), a webhook registered there applies automatically to
8
+ # every tenant that installs the app, no per-winery setup required — this
9
+ # is a different, app-level mechanism from a store's own independent
10
+ # "Developer > Web Hooks" admin page, which is for a winery's own
11
+ # integrations, unrelated to marketplace apps like this one.
12
+ #
13
+ # Per Commerce7's docs (developer.commerce7.com/docs/webhooks), the body
14
+ # is {object, action, payload, user, tenantId}; object/action cover far
15
+ # more than any one app acts on, so this only parses and dispatches —
16
+ # see Commerce7::Webhooks for how a host app registers what it cares
17
+ # about. Anything with no registered handler is silently a no-op rather
18
+ # than an error.
19
+ class WebhooksController < BaseController
20
+ # `action` is also the name Rails reserves for the controller action
21
+ # itself (routing sets params[:action] = "create" on every request
22
+ # regardless of body content) — reading it via `params` would silently
23
+ # return "create" no matter what Commerce7 actually sent, matching no
24
+ # registered handler and turning every webhook into a silent no-op.
25
+ # Parsing the raw JSON body instead sidesteps that collision entirely.
26
+ def create
27
+ body = JSON.parse(request.body.read)
28
+ return head :bad_request unless body["tenantId"].present? && body["object"].present? && body["action"].present?
29
+
30
+ tenant = Commerce7.configuration.tenant_class.active.find_by(commerce7_tenant_id: body["tenantId"])
31
+ handle(tenant, object: body["object"], action: body["action"], payload: body["payload"] || {}, actor: body["user"]) if tenant
32
+
33
+ head :ok
34
+ rescue JSON::ParserError
35
+ head :bad_request
36
+ end
37
+
38
+ private
39
+
40
+ def handle(tenant, object:, action:, payload:, actor:)
41
+ handled = Commerce7::Webhooks.dispatch(object: object, action: action, tenant: tenant, payload: payload, actor: actor)
42
+ return unless handled
43
+
44
+ Commerce7.audit!(
45
+ event_type: "webhook_#{object.parameterize(separator: '_')}_#{action.downcase}",
46
+ success: true,
47
+ actor: actor,
48
+ commerce7_tenant_id: tenant.commerce7_tenant_id,
49
+ origin_ip: request.remote_ip
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Schedule this as a recurring job (e.g. Solid Queue's config/recurring.yml,
5
+ # `schedule: every day at 3am`) to enforce Commerce7's app security policy:
6
+ # customer data deleted within 30 days of app termination. Hard-deletes
7
+ # any tenant still deactivated past Commerce7::TenantConcern::
8
+ # DATA_RETENTION_DAYS — cascading to whatever `dependent: :destroy`
9
+ # associations the host's tenant model declares.
10
+ class PurgeDeactivatedTenantsJob < ::ActiveJob::Base
11
+ queue_as :default
12
+
13
+ def perform
14
+ Commerce7.configuration.tenant_class.pending_deletion.find_each { |tenant| purge(tenant) }
15
+ end
16
+
17
+ private
18
+
19
+ # Many tenant-scoped hosts (see the app's TenantScoped-style pattern)
20
+ # resolve their default_scope through Current.tenant, which
21
+ # `dependent: :destroy` associations rely on to find what to cascade —
22
+ # skipping this can leave dependent rows behind, or make the tenant
23
+ # DELETE itself fail outright against a real FK constraint. Setting
24
+ # Current.tenant here mirrors what Commerce7::ExtensionController and
25
+ # Commerce7::WebhooksController already do per-request.
26
+ def purge(tenant)
27
+ commerce7_tenant_id = tenant.commerce7_tenant_id
28
+ Current.tenant = tenant
29
+ tenant.destroy!
30
+ Commerce7.audit!(event_type: "tenant_data_purged", success: true, commerce7_tenant_id: commerce7_tenant_id)
31
+ ensure
32
+ Current.tenant = nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Include in the host app's own tenant model (see
5
+ # Commerce7.configuration.tenant_class_name) to get the activate/
6
+ # deactivate lifecycle Commerce7::ActivationsController,
7
+ # DeactivationsController, ExtensionController, and WebhooksController all
8
+ # depend on. Expects the model to have commerce7_tenant_id,
9
+ # activated_at, deactivated_at, and raw_activation_payload columns — see
10
+ # Commerce7::Generators::InstallGenerator for a migration that adds them.
11
+ #
12
+ # raw_activation_payload carries PII (the installing staff member's name/
13
+ # email, per Commerce7's activation docs) — add `encrypts
14
+ # :raw_activation_payload` in the including model.
15
+ module TenantConcern
16
+ extend ActiveSupport::Concern
17
+
18
+ # Commerce7's app security policy requires customer data deleted within
19
+ # 30 days of app termination — see Commerce7::PurgeDeactivatedTenantsJob,
20
+ # which hard-deletes any tenant still deactivated after this window.
21
+ # Kept short of 30 days deliberately: a tenant reactivated via
22
+ # .activate! before this elapses keeps all its data.
23
+ DATA_RETENTION_DAYS = 30
24
+
25
+ included do
26
+ validates :commerce7_tenant_id, presence: true, uniqueness: true
27
+
28
+ scope :active, -> { where(deactivated_at: nil) }
29
+ scope :pending_deletion, -> { where.not(deactivated_at: nil).where(deactivated_at: ..DATA_RETENTION_DAYS.days.ago) }
30
+ end
31
+
32
+ class_methods do
33
+ # Handles both first install and a reinstall of a previously
34
+ # deactivated tenant (find_or_initialize_by, not create!) —
35
+ # activation always clears deactivated_at, matching Commerce7
36
+ # sending an activation POST either way.
37
+ def activate!(commerce7_tenant_id:, payload:)
38
+ tenant = find_or_initialize_by(commerce7_tenant_id: commerce7_tenant_id)
39
+ tenant.update!(activated_at: Time.current, deactivated_at: nil, raw_activation_payload: payload)
40
+ tenant
41
+ end
42
+ end
43
+
44
+ def active?
45
+ deactivated_at.nil?
46
+ end
47
+
48
+ # Soft-deactivates, never deletes — an uninstall may be followed by a
49
+ # reinstall (see .activate!), and this app's synced data shouldn't
50
+ # vanish just because the app was temporarily removed.
51
+ def deactivate!
52
+ update!(deactivated_at: Time.current)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Validates the staff JWT Commerce7 passes into an App Extension iframe
5
+ # (the `account` URL param) via GET /account/user. Confirmed against
6
+ # Commerce7's docs: Authorization header carries the raw token (no
7
+ # "Bearer" prefix), `tenant` header carries the tenantId URL param.
8
+ #
9
+ # Distinct from Commerce7::Client: that one authenticates as the app
10
+ # (Basic Auth with App ID/Secret Key) for server-to-server calls; this
11
+ # authenticates as the staff member currently viewing the iframe, and
12
+ # needs no app credentials of its own.
13
+ class AccountClient
14
+ class Error < StandardError; end
15
+ class AuthenticationError < Error; end
16
+ class ApiError < Error; end
17
+
18
+ BASE_URL = Commerce7::Client::BASE_URL
19
+
20
+ def initialize(base_url: BASE_URL)
21
+ @base_url = base_url
22
+ end
23
+
24
+ def fetch_user(tenant_id:, token:)
25
+ response = connection.get("account/user") do |req|
26
+ req.headers["Authorization"] = token
27
+ req.headers["tenant"] = tenant_id
28
+ end
29
+
30
+ handle_response(response)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :base_url
36
+
37
+ def handle_response(response)
38
+ case response.status
39
+ when 200..299
40
+ response.body
41
+ when 401
42
+ raise AuthenticationError, "Commerce7 rejected the staff token"
43
+ else
44
+ raise ApiError, "Commerce7 API error (#{response.status}): #{response.body}"
45
+ end
46
+ end
47
+
48
+ def connection
49
+ @connection ||= Faraday.new(url: base_url) do |f|
50
+ f.response :json
51
+ f.adapter Faraday.default_adapter
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # HTTP client for Commerce7's REST API. Confirmed against Commerce7's public
5
+ # docs: base URL, Basic Auth (App ID/App Secret Key as user/pass), endpoint
6
+ # paths, page/limit pagination (max 50/page, matches PAGE_SIZE), response
7
+ # envelope keys, and the 100 req/min rate limit.
8
+ #
9
+ # The App ID/App Secret Key pair is a single pair for the app as a whole,
10
+ # not one per tenant (see Commerce7.configuration.app_credentials) — so the
11
+ # `tenant` header is the only thing that scopes a request to a specific
12
+ # winery's data.
13
+ class Client
14
+ class Error < StandardError; end
15
+ class AuthenticationError < Error; end
16
+ class RateLimitedError < Error; end
17
+ class ApiError < Error; end
18
+
19
+ # Trailing slash matters: Faraday/URI joins a relative path onto this by
20
+ # RFC 3986 merge rules, so without it "v1" gets treated as a filename and
21
+ # dropped (e.g. base ".../v1" + "customer" => ".../customer", not ".../v1/customer").
22
+ BASE_URL = "https://api.commerce7.com/v1/"
23
+ PAGE_SIZE = 50
24
+ MAX_RETRIES = 3
25
+
26
+ def initialize(tenant, base_url: BASE_URL, sleeper: ->(seconds) { sleep(seconds) })
27
+ @app_id, @app_secret_key = Commerce7.configuration.app_credentials.call
28
+ if @app_id.blank? || @app_secret_key.blank?
29
+ raise ArgumentError, "Commerce7.configuration.app_credentials returned a blank app_id/app_secret_key"
30
+ end
31
+
32
+ @tenant = tenant
33
+ @base_url = base_url
34
+ @sleeper = sleeper
35
+ end
36
+
37
+ def each_customer(&block)
38
+ return enum_for(:each_customer) unless block_given?
39
+
40
+ each_record("customer", "customers", &block)
41
+ end
42
+
43
+ def each_club_membership(&block)
44
+ return enum_for(:each_club_membership) unless block_given?
45
+
46
+ each_record("club-membership", "clubMemberships", &block)
47
+ end
48
+
49
+ # `params` pass straight through as Commerce7 query filters (e.g.
50
+ # `orderPaidDate: "gte:2026-01-01"`), so a caller can bound a listing
51
+ # instead of paging through a tenant's entire order history.
52
+ def each_order(params = {}, &block)
53
+ return enum_for(:each_order, params) unless block_given?
54
+
55
+ each_record("order", "orders", params, &block)
56
+ end
57
+
58
+ # Products carry their variants inline, and each variant carries its
59
+ # per-location inventory counts (variants[].inventory[], keyed by
60
+ # inventoryLocationId) plus the winery's custom fields (metaData) —
61
+ # enough for a full inventory snapshot without a separate call per SKU.
62
+ def each_product(params = {}, &block)
63
+ return enum_for(:each_product, params) unless block_given?
64
+
65
+ each_record("product", "products", params, &block)
66
+ end
67
+
68
+ def each_inventory_location(params = {}, &block)
69
+ return enum_for(:each_inventory_location, params) unless block_given?
70
+
71
+ each_record("inventory-location", "inventoryLocations", params, &block)
72
+ end
73
+
74
+ # Single-order lookup (as opposed to each_order's bulk listing) — some
75
+ # App Extension placements (e.g. an Order Detail tab) only get an
76
+ # orderId from Commerce7. Returns the order hash, which carries a
77
+ # top-level customerId same as club-membership's.
78
+ def fetch_order(order_id)
79
+ get("order/#{order_id}", {})
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :tenant, :base_url, :sleeper
85
+
86
+ def each_record(path, response_key, params = {})
87
+ page = 1
88
+
89
+ loop do
90
+ records = get(path, params.merge(page: page, limit: PAGE_SIZE))[response_key] || []
91
+ records.each { |record| yield record }
92
+
93
+ break if records.size < PAGE_SIZE
94
+
95
+ page += 1
96
+ end
97
+ end
98
+
99
+ def get(path, params)
100
+ response = with_rate_limit_retry { connection.get(path, params) }
101
+ handle_response(response)
102
+ end
103
+
104
+ def with_rate_limit_retry
105
+ attempt = 0
106
+
107
+ loop do
108
+ response = yield
109
+ return response unless response.status == 429
110
+
111
+ attempt += 1
112
+ raise RateLimitedError, "Commerce7 rate limit exceeded after #{MAX_RETRIES} retries" if attempt > MAX_RETRIES
113
+
114
+ sleeper.call(retry_delay(response, attempt))
115
+ end
116
+ end
117
+
118
+ def retry_delay(response, attempt)
119
+ retry_after = response.headers["retry-after"].to_s.to_i
120
+ retry_after.positive? ? retry_after : 2**attempt
121
+ end
122
+
123
+ def handle_response(response)
124
+ case response.status
125
+ when 200..299
126
+ response.body
127
+ when 401
128
+ raise AuthenticationError, "Commerce7 rejected the tenant's credentials"
129
+ else
130
+ raise ApiError, "Commerce7 API error (#{response.status}): #{response.body}"
131
+ end
132
+ end
133
+
134
+ def connection
135
+ @connection ||= Faraday.new(url: base_url) do |f|
136
+ f.request :authorization, :basic, @app_id, @app_secret_key
137
+ f.headers["tenant"] = tenant.commerce7_tenant_id
138
+ f.response :json
139
+ f.adapter Faraday.default_adapter
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,2 @@
1
+ <h1>Unauthorized</h1>
2
+ <p>Commerce7 could not validate your staff session for this page. Please reload it from the Commerce7 admin panel.</p>
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Everything a host app wires up once, in config/initializers/commerce7.rb
5
+ # (see Commerce7::Generators::InstallGenerator for a starting template).
6
+ # There's no default tenant/webhook/app credential source deliberately —
7
+ # this gem never assumes Rails encrypted credentials over any other
8
+ # secrets store, so a missing config surfaces as a clear error rather than
9
+ # silently falling back to something the host never configured.
10
+ class Configuration
11
+ # The ActiveRecord class that includes Commerce7::TenantConcern.
12
+ attr_accessor :tenant_class_name
13
+
14
+ # -> { [webhook_username, webhook_password] } — HTTP Basic credentials
15
+ # Commerce7 was configured (in its Developer Center) to send on the
16
+ # Install/Uninstall URLs and the app-level Web Hook.
17
+ attr_accessor :webhook_credentials
18
+
19
+ # -> { [app_id, app_secret_key] } — the single app-wide Commerce7 API
20
+ # credential pair (not per-tenant) used by Commerce7::Client.
21
+ attr_accessor :app_credentials
22
+
23
+ # ->(event_type:, success:, **kwargs) { ... } — called for every
24
+ # security-relevant event this gem's controllers/jobs produce (auth
25
+ # success/failure, activation/deactivation, webhook-driven mutation,
26
+ # purge). Point this at your app's own audit-log write path (e.g.
27
+ # `->(**kw) { AuditEvent.record!(**kw) }`) so Commerce7-driven events
28
+ # land in the same trail as the rest of the app's.
29
+ attr_accessor :audit
30
+
31
+ def initialize
32
+ @tenant_class_name = "Tenant"
33
+ @webhook_credentials = -> { raise_unconfigured!(:webhook_credentials) }
34
+ @app_credentials = -> { raise_unconfigured!(:app_credentials) }
35
+ @audit = ->(**) { }
36
+ end
37
+
38
+ def tenant_class
39
+ tenant_class_name.constantize
40
+ end
41
+
42
+ private
43
+
44
+ def raise_unconfigured!(setting)
45
+ raise Commerce7::Error,
46
+ "Commerce7.configuration.#{setting} is not set — configure it in config/initializers/commerce7.rb"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Adds this gem's app/{controllers,models/concerns,jobs,services,views}
5
+ # to the host app's autoload/eager-load and view paths — the standard
6
+ # Rails::Engine subclassing convention. Deliberately not isolated
7
+ # (no `isolate_namespace`) and mounts no routes of its own: a host app
8
+ # keeps declaring its own config/routes.rb entries exactly as it would
9
+ # for any in-app controller, just pointing at these gem-provided classes
10
+ # (e.g. `post "activate", to: "commerce7/activations#create"`). That keeps
11
+ # the URLs Commerce7's Developer Center has registered (Install/Uninstall
12
+ # URLs, the App Extension iframe src) stable and host-controlled.
13
+ class Engine < ::Rails::Engine
14
+ engine_name "commerce7"
15
+ end
16
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "commerce7/version"
5
+ require "commerce7/configuration"
6
+ require "commerce7/webhooks"
7
+ require "commerce7/engine" if defined?(Rails::Engine)
8
+
9
+ module Commerce7
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield configuration
19
+ end
20
+
21
+ # Runs after Commerce7::ActivationsController activates (or
22
+ # reactivates) a tenant — typically used to kick off a one-time backfill
23
+ # sync, since Commerce7's Web Hooks only fire on future changes, not a
24
+ # newly (re)installed tenant's pre-existing data.
25
+ def on_activate(&block)
26
+ activate_hooks << block
27
+ end
28
+
29
+ # Runs after Commerce7::DeactivationsController soft-deactivates a
30
+ # tenant. Optional — Commerce7::PurgeDeactivatedTenantsJob independently
31
+ # enforces the 30-day post-uninstall deletion requirement regardless of
32
+ # whether a host registers this hook.
33
+ def on_deactivate(&block)
34
+ deactivate_hooks << block
35
+ end
36
+
37
+ def run_activate_hooks(tenant, payload)
38
+ activate_hooks.each { |hook| hook.call(tenant, payload) }
39
+ end
40
+
41
+ def run_deactivate_hooks(tenant)
42
+ deactivate_hooks.each { |hook| hook.call(tenant) }
43
+ end
44
+
45
+ # Auditing must never be why the thing it's observing fails — a bad
46
+ # configured audit hook shouldn't 500 a webhook delivery or a staff
47
+ # member's page load. Logged loudly on failure so a real bug in the
48
+ # host's audit hook still surfaces instead of vanishing.
49
+ def audit!(**kwargs)
50
+ configuration.audit.call(**kwargs)
51
+ rescue StandardError => e
52
+ Rails.logger.error("Commerce7.audit! hook raised: #{e.class}: #{e.message}") if defined?(Rails)
53
+ end
54
+
55
+ # Test helper: resets configuration, lifecycle hooks, and webhook
56
+ # registrations. Call from a spec suite's global before/after hook so
57
+ # one example's Commerce7.configure doesn't leak into the next.
58
+ def reset!
59
+ @configuration = nil
60
+ @activate_hooks = []
61
+ @deactivate_hooks = []
62
+ Commerce7::Webhooks.reset!
63
+ end
64
+
65
+ private
66
+
67
+ def activate_hooks
68
+ @activate_hooks ||= []
69
+ end
70
+
71
+ def deactivate_hooks
72
+ @deactivate_hooks ||= []
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commerce7
4
+ # Registry for Commerce7::WebhooksController to dispatch into. Commerce7's
5
+ # Web Hooks feature (registered once, app-wide, in the Developer Center's
6
+ # "APIs & Webhooks" step) delivers a body of {object, action, payload,
7
+ # user, tenantId} covering far more object/action pairs than any one app
8
+ # acts on — object/action this app doesn't register a handler for are a
9
+ # silent no-op, not an error, matching Commerce7's own retry-tolerant
10
+ # expectations.
11
+ #
12
+ # Register handlers from a host app initializer:
13
+ #
14
+ # Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
15
+ # SyncJob.perform_later(tenant)
16
+ # end
17
+ #
18
+ # A handler block receives the tenant (an instance of the configured
19
+ # tenant class), the raw payload hash, and the actor string Commerce7
20
+ # attached to the delivery (its "user" field, if any). Handlers are
21
+ # expected to be idempotent by construction (upsert / find-and-destroy) —
22
+ # Commerce7 doesn't expose a delivery/event id to dedupe against, so a
23
+ # redelivered webhook must land at the same end state, not double-apply.
24
+ module Webhooks
25
+ Handler = Struct.new(:object, :actions, :block)
26
+ private_constant :Handler
27
+
28
+ class << self
29
+ def on(object, *actions, &block)
30
+ handlers << Handler.new(object, actions, block)
31
+ end
32
+
33
+ # Runs every handler registered for this (object, action) pair.
34
+ # Returns true if at least one handler ran, so the caller knows
35
+ # whether this was a recognized event worth auditing.
36
+ def dispatch(object:, action:, tenant:, payload:, actor:)
37
+ matched = handlers.select { |handler| handler.object == object && handler.actions.include?(action) }
38
+ matched.each { |handler| handler.block.call(tenant, payload, actor) }
39
+ matched.any?
40
+ end
41
+
42
+ # Test helper: clears registrations between specs so one example's
43
+ # Commerce7::Webhooks.on doesn't leak into the next.
44
+ def reset!
45
+ @handlers = []
46
+ end
47
+
48
+ private
49
+
50
+ def handlers
51
+ @handlers ||= []
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Commerce7
7
+ module Generators
8
+ # `rails generate commerce7:install` — scaffolds the migration and
9
+ # initializer a fresh host app needs to start using this gem. Doesn't
10
+ # touch an existing `tenants` table if the host already has one; review
11
+ # and adapt the generated migration in that case instead of running it
12
+ # as-is.
13
+ class InstallGenerator < Rails::Generators::Base
14
+ include Rails::Generators::Migration
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ def self.next_migration_number(dirname)
19
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
20
+ end
21
+
22
+ def create_migration_file
23
+ migration_template "create_tenants.rb.erb", "db/migrate/create_tenants.rb"
24
+ end
25
+
26
+ def create_initializer
27
+ template "commerce7.rb", "config/initializers/commerce7.rb"
28
+ end
29
+
30
+ def show_readme
31
+ readme "POST_INSTALL.md" if behavior == :invoke
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+
2
+ ==============================================================================
3
+
4
+ commerce7-rails installed. Next steps:
5
+
6
+ 1. Run the generated migration: bin/rails db:migrate
7
+ 2. Add `include Commerce7::TenantConcern` and
8
+ `encrypts :raw_activation_payload` to your Tenant model.
9
+ 3. Fill in config/initializers/commerce7.rb — credentials sources, your
10
+ audit hook, and any on_activate/webhook handlers.
11
+ 4. Add routes (see the README's "Routes" section) pointing at
12
+ commerce7/activations, commerce7/deactivations, commerce7/webhooks,
13
+ and any Commerce7::ExtensionController subclasses you write.
14
+ 5. Schedule Commerce7::PurgeDeactivatedTenantsJob as a recurring job —
15
+ required for Commerce7's 30-day post-uninstall deletion policy.
16
+
17
+ ==============================================================================
@@ -0,0 +1,34 @@
1
+ Commerce7.configure do |c|
2
+ c.tenant_class_name = "Tenant"
3
+
4
+ c.webhook_credentials = -> {
5
+ [
6
+ Rails.application.credentials.dig(:commerce7, :webhook_username),
7
+ Rails.application.credentials.dig(:commerce7, :webhook_password)
8
+ ]
9
+ }
10
+ c.app_credentials = -> {
11
+ [
12
+ Rails.application.credentials.dig(:commerce7, :app_id),
13
+ Rails.application.credentials.dig(:commerce7, :app_secret_key)
14
+ ]
15
+ }
16
+
17
+ # Point this at your app's own audit-log write path so Commerce7-driven
18
+ # events (server auth, activation/deactivation, staff extension auth,
19
+ # webhook-driven mutations, the post-uninstall purge) land in the same
20
+ # trail as the rest of the app's. See the README's "Audit trail" section.
21
+ c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
22
+ end
23
+
24
+ # Runs once, after a tenant activates (first install or a reinstall) —
25
+ # typically a one-time backfill sync, since Commerce7's Web Hooks only fire
26
+ # on future changes.
27
+ # Commerce7.on_activate do |tenant, payload|
28
+ # end
29
+
30
+ # Register a handler per (object, action) pair your app cares about — see
31
+ # Commerce7::Webhooks for the full contract, including the idempotency
32
+ # expectation.
33
+ # Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
34
+ # end
@@ -0,0 +1,17 @@
1
+ class CreateTenants < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :tenants do |t|
4
+ t.string :commerce7_tenant_id, null: false
5
+ t.datetime :activated_at
6
+ t.datetime :deactivated_at
7
+ # PII (installing staff member's name/email, per Commerce7's
8
+ # activation docs) — add `encrypts :raw_activation_payload` on the
9
+ # Tenant model.
10
+ t.jsonb :raw_activation_payload, default: {}, null: false
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :tenants, :commerce7_tenant_id, unique: true
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: commerce7-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Roberts
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Activation/deactivation lifecycle, webhook dispatch, App Extension staff-JWT
41
+ auth, the Commerce7 REST client, and the post-uninstall data purge Commerce7's security
42
+ review requires — configured once, reused across every Commerce7 app.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - app/controllers/commerce7/activations_controller.rb
50
+ - app/controllers/commerce7/base_controller.rb
51
+ - app/controllers/commerce7/deactivations_controller.rb
52
+ - app/controllers/commerce7/extension_controller.rb
53
+ - app/controllers/commerce7/webhooks_controller.rb
54
+ - app/jobs/commerce7/purge_deactivated_tenants_job.rb
55
+ - app/models/concerns/commerce7/tenant_concern.rb
56
+ - app/services/commerce7/account_client.rb
57
+ - app/services/commerce7/client.rb
58
+ - app/views/commerce7/extension/unauthorized.html.erb
59
+ - lib/commerce7/configuration.rb
60
+ - lib/commerce7/engine.rb
61
+ - lib/commerce7/rails.rb
62
+ - lib/commerce7/version.rb
63
+ - lib/commerce7/webhooks.rb
64
+ - lib/generators/commerce7/install/install_generator.rb
65
+ - lib/generators/commerce7/install/templates/POST_INSTALL.md
66
+ - lib/generators/commerce7/install/templates/commerce7.rb
67
+ - lib/generators/commerce7/install/templates/create_tenants.rb.erb
68
+ homepage: https://github.com/ERCubed/commerce7-rails
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/ERCubed/commerce7-rails
73
+ source_code_uri: https://github.com/ERCubed/commerce7-rails
74
+ rubygems_mfa_required: 'true'
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.2'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.6.9
90
+ specification_version: 4
91
+ summary: Rails building blocks for Commerce7 App Store integrations
92
+ test_files: []