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.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -12
  3. data/README.md +235 -82
  4. data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
  5. data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
  6. data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
  7. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
  8. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
  9. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
  10. data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
  11. data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
  12. data/lib/generators/seams/admin/admin_generator.rb +51 -4
  13. data/lib/generators/seams/admin/templates/README.md.tt +33 -3
  14. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
  15. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
  16. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
  17. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
  18. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
  19. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
  20. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
  21. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
  22. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
  23. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
  24. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
  25. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
  26. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
  27. data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
  28. data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
  29. data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
  30. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
  31. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
  32. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
  33. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
  34. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
  35. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
  36. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
  37. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
  38. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
  39. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
  40. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
  41. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
  42. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
  43. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
  44. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
  45. data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
  46. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
  47. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
  48. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
  49. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
  50. data/lib/generators/seams/auth/auth_generator.rb +1 -2
  51. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
  52. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
  53. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
  54. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
  55. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
  56. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
  57. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
  58. data/lib/generators/seams/billing/billing_generator.rb +4 -3
  59. data/lib/generators/seams/billing/templates/README.md.tt +26 -0
  60. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
  61. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
  62. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
  63. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
  64. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
  65. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
  66. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
  67. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
  68. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
  69. data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
  70. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
  71. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
  72. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
  73. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
  74. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
  75. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
  76. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
  77. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
  78. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
  79. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
  80. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
  81. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
  82. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
  83. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
  84. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
  85. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
  86. data/lib/generators/seams/design/design_generator.rb +647 -0
  87. data/lib/generators/seams/design/templates/README.md.tt +129 -0
  88. data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
  89. data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
  90. data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
  91. data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
  92. data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
  93. data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
  94. data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
  95. data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
  96. data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
  97. data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
  98. data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
  99. data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
  100. data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
  101. data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
  102. data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
  103. data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
  104. data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
  105. data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
  106. data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
  107. data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
  108. data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
  109. data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
  110. data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
  111. data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
  112. data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
  113. data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
  114. data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
  115. data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
  116. data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
  117. data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
  118. data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
  119. data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
  120. data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
  121. data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
  122. data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
  123. data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
  124. data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
  125. data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
  126. data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
  127. data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
  128. data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
  129. data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
  130. data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
  131. data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
  132. data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
  133. data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
  134. data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
  135. data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
  136. data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
  137. data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
  138. data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
  139. data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
  140. data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
  141. data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
  142. data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
  143. data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
  144. data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
  145. data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
  146. data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
  147. data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
  148. data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
  149. data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
  150. data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
  151. data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
  152. data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
  153. data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
  154. data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
  155. data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
  156. data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
  157. data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
  158. data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
  159. data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
  160. data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
  161. data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
  162. data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
  163. data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
  164. data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
  165. data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
  166. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
  167. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
  168. data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
  169. data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
  170. data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
  171. data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
  172. data/lib/generators/seams/engine/engine_generator.rb +6 -0
  173. data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
  174. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  175. data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
  176. data/lib/generators/seams/install/install_generator.rb +72 -6
  177. data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
  178. data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
  179. data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
  180. data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
  181. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
  182. data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
  183. data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
  184. data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
  185. data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
  186. data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
  187. data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
  188. data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
  189. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
  190. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
  191. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
  192. data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
  193. data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
  194. data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
  195. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
  196. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
  197. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
  198. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
  199. data/lib/seams/cli/list.rb +15 -0
  200. data/lib/seams/cli/quality.rb +46 -5
  201. data/lib/seams/cli/resolve.rb +9 -5
  202. data/lib/seams/cli/test_changed.rb +5 -1
  203. data/lib/seams/cli.rb +20 -0
  204. data/lib/seams/configuration.rb +24 -1
  205. data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
  206. data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
  207. data/lib/seams/event_registry.rb +19 -0
  208. data/lib/seams/events/adapter.rb +9 -0
  209. data/lib/seams/events/adapters/active_support.rb +4 -0
  210. data/lib/seams/events/publisher.rb +29 -0
  211. data/lib/seams/events.rb +1 -0
  212. data/lib/seams/generators/dummy_app_writer.rb +21 -0
  213. data/lib/seams/generators/follow_up_generator.rb +1 -2
  214. data/lib/seams/generators/host_injector.rb +9 -2
  215. data/lib/seams/generators/splicer.rb +2 -2
  216. data/lib/seams/observability/adapter.rb +22 -0
  217. data/lib/seams/observability.rb +6 -0
  218. data/lib/seams/permission_registry.rb +66 -0
  219. data/lib/seams/permissions.rb +120 -0
  220. data/lib/seams/version.rb +1 -1
  221. data/lib/seams.rb +7 -0
  222. metadata +107 -3
@@ -16,6 +16,12 @@ module Admin
16
16
  scope.where(account_id: account_id)
17
17
  end
18
18
  end
19
+
20
+ private
21
+
22
+ def permission_ability
23
+ "invoice.manage.billing"
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -16,6 +16,12 @@ module Admin
16
16
  scope.where(account_id: account_id)
17
17
  end
18
18
  end
19
+
20
+ private
21
+
22
+ def permission_ability
23
+ "lifetime.manage.billing"
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -20,6 +20,12 @@ module Admin
20
20
  scope.where(account_id: account_id)
21
21
  end
22
22
  end
23
+
24
+ private
25
+
26
+ def permission_ability
27
+ "notification.manage.notifications"
28
+ end
23
29
  end
24
30
  end
25
31
  end
@@ -18,6 +18,12 @@ module Admin
18
18
  scope.where(account_id: account_id)
19
19
  end
20
20
  end
21
+
22
+ private
23
+
24
+ def permission_ability
25
+ "preference.manage.notifications"
26
+ end
21
27
  end
22
28
  end
23
29
  end
@@ -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.
@@ -17,6 +17,12 @@ module Admin
17
17
  scope.where(account_id: account_id)
18
18
  end
19
19
  end
20
+
21
+ private
22
+
23
+ def permission_ability
24
+ "subscription.manage.billing"
25
+ end
20
26
  end
21
27
  end
22
28
  end
@@ -23,6 +23,12 @@ module Admin
23
23
  scope.where(account_id: account_id)
24
24
  end
25
25
  end
26
+
27
+ private
28
+
29
+ def permission_ability
30
+ "team.manage.teams"
31
+ end
26
32
  end
27
33
  end
28
34
  end
@@ -19,6 +19,12 @@ module Admin
19
19
  scope.where(account_id: account_id)
20
20
  end
21
21
  end
22
+
23
+ private
24
+
25
+ def permission_ability
26
+ "member.manage.teams"
27
+ end
22
28
  end
23
29
  end
24
30
  end
@@ -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">&lt;value&gt;</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>
@@ -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
- # engine-prefixed controllers under app/controllers/admin/.
18
- resources :identities, controller: "admin/identities"
19
- resources :accounts, controller: "admin/accounts"
20
- resources :accounts_memberships, controller: "admin/accounts_memberships"
21
- resources :teams, controller: "admin/teams"
22
- resources :teams_memberships, controller: "admin/teams_memberships"
23
- resources :invitations, controller: "admin/invitations"
24
- resources :notifications, controller: "admin/notifications"
25
- resources :notification_preferences, controller: "admin/notification_preferences"
26
- resources :plans, controller: "admin/plans"
27
- resources :subscriptions, controller: "admin/subscriptions"
28
- resources :invoices, controller: "admin/invoices"
29
- resources :lifetime_passes, controller: "admin/lifetime_passes"
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
- @authenticator = ->(controller) { controller.current_identity&.staff? }
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
- describe "Admin::Tenant::ApplicationPolicy permitted?" do
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::ApplicationPolicy.new(ctx, account) }
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
- class CreateAuthOauthProviders < ActiveRecord::Migration[7.1]
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", "~> 13.0")
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
@@ -32,6 +32,8 @@ module Billing
32
32
  private
33
33
 
34
34
  def scoped_invoices
35
+ return Billing::Invoice.none if current_billing_customer_ref.nil?
36
+
35
37
  Billing::Invoice.where(customer_ref: current_billing_customer_ref)
36
38
  end
37
39
 
@@ -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[:subscription],
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
- # Stripe moved the paid timestamp out of the top-level invoice
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
- # We still tolerate a top-level :paid_at on older API versions.
44
+ # A top-level :paid_at is still tolerated for hand-built payloads.
43
45
  def paid_at_for(stripe_response)
44
- unix = stripe_response.dig(:status_transitions, :paid_at) ||
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 Faraday
7
- # errors + Billing::Stripe::Client errors into ServiceResult
8
- # failures with stable codes.
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
- # The Faraday-based Billing::Stripe::Client raises
47
- # Billing::GatewayError on 4xx/5xx, network failures, and
48
- # auth errors. Sub-classify by message prefix so callers can
49
- # branch on `result.code`.
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 = object_hash[:subscription] || object_hash["subscription"]
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" ? Time.current : nil
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