Skip to content

Commit

Permalink
feat(payment-url): generate payment url for stripe and adyen (#1628)
Browse files Browse the repository at this point in the history
* add logic for generating stripe one time payment

* add specs

* add logic for adyen

* add adyen specs

* feat(invoice-payment-url): Add setup for invoice payment url (#1635)

* setup invoice payment url retrieval

* fix specs

* small update

* add small fix

* fix linter issues

* feat(payment-url): handle incoming webhooks (#1644)

* handle incoming webhooks

* fix linter issues
  • Loading branch information
lovrocolic authored Feb 5, 2024
1 parent 4c2e135 commit 6ff82e4
Show file tree
Hide file tree
Showing 16 changed files with 633 additions and 4 deletions.
19 changes: 19 additions & 0 deletions app/controllers/api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ def retry_payment
head(:ok)
end

def payment_url
invoice = current_organization.invoices.not_generating.includes(:customer).find_by(id: params[:id])
return not_found_error(resource: 'invoice') unless invoice

result = ::Invoices::Payments::GeneratePaymentUrlService.call(invoice:)

if result.success?
render(
json: ::V1::PaymentProviders::InvoicePaymentSerializer.new(
invoice,
root_name: 'invoice_payment_details',
payment_url: result.payment_url,
),
)
else
render_error_response(result)
end
end

private

def create_params
Expand Down
17 changes: 17 additions & 0 deletions app/serializers/v1/payment_providers/invoice_payment_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module V1
module PaymentProviders
class InvoicePaymentSerializer < ModelSerializer
def serialize
{
lago_customer_id: model.customer&.id,
external_customer_id: model.customer&.external_id,
payment_provider: model.customer&.payment_provider,
lago_invoice_id: model.id,
payment_url: options[:payment_url],
}
end
end
end
end
69 changes: 67 additions & 2 deletions app/services/invoices/payments/adyen_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ def create
result
end

def update_payment_status(provider_payment_id:, status:)
payment = Payment.find_by(provider_payment_id:)
def update_payment_status(provider_payment_id:, status:, metadata: {})
payment = if metadata[:payment_type] == 'one-time'
create_payment(provider_payment_id:, metadata:)
else
Payment.find_by(provider_payment_id:)
end
return result.not_found_failure!(resource: 'adyen_payment') unless payment

result.payment = payment
Expand All @@ -69,12 +73,44 @@ def update_payment_status(provider_payment_id:, status:)
result.fail_with_error!(e)
end

def generate_payment_url
return result unless should_process_payment?

res = client.checkout.payment_links_api.payment_links(Lago::Adyen::Params.new(payment_url_params).to_h)
handle_adyen_response(res)

return result unless result.success?

result.payment_url = res.response['url']

result
rescue Adyen::AdyenError => e
deliver_error_webhook(e)

result.single_validation_failure!(error_code: 'payment_provider_error')
end

private

attr_accessor :invoice

delegate :organization, :customer, to: :invoice

def create_payment(provider_payment_id:, metadata:)
@invoice = Invoice.find(metadata[:lago_invoice_id])

increment_payment_attempts

Payment.new(
invoice:,
payment_provider_id: adyen_payment_provider.id,
payment_provider_customer_id: customer.adyen_customer.id,
amount_cents: invoice.total_amount_cents,
amount_currency: invoice.currency.upcase,
provider_payment_id:,
)
end

def should_process_payment?
return false if invoice.succeeded? || invoice.voided?
return false if adyen_payment_provider.blank?
Expand All @@ -90,6 +126,10 @@ def client
)
end

def success_redirect_url
adyen_payment_provider.success_redirect_url.presence || ::PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL
end

def adyen_payment_provider
@adyen_payment_provider ||= payment_provider(customer)
end
Expand Down Expand Up @@ -145,6 +185,31 @@ def payment_params
prms
end

def payment_url_params
prms = {
reference: invoice.number,
amount: {
value: invoice.total_amount_cents,
currency: invoice.currency.upcase,
},
merchantAccount: adyen_payment_provider.merchant_account,
returnUrl: success_redirect_url,
shopperReference: customer.external_id,
storePaymentMethodMode: 'enabled',
recurringProcessingModel: 'UnscheduledCardOnFile',
expiresAt: Time.current + 1.day,
metadata: {
lago_customer_id: customer.id,
lago_invoice_id: invoice.id,
invoice_issuing_date: invoice.issuing_date.iso8601,
invoice_type: invoice.invoice_type,
payment_type: 'one-time',
},
}
prms[:shopperEmail] = customer.email if customer.email
prms
end

def invoice_payment_status(payment_status)
return :pending if PENDING_STATUSES.include?(payment_status)
return :succeeded if SUCCESS_STATUSES.include?(payment_status)
Expand Down
38 changes: 38 additions & 0 deletions app/services/invoices/payments/generate_payment_url_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

module Invoices
module Payments
class GeneratePaymentUrlService < BaseService
def initialize(invoice:)
@invoice = invoice
@provider = invoice&.customer&.payment_provider&.to_s

super
end

def call
return result.not_found_failure!(resource: 'invoice') if invoice.blank?
return result.single_validation_failure!(error_code: 'no_linked_payment_provider') unless provider
return result.single_validation_failure!(error_code: 'invalid_payment_provider') if provider == 'gocardless'

if invoice.succeeded? || invoice.voided? || invoice.draft?
return result.single_validation_failure!(error_code: 'invalid_invoice_status_or_payment_status')
end

payment_url_result = Invoices::Payments::PaymentProviders::Factory.new_instance(invoice:).generate_payment_url

return payment_url_result unless payment_url_result.success?

if payment_url_result.payment_url.blank?
return result.single_validation_failure!(error_code: 'payment_provider_error')
end

payment_url_result
end

private

attr_reader :invoice, :provider
end
end
end
26 changes: 26 additions & 0 deletions app/services/invoices/payments/payment_providers/factory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module Invoices
module Payments
module PaymentProviders
class Factory
def self.new_instance(invoice:)
service_class(invoice.customer&.payment_provider).new(invoice)
end

def self.service_class(payment_provider)
case payment_provider&.to_s
when 'stripe'
Invoices::Payments::StripeService
when 'adyen'
Invoices::Payments::AdyenService
when 'gocardless'
Invoices::Payments::GocardlessService
else
raise(NotImplementedError)
end
end
end
end
end
end
75 changes: 74 additions & 1 deletion app/services/invoices/payments/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ def create
end

def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {})
payment = Payment.find_by(provider_payment_id:)
payment = if metadata[:payment_type] == 'one-time'
create_payment(provider_payment_id:, metadata:)
else
Payment.find_by(provider_payment_id:)
end
return handle_missing_payment(organization_id, metadata) unless payment

result.payment = payment
Expand All @@ -71,12 +75,51 @@ def update_payment_status(organization_id:, provider_payment_id:, status:, metad
result.fail_with_error!(e)
end

def generate_payment_url
return result unless should_process_payment?

res = Stripe::Checkout::Session.create(
payment_url_payload,
{
api_key: stripe_api_key,
},
)

result.payment_url = res['url']

result
rescue Stripe::CardError, Stripe::InvalidRequestError, Stripe::AuthenticationError, Stripe::PermissionError => e
deliver_error_webhook(e)

result.single_validation_failure!(error_code: 'payment_provider_error')
end

private

attr_accessor :invoice

delegate :organization, :customer, to: :invoice

def create_payment(provider_payment_id:, metadata:)
@invoice = Invoice.find(metadata[:lago_invoice_id])

increment_payment_attempts

Payment.new(
invoice:,
payment_provider_id: stripe_payment_provider.id,
payment_provider_customer_id: customer.stripe_customer.id,
amount_cents: invoice.total_amount_cents,
amount_currency: invoice.currency&.upcase,
provider_payment_id:,
)
end

def success_redirect_url
stripe_payment_provider.success_redirect_url.presence ||
::PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL
end

def should_process_payment?
return false if invoice.succeeded? || invoice.voided?
return false if stripe_payment_provider.blank?
Expand Down Expand Up @@ -171,6 +214,36 @@ def stripe_payment_payload
}
end

def payment_url_payload
{
line_items: [
{
quantity: 1,
price_data: {
currency: invoice.currency.downcase,
unit_amount: invoice.total_amount_cents,
product_data: {
name: invoice.number,
},
},
},
],
mode: 'payment',
success_url: success_redirect_url,
customer: customer.stripe_customer.provider_customer_id,
payment_method_types: customer.stripe_customer.provider_payment_methods,
payment_intent_data: {
metadata: {
lago_customer_id: customer.id,
lago_invoice_id: invoice.id,
invoice_issuing_date: invoice.issuing_date.iso8601,
invoice_type: invoice.invoice_type,
payment_type: 'one-time',
},
},
}
end

def invoice_payment_status(payment_status)
return :pending if PENDING_STATUSES.include?(payment_status)
return :succeeded if SUCCESS_STATUSES.include?(payment_status)
Expand Down
20 changes: 19 additions & 1 deletion app/services/payment_providers/adyen_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ def handle_event(organization:, event_json:)

case event['eventCode']
when 'AUTHORISATION'
return result if event.dig('amount', 'value') != 0
amount = event.dig('amount', 'value')
payment_type = event.dig('additionalData', 'metadata.payment_type')

if payment_type == 'one-time'
update_result = update_payment_status(event, payment_type)
return update_result.raise_if_error! || update_result
end

return result if amount != 0

service = PaymentProviderCustomers::AdyenService.new

Expand Down Expand Up @@ -116,5 +124,15 @@ def reattach_provider_customers(organization_id:, adyen_provider:)
c.update(payment_provider_id: adyen_provider.id)
end
end

private

def update_payment_status(event, payment_type)
provider_payment_id = event['pspReference']
status = (event['success'] == 'true') ? 'succeeded' : 'failed'
metadata = { payment_type:, lago_invoice_id: event.dig('additionalData', 'metadata.lago_invoice_id') }

Invoices::Payments::AdyenService.new.update_payment_status(provider_payment_id:, status:, metadata:)
end
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
post :download, on: :member
post :void, on: :member
post :retry_payment, on: :member
post :payment_url, on: :member
put :refresh, on: :member
put :finalize, on: :member
end
Expand Down
40 changes: 40 additions & 0 deletions spec/fixtures/adyen/webhook_authorisation_payment_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"live": "false",
"notificationItems": [
{
"NotificationRequestItem": {
"additionalData": {
"authCode": "051793",
"paymentLinkId": "PLF11278A8985273C2",
"metadata.payment_type": "one-time",
"cardSummary": "1142",
"metadata.invoice_type": "subscription",
"checkout.cardAddedBrand": "visa",
"metadata.invoice_issuing_date": "2024-01-24",
"expiryDate": "03/2030",
"metadata.lago_customer_id": "a5488a6c-d2ed-44fd-8c97-7fcca4a6a84a",
"threeds2.cardEnrolled": "false",
"recurringProcessingModel": "CardOnFile",
"metadata.lago_invoice_id": "ec82efeb-88bb-44f8-ba30-0d55b3fd583a"
},
"amount": {
"currency": "EUR",
"value": 71
},
"eventCode": "AUTHORISATION",
"eventDate": "2024-01-26T14:06:02+01:00",
"merchantAccountCode": "LagoAccountECOM",
"merchantReference": "HOO-3588-202401-033",
"operations": [
"CANCEL",
"CAPTURE",
"REFUND"
],
"paymentMethod": "visa",
"pspReference": "SGVWRSNQLDQ2WN82",
"reason": "051793:1142:03/2030",
"success": "true"
}
}
]
}
Loading

0 comments on commit 6ff82e4

Please sign in to comment.