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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +21 -0
- data/README.md +271 -0
- data/lib/belt/generators/pay_generator.rb +352 -0
- data/lib/belt/pay/billable.rb +94 -0
- data/lib/belt/pay/checkout.rb +50 -0
- data/lib/belt/pay/configuration.rb +23 -0
- data/lib/belt/pay/controllers/webhooks_controller.rb +45 -0
- data/lib/belt/pay/customer_provisioner.rb +41 -0
- data/lib/belt/pay/payment_method_attacher.rb +38 -0
- data/lib/belt/pay/providers/stripe.rb +211 -0
- data/lib/belt/pay/setup_intent_creator.rb +30 -0
- data/lib/belt/pay/subscription.rb +63 -0
- data/lib/belt/pay/templates/config/pay_webhooks.yml.erb +33 -0
- data/lib/belt/pay/templates/controllers/pay_webhooks_controller.rb.erb +26 -0
- data/lib/belt/pay/templates/lambda/pay_webhooks.rb.erb +21 -0
- data/lib/belt/pay/templates/terraform/main.tf.erb +73 -0
- data/lib/belt/pay/templates/terraform/outputs.tf.erb +14 -0
- data/lib/belt/pay/templates/terraform/variables.tf.erb +23 -0
- data/lib/belt/pay/transaction.rb +103 -0
- data/lib/belt/pay/version.rb +7 -0
- data/lib/belt/pay/webhook_handler.rb +153 -0
- data/lib/belt/pay.rb +124 -0
- data/lib/belt-pay.rb +3 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 673206d18a5fad12255912de6c7833796e147e88e99ce32d70ba48ef727ff9e4
|
|
4
|
+
data.tar.gz: d5b2171c33ef68447b501183287ab53d209621e859e40a16e2721d0af60a74a2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 475ef6bdadb87b2712145d0e69f3f74c58a7fa07f7a6d2f293e0a34aa19f6a340794feb1d74e76c7d8dd584a1576650c4572670a443b640f7336e2a4e1f16a6a
|
|
7
|
+
data.tar.gz: e03f179b799302a256b741ff882defab8064909ecef72c25f5ec3043e1c26d76fe2ffde9ae7c1014a6ad76c5d1b25dfe385d9df7ec860404e3a74f6d3c73f87a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1 — 2026-09-01
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Stripe provider with customer provisioning, payment methods, checkout sessions
|
|
7
|
+
- Subscription management (create, cancel, billing portal)
|
|
8
|
+
- Transaction model for audit logging (DynamoDB)
|
|
9
|
+
- Billable concern for customer models
|
|
10
|
+
- Webhook handler with signature verification
|
|
11
|
+
- Generator: `belt g pay` (Terraform module, webhook Lambda, config, schema)
|
|
12
|
+
- Getting started guide
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stowzilla
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# belt-pay
|
|
2
|
+
|
|
3
|
+
Payments and subscriptions for [Belt](https://github.com/stowzilla/belt) applications via Stripe.
|
|
4
|
+
|
|
5
|
+
> **New to Belt Pay?** Check out the [Getting Started Guide](docs/getting-started.md) for a complete walkthrough.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'belt-pay'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
belt generate pay
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What You Get
|
|
23
|
+
|
|
24
|
+
### From the gem (no generation needed)
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# Include in your Customer/User model
|
|
28
|
+
class Customer < ActiveItem::Base
|
|
29
|
+
include Belt::Pay::Billable
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Ensure a Stripe customer exists
|
|
33
|
+
customer.ensure_pay_customer!
|
|
34
|
+
|
|
35
|
+
# Create a checkout session (one-time or subscription)
|
|
36
|
+
Belt::Pay.create_checkout(customer,
|
|
37
|
+
line_items: [{ price: 'price_xxx', quantity: 1 }],
|
|
38
|
+
mode: 'subscription',
|
|
39
|
+
success_url: 'https://app.example.com/success?session_id={CHECKOUT_SESSION_ID}',
|
|
40
|
+
cancel_url: 'https://app.example.com/cancel')
|
|
41
|
+
|
|
42
|
+
# Subscribe directly (when payment method is already attached)
|
|
43
|
+
customer.subscribe!(price_id: 'price_xxx')
|
|
44
|
+
|
|
45
|
+
# Check subscription status
|
|
46
|
+
customer.active_subscription? # => true
|
|
47
|
+
|
|
48
|
+
# Customer self-service billing portal
|
|
49
|
+
customer.billing_portal_url(return_url: 'https://app.example.com/settings')
|
|
50
|
+
# => { url: "https://billing.stripe.com/p/session/..." }
|
|
51
|
+
|
|
52
|
+
# Collect payment method (returns client_secret for Stripe Elements)
|
|
53
|
+
result = customer.create_setup_intent
|
|
54
|
+
result.client_secret # => "seti_xxx_secret_yyy"
|
|
55
|
+
|
|
56
|
+
# Attach a payment method
|
|
57
|
+
customer.attach_payment_method('pm_xxx')
|
|
58
|
+
|
|
59
|
+
# View payment details
|
|
60
|
+
customer.payment_method_details
|
|
61
|
+
# => { last4: "4242", brand: "visa", exp_month: 12, exp_year: 2027 }
|
|
62
|
+
|
|
63
|
+
# Transaction history
|
|
64
|
+
customer.transactions
|
|
65
|
+
# => [#<Belt::Pay::Transaction type="subscription" status="completed" ...>]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### From the generator (`belt g pay`)
|
|
69
|
+
|
|
70
|
+
- **Terraform module** — Secrets Manager for Stripe keys, IAM policies
|
|
71
|
+
- **Lambda entry point** — Dedicated webhook Lambda for Stripe events
|
|
72
|
+
- **Lambda config** — `config/lambda/pay_webhooks.yml`
|
|
73
|
+
- **Schema update** — DynamoDB table for transaction audit log
|
|
74
|
+
- **Route injection** — Adds `/pay/webhooks` endpoint
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
### Environment Variables
|
|
79
|
+
|
|
80
|
+
| Variable | Purpose | Default |
|
|
81
|
+
|----------|---------|---------|
|
|
82
|
+
| `BELT_PAY_SECRET_NAME` | Secrets Manager secret name for Stripe keys | — |
|
|
83
|
+
| `BELT_PAY_WEBHOOK_SECRET_NAME` | Secret name for webhook signing (falls back to BELT_PAY_SECRET_NAME) | — |
|
|
84
|
+
| `BELT_PAY_MODE` | `test` or `live` | `test` |
|
|
85
|
+
| `APP_NAME` | App name for table naming | — |
|
|
86
|
+
| `ENVIRONMENT` | Environment for table naming | — |
|
|
87
|
+
|
|
88
|
+
### Programmatic Configuration
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Belt::Pay.configure do |config|
|
|
92
|
+
config.provider = :stripe # Only :stripe for now
|
|
93
|
+
config.secret_name = 'myapp-prod-stripe' # Secrets Manager secret
|
|
94
|
+
config.table_name_prefix = 'myapp-prod' # DynamoDB table prefix
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Secrets Manager Format
|
|
99
|
+
|
|
100
|
+
The Stripe secret should contain:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"stripe_secret_key": "sk_live_...",
|
|
105
|
+
"stripe_webhook_secret": "whsec_..."
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Common Patterns
|
|
110
|
+
|
|
111
|
+
### Annual Subscription (Feature Gating)
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# In your controller
|
|
115
|
+
class SubscriptionsController < BeltController::Base
|
|
116
|
+
def create
|
|
117
|
+
price_id = ENV['STRIPE_ANNUAL_PRICE_ID'] # Created in Stripe Dashboard
|
|
118
|
+
result = current_customer.subscribe!(price_id: price_id, metadata: { plan: 'pro' })
|
|
119
|
+
success_response(subscription_id: result[:subscription_id], status: result[:status])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def status
|
|
123
|
+
success_response(active: current_customer.active_subscription?)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cancel
|
|
127
|
+
current_customer.cancel_subscription! # Cancels at period end
|
|
128
|
+
success_response(message: 'Subscription will cancel at end of billing period')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def portal
|
|
132
|
+
result = current_customer.billing_portal_url(return_url: "#{ENV['FRONTEND_URL']}/settings")
|
|
133
|
+
success_response(url: result[:url])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### One-Time Payment (Product Purchase)
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class CheckoutController < BeltController::Base
|
|
142
|
+
def create
|
|
143
|
+
product = Product.find(params['product_id'])
|
|
144
|
+
|
|
145
|
+
result = Belt::Pay.create_checkout(current_customer,
|
|
146
|
+
line_items: [{
|
|
147
|
+
price_data: {
|
|
148
|
+
currency: 'usd',
|
|
149
|
+
product_data: { name: product.name },
|
|
150
|
+
unit_amount: product.price_cents
|
|
151
|
+
},
|
|
152
|
+
quantity: 1
|
|
153
|
+
}],
|
|
154
|
+
mode: 'payment',
|
|
155
|
+
success_url: "#{ENV['FRONTEND_URL']}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
|
156
|
+
cancel_url: "#{ENV['FRONTEND_URL']}/products")
|
|
157
|
+
|
|
158
|
+
success_response(checkout_url: result[:url])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Checking Subscription Access (Middleware Pattern)
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
class ProController < BeltController::Base
|
|
167
|
+
before_action :require_subscription!
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def require_subscription!
|
|
172
|
+
unless current_customer.active_subscription?
|
|
173
|
+
error_response('Pro subscription required', 403)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Webhook Events
|
|
180
|
+
|
|
181
|
+
The gem automatically handles these Stripe webhook events:
|
|
182
|
+
|
|
183
|
+
| Event | Behavior |
|
|
184
|
+
|-------|----------|
|
|
185
|
+
| `checkout.session.completed` | Marks pending transaction as completed |
|
|
186
|
+
| `checkout.session.expired` | Marks pending transaction as failed |
|
|
187
|
+
| `invoice.paid` | Records subscription renewal transaction |
|
|
188
|
+
| `invoice.payment_failed` | Logs payment failure |
|
|
189
|
+
| `customer.subscription.deleted` | Logs subscription cancellation |
|
|
190
|
+
| `customer.subscription.updated` | Logs subscription status changes |
|
|
191
|
+
|
|
192
|
+
### Customizing Webhook Behavior
|
|
193
|
+
|
|
194
|
+
Override the webhook controller to add custom logic:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
belt g pay --controllers
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This generates a controller in your app that inherits from the gem's default. Override individual handler methods as needed.
|
|
201
|
+
|
|
202
|
+
## Transaction Model
|
|
203
|
+
|
|
204
|
+
`Belt::Pay::Transaction` lives inside the gem and records all payment activity:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
customer.transactions.each do |txn|
|
|
208
|
+
puts "#{txn.type}: #{txn.amount_cents} #{txn.currency} — #{txn.status}"
|
|
209
|
+
end
|
|
210
|
+
# subscription: 9900 usd — completed
|
|
211
|
+
# subscription_renewal: 9900 usd — completed
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Fields
|
|
215
|
+
|
|
216
|
+
| Field | Description |
|
|
217
|
+
|-------|-------------|
|
|
218
|
+
| `id` | UUID primary key |
|
|
219
|
+
| `customer_id` | Your app's customer/user ID |
|
|
220
|
+
| `provider` | Payment provider (`stripe`) |
|
|
221
|
+
| `provider_session_id` | Stripe checkout session ID |
|
|
222
|
+
| `provider_subscription_id` | Stripe subscription ID |
|
|
223
|
+
| `type` | `checkout`, `subscription`, `subscription_renewal`, `refund` |
|
|
224
|
+
| `status` | `pending`, `completed`, `failed`, `refunded`, `canceled` |
|
|
225
|
+
| `amount_cents` | Amount in cents |
|
|
226
|
+
| `currency` | ISO currency code |
|
|
227
|
+
| `metadata` | Free-form JSON metadata |
|
|
228
|
+
| `created_at` | ISO 8601 timestamp |
|
|
229
|
+
|
|
230
|
+
## Terraform Module
|
|
231
|
+
|
|
232
|
+
After running `belt g pay`, add the module to your environment's `main.tf`:
|
|
233
|
+
|
|
234
|
+
```hcl
|
|
235
|
+
module "pay" {
|
|
236
|
+
source = "../modules/pay"
|
|
237
|
+
app_name = var.app_name
|
|
238
|
+
environment = var.environment
|
|
239
|
+
|
|
240
|
+
# Set these or update secrets manually after apply
|
|
241
|
+
# stripe_secret_key = var.stripe_secret_key
|
|
242
|
+
# stripe_webhook_secret = var.stripe_webhook_secret
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Stripe Dashboard Setup
|
|
247
|
+
|
|
248
|
+
After deploying, configure your Stripe webhook endpoint:
|
|
249
|
+
|
|
250
|
+
1. Go to Stripe Dashboard → Developers → Webhooks
|
|
251
|
+
2. Add endpoint: `https://<your-api-domain>/pay/webhooks`
|
|
252
|
+
3. Select events:
|
|
253
|
+
- `checkout.session.completed`
|
|
254
|
+
- `checkout.session.expired`
|
|
255
|
+
- `invoice.paid`
|
|
256
|
+
- `invoice.payment_failed`
|
|
257
|
+
- `customer.subscription.deleted`
|
|
258
|
+
- `customer.subscription.updated`
|
|
259
|
+
4. Copy the signing secret into your Secrets Manager secret
|
|
260
|
+
|
|
261
|
+
## Removing
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
belt destroy pay
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then remove the module reference from your environment `main.tf` files.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module Generators
|
|
8
|
+
class PayGenerator
|
|
9
|
+
TEMPLATE_DIR = File.expand_path('../pay/templates', __dir__)
|
|
10
|
+
|
|
11
|
+
def self.description
|
|
12
|
+
'Install payments and subscriptions (Stripe)'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.run(args)
|
|
16
|
+
if args.include?('--help') || args.include?('-h')
|
|
17
|
+
print_help
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
new(args).generate
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.destroy(args)
|
|
25
|
+
new(args).destroy
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.print_help
|
|
29
|
+
puts <<~HELP
|
|
30
|
+
Install payment and subscription infrastructure for your Belt app.
|
|
31
|
+
|
|
32
|
+
Usage: belt generate pay [options]
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--controllers Generate controller overrides (to customize webhook behavior)
|
|
36
|
+
--force Overwrite existing files
|
|
37
|
+
|
|
38
|
+
What gets created:
|
|
39
|
+
infrastructure/modules/pay/ Terraform module (Secrets Manager, IAM)
|
|
40
|
+
config/lambda/pay_webhooks.yml Lambda configuration (timeout, memory, env)
|
|
41
|
+
lambda/pay_webhooks.rb Lambda entry point for Stripe webhooks
|
|
42
|
+
infrastructure/schema.tf.rb Updated with pay_transactions table
|
|
43
|
+
|
|
44
|
+
What stays in the gem (no generation needed):
|
|
45
|
+
Belt::Pay::Transaction Transaction audit log model
|
|
46
|
+
Belt::Pay::Billable Concern for your Customer model
|
|
47
|
+
Belt::Pay.create_checkout(...) Create checkout sessions
|
|
48
|
+
Belt::Pay.subscribe(...) Manage subscriptions
|
|
49
|
+
Belt::Pay.billing_portal(...) Customer self-service portal
|
|
50
|
+
Belt::Pay::Controllers::WebhooksController Default webhook handler
|
|
51
|
+
|
|
52
|
+
To override the webhook controller:
|
|
53
|
+
belt g pay --controllers
|
|
54
|
+
|
|
55
|
+
After generation:
|
|
56
|
+
1. Add module reference to your environment's main.tf
|
|
57
|
+
2. Create Stripe keys in Secrets Manager (or via Terraform)
|
|
58
|
+
3. Include Belt::Pay::Billable in your Customer/User model
|
|
59
|
+
4. Deploy: belt apply <env>
|
|
60
|
+
5. Configure Stripe webhook URL: https://<api-domain>/pay/webhooks
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
belt g pay # Infrastructure only (use gem defaults)
|
|
64
|
+
belt g pay --controllers # Also generate controller overrides
|
|
65
|
+
belt d pay
|
|
66
|
+
HELP
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize(args)
|
|
70
|
+
@force = args.include?('--force')
|
|
71
|
+
@with_controllers = args.include?('--controllers')
|
|
72
|
+
@app_name = detect_namespace
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def generate
|
|
76
|
+
generate_terraform_module
|
|
77
|
+
generate_lambda_config
|
|
78
|
+
generate_lambda_entry_point
|
|
79
|
+
generate_controllers if @with_controllers
|
|
80
|
+
inject_schema
|
|
81
|
+
inject_routes
|
|
82
|
+
print_success
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def destroy
|
|
86
|
+
remove_terraform_module
|
|
87
|
+
remove_lambda_config
|
|
88
|
+
remove_lambda_entry_point
|
|
89
|
+
remove_controllers
|
|
90
|
+
remove_schema
|
|
91
|
+
remove_routes
|
|
92
|
+
puts "\n✓ Pay removed!"
|
|
93
|
+
puts " Don't forget to remove the module reference from your environment main.tf files."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def detect_namespace
|
|
99
|
+
routes_file = find_routes_file_path
|
|
100
|
+
if routes_file && File.exist?(routes_file)
|
|
101
|
+
match = File.read(routes_file).match(/namespace :(\w+)/)
|
|
102
|
+
return match[1] if match
|
|
103
|
+
end
|
|
104
|
+
File.basename(Dir.pwd)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def find_routes_file_path
|
|
108
|
+
candidates = ['config/routes.tf.rb', 'infrastructure/routes.tf.rb']
|
|
109
|
+
candidates.find { |f| File.exist?(f) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ---- Generate ----
|
|
113
|
+
|
|
114
|
+
def generate_terraform_module
|
|
115
|
+
module_dir = 'infrastructure/modules/pay'
|
|
116
|
+
|
|
117
|
+
if Dir.exist?(module_dir) && !@force
|
|
118
|
+
puts " skip #{module_dir}/ (already exists, use --force to overwrite)"
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(module_dir)
|
|
123
|
+
|
|
124
|
+
write_template('terraform/main.tf.erb', "#{module_dir}/main.tf")
|
|
125
|
+
write_template('terraform/variables.tf.erb', "#{module_dir}/variables.tf")
|
|
126
|
+
write_template('terraform/outputs.tf.erb', "#{module_dir}/outputs.tf")
|
|
127
|
+
|
|
128
|
+
puts " create #{module_dir}/main.tf"
|
|
129
|
+
puts " create #{module_dir}/variables.tf"
|
|
130
|
+
puts " create #{module_dir}/outputs.tf"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def generate_lambda_config
|
|
134
|
+
config_dir = 'config/lambda'
|
|
135
|
+
dest = "#{config_dir}/pay_webhooks.yml"
|
|
136
|
+
|
|
137
|
+
if File.exist?(dest) && !@force
|
|
138
|
+
puts " skip #{dest} (already exists)"
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
FileUtils.mkdir_p(config_dir)
|
|
143
|
+
write_template('config/pay_webhooks.yml.erb', dest)
|
|
144
|
+
puts " create #{dest}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def generate_lambda_entry_point
|
|
148
|
+
dest = 'lambda/pay_webhooks.rb'
|
|
149
|
+
|
|
150
|
+
if File.exist?(dest) && !@force
|
|
151
|
+
puts " skip #{dest} (already exists)"
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
write_template('lambda/pay_webhooks.rb.erb', dest)
|
|
156
|
+
puts " create #{dest}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def generate_controllers
|
|
160
|
+
controller_dir = "lambda/controllers/#{@app_name}"
|
|
161
|
+
FileUtils.mkdir_p(controller_dir)
|
|
162
|
+
|
|
163
|
+
dest = "#{controller_dir}/pay_webhooks_controller.rb"
|
|
164
|
+
if File.exist?(dest) && !@force
|
|
165
|
+
puts " skip #{dest} (already exists)"
|
|
166
|
+
else
|
|
167
|
+
write_template('controllers/pay_webhooks_controller.rb.erb', dest)
|
|
168
|
+
puts " create #{dest}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def inject_schema
|
|
173
|
+
schema_file = find_schema_file_path
|
|
174
|
+
return unless schema_file && File.exist?(schema_file)
|
|
175
|
+
|
|
176
|
+
content = File.read(schema_file)
|
|
177
|
+
return if content.include?('pay_transactions') || content.include?('pay-transactions')
|
|
178
|
+
|
|
179
|
+
# Add transactions table to schema
|
|
180
|
+
schema_block = <<~SCHEMA
|
|
181
|
+
|
|
182
|
+
model :pay_transaction do
|
|
183
|
+
partition_key :id, :string
|
|
184
|
+
global_secondary_index :CustomerIndex, partition_key: :customer_id
|
|
185
|
+
global_secondary_index :ProviderSessionIndex, partition_key: :provider_session_id
|
|
186
|
+
end
|
|
187
|
+
SCHEMA
|
|
188
|
+
|
|
189
|
+
# Insert before the closing `end` of the schema.define block
|
|
190
|
+
if content.match?(/^end\s*\z/m)
|
|
191
|
+
content.sub!(/^end\s*\z/m, "#{schema_block}end\n")
|
|
192
|
+
else
|
|
193
|
+
content << "\n#{schema_block}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
File.write(schema_file, content)
|
|
197
|
+
puts " update #{schema_file} (added pay_transactions table)"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def inject_routes
|
|
201
|
+
routes_file = find_routes_file_path
|
|
202
|
+
return unless routes_file && File.exist?(routes_file)
|
|
203
|
+
|
|
204
|
+
content = File.read(routes_file)
|
|
205
|
+
return if content.include?('pay_webhooks') || content.include?('pay/webhooks')
|
|
206
|
+
|
|
207
|
+
# Add webhook route to the namespace
|
|
208
|
+
namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
|
|
209
|
+
if content.match?(namespace_pattern)
|
|
210
|
+
webhook_route = " post \"pay/webhooks\", controller: :pay_webhooks, action: :webhook, auth: :none"
|
|
211
|
+
content.sub!(namespace_pattern) do |match|
|
|
212
|
+
indent = ::Regexp.last_match(1)
|
|
213
|
+
match.sub(/^(#{indent})end\z/m, "#{webhook_route}\n#{indent}end")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
File.write(routes_file, content)
|
|
218
|
+
puts " update #{routes_file} (added pay webhook route)"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ---- Destroy ----
|
|
222
|
+
|
|
223
|
+
def remove_terraform_module
|
|
224
|
+
module_dir = 'infrastructure/modules/pay'
|
|
225
|
+
if Dir.exist?(module_dir)
|
|
226
|
+
FileUtils.rm_rf(module_dir)
|
|
227
|
+
puts " remove #{module_dir}/"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def remove_lambda_config
|
|
232
|
+
path = 'config/lambda/pay_webhooks.yml'
|
|
233
|
+
if File.exist?(path)
|
|
234
|
+
File.delete(path)
|
|
235
|
+
puts " remove #{path}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def remove_lambda_entry_point
|
|
240
|
+
path = 'lambda/pay_webhooks.rb'
|
|
241
|
+
if File.exist?(path)
|
|
242
|
+
File.delete(path)
|
|
243
|
+
puts " remove #{path}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def remove_controllers
|
|
248
|
+
path = "lambda/controllers/#{@app_name}/pay_webhooks_controller.rb"
|
|
249
|
+
if File.exist?(path)
|
|
250
|
+
File.delete(path)
|
|
251
|
+
puts " remove #{path}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def remove_schema
|
|
256
|
+
schema_file = find_schema_file_path
|
|
257
|
+
return unless schema_file && File.exist?(schema_file)
|
|
258
|
+
|
|
259
|
+
content = File.read(schema_file)
|
|
260
|
+
original = content.dup
|
|
261
|
+
|
|
262
|
+
content.gsub!(/\n\s*model :pay_transaction do.*?end\n/m, '')
|
|
263
|
+
|
|
264
|
+
if content != original
|
|
265
|
+
File.write(schema_file, content)
|
|
266
|
+
puts " update #{schema_file} (removed pay_transactions table)"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def remove_routes
|
|
271
|
+
routes_file = find_routes_file_path
|
|
272
|
+
return unless routes_file && File.exist?(routes_file)
|
|
273
|
+
|
|
274
|
+
content = File.read(routes_file)
|
|
275
|
+
original = content.dup
|
|
276
|
+
|
|
277
|
+
content.gsub!(/^\s*post "pay\/webhooks".*\n/, '')
|
|
278
|
+
|
|
279
|
+
if content != original
|
|
280
|
+
File.write(routes_file, content)
|
|
281
|
+
puts " update #{routes_file}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def find_schema_file_path
|
|
286
|
+
candidates = ['infrastructure/schema.tf.rb', 'config/schema.tf.rb']
|
|
287
|
+
candidates.find { |f| File.exist?(f) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def write_template(template_name, dest_path)
|
|
291
|
+
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
292
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
293
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
294
|
+
File.write(dest_path, content)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def print_success
|
|
298
|
+
puts <<~SUCCESS
|
|
299
|
+
|
|
300
|
+
✓ Payments installed!
|
|
301
|
+
|
|
302
|
+
Next steps:
|
|
303
|
+
1. Add the module to your environment's main.tf:
|
|
304
|
+
|
|
305
|
+
module "pay" {
|
|
306
|
+
source = "../modules/pay"
|
|
307
|
+
app_name = var.app_name
|
|
308
|
+
environment = var.environment
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
2. Include Belt::Pay::Billable in your Customer/User model:
|
|
312
|
+
|
|
313
|
+
class Customer < ActiveItem::Base
|
|
314
|
+
include Belt::Pay::Billable
|
|
315
|
+
# ...
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
3. Create your Stripe API keys in Secrets Manager:
|
|
319
|
+
Secret name: <app_name>-<env>-stripe
|
|
320
|
+
Keys: stripe_secret_key, stripe_webhook_secret
|
|
321
|
+
|
|
322
|
+
4. Deploy:
|
|
323
|
+
belt apply <env>
|
|
324
|
+
|
|
325
|
+
5. Configure Stripe webhook endpoint:
|
|
326
|
+
URL: https://<your-api-domain>/pay/webhooks
|
|
327
|
+
Events: checkout.session.completed, checkout.session.expired,
|
|
328
|
+
invoice.paid, invoice.payment_failed,
|
|
329
|
+
customer.subscription.deleted, customer.subscription.updated
|
|
330
|
+
|
|
331
|
+
Quick Usage:
|
|
332
|
+
# Create a checkout session
|
|
333
|
+
Belt::Pay.create_checkout(customer,
|
|
334
|
+
line_items: [{ price: 'price_xxx', quantity: 1 }],
|
|
335
|
+
mode: 'subscription',
|
|
336
|
+
success_url: 'https://app.example.com/success',
|
|
337
|
+
cancel_url: 'https://app.example.com/cancel')
|
|
338
|
+
|
|
339
|
+
# Subscribe directly (if payment method already attached)
|
|
340
|
+
customer.subscribe!(price_id: 'price_xxx')
|
|
341
|
+
|
|
342
|
+
# Check subscription status
|
|
343
|
+
customer.active_subscription? # => true
|
|
344
|
+
|
|
345
|
+
# Customer self-service
|
|
346
|
+
customer.billing_portal_url(return_url: 'https://app.example.com/settings')
|
|
347
|
+
|
|
348
|
+
SUCCESS
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|