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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +335 -0
- data/LICENSE +21 -0
- data/README.md +104 -0
- data/lib/generators/seams/accounts/accounts_generator.rb +272 -0
- data/lib/generators/seams/accounts/templates/README.md.tt +219 -0
- data/lib/generators/seams/accounts/templates/app/models/account.rb.tt +124 -0
- data/lib/generators/seams/accounts/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/accounts/templates/app/models/current.rb.tt +38 -0
- data/lib/generators/seams/accounts/templates/app/models/membership.rb.tt +114 -0
- data/lib/generators/seams/accounts/templates/config/routes.rb.tt +8 -0
- data/lib/generators/seams/accounts/templates/db/migrate/create_accounts.rb.tt +29 -0
- data/lib/generators/seams/accounts/templates/db/migrate/create_accounts_memberships.rb.tt +49 -0
- data/lib/generators/seams/accounts/templates/lib/accounts.rb.tt +21 -0
- data/lib/generators/seams/accounts/templates/lib/concerns/account_scoped.rb.tt +97 -0
- data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +105 -0
- data/lib/generators/seams/accounts/templates/lib/configuration.rb.tt +26 -0
- data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +55 -0
- data/lib/generators/seams/accounts/templates/spec/factories/accounts.rb.tt +49 -0
- data/lib/generators/seams/accounts/templates/spec/models/accounts/account_spec.rb.tt +64 -0
- data/lib/generators/seams/accounts/templates/spec/models/accounts/membership_spec.rb.tt +99 -0
- data/lib/generators/seams/accounts/templates/spec/runtime/accounts_boot_spec.rb.tt +181 -0
- data/lib/generators/seams/admin/admin_generator.rb +852 -0
- data/lib/generators/seams/admin/templates/README.md.tt +266 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_controller.rb.tt +16 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/accounts_memberships_controller.rb.tt +16 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +282 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/identities_controller.rb.tt +26 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/invitations_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/invoices_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/notification_preferences_controller.rb.tt +15 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/notifications_controller.rb.tt +18 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/plans_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/subscriptions_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/teams_controller.rb.tt +14 -0
- data/lib/generators/seams/admin/templates/app/controllers/admin/teams_memberships_controller.rb.tt +16 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +50 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +58 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +48 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +51 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +67 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +65 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +58 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +43 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +72 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +59 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +39 -0
- data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +43 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/account_policy.rb.tt +10 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/accounts_membership_policy.rb.tt +10 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/application_policy.rb.tt +85 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/identity_policy.rb.tt +18 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/invitation_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/invoice_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/lifetime_pass_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/notification_preference_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/plan_policy.rb.tt +11 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/subscription_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/team_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/platform/teams_membership_policy.rb.tt +9 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +33 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +24 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +169 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +67 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +24 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +21 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +21 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +25 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +23 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +47 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +22 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +28 -0
- data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +24 -0
- data/lib/generators/seams/admin/templates/config/routes.rb.tt +38 -0
- data/lib/generators/seams/admin/templates/lib/admin.rb.tt +36 -0
- data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +66 -0
- data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +90 -0
- data/lib/generators/seams/admin/templates/lib/context.rb.tt +44 -0
- data/lib/generators/seams/admin/templates/lib/engine.rb.tt +68 -0
- data/lib/generators/seams/admin/templates/spec/factories/admin.rb.tt +10 -0
- data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +604 -0
- data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +157 -0
- data/lib/generators/seams/auth/add_oauth_provider/templates/adapter.rb.tt +95 -0
- data/lib/generators/seams/auth/add_oauth_provider/templates/adapter_spec.rb.tt +58 -0
- data/lib/generators/seams/auth/auth_generator.rb +311 -0
- data/lib/generators/seams/auth/templates/README.md.tt +289 -0
- data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +80 -0
- data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +44 -0
- data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +34 -0
- data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +49 -0
- data/lib/generators/seams/auth/templates/app/jobs/application_job.rb.tt +7 -0
- data/lib/generators/seams/auth/templates/app/jobs/cleanup_expired_sessions_job.rb.tt +30 -0
- data/lib/generators/seams/auth/templates/app/mailers/passwords_mailer.rb.tt +15 -0
- data/lib/generators/seams/auth/templates/app/models/api_token.rb.tt +62 -0
- data/lib/generators/seams/auth/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/auth/templates/app/models/current.rb.tt +15 -0
- data/lib/generators/seams/auth/templates/app/models/identity.rb.tt +74 -0
- data/lib/generators/seams/auth/templates/app/models/oauth/provider.rb.tt +48 -0
- data/lib/generators/seams/auth/templates/app/models/session.rb.tt +28 -0
- data/lib/generators/seams/auth/templates/app/services/authenticate_identity.rb.tt +31 -0
- data/lib/generators/seams/auth/templates/app/services/generate_api_token.rb.tt +35 -0
- data/lib/generators/seams/auth/templates/app/services/oauth/authenticator.rb.tt +94 -0
- data/lib/generators/seams/auth/templates/app/services/register_identity.rb.tt +57 -0
- data/lib/generators/seams/auth/templates/app/services/reset_password.rb.tt +41 -0
- data/lib/generators/seams/auth/templates/app/services/revoke_api_token.rb.tt +38 -0
- data/lib/generators/seams/auth/templates/app/views/password_resets/edit.html.erb.tt +12 -0
- data/lib/generators/seams/auth/templates/app/views/password_resets/new.html.erb.tt +11 -0
- data/lib/generators/seams/auth/templates/app/views/passwords_mailer/reset_email.html.erb.tt +7 -0
- data/lib/generators/seams/auth/templates/app/views/registrations/new.html.erb.tt +26 -0
- data/lib/generators/seams/auth/templates/app/views/sessions/_oauth_buttons.html.erb.tt +18 -0
- data/lib/generators/seams/auth/templates/app/views/sessions/new.html.erb.tt +17 -0
- data/lib/generators/seams/auth/templates/config/routes.rb.tt +21 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_api_tokens.rb.tt +26 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_identities.rb.tt +29 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +35 -0
- data/lib/generators/seams/auth/templates/db/migrate/create_auth_sessions.rb.tt +19 -0
- data/lib/generators/seams/auth/templates/lib/auth.rb.tt +39 -0
- data/lib/generators/seams/auth/templates/lib/concerns/api_authenticatable.rb.tt +58 -0
- data/lib/generators/seams/auth/templates/lib/concerns/authenticatable.rb.tt +32 -0
- data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +60 -0
- data/lib/generators/seams/auth/templates/lib/configuration.rb.tt +45 -0
- data/lib/generators/seams/auth/templates/lib/engine.rb.tt +46 -0
- data/lib/generators/seams/auth/templates/lib/oauth/abstract.rb.tt +87 -0
- data/lib/generators/seams/auth/templates/lib/oauth/github.rb.tt +112 -0
- data/lib/generators/seams/auth/templates/lib/oauth/google.rb.tt +78 -0
- data/lib/generators/seams/auth/templates/lib/tasks/auth_pii.rake.tt +68 -0
- data/lib/generators/seams/auth/templates/spec/factories/auth.rb.tt +38 -0
- data/lib/generators/seams/auth/templates/spec/mailers/passwords_mailer_spec.rb.tt +37 -0
- data/lib/generators/seams/auth/templates/spec/models/api_token_spec.rb.tt +84 -0
- data/lib/generators/seams/auth/templates/spec/models/identity_spec.rb.tt +56 -0
- data/lib/generators/seams/auth/templates/spec/models/oauth/provider_spec.rb.tt +64 -0
- data/lib/generators/seams/auth/templates/spec/models/session_spec.rb.tt +34 -0
- data/lib/generators/seams/auth/templates/spec/runtime/boot_spec.rb.tt +30 -0
- data/lib/generators/seams/auth/templates/spec/runtime/event_payload_spec.rb.tt +29 -0
- data/lib/generators/seams/auth/templates/spec/runtime/login_flow_spec.rb.tt +45 -0
- data/lib/generators/seams/billing/billing_generator.rb +476 -0
- data/lib/generators/seams/billing/templates/README.md.tt +355 -0
- data/lib/generators/seams/billing/templates/app/controllers/admin/lifetime_passes_controller.rb.tt +84 -0
- data/lib/generators/seams/billing/templates/app/controllers/checkout_controller.rb.tt +92 -0
- data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +63 -0
- data/lib/generators/seams/billing/templates/app/controllers/plans_controller.rb.tt +14 -0
- data/lib/generators/seams/billing/templates/app/controllers/portal_controller.rb.tt +45 -0
- data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +119 -0
- data/lib/generators/seams/billing/templates/app/controllers/webhooks_controller.rb.tt +98 -0
- data/lib/generators/seams/billing/templates/app/helpers/currency_helper.rb.tt +44 -0
- data/lib/generators/seams/billing/templates/app/jobs/application_job.rb.tt +6 -0
- data/lib/generators/seams/billing/templates/app/jobs/cancel_subscription_job.rb.tt +39 -0
- data/lib/generators/seams/billing/templates/app/jobs/start_subscription_job.rb.tt +32 -0
- data/lib/generators/seams/billing/templates/app/jobs/webhooks/process_event_job.rb.tt +37 -0
- data/lib/generators/seams/billing/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/billing/templates/app/models/invoice.rb.tt +35 -0
- data/lib/generators/seams/billing/templates/app/models/lifetime_pass.rb.tt +60 -0
- data/lib/generators/seams/billing/templates/app/models/plan.rb.tt +95 -0
- data/lib/generators/seams/billing/templates/app/models/subscription.rb.tt +31 -0
- data/lib/generators/seams/billing/templates/app/models/webhook_event.rb.tt +13 -0
- data/lib/generators/seams/billing/templates/app/services/checkout_session_service.rb.tt +25 -0
- data/lib/generators/seams/billing/templates/app/services/customers/find_or_create_service.rb.tt +73 -0
- data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +50 -0
- data/lib/generators/seams/billing/templates/app/services/lifetime/create_lifetime_session_service.rb.tt +82 -0
- data/lib/generators/seams/billing/templates/app/services/lifetime/create_pass_from_checkout_service.rb.tt +88 -0
- data/lib/generators/seams/billing/templates/app/services/lifetime/grant_pass_service.rb.tt +80 -0
- data/lib/generators/seams/billing/templates/app/services/lifetime/revoke_pass_service.rb.tt +59 -0
- data/lib/generators/seams/billing/templates/app/services/portal_session_service.rb.tt +23 -0
- data/lib/generators/seams/billing/templates/app/services/service_result.rb.tt +38 -0
- data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +67 -0
- data/lib/generators/seams/billing/templates/app/services/subscriptions/cancel_service.rb.tt +42 -0
- data/lib/generators/seams/billing/templates/app/services/subscriptions/change_plan_service.rb.tt +48 -0
- data/lib/generators/seams/billing/templates/app/services/subscriptions/reactivate_service.rb.tt +28 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/event_router.rb.tt +54 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handler.rb.tt +93 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/charge_refunded_handler.rb.tt +18 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/checkout_session_completed_handler.rb.tt +58 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_created_handler.rb.tt +16 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_finalized_handler.rb.tt +14 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +80 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_paid_handler.rb.tt +12 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_payment_failed_handler.rb.tt +12 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_voided_handler.rb.tt +12 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_failed_handler.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/payment_succeeded_handler.rb.tt +19 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_created_handler.rb.tt +11 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_deleted_handler.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +92 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_trial_will_end_handler.rb.tt +15 -0
- data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_updated_handler.rb.tt +11 -0
- data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/index.html.erb.tt +36 -0
- data/lib/generators/seams/billing/templates/app/views/admin/lifetime_passes/new.html.erb.tt +37 -0
- data/lib/generators/seams/billing/templates/app/views/checkout/success.html.erb.tt +5 -0
- data/lib/generators/seams/billing/templates/app/views/invoices/index.html.erb.tt +22 -0
- data/lib/generators/seams/billing/templates/app/views/invoices/show.html.erb.tt +14 -0
- data/lib/generators/seams/billing/templates/app/views/plans/index.html.erb.tt +51 -0
- data/lib/generators/seams/billing/templates/app/views/subscriptions/index.html.erb.tt +16 -0
- data/lib/generators/seams/billing/templates/app/views/subscriptions/show.html.erb.tt +25 -0
- data/lib/generators/seams/billing/templates/config/routes.rb.tt +39 -0
- data/lib/generators/seams/billing/templates/db/migrate/create_billing_invoices.rb.tt +32 -0
- data/lib/generators/seams/billing/templates/db/migrate/create_billing_lifetime_passes.rb.tt +43 -0
- data/lib/generators/seams/billing/templates/db/migrate/create_billing_plans.rb.tt +31 -0
- data/lib/generators/seams/billing/templates/db/migrate/create_billing_subscriptions.rb.tt +33 -0
- data/lib/generators/seams/billing/templates/db/migrate/create_billing_webhook_events.rb.tt +24 -0
- data/lib/generators/seams/billing/templates/lib/billing.rb.tt +34 -0
- data/lib/generators/seams/billing/templates/lib/concerns/billable.rb.tt +100 -0
- data/lib/generators/seams/billing/templates/lib/configuration.rb.tt +52 -0
- data/lib/generators/seams/billing/templates/lib/engine.rb.tt +72 -0
- data/lib/generators/seams/billing/templates/lib/gateways/abstract.rb.tt +65 -0
- data/lib/generators/seams/billing/templates/lib/gateways/adyen.rb.tt +16 -0
- data/lib/generators/seams/billing/templates/lib/gateways/paddle.rb.tt +22 -0
- data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +155 -0
- data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +101 -0
- data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +43 -0
- data/lib/generators/seams/billing/templates/lib/tasks/billing_check.rake.tt +34 -0
- data/lib/generators/seams/billing/templates/spec/factories/billing.rb.tt +65 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +19 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +17 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +25 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +17 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +17 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +28 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +18 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +18 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +19 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +20 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +18 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +21 -0
- data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +17 -0
- data/lib/generators/seams/billing/templates/spec/gateways/contract_spec.rb.tt +11 -0
- data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +53 -0
- data/lib/generators/seams/billing/templates/spec/models/plan_spec.rb.tt +81 -0
- data/lib/generators/seams/billing/templates/spec/models/subscription_spec.rb.tt +43 -0
- data/lib/generators/seams/billing/templates/spec/runtime/boot_spec.rb.tt +38 -0
- data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +382 -0
- data/lib/generators/seams/billing/templates/spec/support/shared_examples/a_billing_gateway.rb.tt +100 -0
- data/lib/generators/seams/billing/templates/spec/support/stripe_helpers.rb.tt +59 -0
- data/lib/generators/seams/core/core_generator.rb +191 -0
- data/lib/generators/seams/core/templates/README.md.tt +45 -0
- data/lib/generators/seams/core/templates/app/controllers/concerns/has_current_attributes.rb.tt +71 -0
- data/lib/generators/seams/core/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/core/templates/app/models/audit_log.rb.tt +19 -0
- data/lib/generators/seams/core/templates/app/models/concerns/auditable.rb.tt +64 -0
- data/lib/generators/seams/core/templates/app/models/concerns/sluggable.rb.tt +53 -0
- data/lib/generators/seams/core/templates/app/models/concerns/soft_deletable.rb.tt +37 -0
- data/lib/generators/seams/core/templates/app/models/concerns/tenant_scoped.rb.tt +39 -0
- data/lib/generators/seams/core/templates/app/models/current.rb.tt +16 -0
- data/lib/generators/seams/core/templates/app/services/event_publisher.rb.tt +23 -0
- data/lib/generators/seams/core/templates/app/validators/email_format_validator.rb.tt +21 -0
- data/lib/generators/seams/core/templates/db/migrate/create_core_audit_logs.rb.tt +29 -0
- data/lib/generators/seams/core/templates/lib/core.rb.tt +8 -0
- data/lib/generators/seams/core/templates/lib/engine.rb.tt +28 -0
- data/lib/generators/seams/core/templates/spec/concerns/auditable_spec.rb.tt +39 -0
- data/lib/generators/seams/core/templates/spec/concerns/sluggable_spec.rb.tt +29 -0
- data/lib/generators/seams/core/templates/spec/models/audit_log_spec.rb.tt +22 -0
- data/lib/generators/seams/core/templates/spec/runtime/boot_spec.rb.tt +25 -0
- data/lib/generators/seams/core/templates/spec/validators/email_format_validator_spec.rb.tt +29 -0
- data/lib/generators/seams/engine/engine_generator.rb +165 -0
- data/lib/generators/seams/engine/templates/Gemfile.tt +19 -0
- data/lib/generators/seams/engine/templates/LICENSE.tt +21 -0
- data/lib/generators/seams/engine/templates/README.md.tt +40 -0
- data/lib/generators/seams/engine/templates/Rakefile.tt +14 -0
- data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
- data/lib/generators/seams/engine/templates/app/application_record.rb.tt +16 -0
- data/lib/generators/seams/engine/templates/config/locales/en.yml.tt +14 -0
- data/lib/generators/seams/engine/templates/config/routes.rb.tt +4 -0
- data/lib/generators/seams/engine/templates/gemspec.tt +20 -0
- data/lib/generators/seams/engine/templates/host_initializer.rb.tt +13 -0
- data/lib/generators/seams/engine/templates/lib/engine.rb.tt +27 -0
- data/lib/generators/seams/engine/templates/lib/root.rb.tt +7 -0
- data/lib/generators/seams/engine/templates/lib/version.rb.tt +5 -0
- data/lib/generators/seams/engine/templates/rubocop.yml.tt +55 -0
- data/lib/generators/seams/engine/templates/spec/example_spec.rb.tt +16 -0
- data/lib/generators/seams/engine/templates/spec/spec_helper.rb.tt +23 -0
- data/lib/generators/seams/install/install_generator.rb +211 -0
- data/lib/generators/seams/install/templates/Dockerfile.tt +52 -0
- data/lib/generators/seams/install/templates/Procfile.tt +14 -0
- data/lib/generators/seams/install/templates/bin_seams.tt +107 -0
- data/lib/generators/seams/install/templates/ci.yml.tt +123 -0
- data/lib/generators/seams/install/templates/deploy.yml.tt +63 -0
- data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +86 -0
- data/lib/generators/seams/install/templates/docker-entrypoint.tt +27 -0
- data/lib/generators/seams/install/templates/rubocop.yml.tt +33 -0
- data/lib/generators/seams/install/templates/ruby-version.tt +1 -0
- data/lib/generators/seams/install/templates/script/collate_coverage.rb.tt +33 -0
- data/lib/generators/seams/install/templates/script/run_affected_tests.sh.tt +64 -0
- data/lib/generators/seams/install/templates/seams.rake.tt +65 -0
- data/lib/generators/seams/install/templates/seams.rb.tt +9 -0
- data/lib/generators/seams/install/templates/seams_engines.rb.tt +15 -0
- data/lib/generators/seams/notifications/notifications_generator.rb +395 -0
- data/lib/generators/seams/notifications/templates/README.md.tt +269 -0
- data/lib/generators/seams/notifications/templates/app/channels/notification_channel.rb.tt +36 -0
- data/lib/generators/seams/notifications/templates/app/controllers/notifications_controller.rb.tt +58 -0
- data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +54 -0
- data/lib/generators/seams/notifications/templates/app/javascript/controllers/notification_bell_controller.js.tt +34 -0
- data/lib/generators/seams/notifications/templates/app/jobs/application_job.rb.tt +6 -0
- data/lib/generators/seams/notifications/templates/app/jobs/create_notification_job.rb.tt +31 -0
- data/lib/generators/seams/notifications/templates/app/jobs/send_due_notifications_job.rb.tt +22 -0
- data/lib/generators/seams/notifications/templates/app/jobs/send_notification_job.rb.tt +13 -0
- data/lib/generators/seams/notifications/templates/app/mailers/application_mailer.rb.tt +12 -0
- data/lib/generators/seams/notifications/templates/app/mailers/notification_mailer.rb.tt +23 -0
- data/lib/generators/seams/notifications/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/notifications/templates/app/models/delivery.rb.tt +13 -0
- data/lib/generators/seams/notifications/templates/app/models/notification.rb.tt +218 -0
- data/lib/generators/seams/notifications/templates/app/models/notification_preference.rb.tt +29 -0
- data/lib/generators/seams/notifications/templates/app/models/strategies/email.rb.tt +38 -0
- data/lib/generators/seams/notifications/templates/app/models/strategies/in_app.rb.tt +26 -0
- data/lib/generators/seams/notifications/templates/app/models/strategies/sms.rb.tt +33 -0
- data/lib/generators/seams/notifications/templates/app/subscribers/auth_subscriber.rb.tt +71 -0
- data/lib/generators/seams/notifications/templates/app/subscribers/billing_subscriber.rb.tt +127 -0
- data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.html.erb.tt +22 -0
- data/lib/generators/seams/notifications/templates/app/views/layouts/notifications/mailer.text.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/notifications/_bell.html.erb.tt +15 -0
- data/lib/generators/seams/notifications/templates/app/views/notifications/index.html.erb.tt +15 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.html.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_failed.text.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.html.erb.tt +3 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/invoice_paid.text.erb.tt +3 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.html.erb.tt +5 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_granted.text.erb.tt +5 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.html.erb.tt +5 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/lifetime_purchased.text.erb.tt +5 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.html.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_canceled.text.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.html.erb.tt +4 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_started.text.erb.tt +5 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.html.erb.tt +3 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/billing/subscription_updated.text.erb.tt +3 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/default.html.erb.tt +10 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/default.text.erb.tt +11 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/welcome.html.erb.tt +6 -0
- data/lib/generators/seams/notifications/templates/app/views/templates/welcome.text.erb.tt +6 -0
- data/lib/generators/seams/notifications/templates/config/initializers/notifications.rb.tt +58 -0
- data/lib/generators/seams/notifications/templates/config/routes.rb.tt +17 -0
- data/lib/generators/seams/notifications/templates/db/migrate/create_notification_deliveries.rb.tt +16 -0
- data/lib/generators/seams/notifications/templates/db/migrate/create_notification_preferences.rb.tt +25 -0
- data/lib/generators/seams/notifications/templates/db/migrate/create_notifications.rb.tt +35 -0
- data/lib/generators/seams/notifications/templates/lib/adapters/abstract.rb.tt +20 -0
- data/lib/generators/seams/notifications/templates/lib/adapters/action_mailer.rb.tt +17 -0
- data/lib/generators/seams/notifications/templates/lib/adapters/null_sms.rb.tt +23 -0
- data/lib/generators/seams/notifications/templates/lib/concerns/notifiable.rb.tt +135 -0
- data/lib/generators/seams/notifications/templates/lib/configuration.rb.tt +24 -0
- data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +35 -0
- data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +75 -0
- data/lib/generators/seams/notifications/templates/lib/type_registry.rb.tt +74 -0
- data/lib/generators/seams/notifications/templates/spec/factories/notifications.rb.tt +53 -0
- data/lib/generators/seams/notifications/templates/spec/models/delivery_spec.rb.tt +28 -0
- data/lib/generators/seams/notifications/templates/spec/models/notification_preference_spec.rb.tt +46 -0
- data/lib/generators/seams/notifications/templates/spec/models/notification_spec.rb.tt +60 -0
- data/lib/generators/seams/notifications/templates/spec/runtime/bell_broadcast_spec.rb.tt +59 -0
- data/lib/generators/seams/notifications/templates/spec/runtime/billing_subscriber_skip_spec.rb.tt +87 -0
- data/lib/generators/seams/notifications/templates/spec/runtime/boot_spec.rb.tt +39 -0
- data/lib/generators/seams/notifications/templates/spec/runtime/schedule_round_trip_spec.rb.tt +55 -0
- data/lib/generators/seams/remove/remove_generator.rb +259 -0
- data/lib/generators/seams/teams/teams_generator.rb +298 -0
- data/lib/generators/seams/teams/templates/README.md.tt +88 -0
- data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +102 -0
- data/lib/generators/seams/teams/templates/app/controllers/memberships_controller.rb.tt +54 -0
- data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +68 -0
- data/lib/generators/seams/teams/templates/app/jobs/application_job.rb.tt +6 -0
- data/lib/generators/seams/teams/templates/app/mailers/invitation_mailer.rb.tt +34 -0
- data/lib/generators/seams/teams/templates/app/models/application_record.rb.tt +7 -0
- data/lib/generators/seams/teams/templates/app/models/current.rb.tt +30 -0
- data/lib/generators/seams/teams/templates/app/models/invitation.rb.tt +36 -0
- data/lib/generators/seams/teams/templates/app/models/membership.rb.tt +36 -0
- data/lib/generators/seams/teams/templates/app/models/team.rb.tt +32 -0
- data/lib/generators/seams/teams/templates/app/subscribers/invitation_subscriber.rb.tt +36 -0
- data/lib/generators/seams/teams/templates/app/views/invitation_mailer/invite.text.erb.tt +8 -0
- data/lib/generators/seams/teams/templates/app/views/invitations/index.html.erb.tt +44 -0
- data/lib/generators/seams/teams/templates/app/views/memberships/index.html.erb.tt +32 -0
- data/lib/generators/seams/teams/templates/app/views/teams/edit.html.erb.tt +28 -0
- data/lib/generators/seams/teams/templates/app/views/teams/index.html.erb.tt +15 -0
- data/lib/generators/seams/teams/templates/app/views/teams/new.html.erb.tt +24 -0
- data/lib/generators/seams/teams/templates/app/views/teams/show.html.erb.tt +17 -0
- data/lib/generators/seams/teams/templates/config/routes.rb.tt +19 -0
- data/lib/generators/seams/teams/templates/db/migrate/create_team_invitations.rb.tt +24 -0
- data/lib/generators/seams/teams/templates/db/migrate/create_team_memberships.rb.tt +25 -0
- data/lib/generators/seams/teams/templates/db/migrate/create_teams.rb.tt +18 -0
- data/lib/generators/seams/teams/templates/lib/concerns/account_scoped.rb.tt +79 -0
- data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +55 -0
- data/lib/generators/seams/teams/templates/lib/configuration.rb.tt +45 -0
- data/lib/generators/seams/teams/templates/lib/engine.rb.tt +51 -0
- data/lib/generators/seams/teams/templates/lib/teams.rb.tt +22 -0
- data/lib/generators/seams/teams/templates/spec/factories/teams.rb.tt +47 -0
- data/lib/generators/seams/teams/templates/spec/models/invitation_spec.rb.tt +25 -0
- data/lib/generators/seams/teams/templates/spec/models/membership_spec.rb.tt +29 -0
- data/lib/generators/seams/teams/templates/spec/models/team_spec.rb.tt +23 -0
- data/lib/generators/seams/teams/templates/spec/runtime/boot_spec.rb.tt +32 -0
- data/lib/seams/cli/list.rb +111 -0
- data/lib/seams/cli/quality.rb +99 -0
- data/lib/seams/cli/resolve.rb +276 -0
- data/lib/seams/cli/test_changed.rb +116 -0
- data/lib/seams/cli.rb +48 -0
- data/lib/seams/configuration.rb +19 -0
- data/lib/seams/cops/known_queue_names.rb +42 -0
- data/lib/seams/cops/migration_comments.rb +68 -0
- data/lib/seams/cops/no_cross_engine_dependency.rb +58 -0
- data/lib/seams/cops/no_cross_engine_model_access.rb +153 -0
- data/lib/seams/cops.rb +18 -0
- data/lib/seams/event_registry.rb +49 -0
- data/lib/seams/events/adapter.rb +24 -0
- data/lib/seams/events/adapters/active_support.rb +31 -0
- data/lib/seams/events/publisher.rb +178 -0
- data/lib/seams/events.rb +39 -0
- data/lib/seams/generators/dummy_app_writer.rb +424 -0
- data/lib/seams/generators/eject_aware.rb +102 -0
- data/lib/seams/generators/follow_up_generator.rb +148 -0
- data/lib/seams/generators/host_injector.rb +124 -0
- data/lib/seams/generators/sibling_rubocop_writer.rb +77 -0
- data/lib/seams/generators/splicer.rb +217 -0
- data/lib/seams/observability/adapter.rb +33 -0
- data/lib/seams/observability/adapters/rails_logger.rb +59 -0
- data/lib/seams/observability.rb +34 -0
- data/lib/seams/runtime.rb +23 -0
- data/lib/seams/version.rb +5 -0
- data/lib/seams.rb +23 -0
- metadata +493 -0
data/lib/generators/seams/billing/templates/app/services/customers/find_or_create_service.rb.tt
ADDED
|
@@ -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
|
data/lib/generators/seams/billing/templates/app/services/subscriptions/change_plan_service.rb.tt
ADDED
|
@@ -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
|