seams 0.1.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 (414) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +335 -0
  3. data/LICENSE +21 -0
  4. data/README.md +104 -0
  5. data/lib/generators/seams/accounts/accounts_generator.rb +272 -0
  6. data/lib/generators/seams/accounts/templates/README.md.tt +219 -0
  7. data/lib/generators/seams/accounts/templates/app/models/account.rb.tt +124 -0
  8. data/lib/generators/seams/accounts/templates/app/models/application_record.rb.tt +7 -0
  9. data/lib/generators/seams/accounts/templates/app/models/current.rb.tt +38 -0
  10. data/lib/generators/seams/accounts/templates/app/models/membership.rb.tt +114 -0
  11. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +8 -0
  12. data/lib/generators/seams/accounts/templates/db/migrate/create_accounts.rb.tt +29 -0
  13. data/lib/generators/seams/accounts/templates/db/migrate/create_accounts_memberships.rb.tt +49 -0
  14. data/lib/generators/seams/accounts/templates/lib/accounts.rb.tt +21 -0
  15. data/lib/generators/seams/accounts/templates/lib/concerns/account_scoped.rb.tt +97 -0
  16. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +105 -0
  17. data/lib/generators/seams/accounts/templates/lib/configuration.rb.tt +26 -0
  18. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +55 -0
  19. data/lib/generators/seams/accounts/templates/spec/factories/accounts.rb.tt +49 -0
  20. data/lib/generators/seams/accounts/templates/spec/models/accounts/account_spec.rb.tt +64 -0
  21. data/lib/generators/seams/accounts/templates/spec/models/accounts/membership_spec.rb.tt +99 -0
  22. data/lib/generators/seams/accounts/templates/spec/runtime/accounts_boot_spec.rb.tt +181 -0
  23. data/lib/generators/seams/admin/admin_generator.rb +852 -0
  24. data/lib/generators/seams/admin/templates/README.md.tt +266 -0
  25. data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_controller.rb.tt +16 -0
  26. data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_memberships_controller.rb.tt +16 -0
  27. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +282 -0
  28. data/lib/generators/seams/admin/templates/app/controllers/admin/identities_controller.rb.tt +26 -0
  29. data/lib/generators/seams/admin/templates/app/controllers/admin/invitations_controller.rb.tt +14 -0
  30. data/lib/generators/seams/admin/templates/app/controllers/admin/invoices_controller.rb.tt +14 -0
  31. data/lib/generators/seams/admin/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +14 -0
  32. data/lib/generators/seams/admin/templates/app/controllers/admin/notification_preferences_controller.rb.tt +15 -0
  33. data/lib/generators/seams/admin/templates/app/controllers/admin/notifications_controller.rb.tt +18 -0
  34. data/lib/generators/seams/admin/templates/app/controllers/admin/plans_controller.rb.tt +14 -0
  35. data/lib/generators/seams/admin/templates/app/controllers/admin/subscriptions_controller.rb.tt +14 -0
  36. data/lib/generators/seams/admin/templates/app/controllers/admin/teams_controller.rb.tt +14 -0
  37. data/lib/generators/seams/admin/templates/app/controllers/admin/teams_memberships_controller.rb.tt +16 -0
  38. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +50 -0
  39. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +58 -0
  40. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +48 -0
  41. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +51 -0
  42. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +67 -0
  43. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +65 -0
  44. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +58 -0
  45. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +43 -0
  46. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +72 -0
  47. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +59 -0
  48. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +39 -0
  49. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +43 -0
  50. data/lib/generators/seams/admin/templates/app/policies/admin/platform/account_policy.rb.tt +10 -0
  51. data/lib/generators/seams/admin/templates/app/policies/admin/platform/accounts_membership_policy.rb.tt +10 -0
  52. data/lib/generators/seams/admin/templates/app/policies/admin/platform/application_policy.rb.tt +85 -0
  53. data/lib/generators/seams/admin/templates/app/policies/admin/platform/identity_policy.rb.tt +18 -0
  54. data/lib/generators/seams/admin/templates/app/policies/admin/platform/invitation_policy.rb.tt +9 -0
  55. data/lib/generators/seams/admin/templates/app/policies/admin/platform/invoice_policy.rb.tt +9 -0
  56. data/lib/generators/seams/admin/templates/app/policies/admin/platform/lifetime_pass_policy.rb.tt +9 -0
  57. data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_policy.rb.tt +9 -0
  58. data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_preference_policy.rb.tt +9 -0
  59. data/lib/generators/seams/admin/templates/app/policies/admin/platform/plan_policy.rb.tt +11 -0
  60. data/lib/generators/seams/admin/templates/app/policies/admin/platform/subscription_policy.rb.tt +9 -0
  61. data/lib/generators/seams/admin/templates/app/policies/admin/platform/team_policy.rb.tt +9 -0
  62. data/lib/generators/seams/admin/templates/app/policies/admin/platform/teams_membership_policy.rb.tt +9 -0
  63. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +33 -0
  64. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +24 -0
  65. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +169 -0
  66. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +67 -0
  67. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +24 -0
  68. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +21 -0
  69. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +21 -0
  70. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +25 -0
  71. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +23 -0
  72. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +47 -0
  73. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +22 -0
  74. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +28 -0
  75. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +24 -0
  76. data/lib/generators/seams/admin/templates/config/routes.rb.tt +38 -0
  77. data/lib/generators/seams/admin/templates/lib/admin.rb.tt +36 -0
  78. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +66 -0
  79. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +90 -0
  80. data/lib/generators/seams/admin/templates/lib/context.rb.tt +44 -0
  81. data/lib/generators/seams/admin/templates/lib/engine.rb.tt +68 -0
  82. data/lib/generators/seams/admin/templates/spec/factories/admin.rb.tt +10 -0
  83. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +604 -0
  84. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +157 -0
  85. data/lib/generators/seams/auth/add_oauth_provider/templates/adapter.rb.tt +95 -0
  86. data/lib/generators/seams/auth/add_oauth_provider/templates/adapter_spec.rb.tt +58 -0
  87. data/lib/generators/seams/auth/auth_generator.rb +311 -0
  88. data/lib/generators/seams/auth/templates/README.md.tt +289 -0
  89. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +80 -0
  90. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +44 -0
  91. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +34 -0
  92. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +49 -0
  93. data/lib/generators/seams/auth/templates/app/jobs/application_job.rb.tt +7 -0
  94. data/lib/generators/seams/auth/templates/app/jobs/cleanup_expired_sessions_job.rb.tt +30 -0
  95. data/lib/generators/seams/auth/templates/app/mailers/passwords_mailer.rb.tt +15 -0
  96. data/lib/generators/seams/auth/templates/app/models/api_token.rb.tt +62 -0
  97. data/lib/generators/seams/auth/templates/app/models/application_record.rb.tt +7 -0
  98. data/lib/generators/seams/auth/templates/app/models/current.rb.tt +15 -0
  99. data/lib/generators/seams/auth/templates/app/models/identity.rb.tt +74 -0
  100. data/lib/generators/seams/auth/templates/app/models/oauth/provider.rb.tt +48 -0
  101. data/lib/generators/seams/auth/templates/app/models/session.rb.tt +28 -0
  102. data/lib/generators/seams/auth/templates/app/services/authenticate_identity.rb.tt +31 -0
  103. data/lib/generators/seams/auth/templates/app/services/generate_api_token.rb.tt +35 -0
  104. data/lib/generators/seams/auth/templates/app/services/oauth/authenticator.rb.tt +94 -0
  105. data/lib/generators/seams/auth/templates/app/services/register_identity.rb.tt +57 -0
  106. data/lib/generators/seams/auth/templates/app/services/reset_password.rb.tt +41 -0
  107. data/lib/generators/seams/auth/templates/app/services/revoke_api_token.rb.tt +38 -0
  108. data/lib/generators/seams/auth/templates/app/views/password_resets/edit.html.erb.tt +12 -0
  109. data/lib/generators/seams/auth/templates/app/views/password_resets/new.html.erb.tt +11 -0
  110. data/lib/generators/seams/auth/templates/app/views/passwords_mailer/reset_email.html.erb.tt +7 -0
  111. data/lib/generators/seams/auth/templates/app/views/registrations/new.html.erb.tt +26 -0
  112. data/lib/generators/seams/auth/templates/app/views/sessions/_oauth_buttons.html.erb.tt +18 -0
  113. data/lib/generators/seams/auth/templates/app/views/sessions/new.html.erb.tt +17 -0
  114. data/lib/generators/seams/auth/templates/config/routes.rb.tt +21 -0
  115. data/lib/generators/seams/auth/templates/db/migrate/create_auth_api_tokens.rb.tt +26 -0
  116. data/lib/generators/seams/auth/templates/db/migrate/create_auth_identities.rb.tt +29 -0
  117. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +35 -0
  118. data/lib/generators/seams/auth/templates/db/migrate/create_auth_sessions.rb.tt +19 -0
  119. data/lib/generators/seams/auth/templates/lib/auth.rb.tt +39 -0
  120. data/lib/generators/seams/auth/templates/lib/concerns/api_authenticatable.rb.tt +58 -0
  121. data/lib/generators/seams/auth/templates/lib/concerns/authenticatable.rb.tt +32 -0
  122. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +60 -0
  123. data/lib/generators/seams/auth/templates/lib/configuration.rb.tt +45 -0
  124. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +46 -0
  125. data/lib/generators/seams/auth/templates/lib/oauth/abstract.rb.tt +87 -0
  126. data/lib/generators/seams/auth/templates/lib/oauth/github.rb.tt +112 -0
  127. data/lib/generators/seams/auth/templates/lib/oauth/google.rb.tt +78 -0
  128. data/lib/generators/seams/auth/templates/lib/tasks/auth_pii.rake.tt +68 -0
  129. data/lib/generators/seams/auth/templates/spec/factories/auth.rb.tt +38 -0
  130. data/lib/generators/seams/auth/templates/spec/mailers/passwords_mailer_spec.rb.tt +37 -0
  131. data/lib/generators/seams/auth/templates/spec/models/api_token_spec.rb.tt +84 -0
  132. data/lib/generators/seams/auth/templates/spec/models/identity_spec.rb.tt +56 -0
  133. data/lib/generators/seams/auth/templates/spec/models/oauth/provider_spec.rb.tt +64 -0
  134. data/lib/generators/seams/auth/templates/spec/models/session_spec.rb.tt +34 -0
  135. data/lib/generators/seams/auth/templates/spec/runtime/boot_spec.rb.tt +30 -0
  136. data/lib/generators/seams/auth/templates/spec/runtime/event_payload_spec.rb.tt +29 -0
  137. data/lib/generators/seams/auth/templates/spec/runtime/login_flow_spec.rb.tt +45 -0
  138. data/lib/generators/seams/billing/billing_generator.rb +476 -0
  139. data/lib/generators/seams/billing/templates/README.md.tt +355 -0
  140. data/lib/generators/seams/billing/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +84 -0
  141. data/lib/generators/seams/billing/templates/app/controllers/checkout_controller.rb.tt +92 -0
  142. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +63 -0
  143. data/lib/generators/seams/billing/templates/app/controllers/plans_controller.rb.tt +14 -0
  144. data/lib/generators/seams/billing/templates/app/controllers/portal_controller.rb.tt +45 -0
  145. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +119 -0
  146. data/lib/generators/seams/billing/templates/app/controllers/webhooks_controller.rb.tt +98 -0
  147. data/lib/generators/seams/billing/templates/app/helpers/currency_helper.rb.tt +44 -0
  148. data/lib/generators/seams/billing/templates/app/jobs/application_job.rb.tt +6 -0
  149. data/lib/generators/seams/billing/templates/app/jobs/cancel_subscription_job.rb.tt +39 -0
  150. data/lib/generators/seams/billing/templates/app/jobs/start_subscription_job.rb.tt +32 -0
  151. data/lib/generators/seams/billing/templates/app/jobs/webhooks/process_event_job.rb.tt +37 -0
  152. data/lib/generators/seams/billing/templates/app/models/application_record.rb.tt +7 -0
  153. data/lib/generators/seams/billing/templates/app/models/invoice.rb.tt +35 -0
  154. data/lib/generators/seams/billing/templates/app/models/lifetime_pass.rb.tt +60 -0
  155. data/lib/generators/seams/billing/templates/app/models/plan.rb.tt +95 -0
  156. data/lib/generators/seams/billing/templates/app/models/subscription.rb.tt +31 -0
  157. data/lib/generators/seams/billing/templates/app/models/webhook_event.rb.tt +13 -0
  158. data/lib/generators/seams/billing/templates/app/services/checkout_session_service.rb.tt +25 -0
  159. data/lib/generators/seams/billing/templates/app/services/customers/find_or_create_service.rb.tt +73 -0
  160. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +50 -0
  161. data/lib/generators/seams/billing/templates/app/services/lifetime/create_lifetime_session_service.rb.tt +82 -0
  162. data/lib/generators/seams/billing/templates/app/services/lifetime/create_pass_from_checkout_service.rb.tt +88 -0
  163. data/lib/generators/seams/billing/templates/app/services/lifetime/grant_pass_service.rb.tt +80 -0
  164. data/lib/generators/seams/billing/templates/app/services/lifetime/revoke_pass_service.rb.tt +59 -0
  165. data/lib/generators/seams/billing/templates/app/services/portal_session_service.rb.tt +23 -0
  166. data/lib/generators/seams/billing/templates/app/services/service_result.rb.tt +38 -0
  167. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +67 -0
  168. data/lib/generators/seams/billing/templates/app/services/subscriptions/cancel_service.rb.tt +42 -0
  169. data/lib/generators/seams/billing/templates/app/services/subscriptions/change_plan_service.rb.tt +48 -0
  170. data/lib/generators/seams/billing/templates/app/services/subscriptions/reactivate_service.rb.tt +28 -0
  171. data/lib/generators/seams/billing/templates/app/services/webhooks/event_router.rb.tt +54 -0
  172. data/lib/generators/seams/billing/templates/app/services/webhooks/handler.rb.tt +93 -0
  173. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/charge_refunded_handler.rb.tt +18 -0
  174. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/checkout_session_completed_handler.rb.tt +58 -0
  175. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_created_handler.rb.tt +16 -0
  176. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_finalized_handler.rb.tt +14 -0
  177. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +80 -0
  178. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_paid_handler.rb.tt +12 -0
  179. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_payment_failed_handler.rb.tt +12 -0
  180. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_voided_handler.rb.tt +12 -0
  181. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_failed_handler.rb.tt +15 -0
  182. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_succeeded_handler.rb.tt +19 -0
  183. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_created_handler.rb.tt +11 -0
  184. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_deleted_handler.rb.tt +15 -0
  185. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +92 -0
  186. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_trial_will_end_handler.rb.tt +15 -0
  187. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_updated_handler.rb.tt +11 -0
  188. data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/index.html.erb.tt +36 -0
  189. data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/new.html.erb.tt +37 -0
  190. data/lib/generators/seams/billing/templates/app/views/checkout/success.html.erb.tt +5 -0
  191. data/lib/generators/seams/billing/templates/app/views/invoices/index.html.erb.tt +22 -0
  192. data/lib/generators/seams/billing/templates/app/views/invoices/show.html.erb.tt +14 -0
  193. data/lib/generators/seams/billing/templates/app/views/plans/index.html.erb.tt +51 -0
  194. data/lib/generators/seams/billing/templates/app/views/subscriptions/index.html.erb.tt +16 -0
  195. data/lib/generators/seams/billing/templates/app/views/subscriptions/show.html.erb.tt +25 -0
  196. data/lib/generators/seams/billing/templates/config/routes.rb.tt +39 -0
  197. data/lib/generators/seams/billing/templates/db/migrate/create_billing_invoices.rb.tt +32 -0
  198. data/lib/generators/seams/billing/templates/db/migrate/create_billing_lifetime_passes.rb.tt +43 -0
  199. data/lib/generators/seams/billing/templates/db/migrate/create_billing_plans.rb.tt +31 -0
  200. data/lib/generators/seams/billing/templates/db/migrate/create_billing_subscriptions.rb.tt +33 -0
  201. data/lib/generators/seams/billing/templates/db/migrate/create_billing_webhook_events.rb.tt +24 -0
  202. data/lib/generators/seams/billing/templates/lib/billing.rb.tt +34 -0
  203. data/lib/generators/seams/billing/templates/lib/concerns/billable.rb.tt +100 -0
  204. data/lib/generators/seams/billing/templates/lib/configuration.rb.tt +52 -0
  205. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +72 -0
  206. data/lib/generators/seams/billing/templates/lib/gateways/abstract.rb.tt +65 -0
  207. data/lib/generators/seams/billing/templates/lib/gateways/adyen.rb.tt +16 -0
  208. data/lib/generators/seams/billing/templates/lib/gateways/paddle.rb.tt +22 -0
  209. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +155 -0
  210. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +101 -0
  211. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +43 -0
  212. data/lib/generators/seams/billing/templates/lib/tasks/billing_check.rake.tt +34 -0
  213. data/lib/generators/seams/billing/templates/spec/factories/billing.rb.tt +65 -0
  214. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +19 -0
  215. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +17 -0
  216. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +25 -0
  217. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +17 -0
  218. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +17 -0
  219. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +28 -0
  220. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +18 -0
  221. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +18 -0
  222. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +19 -0
  223. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +20 -0
  224. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +18 -0
  225. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +21 -0
  226. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +17 -0
  227. data/lib/generators/seams/billing/templates/spec/gateways/contract_spec.rb.tt +11 -0
  228. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +53 -0
  229. data/lib/generators/seams/billing/templates/spec/models/plan_spec.rb.tt +81 -0
  230. data/lib/generators/seams/billing/templates/spec/models/subscription_spec.rb.tt +43 -0
  231. data/lib/generators/seams/billing/templates/spec/runtime/boot_spec.rb.tt +38 -0
  232. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +382 -0
  233. data/lib/generators/seams/billing/templates/spec/support/shared_examples/a_billing_gateway.rb.tt +100 -0
  234. data/lib/generators/seams/billing/templates/spec/support/stripe_helpers.rb.tt +59 -0
  235. data/lib/generators/seams/core/core_generator.rb +191 -0
  236. data/lib/generators/seams/core/templates/README.md.tt +45 -0
  237. data/lib/generators/seams/core/templates/app/controllers/concerns/has_current_attributes.rb.tt +71 -0
  238. data/lib/generators/seams/core/templates/app/models/application_record.rb.tt +7 -0
  239. data/lib/generators/seams/core/templates/app/models/audit_log.rb.tt +19 -0
  240. data/lib/generators/seams/core/templates/app/models/concerns/auditable.rb.tt +64 -0
  241. data/lib/generators/seams/core/templates/app/models/concerns/sluggable.rb.tt +53 -0
  242. data/lib/generators/seams/core/templates/app/models/concerns/soft_deletable.rb.tt +37 -0
  243. data/lib/generators/seams/core/templates/app/models/concerns/tenant_scoped.rb.tt +39 -0
  244. data/lib/generators/seams/core/templates/app/models/current.rb.tt +16 -0
  245. data/lib/generators/seams/core/templates/app/services/event_publisher.rb.tt +23 -0
  246. data/lib/generators/seams/core/templates/app/validators/email_format_validator.rb.tt +21 -0
  247. data/lib/generators/seams/core/templates/db/migrate/create_core_audit_logs.rb.tt +29 -0
  248. data/lib/generators/seams/core/templates/lib/core.rb.tt +8 -0
  249. data/lib/generators/seams/core/templates/lib/engine.rb.tt +28 -0
  250. data/lib/generators/seams/core/templates/spec/concerns/auditable_spec.rb.tt +39 -0
  251. data/lib/generators/seams/core/templates/spec/concerns/sluggable_spec.rb.tt +29 -0
  252. data/lib/generators/seams/core/templates/spec/models/audit_log_spec.rb.tt +22 -0
  253. data/lib/generators/seams/core/templates/spec/runtime/boot_spec.rb.tt +25 -0
  254. data/lib/generators/seams/core/templates/spec/validators/email_format_validator_spec.rb.tt +29 -0
  255. data/lib/generators/seams/engine/engine_generator.rb +165 -0
  256. data/lib/generators/seams/engine/templates/Gemfile.tt +19 -0
  257. data/lib/generators/seams/engine/templates/LICENSE.tt +21 -0
  258. data/lib/generators/seams/engine/templates/README.md.tt +40 -0
  259. data/lib/generators/seams/engine/templates/Rakefile.tt +14 -0
  260. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  261. data/lib/generators/seams/engine/templates/app/application_record.rb.tt +16 -0
  262. data/lib/generators/seams/engine/templates/config/locales/en.yml.tt +14 -0
  263. data/lib/generators/seams/engine/templates/config/routes.rb.tt +4 -0
  264. data/lib/generators/seams/engine/templates/gemspec.tt +20 -0
  265. data/lib/generators/seams/engine/templates/host_initializer.rb.tt +13 -0
  266. data/lib/generators/seams/engine/templates/lib/engine.rb.tt +27 -0
  267. data/lib/generators/seams/engine/templates/lib/root.rb.tt +7 -0
  268. data/lib/generators/seams/engine/templates/lib/version.rb.tt +5 -0
  269. data/lib/generators/seams/engine/templates/rubocop.yml.tt +55 -0
  270. data/lib/generators/seams/engine/templates/spec/example_spec.rb.tt +16 -0
  271. data/lib/generators/seams/engine/templates/spec/spec_helper.rb.tt +23 -0
  272. data/lib/generators/seams/install/install_generator.rb +211 -0
  273. data/lib/generators/seams/install/templates/Dockerfile.tt +52 -0
  274. data/lib/generators/seams/install/templates/Procfile.tt +14 -0
  275. data/lib/generators/seams/install/templates/bin_seams.tt +107 -0
  276. data/lib/generators/seams/install/templates/ci.yml.tt +123 -0
  277. data/lib/generators/seams/install/templates/deploy.yml.tt +63 -0
  278. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +86 -0
  279. data/lib/generators/seams/install/templates/docker-entrypoint.tt +27 -0
  280. data/lib/generators/seams/install/templates/rubocop.yml.tt +33 -0
  281. data/lib/generators/seams/install/templates/ruby-version.tt +1 -0
  282. data/lib/generators/seams/install/templates/script/collate_coverage.rb.tt +33 -0
  283. data/lib/generators/seams/install/templates/script/run_affected_tests.sh.tt +64 -0
  284. data/lib/generators/seams/install/templates/seams.rake.tt +65 -0
  285. data/lib/generators/seams/install/templates/seams.rb.tt +9 -0
  286. data/lib/generators/seams/install/templates/seams_engines.rb.tt +15 -0
  287. data/lib/generators/seams/notifications/notifications_generator.rb +395 -0
  288. data/lib/generators/seams/notifications/templates/README.md.tt +269 -0
  289. data/lib/generators/seams/notifications/templates/app/channels/notification_channel.rb.tt +36 -0
  290. data/lib/generators/seams/notifications/templates/app/controllers/notifications_controller.rb.tt +58 -0
  291. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +54 -0
  292. data/lib/generators/seams/notifications/templates/app/javascript/controllers/notification_bell_controller.js.tt +34 -0
  293. data/lib/generators/seams/notifications/templates/app/jobs/application_job.rb.tt +6 -0
  294. data/lib/generators/seams/notifications/templates/app/jobs/create_notification_job.rb.tt +31 -0
  295. data/lib/generators/seams/notifications/templates/app/jobs/send_due_notifications_job.rb.tt +22 -0
  296. data/lib/generators/seams/notifications/templates/app/jobs/send_notification_job.rb.tt +13 -0
  297. data/lib/generators/seams/notifications/templates/app/mailers/application_mailer.rb.tt +12 -0
  298. data/lib/generators/seams/notifications/templates/app/mailers/notification_mailer.rb.tt +23 -0
  299. data/lib/generators/seams/notifications/templates/app/models/application_record.rb.tt +7 -0
  300. data/lib/generators/seams/notifications/templates/app/models/delivery.rb.tt +13 -0
  301. data/lib/generators/seams/notifications/templates/app/models/notification.rb.tt +218 -0
  302. data/lib/generators/seams/notifications/templates/app/models/notification_preference.rb.tt +29 -0
  303. data/lib/generators/seams/notifications/templates/app/models/strategies/email.rb.tt +38 -0
  304. data/lib/generators/seams/notifications/templates/app/models/strategies/in_app.rb.tt +26 -0
  305. data/lib/generators/seams/notifications/templates/app/models/strategies/sms.rb.tt +33 -0
  306. data/lib/generators/seams/notifications/templates/app/subscribers/auth_subscriber.rb.tt +71 -0
  307. data/lib/generators/seams/notifications/templates/app/subscribers/billing_subscriber.rb.tt +127 -0
  308. data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.html.erb.tt +22 -0
  309. data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.text.erb.tt +4 -0
  310. data/lib/generators/seams/notifications/templates/app/views/notifications/_bell.html.erb.tt +15 -0
  311. data/lib/generators/seams/notifications/templates/app/views/notifications/index.html.erb.tt +15 -0
  312. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.html.erb.tt +4 -0
  313. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.text.erb.tt +4 -0
  314. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.html.erb.tt +3 -0
  315. data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.text.erb.tt +3 -0
  316. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.html.erb.tt +5 -0
  317. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.text.erb.tt +5 -0
  318. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.html.erb.tt +5 -0
  319. data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.text.erb.tt +5 -0
  320. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.html.erb.tt +4 -0
  321. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.text.erb.tt +4 -0
  322. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.html.erb.tt +4 -0
  323. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.text.erb.tt +5 -0
  324. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.html.erb.tt +3 -0
  325. data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.text.erb.tt +3 -0
  326. data/lib/generators/seams/notifications/templates/app/views/templates/default.html.erb.tt +10 -0
  327. data/lib/generators/seams/notifications/templates/app/views/templates/default.text.erb.tt +11 -0
  328. data/lib/generators/seams/notifications/templates/app/views/templates/welcome.html.erb.tt +6 -0
  329. data/lib/generators/seams/notifications/templates/app/views/templates/welcome.text.erb.tt +6 -0
  330. data/lib/generators/seams/notifications/templates/config/initializers/notifications.rb.tt +58 -0
  331. data/lib/generators/seams/notifications/templates/config/routes.rb.tt +17 -0
  332. data/lib/generators/seams/notifications/templates/db/migrate/create_notification_deliveries.rb.tt +16 -0
  333. data/lib/generators/seams/notifications/templates/db/migrate/create_notification_preferences.rb.tt +25 -0
  334. data/lib/generators/seams/notifications/templates/db/migrate/create_notifications.rb.tt +35 -0
  335. data/lib/generators/seams/notifications/templates/lib/adapters/abstract.rb.tt +20 -0
  336. data/lib/generators/seams/notifications/templates/lib/adapters/action_mailer.rb.tt +17 -0
  337. data/lib/generators/seams/notifications/templates/lib/adapters/null_sms.rb.tt +23 -0
  338. data/lib/generators/seams/notifications/templates/lib/concerns/notifiable.rb.tt +135 -0
  339. data/lib/generators/seams/notifications/templates/lib/configuration.rb.tt +24 -0
  340. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +35 -0
  341. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +75 -0
  342. data/lib/generators/seams/notifications/templates/lib/type_registry.rb.tt +74 -0
  343. data/lib/generators/seams/notifications/templates/spec/factories/notifications.rb.tt +53 -0
  344. data/lib/generators/seams/notifications/templates/spec/models/delivery_spec.rb.tt +28 -0
  345. data/lib/generators/seams/notifications/templates/spec/models/notification_preference_spec.rb.tt +46 -0
  346. data/lib/generators/seams/notifications/templates/spec/models/notification_spec.rb.tt +60 -0
  347. data/lib/generators/seams/notifications/templates/spec/runtime/bell_broadcast_spec.rb.tt +59 -0
  348. data/lib/generators/seams/notifications/templates/spec/runtime/billing_subscriber_skip_spec.rb.tt +87 -0
  349. data/lib/generators/seams/notifications/templates/spec/runtime/boot_spec.rb.tt +39 -0
  350. data/lib/generators/seams/notifications/templates/spec/runtime/schedule_round_trip_spec.rb.tt +55 -0
  351. data/lib/generators/seams/remove/remove_generator.rb +259 -0
  352. data/lib/generators/seams/teams/teams_generator.rb +298 -0
  353. data/lib/generators/seams/teams/templates/README.md.tt +88 -0
  354. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +102 -0
  355. data/lib/generators/seams/teams/templates/app/controllers/memberships_controller.rb.tt +54 -0
  356. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +68 -0
  357. data/lib/generators/seams/teams/templates/app/jobs/application_job.rb.tt +6 -0
  358. data/lib/generators/seams/teams/templates/app/mailers/invitation_mailer.rb.tt +34 -0
  359. data/lib/generators/seams/teams/templates/app/models/application_record.rb.tt +7 -0
  360. data/lib/generators/seams/teams/templates/app/models/current.rb.tt +30 -0
  361. data/lib/generators/seams/teams/templates/app/models/invitation.rb.tt +36 -0
  362. data/lib/generators/seams/teams/templates/app/models/membership.rb.tt +36 -0
  363. data/lib/generators/seams/teams/templates/app/models/team.rb.tt +32 -0
  364. data/lib/generators/seams/teams/templates/app/subscribers/invitation_subscriber.rb.tt +36 -0
  365. data/lib/generators/seams/teams/templates/app/views/invitation_mailer/invite.text.erb.tt +8 -0
  366. data/lib/generators/seams/teams/templates/app/views/invitations/index.html.erb.tt +44 -0
  367. data/lib/generators/seams/teams/templates/app/views/memberships/index.html.erb.tt +32 -0
  368. data/lib/generators/seams/teams/templates/app/views/teams/edit.html.erb.tt +28 -0
  369. data/lib/generators/seams/teams/templates/app/views/teams/index.html.erb.tt +15 -0
  370. data/lib/generators/seams/teams/templates/app/views/teams/new.html.erb.tt +24 -0
  371. data/lib/generators/seams/teams/templates/app/views/teams/show.html.erb.tt +17 -0
  372. data/lib/generators/seams/teams/templates/config/routes.rb.tt +19 -0
  373. data/lib/generators/seams/teams/templates/db/migrate/create_team_invitations.rb.tt +24 -0
  374. data/lib/generators/seams/teams/templates/db/migrate/create_team_memberships.rb.tt +25 -0
  375. data/lib/generators/seams/teams/templates/db/migrate/create_teams.rb.tt +18 -0
  376. data/lib/generators/seams/teams/templates/lib/concerns/account_scoped.rb.tt +79 -0
  377. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +55 -0
  378. data/lib/generators/seams/teams/templates/lib/configuration.rb.tt +45 -0
  379. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +51 -0
  380. data/lib/generators/seams/teams/templates/lib/teams.rb.tt +22 -0
  381. data/lib/generators/seams/teams/templates/spec/factories/teams.rb.tt +47 -0
  382. data/lib/generators/seams/teams/templates/spec/models/invitation_spec.rb.tt +25 -0
  383. data/lib/generators/seams/teams/templates/spec/models/membership_spec.rb.tt +29 -0
  384. data/lib/generators/seams/teams/templates/spec/models/team_spec.rb.tt +23 -0
  385. data/lib/generators/seams/teams/templates/spec/runtime/boot_spec.rb.tt +32 -0
  386. data/lib/seams/cli/list.rb +111 -0
  387. data/lib/seams/cli/quality.rb +99 -0
  388. data/lib/seams/cli/resolve.rb +276 -0
  389. data/lib/seams/cli/test_changed.rb +116 -0
  390. data/lib/seams/cli.rb +48 -0
  391. data/lib/seams/configuration.rb +19 -0
  392. data/lib/seams/cops/known_queue_names.rb +42 -0
  393. data/lib/seams/cops/migration_comments.rb +68 -0
  394. data/lib/seams/cops/no_cross_engine_dependency.rb +58 -0
  395. data/lib/seams/cops/no_cross_engine_model_access.rb +153 -0
  396. data/lib/seams/cops.rb +18 -0
  397. data/lib/seams/event_registry.rb +49 -0
  398. data/lib/seams/events/adapter.rb +24 -0
  399. data/lib/seams/events/adapters/active_support.rb +31 -0
  400. data/lib/seams/events/publisher.rb +178 -0
  401. data/lib/seams/events.rb +39 -0
  402. data/lib/seams/generators/dummy_app_writer.rb +424 -0
  403. data/lib/seams/generators/eject_aware.rb +102 -0
  404. data/lib/seams/generators/follow_up_generator.rb +148 -0
  405. data/lib/seams/generators/host_injector.rb +124 -0
  406. data/lib/seams/generators/sibling_rubocop_writer.rb +77 -0
  407. data/lib/seams/generators/splicer.rb +217 -0
  408. data/lib/seams/observability/adapter.rb +33 -0
  409. data/lib/seams/observability/adapters/rails_logger.rb +59 -0
  410. data/lib/seams/observability.rb +34 -0
  411. data/lib/seams/runtime.rb +23 -0
  412. data/lib/seams/version.rb +5 -0
  413. data/lib/seams.rb +23 -0
  414. metadata +493 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Billing
6
+ module Customers
7
+ # Resolves a Stripe customer for an Accounts::Account. Looks up by
8
+ # `email` first via /v1/customers/search; creates a new one if no
9
+ # match is found. Returns ServiceResult with `value: customer_ref`
10
+ # (the Stripe `cus_*` id).
11
+ #
12
+ # result = Billing::Customers::FindOrCreateService.call(
13
+ # email: "owner@acme.com",
14
+ # metadata: { account_id: "5e3b...", account_name: "Acme Inc" }
15
+ # )
16
+ # result.ok? # => true
17
+ # result.value # => "cus_xyz"
18
+ #
19
+ # Idempotent — re-running with the same email returns the same
20
+ # customer_ref. Stripe's `/v1/customers/search` index is eventually
21
+ # consistent (the docs quote a "less than a minute" delay:
22
+ # https://docs.stripe.com/api/customers/search), so two
23
+ # near-simultaneous signups for the same email can both miss the
24
+ # search and both fall through to `POST /v1/customers`. Stripe
25
+ # does not dedupe customers by email server-side, so without
26
+ # protection that produces two `cus_*` rows for the same person.
27
+ #
28
+ # We defend against that with a deterministic Stripe
29
+ # `Idempotency-Key` header derived from the email plus the
30
+ # caller-supplied scope (typically `account_id` — the tenant the
31
+ # customer represents). Per
32
+ # https://docs.stripe.com/api/idempotent_requests Stripe caches
33
+ # the response for at least 24h and replays it for the same key,
34
+ # so two concurrent calls with the same key produce one customer.
35
+ # The key is SHA-256(email:scope) so it stays well under the 255-
36
+ # character ceiling and is stable across retries.
37
+ class FindOrCreateService < Billing::StripeService
38
+ def initialize(email:, metadata: {})
39
+ @email = email
40
+ @metadata = metadata
41
+ end
42
+
43
+ def call_stripe(client)
44
+ existing = client.search_customers(query: %(email:"#{@email}"), limit: 1)
45
+ return existing[:data].first if existing[:data].any?
46
+
47
+ client.create_customer(
48
+ email: @email,
49
+ metadata: @metadata,
50
+ idempotency_key: idempotency_key
51
+ )
52
+ end
53
+
54
+ def on_success(stripe_response)
55
+ ServiceResult.ok(value: stripe_response[:id])
56
+ end
57
+
58
+ private
59
+
60
+ # Deterministic per-(email, scope) key. SHA-256 hex is 64 chars
61
+ # — well within Stripe's 255-character limit
62
+ # (https://docs.stripe.com/api/idempotent_requests). The
63
+ # `seams:billing:customer:` prefix namespaces the key so it
64
+ # cannot collide with other engines that share the same Stripe
65
+ # account. Scope defaults to the caller-supplied `account_id`
66
+ # (the tenant the Stripe customer represents post-Wave-9).
67
+ def idempotency_key
68
+ scope = @metadata[:account_id] || @metadata["account_id"] || ""
69
+ Digest::SHA256.hexdigest("seams:billing:customer:#{@email}:#{scope}")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Invoices
5
+ # Fetches an invoice from Stripe and upserts the local
6
+ # Billing::Invoice row. Used by the invoice.* webhook handlers
7
+ # for retries / out-of-order delivery, and by the
8
+ # InvoicesController for on-demand refresh when a host wants the
9
+ # latest status without waiting for the next webhook.
10
+ #
11
+ # result = Billing::Invoices::SyncService.call(invoice_ref: "in_xyz")
12
+ # result.value.status # => "paid"
13
+ class SyncService < Billing::StripeService
14
+ def initialize(invoice_ref:)
15
+ @invoice_ref = invoice_ref
16
+ end
17
+
18
+ def call_stripe(client)
19
+ client.retrieve_invoice(@invoice_ref)
20
+ end
21
+
22
+ def on_success(stripe_response)
23
+ invoice = Billing::Invoice.find_or_initialize_by(gateway_ref: stripe_response[:id])
24
+ invoice.assign_attributes(
25
+ customer_ref: stripe_response[:customer],
26
+ subscription_ref: stripe_response[:subscription],
27
+ status: stripe_response[:status],
28
+ amount_cents: stripe_response[:amount_paid] || stripe_response[:amount_due],
29
+ currency: stripe_response[:currency].to_s.upcase,
30
+ paid_at: paid_at_for(stripe_response)
31
+ )
32
+ invoice.save!
33
+
34
+ ServiceResult.ok(value: invoice)
35
+ end
36
+
37
+ private
38
+
39
+ # Stripe moved the paid timestamp out of the top-level invoice
40
+ # object into status_transitions.paid_at — see
41
+ # https://docs.stripe.com/api/invoices/object#invoice_object-status_transitions.
42
+ # We still tolerate a top-level :paid_at on older API versions.
43
+ def paid_at_for(stripe_response)
44
+ unix = stripe_response.dig(:status_transitions, :paid_at) ||
45
+ stripe_response[:paid_at]
46
+ unix && Time.at(unix)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Lifetime
5
+ # Creates a Stripe Checkout session for an LTD plan (`mode: payment`).
6
+ # Validates inventory + plan-is-lifetime before round-tripping to
7
+ # the gateway so a sold-out plan doesn't burn a Stripe API call.
8
+ #
9
+ # The `account_id:` arg is the Accounts::Account id that will own
10
+ # the LifetimePass once the buyer completes Checkout. It's
11
+ # threaded into Stripe's session metadata so the webhook handler
12
+ # (CreatePassFromCheckoutService) can write the correct
13
+ # `account_id` on the resulting LifetimePass row.
14
+ #
15
+ # Concurrency note (the reason this service is not "just call Stripe"):
16
+ # `Billing::Plan#max_lifetime_units` is enforced by counting issued
17
+ # `LifetimePass` rows. A plain count is racey — two browsers clicking
18
+ # "Buy lifetime" within the same second on a plan with one seat left
19
+ # will both pass the check, both hit Stripe Checkout, both pay, and
20
+ # the unique index on `(account_id, plan_ref)` does not save us
21
+ # because the buyers are on different accounts. We end up with
22
+ # `count > max_lifetime_units` and an oversold promo.
23
+ #
24
+ # The fix: open a transaction, take a `SELECT ... FOR UPDATE`
25
+ # row-lock on the plan, re-check inventory under the lock, and
26
+ # only THEN create the Stripe Checkout session. The lock is held
27
+ # for the duration of the Stripe API call (released on commit).
28
+ #
29
+ # Trade-off: the Stripe call can take up to ~30s, so under a viral
30
+ # promo with 1000+ concurrent buyers on the same plan, checkout
31
+ # session creation serialises. For LTDs (low-volume by design) this
32
+ # is the right trade — correctness over throughput. For high-volume
33
+ # workloads the alternative is optimistic concurrency via a counter
34
+ # column + `UPDATE ... WHERE remaining > 0 RETURNING`, which is out
35
+ # of scope for the seams generator.
36
+ #
37
+ # Returns a Result with ok?, url, error.
38
+ module CreateLifetimeSessionService
39
+ Result = Struct.new(:ok?, :url, :error, keyword_init: true)
40
+
41
+ module_function
42
+
43
+ def call(account_id:, customer_ref:, plan_ref:, success_url:, cancel_url:)
44
+ url = Billing::Plan.transaction do
45
+ plan = Billing::Plan.find_by(gateway_ref: plan_ref)
46
+ raise PlanLookupError, "Plan #{plan_ref.inspect} not found" unless plan
47
+ raise PlanLookupError, "Plan #{plan_ref.inspect} is not a lifetime plan" unless plan.lifetime?
48
+
49
+ # Inside the transaction so the row-lock is held until commit.
50
+ # Raises Billing::Plan::SoldOut if the cap is exhausted.
51
+ plan.enforce_lifetime_inventory_or_raise!
52
+
53
+ # Stripe Checkout session creation happens under the lock.
54
+ # Yes, this serialises concurrent buyers on the same plan —
55
+ # see the class comment for why that's the right trade for LTDs.
56
+ session = Billing.gateway.create_lifetime_checkout_session(
57
+ customer_ref: customer_ref,
58
+ plan_ref: plan_ref,
59
+ success_url: success_url,
60
+ cancel_url: cancel_url,
61
+ metadata: { account_id: account_id }
62
+ )
63
+ session[:url]
64
+ end
65
+
66
+ Result.new(ok?: true, url: url)
67
+ rescue PlanLookupError => e
68
+ Result.new(ok?: false, error: e.message)
69
+ rescue Billing::Plan::SoldOut => e
70
+ Result.new(ok?: false, error: e.message)
71
+ rescue Billing::GatewayError => e
72
+ Result.new(ok?: false, error: e.message)
73
+ end
74
+
75
+ # Internal sentinel so `find_by` misses + non-lifetime plans bubble
76
+ # out of the transaction block as failures (rather than as a return
77
+ # value, which `transaction { }` would commit). Not part of the
78
+ # public API.
79
+ class PlanLookupError < StandardError; end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Lifetime
5
+ # Creates a LifetimePass from a successful Stripe Checkout Session.
6
+ # Called from the WebhooksController when a `checkout.session.completed`
7
+ # (or `checkout.session.async_payment_succeeded`) event arrives with
8
+ # `mode == "payment"` AND the session metadata flags `access_type:
9
+ # "lifetime"`.
10
+ #
11
+ # The session metadata MUST include `account_id` (the
12
+ # Accounts::Account id) — CreateLifetimeSessionService writes it
13
+ # when generating the Stripe Checkout session, so a webhook that
14
+ # arrives without it is from a manually-created session and
15
+ # should fail loudly.
16
+ #
17
+ # Idempotent on `gateway_ref` (the Stripe Checkout Session id) so
18
+ # Stripe retries hit the unique index and short-circuit.
19
+ #
20
+ # Publishes `lifetime.purchased.billing` on success.
21
+ #
22
+ # Docs:
23
+ # https://docs.stripe.com/api/checkout/sessions/object
24
+ # https://docs.stripe.com/payments/checkout/fulfill-orders
25
+ module CreatePassFromCheckoutService
26
+ Result = Struct.new(:ok?, :pass, :error, keyword_init: true)
27
+
28
+ module_function
29
+
30
+ def call(session:, livemode: false)
31
+ gateway_ref = session_field(session, :id)
32
+ customer_ref = session_field(session, :customer)
33
+ metadata = session_field(session, :metadata) || {}
34
+ plan_ref = metadata_value(metadata, :plan_ref)
35
+ account_id = metadata_value(metadata, :account_id)
36
+
37
+ return Result.new(ok?: false, error: "Session id missing") if gateway_ref.nil?
38
+ return Result.new(ok?: false, error: "Customer ref missing on session") if customer_ref.nil?
39
+ return Result.new(ok?: false, error: "plan_ref metadata missing on session") if plan_ref.nil?
40
+ return Result.new(ok?: false, error: "account_id metadata missing on session") if account_id.nil?
41
+
42
+ pass = Billing::LifetimePass.find_or_initialize_by(gateway_ref: gateway_ref)
43
+ return Result.new(ok?: true, pass: pass) if pass.persisted?
44
+
45
+ pass.assign_attributes(
46
+ account_id: account_id,
47
+ customer_ref: customer_ref,
48
+ plan_ref: plan_ref,
49
+ granted_at: Time.current,
50
+ revoked_at: nil
51
+ )
52
+ pass.save!
53
+
54
+ Seams::Events::Publisher.publish(
55
+ "lifetime.purchased.billing",
56
+ gateway: Billing.configuration.gateway_name,
57
+ livemode: livemode,
58
+ account_id: account_id,
59
+ customer_ref: customer_ref,
60
+ ref: pass.id.to_s,
61
+ object_id: gateway_ref,
62
+ object: { id: gateway_ref, account_id: account_id, plan_ref: plan_ref, pass_id: pass.id }
63
+ )
64
+ Result.new(ok?: true, pass: pass)
65
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
66
+ Result.new(ok?: false, error: e.message)
67
+ end
68
+
69
+ # Stripe SDK objects respond to method-style access; webhook
70
+ # payloads parsed from JSON are Hashes. Handle both.
71
+ def self.session_field(session, name)
72
+ if session.respond_to?(name)
73
+ session.public_send(name)
74
+ elsif session.is_a?(Hash)
75
+ session[name] || session[name.to_s]
76
+ end
77
+ end
78
+
79
+ def self.metadata_value(metadata, key)
80
+ if metadata.respond_to?(key)
81
+ metadata.public_send(key)
82
+ elsif metadata.is_a?(Hash)
83
+ metadata[key] || metadata[key.to_s]
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Lifetime
5
+ # Privately grants a Lifetime Pass to an Account. NO Stripe charge —
6
+ # used for early adopters, influencer giveaways, support gestures,
7
+ # ToS-violation refunds-then-re-grant.
8
+ #
9
+ # The `granted_by` argument is an Auth::Identity (the human who
10
+ # pressed the "grant lifetime" button) — Identity, not the
11
+ # Account, because we record human action here. Pass `nil` for
12
+ # system-granted passes (background job / migration / seed data).
13
+ #
14
+ # Idempotent on (account_id, plan_ref) — re-granting an existing
15
+ # pass returns ok? + the existing record rather than raising.
16
+ #
17
+ # Publishes `lifetime.granted.billing` on success (canonical billing
18
+ # payload shape — see billing/README).
19
+ module GrantPassService
20
+ Result = Struct.new(:ok?, :pass, :error, keyword_init: true)
21
+
22
+ module_function
23
+
24
+ def call(account_id:, customer_ref:, plan_ref:, granted_by:, notes: nil)
25
+ plan = Billing::Plan.find_by(gateway_ref: plan_ref)
26
+ return Result.new(ok?: false, error: "Plan #{plan_ref.inspect} not found") unless plan
27
+ return Result.new(ok?: false, error: "Plan #{plan_ref.inspect} is not a lifetime plan") unless plan.lifetime?
28
+ return Result.new(ok?: false, error: "No lifetime inventory remaining for #{plan_ref.inspect}") if plan.lifetime_sold_out?
29
+
30
+ pass = Billing::LifetimePass.find_or_initialize_by(account_id: account_id, plan_ref: plan_ref)
31
+ return Result.new(ok?: true, pass: pass) if pass.persisted? && pass.active?
32
+
33
+ pass.assign_attributes(
34
+ customer_ref: customer_ref,
35
+ gateway_ref: nil,
36
+ granted_by_identity_id: granted_by_identity_id_from(granted_by),
37
+ granted_at: Time.current,
38
+ revoked_at: nil,
39
+ notes: notes
40
+ )
41
+ pass.save!
42
+
43
+ publish_granted(pass)
44
+ Result.new(ok?: true, pass: pass)
45
+ rescue ActiveRecord::RecordInvalid => e
46
+ Result.new(ok?: false, error: e.message)
47
+ end
48
+
49
+ # Coerces the `granted_by` argument into an integer Identity id.
50
+ # Accepts:
51
+ # - an Integer (assumed already an Auth::Identity#id)
52
+ # - any object responding to `#id` (an Auth::Identity record)
53
+ # - nil (system-granted; column stays NULL)
54
+ def self.granted_by_identity_id_from(granted_by)
55
+ return granted_by if granted_by.is_a?(Integer)
56
+ return granted_by.id if granted_by.respond_to?(:id) && !granted_by.nil?
57
+
58
+ nil
59
+ end
60
+
61
+ def self.publish_granted(pass)
62
+ Seams::Events::Publisher.publish(
63
+ "lifetime.granted.billing",
64
+ gateway: Billing.configuration.gateway_name,
65
+ livemode: false, # private grants are never livemode
66
+ account_id: pass.account_id,
67
+ customer_ref: pass.customer_ref,
68
+ ref: pass.id.to_s,
69
+ object_id: pass.id.to_s,
70
+ object: {
71
+ id: pass.id,
72
+ account_id: pass.account_id,
73
+ plan_ref: pass.plan_ref,
74
+ granted_by_identity_id: pass.granted_by_identity_id
75
+ }
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Lifetime
5
+ # Revokes a previously-issued LifetimePass. Soft delete: sets
6
+ # `revoked_at` (and `revoked_by_identity_id`) so the audit trail
7
+ # survives. Use for refunds, ToS violations, support gestures
8
+ # that are later reversed.
9
+ #
10
+ # The `revoked_by` argument is an Auth::Identity (the human who
11
+ # pressed "revoke") — Identity, not the Account that lost the
12
+ # entitlement. Pass `nil` for system-revoked passes (a background
13
+ # ToS sweep, for instance).
14
+ #
15
+ # Stripe refund (if the pass was paid) is NOT issued here — the
16
+ # caller is expected to refund via the gateway separately. This
17
+ # service updates only the local row.
18
+ #
19
+ # Idempotent: revoking an already-revoked pass returns ok? + the
20
+ # same row without re-publishing the event.
21
+ module RevokePassService
22
+ Result = Struct.new(:ok?, :pass, :error, keyword_init: true)
23
+
24
+ module_function
25
+
26
+ def call(pass:, revoked_by:, notes: nil)
27
+ pass = Billing::LifetimePass.find(pass) if pass.is_a?(Integer)
28
+ return Result.new(ok?: false, error: "Pass not found") unless pass
29
+
30
+ return Result.new(ok?: true, pass: pass) if pass.revoked?
31
+
32
+ pass.update!(
33
+ revoked_at: Time.current,
34
+ revoked_by_identity_id: GrantPassService.granted_by_identity_id_from(revoked_by),
35
+ notes: [pass.notes, notes].compact.join("\n---\n").presence
36
+ )
37
+
38
+ Seams::Events::Publisher.publish(
39
+ "lifetime.revoked.billing",
40
+ gateway: Billing.configuration.gateway_name,
41
+ livemode: false,
42
+ account_id: pass.account_id,
43
+ customer_ref: pass.customer_ref,
44
+ ref: pass.id.to_s,
45
+ object_id: pass.id.to_s,
46
+ object: {
47
+ id: pass.id,
48
+ account_id: pass.account_id,
49
+ plan_ref: pass.plan_ref,
50
+ revoked_by_identity_id: pass.revoked_by_identity_id
51
+ }
52
+ )
53
+ Result.new(ok?: true, pass: pass)
54
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
55
+ Result.new(ok?: false, error: e.message)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Portal
5
+ # Creates a customer-portal session — Stripe-hosted UI for the
6
+ # user to manage their subscription. Returns Result(ok?, url, error).
7
+ module CreateSessionService
8
+ Result = Struct.new(:ok?, :url, :error, keyword_init: true)
9
+
10
+ module_function
11
+
12
+ def call(customer_ref:, return_url:)
13
+ session = Billing.gateway.create_billing_portal_session(
14
+ customer_ref: customer_ref,
15
+ return_url: return_url
16
+ )
17
+ Result.new(ok?: true, url: session[:url])
18
+ rescue Billing::GatewayError => e
19
+ Result.new(ok?: false, error: e.message)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ # Uniform return shape for every Billing service object. Services
5
+ # return `Billing::ServiceResult.ok(value: ...)` or
6
+ # `Billing::ServiceResult.failure(error: ..., code: ...)` so callers
7
+ # can branch on `result.ok?` without each service inventing its own
8
+ # contract.
9
+ #
10
+ # result = Billing::Subscriptions::CancelService.call(subscription_ref: "sub_X")
11
+ # if result.ok?
12
+ # redirect_to billing_root_path, notice: "Cancelled"
13
+ # else
14
+ # flash[:alert] = result.error
15
+ # redirect_back(fallback_location: billing_root_path)
16
+ # end
17
+ #
18
+ # `code:` is an optional machine-readable tag (`:not_found`,
19
+ # `:gateway_error`, `:already_cancelled`, etc.) that callers can
20
+ # branch on without parsing the human message in `error:`.
21
+ ServiceResult = Struct.new(:ok, :value, :error, :code, keyword_init: true) do
22
+ def self.ok(value: nil)
23
+ new(ok: true, value: value)
24
+ end
25
+
26
+ def self.failure(error:, code: nil)
27
+ new(ok: false, error: error, code: code)
28
+ end
29
+
30
+ def ok?
31
+ ok == true
32
+ end
33
+
34
+ def failure?
35
+ !ok?
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ # Base class for service objects that talk to Stripe via
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.
9
+ #
10
+ # class MyService < Billing::StripeService
11
+ # def initialize(plan_ref:)
12
+ # @plan_ref = plan_ref
13
+ # end
14
+ #
15
+ # def call_stripe(client)
16
+ # client.create_checkout_session(...)
17
+ # end
18
+ #
19
+ # def on_success(stripe_response)
20
+ # ServiceResult.ok(value: stripe_response)
21
+ # end
22
+ # end
23
+ #
24
+ # MyService.call(plan_ref: "p_pro")
25
+ class StripeService
26
+ def self.call(**kwargs)
27
+ new(**kwargs).call
28
+ end
29
+
30
+ # Override in subclasses. Receives a configured
31
+ # Billing::Stripe::Client. Return whatever the Stripe call
32
+ # returns — the base class translates it via #on_success.
33
+ def call_stripe(_client)
34
+ raise NotImplementedError, "#{self.class} must implement #call_stripe"
35
+ end
36
+
37
+ # Override to shape the Stripe response into the service's public
38
+ # ServiceResult value. Default: pass the raw response through.
39
+ def on_success(stripe_response)
40
+ ServiceResult.ok(value: stripe_response)
41
+ end
42
+
43
+ def call
44
+ on_success(call_stripe(client))
45
+ 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`.
50
+ ServiceResult.failure(error: e.message, code: classify_gateway_error(e))
51
+ end
52
+
53
+ private
54
+
55
+ def classify_gateway_error(error)
56
+ case error.message
57
+ when /connection error|TimeoutError|ConnectionFailed/i then :gateway_unreachable
58
+ when /authentication|invalid api key/i then :gateway_auth
59
+ else :gateway_error
60
+ end
61
+ end
62
+
63
+ def client
64
+ @client ||= Billing::Stripe::Client.new(api_key: Billing.configuration.api_key)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Subscriptions
5
+ # Cancels a Stripe subscription. By default schedules the cancel
6
+ # for end-of-period (cancel_at_period_end: true) so the user
7
+ # keeps access through what they've already paid for.
8
+ # `immediate: true` cancels the subscription right away — the
9
+ # webhook fires customer.subscription.deleted shortly after.
10
+ #
11
+ # Billing::Subscriptions::CancelService.call(
12
+ # subscription_ref: subscription.gateway_ref
13
+ # )
14
+ #
15
+ # Verified against
16
+ # https://docs.stripe.com/api/subscriptions/update (period-end)
17
+ # and https://docs.stripe.com/api/subscriptions/cancel (immediate).
18
+ class CancelService < Billing::StripeService
19
+ def initialize(subscription_ref:, immediate: false)
20
+ @subscription_ref = subscription_ref
21
+ @immediate = immediate
22
+ end
23
+
24
+ def call_stripe(client)
25
+ if @immediate
26
+ client.cancel_subscription(@subscription_ref)
27
+ else
28
+ client.update_subscription(@subscription_ref, cancel_at_period_end: true)
29
+ end
30
+ end
31
+
32
+ def on_success(stripe_response)
33
+ ServiceResult.ok(value: {
34
+ id: stripe_response[:id],
35
+ status: stripe_response[:status],
36
+ cancel_at_period_end: stripe_response[:cancel_at_period_end],
37
+ canceled_at: stripe_response[:canceled_at]
38
+ })
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Billing
4
+ module Subscriptions
5
+ # Switches a Stripe subscription to a new price. Stripe requires
6
+ # the existing subscription_item id (not just the new price), so
7
+ # this service does a retrieve-then-update rather than a single
8
+ # call. Proration mode defaults to "create_prorations" so the
9
+ # user is billed/credited the difference immediately; pass
10
+ # `proration_behavior: "none"` to defer until the next cycle.
11
+ #
12
+ # Billing::Subscriptions::ChangePlanService.call(
13
+ # subscription_ref: "sub_xyz",
14
+ # new_price_ref: "price_pro_annual"
15
+ # )
16
+ #
17
+ # Verified against
18
+ # https://docs.stripe.com/billing/subscriptions/upgrade-downgrade.
19
+ class ChangePlanService < Billing::StripeService
20
+ def initialize(subscription_ref:, new_price_ref:, proration_behavior: "create_prorations")
21
+ @subscription_ref = subscription_ref
22
+ @new_price_ref = new_price_ref
23
+ @proration_behavior = proration_behavior
24
+ end
25
+
26
+ def call_stripe(client)
27
+ existing = client.retrieve_subscription(@subscription_ref)
28
+ existing_item = existing[:items][:data].first
29
+ return :no_items if existing_item.nil?
30
+
31
+ client.update_subscription(
32
+ @subscription_ref,
33
+ items: [
34
+ { id: existing_item[:id], price: @new_price_ref }
35
+ ],
36
+ proration_behavior: @proration_behavior
37
+ )
38
+ end
39
+
40
+ def on_success(stripe_response)
41
+ return ServiceResult.failure(error: "Subscription has no items", code: :invalid_state) if stripe_response == :no_items
42
+
43
+ new_price = stripe_response[:items][:data].first[:price][:id]
44
+ ServiceResult.ok(value: { id: stripe_response[:id], plan_ref: new_price })
45
+ end
46
+ end
47
+ end
48
+ end