belt-pay 0.0.1

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.
@@ -0,0 +1,73 @@
1
+ # Pay Module — Stripe Payments & Subscriptions
2
+ # Generated by: belt generate pay
3
+ #
4
+ # Provides:
5
+ # - Secrets Manager secret for Stripe API keys
6
+ # - IAM policy for Lambda to read secrets
7
+ # - DynamoDB table for transaction audit log
8
+
9
+ terraform {
10
+ required_providers {
11
+ aws = {
12
+ source = "hashicorp/aws"
13
+ version = "~> 5.0"
14
+ }
15
+ }
16
+ }
17
+
18
+ locals {
19
+ common_tags = {
20
+ Module = "pay"
21
+ Environment = var.environment
22
+ ManagedBy = "Terraform"
23
+ }
24
+ secret_name = "${var.app_name}-${var.environment}-stripe"
25
+ }
26
+
27
+ # ============================================================================
28
+ # Secrets Manager — Stripe API Keys
29
+ # ============================================================================
30
+
31
+ resource "aws_secretsmanager_secret" "stripe" {
32
+ name = local.secret_name
33
+ description = "Stripe API keys for ${var.app_name} (${var.environment})"
34
+
35
+ tags = merge(local.common_tags, {
36
+ Name = local.secret_name
37
+ })
38
+ }
39
+
40
+ # Initial secret value — update manually or via CI after creation
41
+ resource "aws_secretsmanager_secret_version" "stripe" {
42
+ secret_id = aws_secretsmanager_secret.stripe.id
43
+ secret_string = jsonencode({
44
+ stripe_secret_key = var.stripe_secret_key
45
+ stripe_webhook_secret = var.stripe_webhook_secret
46
+ })
47
+
48
+ lifecycle {
49
+ ignore_changes = [secret_string]
50
+ }
51
+ }
52
+
53
+ # ============================================================================
54
+ # IAM Policy — Read Stripe Secrets
55
+ # ============================================================================
56
+
57
+ data "aws_iam_policy_document" "read_stripe_secrets" {
58
+ statement {
59
+ effect = "Allow"
60
+ actions = [
61
+ "secretsmanager:GetSecretValue"
62
+ ]
63
+ resources = [aws_secretsmanager_secret.stripe.arn]
64
+ }
65
+ }
66
+
67
+ resource "aws_iam_policy" "read_stripe_secrets" {
68
+ name = "${var.app_name}-${var.environment}-pay-secrets"
69
+ description = "Allow Lambda to read Stripe secrets"
70
+ policy = data.aws_iam_policy_document.read_stripe_secrets.json
71
+
72
+ tags = local.common_tags
73
+ }
@@ -0,0 +1,14 @@
1
+ output "stripe_secret_arn" {
2
+ description = "ARN of the Stripe secrets in Secrets Manager"
3
+ value = aws_secretsmanager_secret.stripe.arn
4
+ }
5
+
6
+ output "stripe_secret_name" {
7
+ description = "Name of the Stripe secret (for Lambda env vars)"
8
+ value = local.secret_name
9
+ }
10
+
11
+ output "read_secrets_policy_arn" {
12
+ description = "ARN of the IAM policy for reading Stripe secrets"
13
+ value = aws_iam_policy.read_stripe_secrets.arn
14
+ }
@@ -0,0 +1,23 @@
1
+ variable "app_name" {
2
+ description = "Application name"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "Environment name (dev, staging, prod)"
8
+ type = string
9
+ }
10
+
11
+ variable "stripe_secret_key" {
12
+ description = "Stripe secret API key (initial value — update via console/CI)"
13
+ type = string
14
+ default = "sk_test_PLACEHOLDER"
15
+ sensitive = true
16
+ }
17
+
18
+ variable "stripe_webhook_secret" {
19
+ description = "Stripe webhook signing secret (initial value — update via console/CI)"
20
+ type = string
21
+ default = "whsec_PLACEHOLDER"
22
+ sensitive = true
23
+ }
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'activeitem'
4
+ require 'securerandom'
5
+
6
+ module Belt
7
+ module Pay
8
+ # Internal Transaction model — lives in the gem, not generated into apps.
9
+ # Records all payment events (checkouts, subscriptions, refunds) for audit logging.
10
+ #
11
+ # Override by creating your own Belt::Pay::Transaction class before requiring belt-pay,
12
+ # or monkey-patch individual methods as needed.
13
+ class Transaction < ActiveItem::Base
14
+ self.table_name = -> { Belt::Pay.configuration.transactions_table_name }
15
+ self.primary_key = :id
16
+
17
+ attr_accessor :id, :customer_id, :provider, :provider_customer_id,
18
+ :provider_session_id, :provider_subscription_id, :provider_payment_intent_id,
19
+ :type, :status, :amount_cents, :currency, :description,
20
+ :metadata, :created_at, :updated_at
21
+
22
+ validates :customer_id, presence: true
23
+ validates :type, presence: true
24
+ validates :status, presence: true
25
+
26
+ before_create { self.id ||= SecureRandom.uuid }
27
+ before_create { self.created_at ||= Time.now.utc.iso8601 }
28
+ before_save { self.updated_at = Time.now.utc.iso8601 }
29
+
30
+ # Transaction types
31
+ TYPES = %w[checkout subscription subscription_renewal refund payment_method_update].freeze
32
+
33
+ # Transaction statuses
34
+ STATUSES = %w[pending completed failed refunded canceled].freeze
35
+
36
+ class << self
37
+ # Record a checkout completion
38
+ def record_checkout(customer_id:, session_id:, amount_cents:, currency: 'usd', metadata: {})
39
+ create!(
40
+ customer_id: customer_id,
41
+ provider: Belt::Pay.configuration.provider.to_s,
42
+ provider_session_id: session_id,
43
+ type: 'checkout',
44
+ status: 'completed',
45
+ amount_cents: amount_cents,
46
+ currency: currency,
47
+ description: 'Checkout session completed',
48
+ metadata: metadata
49
+ )
50
+ end
51
+
52
+ # Record a subscription creation
53
+ def record_subscription(customer_id:, subscription_id:, amount_cents:, currency: 'usd', metadata: {})
54
+ create!(
55
+ customer_id: customer_id,
56
+ provider: Belt::Pay.configuration.provider.to_s,
57
+ provider_subscription_id: subscription_id,
58
+ type: 'subscription',
59
+ status: 'completed',
60
+ amount_cents: amount_cents,
61
+ currency: currency,
62
+ description: 'Subscription created',
63
+ metadata: metadata
64
+ )
65
+ end
66
+
67
+ # Record a subscription renewal (invoice paid)
68
+ def record_renewal(customer_id:, subscription_id:, amount_cents:, currency: 'usd', metadata: {})
69
+ create!(
70
+ customer_id: customer_id,
71
+ provider: Belt::Pay.configuration.provider.to_s,
72
+ provider_subscription_id: subscription_id,
73
+ type: 'subscription_renewal',
74
+ status: 'completed',
75
+ amount_cents: amount_cents,
76
+ currency: currency,
77
+ description: 'Subscription renewal',
78
+ metadata: metadata
79
+ )
80
+ end
81
+
82
+ # Record a refund
83
+ def record_refund(customer_id:, amount_cents:, currency: 'usd', metadata: {})
84
+ create!(
85
+ customer_id: customer_id,
86
+ provider: Belt::Pay.configuration.provider.to_s,
87
+ type: 'refund',
88
+ status: 'completed',
89
+ amount_cents: amount_cents,
90
+ currency: currency,
91
+ description: 'Refund issued',
92
+ metadata: metadata
93
+ )
94
+ end
95
+
96
+ # Find transactions for a customer
97
+ def for_customer(customer_id)
98
+ where(customer_id: customer_id, index: 'CustomerIndex')
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Pay
5
+ VERSION = '0.0.1'
6
+ end
7
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Pay
5
+ # Handles incoming webhooks from the payment provider.
6
+ # Verifies signatures, dispatches events, and records transactions.
7
+ module WebhookHandler
8
+ class << self
9
+ # Process a raw webhook request.
10
+ # @param payload [String] Raw request body
11
+ # @param signature [String] Provider signature header
12
+ # @return [Hash] { received: true }
13
+ def process(payload:, signature:)
14
+ event = Belt::Pay.provider.verify_webhook(payload, signature)
15
+ dispatch(event)
16
+ { received: true }
17
+ end
18
+
19
+ private
20
+
21
+ def dispatch(event)
22
+ event_type = event['type'] || event.type
23
+
24
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: received event', event_type: event_type)
25
+
26
+ case event_type
27
+ when 'checkout.session.completed'
28
+ handle_checkout_completed(event.data.object)
29
+ when 'checkout.session.expired'
30
+ handle_checkout_expired(event.data.object)
31
+ when 'invoice.paid'
32
+ handle_invoice_paid(event.data.object)
33
+ when 'invoice.payment_failed'
34
+ handle_invoice_payment_failed(event.data.object)
35
+ when 'customer.subscription.deleted'
36
+ handle_subscription_deleted(event.data.object)
37
+ when 'customer.subscription.updated'
38
+ handle_subscription_updated(event.data.object)
39
+ else
40
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: unhandled event type', event_type: event_type)
41
+ end
42
+ end
43
+
44
+ def handle_checkout_completed(session)
45
+ customer_id = session.metadata&.app_customer_id || session.metadata&.[]('app_customer_id')
46
+ return unless customer_id
47
+
48
+ # Complete the pending transaction
49
+ pending = Transaction.where(
50
+ provider_session_id: session.id,
51
+ index: 'ProviderSessionIndex'
52
+ ).first
53
+
54
+ if pending&.status == 'pending'
55
+ pending.status = 'completed'
56
+ pending.amount_cents = session.amount_total
57
+ pending.save!
58
+ else
59
+ # No pending record — create one (webhook-first flow)
60
+ Transaction.record_checkout(
61
+ customer_id: customer_id,
62
+ session_id: session.id,
63
+ amount_cents: session.amount_total || 0,
64
+ metadata: { payment_status: session.payment_status }
65
+ )
66
+ end
67
+
68
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: checkout completed',
69
+ customer_id: customer_id, session_id: session.id,
70
+ amount_cents: session.amount_total)
71
+ end
72
+
73
+ def handle_checkout_expired(session)
74
+ # Mark pending transaction as failed
75
+ pending = Transaction.where(
76
+ provider_session_id: session.id,
77
+ index: 'ProviderSessionIndex'
78
+ ).first
79
+
80
+ if pending&.status == 'pending'
81
+ pending.status = 'failed'
82
+ pending.description = 'Checkout session expired'
83
+ pending.save!
84
+ end
85
+
86
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: checkout expired', session_id: session.id)
87
+ end
88
+
89
+ def handle_invoice_paid(invoice)
90
+ subscription_id = invoice.subscription
91
+ customer_id = invoice.metadata&.app_customer_id || invoice.metadata&.[]('app_customer_id')
92
+
93
+ # Try to resolve customer from subscription metadata if not on invoice
94
+ unless customer_id
95
+ customer_id = resolve_customer_from_subscription(subscription_id)
96
+ end
97
+
98
+ return unless customer_id && subscription_id
99
+
100
+ Transaction.record_renewal(
101
+ customer_id: customer_id,
102
+ subscription_id: subscription_id,
103
+ amount_cents: invoice.amount_paid || 0,
104
+ metadata: { invoice_id: invoice.id, billing_reason: invoice.billing_reason }
105
+ )
106
+
107
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: invoice paid',
108
+ customer_id: customer_id, subscription_id: subscription_id,
109
+ amount_cents: invoice.amount_paid)
110
+ end
111
+
112
+ def handle_invoice_payment_failed(invoice)
113
+ customer_id = invoice.metadata&.app_customer_id || invoice.metadata&.[]('app_customer_id')
114
+ unless customer_id
115
+ customer_id = resolve_customer_from_subscription(invoice.subscription)
116
+ end
117
+
118
+ Belt::Pay.log(:warn, 'Belt::Pay::WebhookHandler: invoice payment failed',
119
+ customer_id: customer_id, invoice_id: invoice.id)
120
+ end
121
+
122
+ def handle_subscription_deleted(subscription)
123
+ customer_id = subscription.metadata&.app_customer_id || subscription.metadata&.[]('app_customer_id')
124
+ return unless customer_id
125
+
126
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: subscription deleted',
127
+ customer_id: customer_id, subscription_id: subscription.id)
128
+ end
129
+
130
+ def handle_subscription_updated(subscription)
131
+ customer_id = subscription.metadata&.app_customer_id || subscription.metadata&.[]('app_customer_id')
132
+ return unless customer_id
133
+
134
+ Belt::Pay.log(:info, 'Belt::Pay::WebhookHandler: subscription updated',
135
+ customer_id: customer_id, subscription_id: subscription.id,
136
+ status: subscription.status)
137
+ end
138
+
139
+ def resolve_customer_from_subscription(subscription_id)
140
+ return nil unless subscription_id
141
+
142
+ Belt::Pay.provider.ensure_api_key!
143
+ sub = ::Stripe::Subscription.retrieve(subscription_id)
144
+ sub.metadata&.app_customer_id || sub.metadata&.[]('app_customer_id')
145
+ rescue StandardError => e
146
+ Belt::Pay.log(:warn, 'Belt::Pay::WebhookHandler: failed to resolve customer from subscription',
147
+ subscription_id: subscription_id, error: e.message)
148
+ nil
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/belt/pay.rb ADDED
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pay/version'
4
+ require_relative 'pay/configuration'
5
+ require_relative 'pay/transaction'
6
+ require_relative 'pay/billable'
7
+ require_relative 'pay/checkout'
8
+ require_relative 'pay/subscription'
9
+ require_relative 'pay/customer_provisioner'
10
+ require_relative 'pay/payment_method_attacher'
11
+ require_relative 'pay/setup_intent_creator'
12
+ require_relative 'pay/webhook_handler'
13
+ require_relative 'pay/providers/stripe'
14
+
15
+ module Belt
16
+ module Pay
17
+ class Error < StandardError; end
18
+ class ConfigurationError < Error; end
19
+ class ProviderError < Error; end
20
+
21
+ class << self
22
+ attr_writer :configuration
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield(configuration)
30
+ end
31
+
32
+ def reset_configuration!
33
+ @configuration = Configuration.new
34
+ end
35
+
36
+ # --- Convenience API (provider-agnostic names) ---
37
+
38
+ # Ensure a payment provider customer exists for this user.
39
+ # @param customer [Object] Your app's user/customer model instance
40
+ # @return [String] The provider customer ID
41
+ def ensure_customer(customer)
42
+ CustomerProvisioner.new(customer).call
43
+ end
44
+
45
+ # Attach a payment method and set as default.
46
+ # @param customer [Object] Your app's user/customer model instance
47
+ # @param payment_method_id [String] Provider payment method token
48
+ # @return [PaymentMethodAttacher::Result]
49
+ def attach_payment_method(customer, payment_method_id)
50
+ PaymentMethodAttacher.new(customer, payment_method_id).call
51
+ end
52
+
53
+ # Create a setup intent for collecting payment details on the frontend.
54
+ # @param customer [Object] Your app's user/customer model instance
55
+ # @return [SetupIntentCreator::Result]
56
+ def create_setup_intent(customer)
57
+ SetupIntentCreator.new(customer).call
58
+ end
59
+
60
+ # Create a checkout session for one-time or subscription payments.
61
+ # @param customer [Object] Your app's user/customer model instance
62
+ # @param options [Hash] Checkout options (line_items, mode, success_url, cancel_url, metadata)
63
+ # @return [Hash] { url:, session_id: }
64
+ def create_checkout(customer, **options)
65
+ Checkout.create(customer, **options)
66
+ end
67
+
68
+ # Subscribe a customer to a plan.
69
+ # @param customer [Object] Your app's user/customer model instance
70
+ # @param price_id [String] Provider price/plan ID
71
+ # @param metadata [Hash] Additional metadata
72
+ # @return [Hash] { subscription_id:, status: }
73
+ def subscribe(customer, price_id:, metadata: {})
74
+ Subscription.create(customer, price_id: price_id, metadata: metadata)
75
+ end
76
+
77
+ # Cancel a customer's subscription.
78
+ # @param customer [Object] Your app's user/customer model instance
79
+ # @param immediately [Boolean] Cancel now vs at period end (default: false)
80
+ def cancel_subscription(customer, immediately: false)
81
+ Subscription.cancel(customer, immediately: immediately)
82
+ end
83
+
84
+ # Generate a billing portal URL for customer self-service.
85
+ # @param customer [Object] Your app's user/customer model instance
86
+ # @param return_url [String] URL to redirect back to after portal
87
+ # @return [Hash] { url: }
88
+ def billing_portal(customer, return_url:)
89
+ provider.billing_portal(customer, return_url: return_url)
90
+ end
91
+
92
+ # Get the configured provider adapter instance.
93
+ # @return [Belt::Pay::Providers::Stripe] (or future providers)
94
+ def provider
95
+ @provider ||= resolve_provider
96
+ end
97
+
98
+ # Reset provider (useful for testing)
99
+ def reset_provider!
100
+ @provider = nil
101
+ end
102
+
103
+ # Internal logging helper
104
+ def log(level, message, **kwargs)
105
+ if defined?(Belt::Observability::Logger)
106
+ Belt::Observability::Logger.public_send(level, message, **kwargs)
107
+ elsif configuration.logger
108
+ configuration.logger.public_send(level, message, **kwargs)
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def resolve_provider
115
+ case configuration.provider
116
+ when :stripe
117
+ Providers::Stripe.new
118
+ else
119
+ raise ConfigurationError, "Unknown provider: #{configuration.provider}. Supported: :stripe"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
data/lib/belt-pay.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'belt/pay'
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: belt-pay
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Stowzilla
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: belt
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: stripe
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: Belt plugin providing payment collection, subscriptions, and billing
41
+ management. Ships with Stripe as the default provider. Includes webhook handling,
42
+ customer provisioning, and a Transaction model for audit logging.
43
+ email:
44
+ - andy@stowzilla.com
45
+ - adam@stowzilla.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/belt-pay.rb
54
+ - lib/belt/generators/pay_generator.rb
55
+ - lib/belt/pay.rb
56
+ - lib/belt/pay/billable.rb
57
+ - lib/belt/pay/checkout.rb
58
+ - lib/belt/pay/configuration.rb
59
+ - lib/belt/pay/controllers/webhooks_controller.rb
60
+ - lib/belt/pay/customer_provisioner.rb
61
+ - lib/belt/pay/payment_method_attacher.rb
62
+ - lib/belt/pay/providers/stripe.rb
63
+ - lib/belt/pay/setup_intent_creator.rb
64
+ - lib/belt/pay/subscription.rb
65
+ - lib/belt/pay/templates/config/pay_webhooks.yml.erb
66
+ - lib/belt/pay/templates/controllers/pay_webhooks_controller.rb.erb
67
+ - lib/belt/pay/templates/lambda/pay_webhooks.rb.erb
68
+ - lib/belt/pay/templates/terraform/main.tf.erb
69
+ - lib/belt/pay/templates/terraform/outputs.tf.erb
70
+ - lib/belt/pay/templates/terraform/variables.tf.erb
71
+ - lib/belt/pay/transaction.rb
72
+ - lib/belt/pay/version.rb
73
+ - lib/belt/pay/webhook_handler.rb
74
+ homepage: https://github.com/stowzilla/belt-pay
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://github.com/stowzilla/belt-pay
79
+ source_code_uri: https://github.com/stowzilla/belt-pay
80
+ changelog_uri: https://github.com/stowzilla/belt-pay/blob/main/CHANGELOG.md
81
+ rubygems_mfa_required: 'true'
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.3'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 3.6.9
97
+ specification_version: 4
98
+ summary: Payments and subscriptions for Belt applications via Stripe
99
+ test_files: []