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
@@ -60,32 +60,18 @@ module Billing
60
60
  end
61
61
 
62
62
  def plan_ref_from_object_hash
63
- first = first_subscription_item
64
- return nil unless first
65
-
66
- price = first[:price] || first["price"]
67
- price.is_a?(Hash) ? (price[:id] || price["id"]) : nil
63
+ Billing::Stripe::Payload.subscription_price_ref(object_hash)
68
64
  end
69
65
 
70
- # Stripe moved current_period_end onto each subscription item
71
- # in 2024; older API versions still set it at the root. Try
72
- # root first, fall back to items.data[0].
73
- # https://docs.stripe.com/api/subscriptions/object
66
+ # Since API 2025-03-31.basil the billing period lives on each
67
+ # subscription item (items.data[].current_period_end); the
68
+ # subscription-level field was removed. Read the first item,
69
+ # fall back to the root for payloads from older API versions.
70
+ # https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end
74
71
  def current_period_end_at
75
- unix = object_hash[:current_period_end] ||
76
- object_hash["current_period_end"]
77
- unix ||= (item = first_subscription_item) &&
78
- (item[:current_period_end] || item["current_period_end"])
72
+ unix = Billing::Stripe::Payload.subscription_current_period_end(object_hash)
79
73
  unix && Time.at(unix)
80
74
  end
81
-
82
- def first_subscription_item
83
- items = object_hash[:items] || object_hash["items"]
84
- return nil unless items.is_a?(Hash)
85
-
86
- first = (items[:data] || items["data"] || []).first
87
- first if first.is_a?(Hash)
88
- end
89
75
  end
90
76
  end
91
77
  end
@@ -33,6 +33,21 @@ module Billing
33
33
  # seams:insertion-point billing.engine.events
34
34
  end
35
35
 
36
+ initializer "billing.register_abilities" do
37
+ # Ability codes this engine owns, registered exactly like events
38
+ # (resource.action.engine). `read` is the member-level view;
39
+ # `manage` is the admin-level mutate. Host grant maps reference
40
+ # these codes; see Seams::Permissions::DEFAULT_GRANTS.
41
+ Seams::PermissionRegistry.register("invoice.read.billing", owned_by: "Billing")
42
+ Seams::PermissionRegistry.register("invoice.manage.billing", owned_by: "Billing")
43
+ Seams::PermissionRegistry.register("subscription.read.billing", owned_by: "Billing")
44
+ Seams::PermissionRegistry.register("subscription.manage.billing", owned_by: "Billing")
45
+ Seams::PermissionRegistry.register("plan.manage.billing", owned_by: "Billing")
46
+ Seams::PermissionRegistry.register("lifetime.manage.billing", owned_by: "Billing")
47
+ # Follow-up generators that ship new billing abilities register them here.
48
+ # seams:insertion-point billing.engine.abilities
49
+ end
50
+
36
51
  initializer "billing.append_migrations" do |app|
37
52
  unless app.root == root
38
53
  config.paths["db/migrate"].expanded.each do |expanded_path|
@@ -3,6 +3,7 @@
3
3
  require "billing/gateways/abstract"
4
4
  require "billing/stripe/client"
5
5
  require "billing/stripe/webhook_signature"
6
+ require "billing/stripe/payload"
6
7
 
7
8
  module Billing
8
9
  module Gateways
@@ -136,18 +137,17 @@ module Billing
136
137
 
137
138
  private
138
139
 
139
- # Stripe's response shape changed: current_period_end now lives
140
- # on items.data[].current_period_end. We try the subscription
141
- # root first for backwards compatibility, then fall back to the
142
- # first item.
140
+ # `sub` is a ::Stripe::Subscription from the gem (no #dig) or a
141
+ # Hash in specs; Billing::Stripe::Payload reads both.
142
+ # Since API 2025-03-31.basil current_period_end lives on
143
+ # items.data[] only; the subscription root is the fallback for
144
+ # older payloads.
143
145
  def normalise_subscription(sub)
144
- items_data = sub.dig("items", "data") || []
145
- first_item = items_data.first || {}
146
146
  {
147
- id: sub["id"],
148
- status: sub["status"],
149
- current_period_end: sub["current_period_end"] || first_item["current_period_end"],
150
- plan_ref: first_item.dig("price", "id")
147
+ id: Billing::Stripe::Payload.dig(sub, :id),
148
+ status: Billing::Stripe::Payload.dig(sub, :status),
149
+ current_period_end: Billing::Stripe::Payload.subscription_current_period_end(sub),
150
+ plan_ref: Billing::Stripe::Payload.subscription_price_ref(sub)
151
151
  }
152
152
  end
153
153
  end
@@ -17,10 +17,23 @@ module Billing
17
17
  # and the gateway adapter call this Client without knowing
18
18
  # whether the implementation is Faraday or the gem.
19
19
  #
20
- # Stripe gem version pinned to ~> 13 in the host Gemfile by the
21
- # Billing generator. The StripeClient API
22
- # (`client.v1.<resource>.<action>`) is what Stripe documents at
23
- # https://docs.stripe.com/api?lang=ruby for every endpoint we use.
20
+ # Stripe gem version pinned to ~> 19 in the host Gemfile by the
21
+ # Billing generator. stripe-ruby 19 pins API version
22
+ # 2026-08-26.dahlia (see ::Stripe.api_version). The StripeClient
23
+ # API (`client.v1.<resource>.<action>`) is what Stripe documents
24
+ # at https://docs.stripe.com/api?lang=ruby for every endpoint we
25
+ # use. Responses are ::Stripe::StripeObject instances: read them
26
+ # with `obj[:key]` or Billing::Stripe::Payload.dig — they have no
27
+ # Hash#dig.
28
+ #
29
+ # Errors: every ::Stripe::StripeError raised by the gem is
30
+ # re-raised as Billing::GatewayError (original kept as #cause),
31
+ # because every caller in the engine rescues Billing::GatewayError.
32
+ # The message prefix is what Billing::StripeService#classify_gateway_error
33
+ # matches on:
34
+ # ::Stripe::APIConnectionError -> "Stripe connection error: ..."
35
+ # ::Stripe::AuthenticationError/Permission -> "Stripe authentication error: ..."
36
+ # any other ::Stripe::StripeError -> "Stripe <ErrorClass>: ..."
24
37
  #
25
38
  # Verified against:
26
39
  # https://docs.stripe.com/api/customers/create
@@ -34,42 +47,45 @@ module Billing
34
47
  # https://docs.stripe.com/api/customer_portal/sessions/create
35
48
  # https://docs.stripe.com/api/idempotent_requests
36
49
  class Client
50
+ # StripeClient.new raises ::Stripe::AuthenticationError on a nil
51
+ # key; translated like any other call so callers see
52
+ # Billing::GatewayError (code :gateway_auth).
37
53
  def initialize(api_key:)
38
- @sdk = ::Stripe::StripeClient.new(api_key)
54
+ @sdk = translate_errors { ::Stripe::StripeClient.new(api_key) }
39
55
  end
40
56
 
41
57
  # POST /v1/subscriptions
42
58
  def create_subscription(customer:, items:, **extra)
43
- sdk.v1.subscriptions.create({ customer: customer, items: items }.merge(extra))
59
+ translate_errors { sdk.v1.subscriptions.create({ customer: customer, items: items }.merge(extra)) }
44
60
  end
45
61
 
46
62
  # DELETE /v1/subscriptions/{id}
47
63
  def cancel_subscription(subscription_id, **extra)
48
- sdk.v1.subscriptions.cancel(subscription_id, extra)
64
+ translate_errors { sdk.v1.subscriptions.cancel(subscription_id, extra) }
49
65
  end
50
66
 
51
67
  # GET /v1/subscriptions/{id}
52
68
  def retrieve_subscription(subscription_id)
53
- sdk.v1.subscriptions.retrieve(subscription_id)
69
+ translate_errors { sdk.v1.subscriptions.retrieve(subscription_id) }
54
70
  end
55
71
 
56
72
  # POST /v1/subscriptions/{id} — used by ChangePlanService (items[])
57
73
  # and ReactivateService (cancel_at_period_end: false).
58
74
  def update_subscription(subscription_id, **fields)
59
- sdk.v1.subscriptions.update(subscription_id, fields)
75
+ translate_errors { sdk.v1.subscriptions.update(subscription_id, fields) }
60
76
  end
61
77
 
62
78
  # POST /v1/checkout/sessions — params is a Hash (subscription
63
79
  # mode for recurring; payment mode for LTDs).
64
80
  def create_checkout_session(params)
65
- sdk.v1.checkout.sessions.create(params)
81
+ translate_errors { sdk.v1.checkout.sessions.create(params) }
66
82
  end
67
83
 
68
84
  # POST /v1/billing_portal/sessions
69
85
  def create_billing_portal_session(customer:, return_url: nil)
70
86
  body = { customer: customer }
71
87
  body[:return_url] = return_url if return_url
72
- sdk.v1.billing_portal.sessions.create(body)
88
+ translate_errors { sdk.v1.billing_portal.sessions.create(body) }
73
89
  end
74
90
 
75
91
  # POST /v1/customers. The optional `idempotency_key:` is the gem's
@@ -80,22 +96,32 @@ module Billing
80
96
  def create_customer(email:, idempotency_key: nil, **extra)
81
97
  body = { email: email }.merge(extra)
82
98
  opts = idempotency_key ? { idempotency_key: idempotency_key } : {}
83
- sdk.v1.customers.create(body, opts)
99
+ translate_errors { sdk.v1.customers.create(body, opts) }
84
100
  end
85
101
 
86
102
  # GET /v1/customers/search
87
103
  def search_customers(query:, limit: 1)
88
- sdk.v1.customers.search(query: query, limit: limit)
104
+ translate_errors { sdk.v1.customers.search({ query: query, limit: limit }) }
89
105
  end
90
106
 
91
107
  # GET /v1/invoices/{id}
92
108
  def retrieve_invoice(invoice_id)
93
- sdk.v1.invoices.retrieve(invoice_id)
109
+ translate_errors { sdk.v1.invoices.retrieve(invoice_id) }
94
110
  end
95
111
 
96
112
  private
97
113
 
98
114
  attr_reader :sdk
115
+
116
+ def translate_errors
117
+ yield
118
+ rescue ::Stripe::APIConnectionError => e
119
+ raise Billing::GatewayError, "Stripe connection error: #{e.message}"
120
+ rescue ::Stripe::AuthenticationError, ::Stripe::PermissionError => e
121
+ raise Billing::GatewayError, "Stripe authentication error: #{e.message}"
122
+ rescue ::Stripe::StripeError => e
123
+ raise Billing::GatewayError, "Stripe #{e.class.name.split('::').last}: #{e.message}"
124
+ end
99
125
  end
100
126
  end
101
127
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Stripe
5
+ # Reads Stripe objects without caring where they came from.
6
+ #
7
+ # The engine sees the same Stripe resource in three shapes:
8
+ #
9
+ # 1. a `::Stripe::StripeObject` returned by the `stripe` gem
10
+ # (Billing::Stripe::Client). It supports `obj[:key]` but has
11
+ # NO `#dig` — calling `dig` on it raises NoMethodError.
12
+ # 2. the Hash built by Billing::Gateways::Stripe#verify_webhook
13
+ # (string keys at the top level, symbol keys below it).
14
+ # 3. a JSON-parsed Hash (string keys, or symbol keys in specs).
15
+ #
16
+ # `dig` walks any mix of those. The field readers below also hide
17
+ # the Stripe API-version differences the engine cares about, so
18
+ # the rest of the engine reads one field instead of two.
19
+ #
20
+ # API-version notes (stripe-ruby 19 pins 2026-08-26.dahlia; the
21
+ # shape changes below arrived in 2025-03-31.basil):
22
+ # https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects
23
+ # https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end
24
+ # https://docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices
25
+ module Payload
26
+ module_function
27
+
28
+ # Key-tolerant dig. Integer segments index into Arrays.
29
+ #
30
+ # Payload.dig(invoice, :parent, :subscription_details, :subscription)
31
+ # Payload.dig(subscription, :items, :data, 0, :current_period_end)
32
+ def dig(object, *path)
33
+ path.reduce(object) do |node, key|
34
+ return nil if node.nil?
35
+
36
+ fetch(node, key)
37
+ end
38
+ end
39
+
40
+ def fetch(node, key)
41
+ if key.is_a?(Integer)
42
+ node.is_a?(Array) ? node[key] : nil
43
+ elsif node.is_a?(Hash)
44
+ node.key?(key.to_s) ? node[key.to_s] : node[key.to_sym]
45
+ elsif node.respond_to?(:keys) && node.respond_to?(:[])
46
+ node[key.to_sym] # ::Stripe::StripeObject
47
+ end
48
+ end
49
+
50
+ # Stripe id for a field that is either an id String or an
51
+ # expanded object.
52
+ def ref(value)
53
+ value.is_a?(String) ? value : dig(value, :id)
54
+ end
55
+
56
+ # The subscription an invoice belongs to.
57
+ # Current API: invoice.parent.subscription_details.subscription.
58
+ # Before 2025-03-31.basil: invoice.subscription.
59
+ def invoice_subscription_ref(invoice)
60
+ ref(dig(invoice, :parent, :subscription_details, :subscription)) ||
61
+ ref(dig(invoice, :subscription))
62
+ end
63
+
64
+ # Unix time the invoice was paid, or nil. Stripe reports it on
65
+ # status_transitions.paid_at; a top-level paid_at is tolerated
66
+ # for hand-built payloads.
67
+ def invoice_paid_at(invoice)
68
+ dig(invoice, :status_transitions, :paid_at) || dig(invoice, :paid_at)
69
+ end
70
+
71
+ # First subscription item (items.data[0]) or nil.
72
+ def first_subscription_item(subscription)
73
+ dig(subscription, :items, :data, 0)
74
+ end
75
+
76
+ # Unix end of the current billing period.
77
+ # Current API: items.data[].current_period_end (one per item).
78
+ # Before 2025-03-31.basil: subscription.current_period_end.
79
+ def subscription_current_period_end(subscription)
80
+ dig(first_subscription_item(subscription), :current_period_end) ||
81
+ dig(subscription, :current_period_end)
82
+ end
83
+
84
+ # Price id of the first subscription item.
85
+ def subscription_price_ref(subscription)
86
+ ref(dig(first_subscription_item(subscription), :price))
87
+ end
88
+ end
89
+ end
90
+ end
@@ -28,7 +28,15 @@ module Billing
28
28
  # Returns the parsed Stripe::Event on success; raises
29
29
  # Billing::WebhookError on any failure mode (no header,
30
30
  # malformed header, no v1, no matching v1, timestamp outside
31
- # tolerance, malformed JSON).
31
+ # tolerance, malformed JSON, or a v2 "thin" event).
32
+ #
33
+ # stripe-ruby 19 made construct_event raise ArgumentError when
34
+ # the signed body is a v2 event notification
35
+ # (object: "v2.core.event"). This endpoint handles v1 snapshot
36
+ # events only, so a v2 payload means the event destination is
37
+ # misconfigured: we answer 400 via Billing::WebhookError rather
38
+ # than a 500.
39
+ # https://github.com/stripe/stripe-ruby/wiki/Migration-guide-for-v19
32
40
  def verify!(payload:, signature_header:, secret:, tolerance: DEFAULT_TOLERANCE)
33
41
  raise Billing::WebhookError, "Stripe-Signature header missing" if signature_header.nil? || signature_header.empty?
34
42
 
@@ -37,6 +45,8 @@ module Billing
37
45
  raise Billing::WebhookError, "Stripe signature invalid: #{e.message}"
38
46
  rescue JSON::ParserError => e
39
47
  raise Billing::WebhookError, "Stripe payload not JSON: #{e.message}"
48
+ rescue ArgumentError => e
49
+ raise Billing::WebhookError, "Stripe payload is not a v1 event: #{e.message}"
40
50
  end
41
51
  end
42
52
  end
@@ -4,6 +4,7 @@
4
4
  "type": "charge.refunded",
5
5
  "livemode": false,
6
6
  "created": 1731100000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "ch_test_123",
@@ -4,6 +4,7 @@
4
4
  "type": "checkout.session.completed",
5
5
  "livemode": false,
6
6
  "created": 1731200000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "cs_test_123",
@@ -4,21 +4,32 @@
4
4
  "type": "customer.subscription.created",
5
5
  "livemode": false,
6
6
  "created": 1730000000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "sub_test_123",
10
11
  "object": "subscription",
11
12
  "customer": "cus_test_123",
12
13
  "status": "active",
13
- "current_period_end": 1732678400,
14
14
  "items": {
15
+ "object": "list",
15
16
  "data": [
16
17
  {
17
18
  "id": "si_test_123",
18
- "price": { "id": "price_test_pro", "lookup_key": "pro" },
19
- "current_period_end": 1732678400
19
+ "object": "subscription_item",
20
+ "current_period_start": 1730000000,
21
+ "current_period_end": 1732678400,
22
+ "price": {
23
+ "id": "price_test_pro",
24
+ "object": "price",
25
+ "lookup_key": "pro"
26
+ },
27
+ "quantity": 1,
28
+ "subscription": "sub_test_123"
20
29
  }
21
- ]
30
+ ],
31
+ "has_more": false,
32
+ "url": "/v1/subscription_items?subscription=sub_test_123"
22
33
  }
23
34
  }
24
35
  }
@@ -4,6 +4,7 @@
4
4
  "type": "customer.subscription.deleted",
5
5
  "livemode": false,
6
6
  "created": 1730200000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "sub_test_123",
@@ -11,7 +12,12 @@
11
12
  "customer": "cus_test_123",
12
13
  "status": "canceled",
13
14
  "canceled_at": 1730200000,
14
- "items": { "data": [] }
15
+ "items": {
16
+ "object": "list",
17
+ "data": [],
18
+ "has_more": false,
19
+ "url": "/v1/subscription_items?subscription=sub_test_123"
20
+ }
15
21
  }
16
22
  }
17
23
  }
@@ -4,6 +4,7 @@
4
4
  "type": "customer.subscription.trial_will_end",
5
5
  "livemode": false,
6
6
  "created": 1730300000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "sub_test_123",
@@ -11,7 +12,23 @@
11
12
  "customer": "cus_test_123",
12
13
  "status": "trialing",
13
14
  "trial_end": 1730560000,
14
- "items": { "data": [{ "price": { "lookup_key": "pro" } }] }
15
+ "items": {
16
+ "object": "list",
17
+ "data": [
18
+ {
19
+ "id": "si_test_123",
20
+ "object": "subscription_item",
21
+ "current_period_start": 1729955200,
22
+ "current_period_end": 1730560000,
23
+ "price": {
24
+ "object": "price",
25
+ "lookup_key": "pro"
26
+ }
27
+ }
28
+ ],
29
+ "has_more": false,
30
+ "url": "/v1/subscription_items?subscription=sub_test_123"
31
+ }
15
32
  }
16
33
  }
17
34
  }
@@ -4,25 +4,46 @@
4
4
  "type": "customer.subscription.updated",
5
5
  "livemode": false,
6
6
  "created": 1730100000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "sub_test_123",
10
11
  "object": "subscription",
11
12
  "customer": "cus_test_123",
12
13
  "status": "active",
13
- "current_period_end": 1735270400,
14
14
  "items": {
15
+ "object": "list",
15
16
  "data": [
16
17
  {
17
18
  "id": "si_test_123",
18
- "price": { "id": "price_test_pro_annual", "lookup_key": "pro_annual" },
19
- "current_period_end": 1735270400
19
+ "object": "subscription_item",
20
+ "current_period_start": 1732678400,
21
+ "current_period_end": 1735270400,
22
+ "price": {
23
+ "id": "price_test_pro_annual",
24
+ "object": "price",
25
+ "lookup_key": "pro_annual"
26
+ },
27
+ "quantity": 1,
28
+ "subscription": "sub_test_123"
20
29
  }
21
- ]
30
+ ],
31
+ "has_more": false,
32
+ "url": "/v1/subscription_items?subscription=sub_test_123"
22
33
  }
23
34
  },
24
35
  "previous_attributes": {
25
- "items": { "data": [{ "price": { "lookup_key": "pro" } }] }
36
+ "items": {
37
+ "data": [
38
+ {
39
+ "id": "si_test_123",
40
+ "price": {
41
+ "id": "price_test_pro",
42
+ "lookup_key": "pro"
43
+ }
44
+ }
45
+ ]
46
+ }
26
47
  }
27
48
  }
28
49
  }
@@ -4,15 +4,29 @@
4
4
  "type": "invoice.created",
5
5
  "livemode": false,
6
6
  "created": 1730400000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "in_test_123",
10
11
  "object": "invoice",
11
12
  "customer": "cus_test_123",
12
- "subscription": "sub_test_123",
13
+ "parent": {
14
+ "type": "subscription_details",
15
+ "quote_details": null,
16
+ "subscription_details": {
17
+ "metadata": {},
18
+ "subscription": "sub_test_123"
19
+ }
20
+ },
13
21
  "status": "draft",
14
22
  "amount_due": 1299,
15
- "currency": "gbp"
23
+ "currency": "gbp",
24
+ "status_transitions": {
25
+ "finalized_at": null,
26
+ "marked_uncollectible_at": null,
27
+ "paid_at": null,
28
+ "voided_at": null
29
+ }
16
30
  }
17
31
  }
18
32
  }
@@ -4,15 +4,29 @@
4
4
  "type": "invoice.finalized",
5
5
  "livemode": false,
6
6
  "created": 1730700000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "in_test_123",
10
11
  "object": "invoice",
11
12
  "customer": "cus_test_123",
12
- "subscription": "sub_test_123",
13
+ "parent": {
14
+ "type": "subscription_details",
15
+ "quote_details": null,
16
+ "subscription_details": {
17
+ "metadata": {},
18
+ "subscription": "sub_test_123"
19
+ }
20
+ },
13
21
  "status": "open",
14
22
  "amount_due": 1299,
15
- "currency": "gbp"
23
+ "currency": "gbp",
24
+ "status_transitions": {
25
+ "finalized_at": 1730700000,
26
+ "marked_uncollectible_at": null,
27
+ "paid_at": null,
28
+ "voided_at": null
29
+ }
16
30
  }
17
31
  }
18
32
  }
@@ -4,16 +4,31 @@
4
4
  "type": "invoice.paid",
5
5
  "livemode": false,
6
6
  "created": 1730500000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "in_test_123",
10
11
  "object": "invoice",
11
12
  "customer": "cus_test_123",
12
- "subscription": "sub_test_123",
13
+ "parent": {
14
+ "type": "subscription_details",
15
+ "quote_details": null,
16
+ "subscription_details": {
17
+ "metadata": {},
18
+ "subscription": "sub_test_123"
19
+ }
20
+ },
13
21
  "status": "paid",
14
22
  "amount_paid": 1299,
15
23
  "currency": "gbp",
16
- "paid_at": 1730500000
24
+ "amount_due": 1299,
25
+ "amount_remaining": 0,
26
+ "status_transitions": {
27
+ "finalized_at": 1730450000,
28
+ "marked_uncollectible_at": null,
29
+ "paid_at": 1730500000,
30
+ "voided_at": null
31
+ }
17
32
  }
18
33
  }
19
34
  }
@@ -4,17 +4,31 @@
4
4
  "type": "invoice.payment_failed",
5
5
  "livemode": false,
6
6
  "created": 1730600000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "in_test_123",
10
11
  "object": "invoice",
11
12
  "customer": "cus_test_123",
12
- "subscription": "sub_test_123",
13
+ "parent": {
14
+ "type": "subscription_details",
15
+ "quote_details": null,
16
+ "subscription_details": {
17
+ "metadata": {},
18
+ "subscription": "sub_test_123"
19
+ }
20
+ },
13
21
  "status": "open",
14
22
  "amount_due": 1299,
15
23
  "currency": "gbp",
16
24
  "attempt_count": 1,
17
- "next_payment_attempt": 1730700000
25
+ "next_payment_attempt": 1730700000,
26
+ "status_transitions": {
27
+ "finalized_at": 1730550000,
28
+ "marked_uncollectible_at": null,
29
+ "paid_at": null,
30
+ "voided_at": null
31
+ }
18
32
  }
19
33
  }
20
34
  }
@@ -4,15 +4,29 @@
4
4
  "type": "invoice.voided",
5
5
  "livemode": false,
6
6
  "created": 1730800000,
7
+ "api_version": "2026-08-26.dahlia",
7
8
  "data": {
8
9
  "object": {
9
10
  "id": "in_test_123",
10
11
  "object": "invoice",
11
12
  "customer": "cus_test_123",
12
- "subscription": "sub_test_123",
13
+ "parent": {
14
+ "type": "subscription_details",
15
+ "quote_details": null,
16
+ "subscription_details": {
17
+ "metadata": {},
18
+ "subscription": "sub_test_123"
19
+ }
20
+ },
13
21
  "status": "void",
14
22
  "amount_due": 1299,
15
- "currency": "gbp"
23
+ "currency": "gbp",
24
+ "status_transitions": {
25
+ "finalized_at": 1730700000,
26
+ "marked_uncollectible_at": null,
27
+ "paid_at": null,
28
+ "voided_at": 1730800000
29
+ }
16
30
  }
17
31
  }
18
32
  }