payflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +116 -0
  4. data/app/controllers/payflow/application_controller.rb +6 -0
  5. data/app/controllers/payflow/webhooks/asaas_controller.rb +18 -0
  6. data/app/controllers/payflow/webhooks/stripe_controller.rb +18 -0
  7. data/app/jobs/payflow/application_job.rb +6 -0
  8. data/app/jobs/payflow/overdue_accounts_job.rb +16 -0
  9. data/app/jobs/payflow/webhook_job.rb +21 -0
  10. data/app/models/payflow/application_record.rb +7 -0
  11. data/app/models/payflow/invoice.rb +18 -0
  12. data/app/models/payflow/subscription.rb +32 -0
  13. data/app/models/payflow/webhook_event.rb +19 -0
  14. data/config/routes.rb +8 -0
  15. data/db/migrate/20260617180001_create_payflow_subscriptions.rb +19 -0
  16. data/db/migrate/20260617180002_create_payflow_invoices.rb +18 -0
  17. data/db/migrate/20260617180003_create_payflow_webhook_events.rb +17 -0
  18. data/lib/generators/payflow/install_generator.rb +40 -0
  19. data/lib/generators/payflow/templates/initializer.rb +13 -0
  20. data/lib/generators/payflow/templates/migration_invoice.rb +16 -0
  21. data/lib/generators/payflow/templates/migration_subscription.rb +17 -0
  22. data/lib/generators/payflow/templates/migration_webhook_event.rb +15 -0
  23. data/lib/payflow/billable.rb +34 -0
  24. data/lib/payflow/configuration.rb +31 -0
  25. data/lib/payflow/engine.rb +23 -0
  26. data/lib/payflow/errors.rb +13 -0
  27. data/lib/payflow/events.rb +9 -0
  28. data/lib/payflow/provider_resolver.rb +18 -0
  29. data/lib/payflow/providers/asaas/customer.rb +17 -0
  30. data/lib/payflow/providers/asaas/provider.rb +41 -0
  31. data/lib/payflow/providers/asaas/subscription.rb +50 -0
  32. data/lib/payflow/providers/asaas/webhook.rb +43 -0
  33. data/lib/payflow/providers/base.rb +51 -0
  34. data/lib/payflow/providers/stripe/provider.rb +40 -0
  35. data/lib/payflow/providers/stripe/subscription.rb +28 -0
  36. data/lib/payflow/providers/stripe/webhook.rb +31 -0
  37. data/lib/payflow/subscription_service.rb +48 -0
  38. data/lib/payflow/version.rb +5 -0
  39. data/lib/payflow/webhooks/dispatcher.rb +50 -0
  40. data/lib/payflow/webhooks/signature_verifier.rb +57 -0
  41. data/lib/payflow.rb +39 -0
  42. data/lib/tasks/payflow_tasks.rake +28 -0
  43. metadata +184 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "customer"
4
+ require_relative "subscription"
5
+ require_relative "webhook"
6
+
7
+ module Payflow
8
+ module Providers
9
+ module Asaas
10
+ class Provider < Base
11
+ def initialize
12
+ super(provider_name: :asaas)
13
+ end
14
+
15
+ def create_customer(attrs)
16
+ Customer.new(self).create(attrs)
17
+ end
18
+
19
+ def create_subscription(customer_id:, plan_id:, **options)
20
+ Subscription.new(self).create(customer_id: customer_id, plan_id: plan_id, **options)
21
+ end
22
+
23
+ def cancel_subscription(provider_subscription_id)
24
+ Subscription.new(self).cancel(provider_subscription_id)
25
+ end
26
+
27
+ def list_invoices(provider_subscription_id:)
28
+ []
29
+ end
30
+
31
+ def verify_webhook_signature(payload:, headers:)
32
+ Webhook.new(self).verify_signature(payload: payload, headers: headers)
33
+ end
34
+
35
+ def parse_webhook(payload:, headers: {})
36
+ Webhook.new(self).parse(payload: payload, headers: headers)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Providers
5
+ module Asaas
6
+ class Subscription
7
+ BASE_URL = "https://api.asaas.com/v3"
8
+
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ def create(customer_id:, plan_id:, **)
14
+ response = http_client.post("/subscriptions") do |req|
15
+ req.body = {
16
+ customer: customer_id,
17
+ billingType: "CREDIT_CARD",
18
+ value: plan_value(plan_id),
19
+ cycle: "MONTHLY",
20
+ description: "Plan #{plan_id}"
21
+ }.to_json
22
+ end
23
+
24
+ data = JSON.parse(response.body)
25
+ { id: data["id"], provider: :asaas, status: "active" }
26
+ end
27
+
28
+ def cancel(provider_subscription_id)
29
+ http_client.delete("/subscriptions/#{provider_subscription_id}")
30
+ { id: provider_subscription_id, provider: :asaas, status: "cancelled" }
31
+ end
32
+
33
+ private
34
+
35
+ def http_client
36
+ @http_client ||= Faraday.new(url: BASE_URL) do |f|
37
+ f.request :json
38
+ f.response :raise_error
39
+ f.headers["access_token"] = @client.send(:credentials)[:api_key]
40
+ f.headers["Content-Type"] = "application/json"
41
+ end
42
+ end
43
+
44
+ def plan_value(plan)
45
+ { "basic" => 29.90, "premium" => 99.90 }[plan.to_s] || 49.90
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Providers
5
+ module Asaas
6
+ class Webhook
7
+ ASAAS_TOKEN_HEADER = "asaas-access-token"
8
+
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ def verify_signature(payload:, headers:)
14
+ token = headers[ASAAS_TOKEN_HEADER] || headers[ASAAS_TOKEN_HEADER.upcase]
15
+ expected = @client.send(:credentials)[:webhook_token]
16
+ return false if expected.blank? || token.blank?
17
+ ActiveSupport::SecurityUtils.secure_compare(expected.to_s, token.to_s)
18
+ end
19
+
20
+ def parse(payload:, headers: {})
21
+ body = payload.is_a?(String) ? JSON.parse(payload) : payload
22
+ {
23
+ idempotency_key: body["id"] || body.dig("payment", "id"),
24
+ event_type: map_event_type(body["event"]),
25
+ provider: :asaas,
26
+ data: body
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def map_event_type(asaas_event)
33
+ case asaas_event
34
+ when "PAYMENT_RECEIVED" then Payflow::Events::INVOICE_PAID
35
+ when "SUBSCRIPTION_CREATED" then Payflow::Events::SUBSCRIPTION_CREATED
36
+ when "SUBSCRIPTION_DELETED" then Payflow::Events::SUBSCRIPTION_CANCELED
37
+ else asaas_event.to_s.downcase.tr("_", ".")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Providers
5
+ class Base
6
+ attr_reader :provider_name
7
+
8
+ def initialize(provider_name:)
9
+ @provider_name = provider_name.to_sym
10
+ end
11
+
12
+ def create_customer(attrs)
13
+ raise NotImplementedError, "#{self.class}##{__method__}"
14
+ end
15
+
16
+ def find_customer(provider_customer_id)
17
+ raise NotImplementedError, "#{self.class}##{__method__}"
18
+ end
19
+
20
+ def create_subscription(customer_id:, plan_id:, **options)
21
+ raise NotImplementedError, "#{self.class}##{__method__}"
22
+ end
23
+
24
+ def cancel_subscription(provider_subscription_id)
25
+ raise NotImplementedError, "#{self.class}##{__method__}"
26
+ end
27
+
28
+ def find_subscription(provider_subscription_id)
29
+ raise NotImplementedError, "#{self.class}##{__method__}"
30
+ end
31
+
32
+ def list_invoices(provider_subscription_id:)
33
+ raise NotImplementedError, "#{self.class}##{__method__}"
34
+ end
35
+
36
+ def verify_webhook_signature(payload:, headers:)
37
+ raise NotImplementedError, "#{self.class}##{__method__}"
38
+ end
39
+
40
+ def parse_webhook(payload:, headers: {})
41
+ raise NotImplementedError, "#{self.class}##{__method__}"
42
+ end
43
+
44
+ protected
45
+
46
+ def credentials
47
+ Payflow.config.provider_credentials(provider_name)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subscription"
4
+ require_relative "webhook"
5
+
6
+ module Payflow
7
+ module Providers
8
+ module Stripe
9
+ class Provider < Base
10
+ def initialize
11
+ super(provider_name: :stripe)
12
+ end
13
+
14
+ def create_customer(attrs)
15
+ { id: "cus_stripe_stub_#{SecureRandom.hex(4)}", provider: :stripe, email: attrs[:email], name: attrs[:name] }
16
+ end
17
+
18
+ def create_subscription(customer_id:, plan_id:, **options)
19
+ Subscription.new(self).create(customer_id: customer_id, plan_id: plan_id, **options)
20
+ end
21
+
22
+ def cancel_subscription(provider_subscription_id)
23
+ Subscription.new(self).cancel(provider_subscription_id)
24
+ end
25
+
26
+ def list_invoices(provider_subscription_id:)
27
+ []
28
+ end
29
+
30
+ def verify_webhook_signature(payload:, headers:)
31
+ Webhook.new(self).verify_signature(payload: payload, headers: headers)
32
+ end
33
+
34
+ def parse_webhook(payload:, headers: {})
35
+ Webhook.new(self).parse(payload: payload, headers: headers)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Providers
5
+ module Stripe
6
+ class Subscription
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def create(customer_id:, plan_id:, **options)
12
+ {
13
+ id: "sub_stripe_stub_#{SecureRandom.hex(4)}",
14
+ provider: :stripe,
15
+ customer_id: customer_id,
16
+ plan_id: plan_id,
17
+ status: "active",
18
+ current_period_end: options[:current_period_end]
19
+ }
20
+ end
21
+
22
+ def cancel(provider_subscription_id)
23
+ { id: provider_subscription_id, provider: :stripe, status: "canceled" }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Providers
5
+ module Stripe
6
+ class Webhook
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def verify_signature(payload:, headers:)
12
+ Payflow::Webhooks::SignatureVerifier.verify_stripe(
13
+ payload: payload,
14
+ signature_header: headers["HTTP_STRIPE_SIGNATURE"] || headers["Stripe-Signature"],
15
+ secret: @client.send(:credentials)[:webhook_secret]
16
+ )
17
+ end
18
+
19
+ def parse(payload:, headers: {})
20
+ body = payload.is_a?(String) ? JSON.parse(payload) : payload
21
+ {
22
+ idempotency_key: body["id"],
23
+ event_type: body["type"],
24
+ provider: :stripe,
25
+ data: body.dig("data", "object") || {}
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ class SubscriptionService
5
+ def initialize(billable:)
6
+ @billable = billable
7
+ end
8
+
9
+ def subscribe!(plan:, provider: Payflow.config.provider)
10
+ current = @billable.subscription
11
+ raise SubscriptionError, "Already subscribed" if current&.active?
12
+
13
+ client = Payflow.provider(provider)
14
+ remote = client.create_subscription(
15
+ customer_id: customer_id_for(@billable),
16
+ plan_id: plan
17
+ )
18
+
19
+ Subscription.create!(
20
+ billable: @billable,
21
+ plan: plan,
22
+ provider: provider.to_s,
23
+ external_id: remote[:id],
24
+ status: :active
25
+ )
26
+ end
27
+
28
+ def cancel!
29
+ subscription = @billable.subscription
30
+ raise SubscriptionError, "No active subscription" unless subscription
31
+
32
+ client = Payflow.provider(subscription.provider.to_sym)
33
+ client.cancel_subscription(subscription.external_id)
34
+ subscription.update!(status: :cancelled, cancelled_at: Time.current)
35
+ subscription
36
+ end
37
+
38
+ private
39
+
40
+ def customer_id_for(billable)
41
+ if billable.respond_to?(:payflow_provider_customer_id)
42
+ billable.payflow_provider_customer_id
43
+ else
44
+ "cus_#{billable.class.name.underscore}_#{billable.id}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Webhooks
5
+ class Dispatcher
6
+ PAYMENT_OVERDUE = "PAYMENT_OVERDUE"
7
+ PAYMENT_RECEIVED = "PAYMENT_RECEIVED"
8
+ SUBSCRIPTION_DELETED = "SUBSCRIPTION_DELETED"
9
+
10
+ def initialize(webhook_event:)
11
+ @webhook_event = webhook_event
12
+ end
13
+
14
+ def dispatch!
15
+ payload = @webhook_event.payload
16
+ event_type = payload["event"]
17
+
18
+ case event_type
19
+ when PAYMENT_OVERDUE
20
+ mark_subscription_overdue(payload)
21
+ when PAYMENT_RECEIVED
22
+ mark_subscription_active(payload)
23
+ when SUBSCRIPTION_DELETED
24
+ mark_subscription_cancelled(payload)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def find_subscription(payload)
31
+ external_id = payload.dig("payment", "subscription") || payload.dig("subscription", "id")
32
+ return unless external_id
33
+
34
+ Subscription.find_by(external_id: external_id)
35
+ end
36
+
37
+ def mark_subscription_overdue(payload)
38
+ find_subscription(payload)&.update!(status: :overdue)
39
+ end
40
+
41
+ def mark_subscription_active(payload)
42
+ find_subscription(payload)&.update!(status: :active)
43
+ end
44
+
45
+ def mark_subscription_cancelled(payload)
46
+ find_subscription(payload)&.update!(status: :cancelled, cancelled_at: Time.current)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payflow
4
+ module Webhooks
5
+ class SignatureVerifier
6
+ def initialize(provider:, request:)
7
+ @provider = provider.to_sym
8
+ @request = request
9
+ end
10
+
11
+ def valid?
12
+ case @provider
13
+ when :asaas
14
+ verify_asaas
15
+ when :stripe
16
+ verify_stripe
17
+ else
18
+ false
19
+ end
20
+ end
21
+
22
+ def self.verify_stripe(payload:, signature_header:, secret:)
23
+ return false if secret.blank? || signature_header.blank?
24
+
25
+ timestamp, signature = extract_stripe_parts(signature_header)
26
+ return false unless timestamp && signature
27
+
28
+ signed_payload = "#{timestamp}.#{payload}"
29
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
30
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
31
+ end
32
+
33
+ def self.extract_stripe_parts(header)
34
+ parts = header.split(",").to_h { |part| part.split("=", 2) }
35
+ [parts["t"], parts["v1"]]
36
+ end
37
+
38
+ private
39
+
40
+ def verify_asaas
41
+ token = @request.headers["asaas-access-token"]
42
+ expected = Payflow.config.asaas_webhook_token
43
+ return false if token.blank? || expected.blank?
44
+
45
+ ActiveSupport::SecurityUtils.secure_compare(token, expected)
46
+ end
47
+
48
+ def verify_stripe
49
+ self.class.verify_stripe(
50
+ payload: @request.raw_post,
51
+ signature_header: @request.headers["HTTP_STRIPE_SIGNATURE"] || @request.headers["Stripe-Signature"],
52
+ secret: Payflow.config.stripe_webhook_secret
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/payflow.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_job/railtie"
5
+ require "active_record/railtie"
6
+ require "action_controller/railtie"
7
+ require "faraday"
8
+ require "sidekiq"
9
+
10
+ require "payflow/version"
11
+ require "payflow/errors"
12
+ require "payflow/events"
13
+ require "payflow/configuration"
14
+ require "payflow/providers/base"
15
+ require "payflow/providers/asaas/provider"
16
+ require "payflow/providers/stripe/provider"
17
+ require "payflow/provider_resolver"
18
+ require "payflow/subscription_service"
19
+ require "payflow/billable"
20
+ require "payflow/webhooks/signature_verifier"
21
+ require "payflow/webhooks/dispatcher"
22
+ require "payflow/engine"
23
+
24
+ module Payflow
25
+ class << self
26
+ def config
27
+ @config ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield config if block_given?
32
+ config
33
+ end
34
+
35
+ def provider(name = nil)
36
+ ProviderResolver.for(name || config.provider)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payflow/version"
4
+
5
+ module Payflow
6
+ module Tasks
7
+ extend Rake::DSL
8
+
9
+ namespace :payflow do
10
+ namespace :install do
11
+ desc "Copy migrations from Payflow to host application"
12
+ task migrations: :environment do
13
+ engine = Payflow::Engine
14
+ source = engine.root.join("db/migrate")
15
+ destination = Rails.root.join("db/migrate")
16
+
17
+ Dir.glob(source.join("*.rb")).each do |migration|
18
+ target = destination.join(File.basename(migration))
19
+ next if target.exist?
20
+
21
+ FileUtils.cp(migration, target)
22
+ puts "Copied #{File.basename(migration)}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end