seams 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +212 -12
- data/README.md +235 -82
- data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
- data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
- data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
- data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
- data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
- data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
- data/lib/generators/seams/admin/admin_generator.rb +51 -4
- data/lib/generators/seams/admin/templates/README.md.tt +33 -3
- data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
- data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
- data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
- data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
- data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
- data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
- data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
- data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
- data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
- data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
- data/lib/generators/seams/auth/auth_generator.rb +1 -2
- data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
- data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
- data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
- data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
- data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
- data/lib/generators/seams/billing/billing_generator.rb +4 -3
- data/lib/generators/seams/billing/templates/README.md.tt +26 -0
- data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
- data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
- data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
- data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
- data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
- data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
- data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
- data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
- data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
- data/lib/generators/seams/design/design_generator.rb +647 -0
- data/lib/generators/seams/design/templates/README.md.tt +129 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
- data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
- data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
- data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
- data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
- data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
- data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
- data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
- data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
- data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
- data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
- data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
- data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
- data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
- data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
- data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
- data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
- data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
- data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
- data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
- data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
- data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
- data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
- data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
- data/lib/generators/seams/engine/engine_generator.rb +6 -0
- data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
- data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
- data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
- data/lib/generators/seams/install/install_generator.rb +72 -6
- data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
- data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
- data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
- data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
- data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
- data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
- data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
- data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
- data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
- data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
- data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
- data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
- data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
- data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
- data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
- data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
- data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
- data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
- data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
- data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
- data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
- data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
- data/lib/seams/cli/list.rb +15 -0
- data/lib/seams/cli/quality.rb +46 -5
- data/lib/seams/cli/resolve.rb +9 -5
- data/lib/seams/cli/test_changed.rb +5 -1
- data/lib/seams/cli.rb +20 -0
- data/lib/seams/configuration.rb +24 -1
- data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
- data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
- data/lib/seams/event_registry.rb +19 -0
- data/lib/seams/events/adapter.rb +9 -0
- data/lib/seams/events/adapters/active_support.rb +4 -0
- data/lib/seams/events/publisher.rb +29 -0
- data/lib/seams/events.rb +1 -0
- data/lib/seams/generators/dummy_app_writer.rb +21 -0
- data/lib/seams/generators/follow_up_generator.rb +1 -2
- data/lib/seams/generators/host_injector.rb +9 -2
- data/lib/seams/generators/splicer.rb +2 -2
- data/lib/seams/observability/adapter.rb +22 -0
- data/lib/seams/observability.rb +6 -0
- data/lib/seams/permission_registry.rb +66 -0
- data/lib/seams/permissions.rb +120 -0
- data/lib/seams/version.rb +1 -1
- data/lib/seams.rb +7 -0
- metadata +107 -3
|
@@ -36,6 +36,13 @@ module Admin
|
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
+
# Read actions (index/show) resolve through the global plan
|
|
40
|
+
# catalogue ability; writes short-circuit on the staff? overrides
|
|
41
|
+
# above (tenant admins must not mutate the shared catalogue).
|
|
42
|
+
def permission_ability
|
|
43
|
+
"plan.manage.billing"
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
# Plans are global; per-record tenant guard always passes for
|
|
40
47
|
# the read actions, and the write actions short-circuit on the
|
|
41
48
|
# staff? predicates above.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<%%#
|
|
2
|
+
Copy of Administrate 1.0's _index_header with one change: the "New"
|
|
3
|
+
link. Administrate builds it from the dashboard class name, which
|
|
4
|
+
gives new_admin_admin_<resource>_path for Admin::*Dashboard. The
|
|
5
|
+
link is built from this controller instead.
|
|
6
|
+
%>
|
|
7
|
+
<%% content_for(:title) do %>
|
|
8
|
+
<%%= display_resource_name(page.resource_name) %>
|
|
9
|
+
<%% end %>
|
|
10
|
+
|
|
11
|
+
<header class="main-content__header">
|
|
12
|
+
<h1 class="main-content__page-title" id="page-title">
|
|
13
|
+
<%%= content_for(:title) %>
|
|
14
|
+
</h1>
|
|
15
|
+
|
|
16
|
+
<%%= content_for(:header_middle) %>
|
|
17
|
+
|
|
18
|
+
<%% if show_search_bar %>
|
|
19
|
+
<%%= render(
|
|
20
|
+
"search",
|
|
21
|
+
search_term: search_term,
|
|
22
|
+
resource_name: display_resource_name(page.resource_name)
|
|
23
|
+
) %>
|
|
24
|
+
|
|
25
|
+
<%% if filters.any? %>
|
|
26
|
+
<div data-controller="tooltip">
|
|
27
|
+
<button data-tooltip-target="tooltip" popovertarget="search-tooltip" class="button--tooltip search__tooltip">
|
|
28
|
+
<svg role="img">
|
|
29
|
+
<use xlink:href="#icon-question-mark" />
|
|
30
|
+
</svg>
|
|
31
|
+
</button>
|
|
32
|
+
|
|
33
|
+
<div data-tooltip-target="popover" popover id="search-tooltip" role="tooltip" class="search__tooltip-popover search__tooltip-popover-positioning">
|
|
34
|
+
<p><strong>Use filters to refine your search</strong></p>
|
|
35
|
+
<ul>
|
|
36
|
+
<%% filters.keys.each do |filter_key| %>
|
|
37
|
+
<li><%%= filter_key %>:<span class="search__tooltip-popover-value"><value></span></li>
|
|
38
|
+
<%% end %>
|
|
39
|
+
</ul>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<%% end %>
|
|
43
|
+
<%% end %>
|
|
44
|
+
|
|
45
|
+
<div>
|
|
46
|
+
<%%= link_to(
|
|
47
|
+
t(
|
|
48
|
+
"administrate.actions.new_resource",
|
|
49
|
+
name: display_resource_name(page.resource_name, singular: true).downcase
|
|
50
|
+
),
|
|
51
|
+
url_for(controller: "/admin/#{controller_name}", action: :new),
|
|
52
|
+
class: "button",
|
|
53
|
+
) if accessible_action?(new_resource, :new) %>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<%%= content_for(:header_last) %>
|
|
57
|
+
</header>
|
data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%%#
|
|
2
|
+
Replaces Administrate's navigation partial. Administrate lists the
|
|
3
|
+
resources from Rails.application.routes, which does not include this
|
|
4
|
+
mounted engine, so its sidebar came out empty. Entries come from
|
|
5
|
+
Seams::Admin::ApplicationController#seams_admin_navigation: every
|
|
6
|
+
dashboard with an index route the current user may list.
|
|
7
|
+
%>
|
|
8
|
+
<nav class="navigation">
|
|
9
|
+
<%%= link_to(t("administrate.navigation.back_to_app"), main_app.root_path, class: "button button--alt button--nav") if main_app.respond_to?(:root_path) %>
|
|
10
|
+
|
|
11
|
+
<%% seams_admin_navigation.each do |entry| %>
|
|
12
|
+
<%%= link_to(entry[:label], entry[:path], class: "navigation__link navigation__link--#{entry[:active] ? "active" : "inactive"}") %>
|
|
13
|
+
<%% end %>
|
|
14
|
+
</nav>
|
|
@@ -14,19 +14,29 @@ Seams::Admin::Engine.routes.draw do
|
|
|
14
14
|
# Phase 2 dashboards. Per Administrate convention, each resource
|
|
15
15
|
# gets a full CRUD route surface (index, show, new, create, edit,
|
|
16
16
|
# update, destroy). The `controller:` option points at the
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
# top-level `Admin::*` controllers under app/controllers/admin/. The
|
|
18
|
+
# leading slash is required: isolate_namespace Seams::Admin would
|
|
19
|
+
# otherwise resolve "admin/identities" to
|
|
20
|
+
# Seams::Admin::Admin::IdentitiesController.
|
|
21
|
+
#
|
|
22
|
+
# `scope as: :admin` names the routes admin_identities, admin_plans,
|
|
23
|
+
# etc. Administrate's views build links and forms with those names
|
|
24
|
+
# (polymorphic `[:admin, resource]`); paths and controllers are
|
|
25
|
+
# unchanged.
|
|
26
|
+
scope as: :admin do
|
|
27
|
+
resources :identities, controller: "/admin/identities"
|
|
28
|
+
resources :accounts, controller: "/admin/accounts"
|
|
29
|
+
resources :accounts_memberships, controller: "/admin/accounts_memberships"
|
|
30
|
+
resources :teams, controller: "/admin/teams"
|
|
31
|
+
resources :teams_memberships, controller: "/admin/teams_memberships"
|
|
32
|
+
resources :invitations, controller: "/admin/invitations"
|
|
33
|
+
resources :notifications, controller: "/admin/notifications"
|
|
34
|
+
resources :notification_preferences, controller: "/admin/notification_preferences"
|
|
35
|
+
resources :plans, controller: "/admin/plans"
|
|
36
|
+
resources :subscriptions, controller: "/admin/subscriptions"
|
|
37
|
+
resources :invoices, controller: "/admin/invoices"
|
|
38
|
+
resources :lifetime_passes, controller: "/admin/lifetime_passes"
|
|
39
|
+
end
|
|
30
40
|
|
|
31
41
|
# Follow-up generators that add NEW route surfaces (custom collection routes, JSON-only endpoints) splice their resources here.
|
|
32
42
|
# seams:insertion-point admin.routes.after_resources
|
|
@@ -34,5 +44,5 @@ Seams::Admin::Engine.routes.draw do
|
|
|
34
44
|
# Default landing page so visiting `/admin` lands somewhere
|
|
35
45
|
# meaningful. Identities is the natural index — the operator
|
|
36
46
|
# console use case starts with "find this user".
|
|
37
|
-
root to: "admin/identities#index"
|
|
47
|
+
root to: "/admin/identities#index"
|
|
38
48
|
end
|
|
@@ -26,6 +26,9 @@ module Seams
|
|
|
26
26
|
|
|
27
27
|
def authenticate_admin!
|
|
28
28
|
return if admin_authenticator_call
|
|
29
|
+
# Signed-out visitors go to the auth engine's sign-in page; a
|
|
30
|
+
# signed-in identity that fails the gate gets the 403 below.
|
|
31
|
+
return authenticate_identity! if respond_to?(:signed_in?, true) && !signed_in?
|
|
29
32
|
|
|
30
33
|
respond_with_admin_forbidden(<<~MSG.strip)
|
|
31
34
|
Access denied. The seams admin surface is gated on
|
|
@@ -56,7 +56,9 @@ module Seams
|
|
|
56
56
|
# Default authenticator: the current Identity must carry the
|
|
57
57
|
# `staff` flag. Hosts that need a finer-grained gate (per-
|
|
58
58
|
# account admin role, IP allow-list, etc.) override this.
|
|
59
|
-
|
|
59
|
+
# `try` keeps the default safe on a controller without
|
|
60
|
+
# Auth::Authentication: no identity means the gate says no.
|
|
61
|
+
@authenticator = ->(controller) { controller.try(:current_identity)&.staff? }
|
|
60
62
|
|
|
61
63
|
# :platform → admins see every Account's data. :tenant → admins
|
|
62
64
|
# are scoped to the Account they're a member of. Phase 3 reads
|
|
@@ -431,12 +431,14 @@ RSpec.describe "Seams::Admin engine boot", type: :integration do
|
|
|
431
431
|
end
|
|
432
432
|
end
|
|
433
433
|
|
|
434
|
-
|
|
434
|
+
# The base ApplicationPolicy names no ability and fails closed, so
|
|
435
|
+
# the role hierarchy is exercised through a concrete policy.
|
|
436
|
+
describe "Admin::Tenant::ApplicationPolicy permitted? (via AccountPolicy)" do
|
|
435
437
|
let(:identity) { Auth::Identity.create!(email: "tap-#{SecureRandom.hex(4)}@example.com", password: "verysecret", staff: false) }
|
|
436
438
|
let(:account) { Accounts::Account.create!(name: "Tenant", external_account_id: SecureRandom.random_number(2**31)) }
|
|
437
439
|
let(:membership) { Accounts::Membership.new(account: account, identity_id: identity.id, name: "Member", role: role) }
|
|
438
440
|
let(:ctx) { Seams::Admin::Context.new(identity, membership) }
|
|
439
|
-
let(:policy) { Admin::Tenant::
|
|
441
|
+
let(:policy) { Admin::Tenant::AccountPolicy.new(ctx, account) }
|
|
440
442
|
|
|
441
443
|
context "with role=admin" do
|
|
442
444
|
let(:role) { "admin" }
|
|
@@ -94,7 +94,7 @@ module Seams
|
|
|
94
94
|
engine_path("spec/lib/auth/oauth/#{snake_name}_spec.rb")
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# rubocop:disable Metrics/AbcSize
|
|
97
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
98
98
|
def report_summary
|
|
99
99
|
say ""
|
|
100
100
|
say " Auth OAuth provider `#{snake_name}` added.", :green
|
|
@@ -122,7 +122,6 @@ module Seams
|
|
|
122
122
|
say " bin/seams resolve --eject auth/lib/auth/oauth/#{snake_name}.rb"
|
|
123
123
|
say ""
|
|
124
124
|
end
|
|
125
|
-
# rubocop:enable Metrics/AbcSize
|
|
126
125
|
|
|
127
126
|
private
|
|
128
127
|
|
|
@@ -25,7 +25,7 @@ module Seams
|
|
|
25
25
|
# identity.signed_out.auth, session.expired.auth.
|
|
26
26
|
#
|
|
27
27
|
# Run with: bin/rails generate seams:auth
|
|
28
|
-
# rubocop:disable Metrics/ClassLength
|
|
28
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
29
29
|
class AuthGenerator < Rails::Generators::Base
|
|
30
30
|
include Seams::Generators::HostInjector
|
|
31
31
|
include Seams::Generators::EjectAware
|
|
@@ -306,6 +306,5 @@ module Seams
|
|
|
306
306
|
SCHEMA
|
|
307
307
|
end
|
|
308
308
|
end
|
|
309
|
-
# rubocop:enable Metrics/ClassLength
|
|
310
309
|
end
|
|
311
310
|
end
|
|
@@ -11,6 +11,9 @@ module Auth
|
|
|
11
11
|
# can trick a victim into linking the attacker's OAuth identity to
|
|
12
12
|
# the victim's account.
|
|
13
13
|
class CallbacksController < ApplicationController
|
|
14
|
+
# The OAuth dance happens while signed out, so it must bypass the
|
|
15
|
+
# engine base controller's `authenticate_identity!`.
|
|
16
|
+
skip_before_action :authenticate_identity!, only: %i[start callback]
|
|
14
17
|
skip_before_action :verify_authenticity_token, only: %i[callback]
|
|
15
18
|
|
|
16
19
|
rescue_from Auth::OAuthProviderUnknown do |e|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Auth
|
|
4
4
|
class PasswordResetsController < ApplicationController
|
|
5
|
+
# Password reset is for signed-out users by definition; opt every
|
|
6
|
+
# action out of the engine base controller's `authenticate_identity!`.
|
|
7
|
+
skip_before_action :authenticate_identity!, only: %i[new create edit update]
|
|
8
|
+
|
|
5
9
|
# 5 reset requests per hour per IP. Cheap to send mail but
|
|
6
10
|
# creating reset tokens for every email is a guess-the-user attack
|
|
7
11
|
# vector — rate-limit modestly.
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Auth
|
|
4
4
|
class RegistrationsController < ApplicationController
|
|
5
|
+
# Sign-up must be reachable while signed out (the engine base
|
|
6
|
+
# controller gates every action by default, which would redirect the
|
|
7
|
+
# sign-up form to the sign-in page).
|
|
8
|
+
skip_before_action :authenticate_identity!, only: %i[new create]
|
|
9
|
+
|
|
5
10
|
# 5 sign-ups per hour per IP. Tighter than sign-in because each
|
|
6
11
|
# successful row also sends a welcome email + provisions side
|
|
7
12
|
# effects. Rails 8's built-in rate_limit uses Solid Cache.
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Auth
|
|
4
4
|
class SessionsController < ApplicationController
|
|
5
|
+
# The sign-in form must be reachable while signed out, or the engine
|
|
6
|
+
# base controller's `before_action :authenticate_identity!` redirects
|
|
7
|
+
# the login page to itself. `destroy` stays gated.
|
|
8
|
+
skip_before_action :authenticate_identity!, only: %i[new create]
|
|
9
|
+
|
|
5
10
|
# 10 attempts per minute per IP. Rails 8's built-in rate_limit
|
|
6
11
|
# uses Solid Cache by default; configure cache_store in your host
|
|
7
12
|
# if you need to override.
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
# Risk: append-mostly. profile_data is a json column for the raw
|
|
11
11
|
# provider response (sub/login/etc) so we can re-derive things
|
|
12
12
|
# like display name without re-fetching.
|
|
13
|
-
|
|
13
|
+
# Class name uses the camel-OA "OAuth" spelling: the auth engine registers
|
|
14
|
+
# `OAuth` as an ActiveSupport inflector acronym, so the migrator camelizes
|
|
15
|
+
# this file's name to CreateAuthOAuthProviders. A single-O class name would
|
|
16
|
+
# raise `uninitialized constant CreateAuthOAuthProviders` at migrate time.
|
|
17
|
+
class CreateAuthOAuthProviders < ActiveRecord::Migration[7.1]
|
|
14
18
|
def change
|
|
15
19
|
create_table :auth_oauth_providers do |t|
|
|
16
20
|
t.references :identity, null: false, foreign_key: { to_table: :auth_identities }
|
|
@@ -9,9 +9,20 @@ module Auth
|
|
|
9
9
|
#
|
|
10
10
|
# class ApplicationController < ActionController::Base
|
|
11
11
|
# include Auth::Authentication
|
|
12
|
-
# before_action :authenticate_identity!
|
|
13
12
|
# end
|
|
14
13
|
#
|
|
14
|
+
# Including this concern is what makes `authenticate_identity!` available
|
|
15
|
+
# to every engine controller that inherits from the host's
|
|
16
|
+
# `::ApplicationController`. Engine generators produced by `seams:engine`
|
|
17
|
+
# enforce authentication on every action by default via:
|
|
18
|
+
#
|
|
19
|
+
# before_action :authenticate_identity!
|
|
20
|
+
#
|
|
21
|
+
# Controllers serving public flows (e.g. password reset, invitation
|
|
22
|
+
# accept-via-token) must opt out explicitly:
|
|
23
|
+
#
|
|
24
|
+
# skip_before_action :authenticate_identity!, only: %i[accept_form accept]
|
|
25
|
+
#
|
|
15
26
|
# Side-effect: when a sign-in is resolved, `Current.identity` is set
|
|
16
27
|
# so models / services downstream can access the signed-in identity
|
|
17
28
|
# without threading it through every method. Future waves will add
|
|
@@ -20,6 +20,20 @@ module Auth
|
|
|
20
20
|
Rails.autoloaders.main.inflector.inflect("oauth" => "OAuth")
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The Zeitwerk inflection above only governs *autoloading* (file path
|
|
24
|
+
# → constant). Routing resolves a controller from its path through
|
|
25
|
+
# ActiveSupport::Inflector#camelize, which is a *separate* inflector:
|
|
26
|
+
# the route `auth/oauth/callbacks#start` camelizes to
|
|
27
|
+
# `Auth::Oauth::CallbacksController` (single-O) and raises
|
|
28
|
+
# `uninitialized constant Auth::Oauth`, because the real class is
|
|
29
|
+
# `Auth::OAuth::CallbacksController`. Registering "OAuth" as an acronym
|
|
30
|
+
# makes camelize agree with Zeitwerk, so the OAuth routes resolve.
|
|
31
|
+
initializer "auth.inflection_acronym", before: :set_autoload_paths do
|
|
32
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
33
|
+
inflect.acronym "OAuth"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
23
37
|
initializer "auth.register_events" do
|
|
24
38
|
Seams::EventRegistry.register("identity.signed_up.auth", emitted_by: "Auth")
|
|
25
39
|
Seams::EventRegistry.register("identity.signed_in.auth", emitted_by: "Auth")
|
|
@@ -32,6 +46,19 @@ module Auth
|
|
|
32
46
|
# seams:insertion-point auth.engine.events
|
|
33
47
|
end
|
|
34
48
|
|
|
49
|
+
initializer "auth.register_abilities" do
|
|
50
|
+
# Ability codes this engine owns, registered exactly like events
|
|
51
|
+
# (resource.action.engine). `read` is the member-level view;
|
|
52
|
+
# `manage` is the admin-level mutate. Host grant maps reference
|
|
53
|
+
# these codes; see Seams::Permissions::DEFAULT_GRANTS. The admin
|
|
54
|
+
# engine's tenant Identity policy resolves through
|
|
55
|
+
# `identity.manage.auth`.
|
|
56
|
+
Seams::PermissionRegistry.register("identity.read.auth", owned_by: "Auth")
|
|
57
|
+
Seams::PermissionRegistry.register("identity.manage.auth", owned_by: "Auth")
|
|
58
|
+
# Follow-up generators that ship new auth abilities register them here.
|
|
59
|
+
# seams:insertion-point auth.engine.abilities
|
|
60
|
+
end
|
|
61
|
+
|
|
35
62
|
initializer "auth.append_migrations" do |app|
|
|
36
63
|
unless app.root == root
|
|
37
64
|
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
@@ -17,7 +17,7 @@ module Seams
|
|
|
17
17
|
# the host's user model can include.
|
|
18
18
|
#
|
|
19
19
|
# Run with: bin/rails generate seams:billing
|
|
20
|
-
# rubocop:disable Metrics/ClassLength
|
|
20
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
21
21
|
class BillingGenerator < Rails::Generators::Base
|
|
22
22
|
include Seams::Generators::HostInjector
|
|
23
23
|
include Seams::Generators::EjectAware
|
|
@@ -77,6 +77,8 @@ module Seams
|
|
|
77
77
|
engine_path("lib/billing/stripe/client.rb")
|
|
78
78
|
template_unless_ejected "lib/stripe/webhook_signature.rb.tt",
|
|
79
79
|
engine_path("lib/billing/stripe/webhook_signature.rb")
|
|
80
|
+
template_unless_ejected "lib/stripe/payload.rb.tt",
|
|
81
|
+
engine_path("lib/billing/stripe/payload.rb")
|
|
80
82
|
|
|
81
83
|
return if gateway == "stripe"
|
|
82
84
|
|
|
@@ -316,7 +318,7 @@ module Seams
|
|
|
316
318
|
# maintains the gem as a first-class deliverable; tracking
|
|
317
319
|
# their API ourselves wasn't worth the centralisation
|
|
318
320
|
# benefit. See feedback_external_apis.md.
|
|
319
|
-
host_inject_gem("stripe", "~>
|
|
321
|
+
host_inject_gem("stripe", "~> 19.0")
|
|
320
322
|
host_inject_gem("factory_bot_rails", "~> 6.4", group: :test)
|
|
321
323
|
host_inject_gem("webmock", "~> 3.23", group: :test)
|
|
322
324
|
host_inject_mount(engine_class: "Billing::Engine", at: "/billing")
|
|
@@ -471,6 +473,5 @@ module Seams
|
|
|
471
473
|
SCHEMA
|
|
472
474
|
end
|
|
473
475
|
end
|
|
474
|
-
# rubocop:enable Metrics/ClassLength
|
|
475
476
|
end
|
|
476
477
|
end
|
|
@@ -215,6 +215,32 @@ STRIPE_SECRET_KEY=sk_live_...
|
|
|
215
215
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
+
### Stripe gem and API version
|
|
219
|
+
|
|
220
|
+
The generator pins `gem "stripe", "~> 19.0"`. stripe-ruby 19 sends API
|
|
221
|
+
version `2026-08-26.dahlia` on every request (`Stripe.api_version`).
|
|
222
|
+
Webhook payloads use the version set on the webhook endpoint, not the
|
|
223
|
+
gem's. Create the endpoint on the same version so API responses and
|
|
224
|
+
webhook payloads have the same shape.
|
|
225
|
+
|
|
226
|
+
The handlers read the current (2025-03-31.basil and later) shapes and
|
|
227
|
+
still accept the older ones:
|
|
228
|
+
|
|
229
|
+
| Field | Current location | Pre-basil fallback |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| Invoice's subscription | `invoice.parent.subscription_details.subscription` | `invoice.subscription` |
|
|
232
|
+
| Subscription period end | `subscription.items.data[0].current_period_end` | `subscription.current_period_end` |
|
|
233
|
+
| Invoice paid time | `invoice.status_transitions.paid_at` | none |
|
|
234
|
+
|
|
235
|
+
`Billing::Stripe::Payload` holds these readers. Use it from your own
|
|
236
|
+
subscribers too: SDK objects (`::Stripe::StripeObject`) have no `#dig`.
|
|
237
|
+
|
|
238
|
+
The endpoint accepts v1 snapshot events only. A v2 thin event is
|
|
239
|
+
rejected with `400` (stripe-ruby 19 raises `ArgumentError` for it).
|
|
240
|
+
|
|
241
|
+
Every `::Stripe::StripeError` from `Billing::Stripe::Client` is
|
|
242
|
+
re-raised as `Billing::GatewayError`, with the original on `#cause`.
|
|
243
|
+
|
|
218
244
|
In your Stripe dashboard, configure the webhook endpoint to send any
|
|
219
245
|
of the events listed below. Each maps to a handler class in
|
|
220
246
|
`app/services/billing/webhooks/handlers/`; missing handlers no-op
|
|
@@ -77,6 +77,8 @@ module Billing
|
|
|
77
77
|
# — the Stripe `cus_*` id resolved from the current Account — which
|
|
78
78
|
# is correct whenever the controller has an Account in scope.
|
|
79
79
|
def scoped_subscriptions
|
|
80
|
+
return Billing::Subscription.none if current_billing_customer_ref.nil?
|
|
81
|
+
|
|
80
82
|
Billing::Subscription.where(customer_ref: current_billing_customer_ref)
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -19,11 +19,14 @@ module Billing
|
|
|
19
19
|
client.retrieve_invoice(@invoice_ref)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# stripe_response is a ::Stripe::Invoice (no #dig). The
|
|
23
|
+
# subscription id moved to parent.subscription_details.subscription
|
|
24
|
+
# in API 2025-03-31.basil; Billing::Stripe::Payload reads both.
|
|
22
25
|
def on_success(stripe_response)
|
|
23
26
|
invoice = Billing::Invoice.find_or_initialize_by(gateway_ref: stripe_response[:id])
|
|
24
27
|
invoice.assign_attributes(
|
|
25
|
-
customer_ref: stripe_response[:customer],
|
|
26
|
-
subscription_ref: stripe_response
|
|
28
|
+
customer_ref: Billing::Stripe::Payload.ref(stripe_response[:customer]),
|
|
29
|
+
subscription_ref: Billing::Stripe::Payload.invoice_subscription_ref(stripe_response),
|
|
27
30
|
status: stripe_response[:status],
|
|
28
31
|
amount_cents: stripe_response[:amount_paid] || stripe_response[:amount_due],
|
|
29
32
|
currency: stripe_response[:currency].to_s.upcase,
|
|
@@ -36,13 +39,11 @@ module Billing
|
|
|
36
39
|
|
|
37
40
|
private
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
# object into status_transitions.paid_at — see
|
|
42
|
+
# The paid timestamp is status_transitions.paid_at — see
|
|
41
43
|
# https://docs.stripe.com/api/invoices/object#invoice_object-status_transitions.
|
|
42
|
-
#
|
|
44
|
+
# A top-level :paid_at is still tolerated for hand-built payloads.
|
|
43
45
|
def paid_at_for(stripe_response)
|
|
44
|
-
unix =
|
|
45
|
-
stripe_response[:paid_at]
|
|
46
|
+
unix = Billing::Stripe::Payload.invoice_paid_at(stripe_response)
|
|
46
47
|
unix && Time.at(unix)
|
|
47
48
|
end
|
|
48
49
|
end
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module Billing
|
|
4
4
|
# Base class for service objects that talk to Stripe via
|
|
5
5
|
# Billing::Stripe::Client. Subclasses define #call_stripe and the
|
|
6
|
-
# base wraps it in uniform error handling that converts
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# base wraps it in uniform error handling that converts the
|
|
7
|
+
# Billing::GatewayError raised by Billing::Stripe::Client (which
|
|
8
|
+
# translates every ::Stripe::StripeError from the `stripe` gem)
|
|
9
|
+
# into ServiceResult failures with stable codes.
|
|
9
10
|
#
|
|
10
11
|
# class MyService < Billing::StripeService
|
|
11
12
|
# def initialize(plan_ref:)
|
|
@@ -43,10 +44,11 @@ module Billing
|
|
|
43
44
|
def call
|
|
44
45
|
on_success(call_stripe(client))
|
|
45
46
|
rescue Billing::GatewayError => e
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# branch on
|
|
47
|
+
# Billing::Stripe::Client re-raises every ::Stripe::StripeError
|
|
48
|
+
# (4xx/5xx, network failures, auth errors) as
|
|
49
|
+
# Billing::GatewayError with a stable message prefix.
|
|
50
|
+
# Sub-classify by that prefix so callers can branch on
|
|
51
|
+
# `result.code`.
|
|
50
52
|
ServiceResult.failure(error: e.message, code: classify_gateway_error(e))
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -12,6 +12,13 @@ module Billing
|
|
|
12
12
|
# subscription_ref → Billing::Subscription#account_id, falling
|
|
13
13
|
# back to a sibling Billing::Invoice on the same customer_ref
|
|
14
14
|
# for one-off invoices that have no subscription.
|
|
15
|
+
#
|
|
16
|
+
# Payload shape (stripe-ruby 19 / API 2026-08-26.dahlia): the
|
|
17
|
+
# subscription id is at invoice.parent.subscription_details.subscription.
|
|
18
|
+
# The top-level invoice.subscription was removed in
|
|
19
|
+
# 2025-03-31.basil; Billing::Stripe::Payload still reads it so
|
|
20
|
+
# events from an endpoint pinned to an older API version work.
|
|
21
|
+
# https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects
|
|
15
22
|
class InvoiceHandlerBase < Billing::Webhooks::Handler
|
|
16
23
|
DEFAULT_STATUS = "open"
|
|
17
24
|
|
|
@@ -36,7 +43,7 @@ module Billing
|
|
|
36
43
|
|
|
37
44
|
invoice.account_id = resolved_account_id
|
|
38
45
|
invoice.customer_ref = customer_ref
|
|
39
|
-
invoice.subscription_ref =
|
|
46
|
+
invoice.subscription_ref = subscription_ref
|
|
40
47
|
invoice.amount_cents = object_hash[:amount_paid] || object_hash["amount_paid"] ||
|
|
41
48
|
object_hash[:amount_due] || object_hash["amount_due"] || 0
|
|
42
49
|
invoice.currency = (object_hash[:currency] || object_hash["currency"] || "usd").to_s.upcase
|
|
@@ -53,7 +60,6 @@ module Billing
|
|
|
53
60
|
# customer_ref (covers the case of a one-off invoice that
|
|
54
61
|
# arrives before any sibling Invoice).
|
|
55
62
|
def resolve_account_id_for_invoice
|
|
56
|
-
subscription_ref = object_hash[:subscription] || object_hash["subscription"]
|
|
57
63
|
if subscription_ref
|
|
58
64
|
id = Billing::Subscription.where(gateway_ref: subscription_ref).pick(:account_id)
|
|
59
65
|
return id if id
|
|
@@ -67,12 +73,21 @@ module Billing
|
|
|
67
73
|
.pick(:account_id)
|
|
68
74
|
end
|
|
69
75
|
|
|
76
|
+
def subscription_ref
|
|
77
|
+
@subscription_ref ||= Billing::Stripe::Payload.invoice_subscription_ref(object_hash)
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
def invoice_status
|
|
71
81
|
self.class::INVOICE_STATUS || DEFAULT_STATUS
|
|
72
82
|
end
|
|
73
83
|
|
|
84
|
+
# Stripe's own paid timestamp (status_transitions.paid_at) when
|
|
85
|
+
# the payload carries it; otherwise the time we saw the event.
|
|
74
86
|
def paid_at_for_status
|
|
75
|
-
invoice_status == "paid"
|
|
87
|
+
return nil unless invoice_status == "paid"
|
|
88
|
+
|
|
89
|
+
unix = Billing::Stripe::Payload.invoice_paid_at(object_hash)
|
|
90
|
+
unix ? Time.at(unix) : Time.current
|
|
76
91
|
end
|
|
77
92
|
end
|
|
78
93
|
end
|