Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add stripe checkout for invoices #325

Merged
merged 1 commit into from
Apr 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ NEW_RELIC_LICENSE_KEY='replace with newrelic licence key'

# Redis url
REDIS_URL='redis://127.0.0.1:6379/12'

# Stripe
STRIPE_PUBLISHABLE_KEY="pk_test_NOgckL4BT40aggiIRMiU8O2g00yhQxp1yS"
STRIPE_SECRET_KEY="sk_test_0upT8snNIjPXvOteHwRKhOHK00dGraUDu5"
37 changes: 37 additions & 0 deletions app/controllers/invoices/payments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

class Invoices::PaymentsController < ApplicationController
skip_before_action :authenticate_user!
skip_after_action :verify_authorized
before_action :load_invoice
before_action :ensure_invoice_unpaid, only: [:new]

def new
session = @invoice.create_checkout_session!(
success_url: success_invoice_payments_url(@invoice),
cancel_url: cancel_invoice_payments_url(@invoice)
)

redirect_to session.url, allow_other_host: true
end

def success
@invoice.paid!
end

def cancel
render
end
Comment on lines +18 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We prefer REST based controllers.


private

def load_invoice
@invoice = Invoice.includes(client: :company).find(params[:invoice_id])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the load_invoice before action

Suggested change
@invoice = Invoice.includes(client: :company).find(params[:invoice_id])
def invoice
@_invoice ||= Invoice.includes(client: :company).find(params[:invoice_id])
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why @_ ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We follow this convention in Miru for memorized variables. Global search for @_

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw how will the view templates access this instance variable since we are not loading this in before_action?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass as locals

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one last confusion, Why are we using instance variables if we want to pass them as locals? They should anyway be available to the views, right?

It feels like we are deliberately not taking advantage of the magic that Rails provides us.

Is there any specific reason we decided to move away from our own set standards in Miru?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think because, in this action, we are using the variable only to pass as locals, but for a complex action we might use the invoice method at multiple places and so an instance variable will be accessible everywhere, for example a private method would not need an argument to be passed

end

def ensure_invoice_unpaid
if @invoice.paid?
redirect_to success_invoice_payments_url(@invoice.id)
end
end
end
17 changes: 17 additions & 0 deletions app/models/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# discarded_at :datetime
# stripe_id :string
#
# Indexes
#
Expand Down Expand Up @@ -103,6 +104,22 @@ def client_overdue_and_outstanding_calculation
}
end

def register_on_stripe!
self.transaction do
customer = Stripe::Customer.create(
{
email:,
name:,
phone:,
metadata: {
platform_id: id
}
})

update!(stripe_id: customer.id)
end
end

private

def discard_projects
Expand Down
4 changes: 4 additions & 0 deletions app/models/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,8 @@ def update_timesheet_entry_status!
timesheet_entry_ids = invoice_line_items.pluck(:timesheet_entry_id)
TimesheetEntry.where(id: timesheet_entry_ids).update!(bill_status: :billed)
end

def create_checkout_session!(success_url:, cancel_url:)
InvoicePayment::Checkout.process(invoice: self, success_url:, cancel_url:)
end
end
7 changes: 7 additions & 0 deletions app/services/application_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class ApplicationService
def self.process(*args, &block)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to name the method perform. Also @alkesh26 @keshavbiswa @akhilgkrishnan We were going to use an interactor pattern right? Or since this PR is urgent, we could go with service classes and then refactor later

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes initially the plan was to add interactor classes rather than service classes, if the PR is urgent we can go with service classes too.

new(*args, &block).process
end
end
59 changes: 59 additions & 0 deletions app/services/invoice_payment/checkout.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module InvoicePayment
class Checkout < ApplicationService
def initialize(params)
@invoice = params[:invoice]
@company = invoice.client.company
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add an association, or delegation so we could access like invoice.company

@client = invoice.client
@success_url = params[:success_url]
@cancel_url = params[:cancel_url]
end

def process
Invoice.transaction do
ensure_client_registered!
checkout!
Comment on lines +15 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious why are we using bang methods everywhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are not rescuing the exceptions, I added ! to indicate that we should expect these methods to raise exceptions.

end
end

private

attr_reader :invoice, :company, :client, :success_url, :cancel_url

def ensure_client_registered!
return if client.stripe_id?

client.register_on_stripe!
end

def description
"Invoice from #{company.name} for #{currency} #{invoice.amount} due on #{invoice.due_date}"
end
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's consider adding an invoice_presenter cc @keshavbiswa @alkesh26

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presenters are needed in many places, maybe we can start with this PR.


def currency
company.base_currency
end

def checkout!
Stripe::Checkout::Session.create(
{
line_items: [{
price_data: {
currency: company.base_currency.downcase,
product_data: {
name: invoice.invoice_number,
description:
},
unit_amount: invoice.amount.to_i
},
quantity: 1
}],
mode: "payment",
customer: client.reload.stripe_id,
success_url:,
cancel_url:
})
end
end
end
2 changes: 2 additions & 0 deletions app/views/invoice_mailer/invoice.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
The due date is <%= @invoice.due_date %>.
</p>

<p>You can pay for the invoice here: <%= link_to nil, new_invoice_payment_url(@invoice), target: "_blank", rel: "nofollow" %></p>

<p>Thanks!</p>
</body>
</html>
2 changes: 2 additions & 0 deletions app/views/invoice_mailer/invoice.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ Hi, <%= @invoice.client_name %>
You have an invoice - <%= @invoice.invoice_number %> with amount <%= @invoice.amount %>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amount would be in cents, convert it to $

due date is: <%= @invoice.due_date %>.

You can pay for the invoice here: <%= new_invoice_payment_url(@invoice) %>

Thanks!
Miru
21 changes: 21 additions & 0 deletions app/views/invoices/payments/cancel.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<div class="flex flex-col min-h-full pt-16 pb-12 my-auto">
<main class="flex flex-col self-center justify-center flex-grow w-full px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="flex justify-center flex-shrink-0">
<svg class="w-auto h-16 text-red-400 bg-red-200 rounded-full shadow-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>

<div class="py-16">
<div class="text-center">
<p class="text-sm font-semibold tracking-wide uppercase text-miru-han-purple-600">Invoice #<%= @invoice.invoice_number %></p>
<h1 class="mt-2 text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl">Payment was cancelled.</h1>
<p class="mt-2 text-base text-gray-500">Didn't cancel the payment?</p>
<div class="mt-6 group">
<%= link_to "Try again", new_invoice_payment_url(@invoice), class: "text-base font-medium text-miru-han-purple-600 group-hover:text-miru-han-purple-400", target: "_blank", rel: "nofollow" %>
<span aria-hidden="true" class="text-base font-medium text-miru-han-purple-600 group-hover:text-miru-han-purple-400"> &rarr;</span>
</div>
</div>
</div>
</main>
</div>
17 changes: 17 additions & 0 deletions app/views/invoices/payments/success.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div class="flex flex-col min-h-full pt-16 pb-12 my-auto">
<main class="flex flex-col self-center justify-center flex-grow w-full px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="flex justify-center flex-shrink-0">
<svg class="w-auto h-16 text-green-400 bg-green-200 rounded-full shadow-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>

<div class="py-16">
<div class="text-center">
<p class="text-sm font-semibold tracking-wide text-indigo-600 uppercase">Invoice #<%= @invoice.invoice_number %></p>
<h1 class="mt-2 text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl">Payment was successful. 🎉</h1>
<p class="mt-2 text-base text-gray-500">We have received your payment.</p>
</div>
</div>
</main>
</div>
3 changes: 3 additions & 0 deletions config/initializers/stripe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

Stripe.api_key = ENV["STRIPE_SECRET_KEY"]
9 changes: 9 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ def draw(routes_name)

resources :workspaces, only: [:update]

resources :invoices, only: [], module: :invoices do
resources :payments, only: [:new] do
collection do
get :success
get :cancel
end
end
end

get "clients/*path", to: "clients#index", via: :all
get "clients", to: "clients#index"

Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20220425074402_add_stripe_id_to_clients.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddStripeIdToClients < ActiveRecord::Migration[7.0]
def change
add_column :clients, :stripe_id, :string, default: nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@supriya3105 Do we have only one stripe account per client?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to sync customers and payments on stripe.

end
end
5 changes: 3 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions spec/requests/invoices/payments_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "Invoices::Payments", type: :request do
describe "GET /index" do
pending "add some examples (or delete) #{__FILE__}"
end
end
Comment on lines +3 to +9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This is awesome🚀