Skip to content

Commit

Permalink
Marketplace: Vendor Accepts Payments (#1031)
Browse files Browse the repository at this point in the history
* Marketplace: Start sketching out docs for how we Checkout

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>

* Stripe: Install Stripe Gem

Co-authored-by: Dalton <[email protected]>

* Marketplace: Buyer checks-out with Stripe Checkout!

FUCK YEAAHHHHHHH

Co-authored-by: Dalton <[email protected]>

* Marketplace: Tidy up generation of Stripe session

Co-authored-by: Dalton <[email protected]>
Co-authored-by: Naomi Quinones <[email protected]>

* Marketplace; Turbo does not like the URL used for Stripe Checkout

For whatever reason, it is truncating the urls; and rather than fucking
around trying to figure out how to make turbo play nicely, just disable;
since we know we will need to redraw the entire DOM anyway, since we are
redirecting to a Stripe Checkout page.

Co-authored-by: Dalton <[email protected]>
Co-authored-by: Naomi Quinones <[email protected]>

* Marketplace: Buyer sees Checkout after Checkout

It does make me feel like we are using Checkout for too many things, and
maybe we want an Order class which is distinct from a Checkout but we
didn't want to jump through those hoops in this particual session.

Co-authored-by: Dalton <[email protected]>
Co-authored-by: Naomi Quinones <[email protected]>

* Freeze the Cart and Checkout after a successful payment, and clear out the
current cart.

Co-authored-by: Zee <[email protected]>
Co-authored-by: Kelly <[email protected]>
Co-authored-by: Dalton <[email protected]>

* Update CheckoutPolicy to match spec expectations (#1044)

This PR updates the CheckoutPolicy implementation to satisfy the expectations in the spec. I do not know if this is precisely the behavior we want -- e.g. maybe we want a "marketplace admin" (space owner?) to be able to always see a Checkout -- but we can expand the behavior later.

* Handle the "failed to initiate checkout" case and add request specs.

* Add guards for the cases when we don't have a cart.

* Marketplace: `current_cart` is a lie, it's the shopper!

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>

* Marketplace: Checking out works again!

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>

* Marketplace: Test that receiving a Stripe session id

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>

* Marketplace: Test relationships between Checkout and OrderedProducts

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>

Co-authored-by: Ana <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Kelly Hong <[email protected]>
Co-authored-by: Dalton <[email protected]>
Co-authored-by: Naomi Quinones <[email protected]>
Co-authored-by: Ana Ulin <[email protected]>
Co-authored-by: Zee <[email protected]>
Co-authored-by: Kelly <[email protected]>
Co-authored-by: Dalton P <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
  • Loading branch information
11 people authored Jan 19, 2023
1 parent eaee16c commit e6bf54c
Show file tree
Hide file tree
Showing 26 changed files with 233 additions and 17 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ gem "pundit", "~> 2.3"

# Utility hookup support
gem "money-rails"
gem "stripe"

# Workers and Background Jobs
gem "sidekiq"
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ GEM
standard
stimulus-rails (1.2.1)
railties (>= 6.0.0)
stripe (8.0.0)
strscan (3.0.3)
thor (1.2.1)
tilt (2.0.11)
Expand Down Expand Up @@ -454,6 +455,7 @@ DEPENDENCIES
sprockets-rails
standardrb (~> 1.0)
stimulus-rails
stripe
turbo-rails
tzinfo-data (~> 1.2021)
view_component (~> 2.82)
Expand Down
2 changes: 1 addition & 1 deletion app/furniture/marketplace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def self.append_routes(router)
router.resources :carts do
router.resources :cart_products
end
router.resources :checkouts
router.resources :checkouts, only: [:show, :create]
end
end

Expand Down
13 changes: 13 additions & 0 deletions app/furniture/marketplace/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Marketplace

The Marketplace uses Stripe, we anticipate that we will want to use the "Connect then Transfer" workflow: https://stripe.com/docs/connect/collect-then-transfer-guide

1. Build the Workflow for connecting a Stripe Account
2. Checkout with Stripe Checkout, and include the payment_intent_data with a transfer_group: https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_intent_data-transfer_group
3. Upon completion of Checkout, we transfer the Money: https://stripe.com/docs/connect/charges-transfers

## Testing with Stripe

Stripe provides test API keys and testing credit card numbers, see:
* https://stripe.com/docs/keys#obtain-api-keys
* https://stripe.com/docs/testing
5 changes: 5 additions & 0 deletions app/furniture/marketplace/cart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ class Cart < ApplicationRecord
has_many :products, through: :cart_products, inverse_of: :carts
has_one :checkout, inverse_of: :cart

enum status: {
pre_checkout: "pre_checkout",
checked_out: "check_out"
}

def self.model_name
@_model_name ||= ActiveModel::Name.new(self, ::Marketplace)
end
Expand Down
12 changes: 12 additions & 0 deletions app/furniture/marketplace/cart_product.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@ class CartProduct < ApplicationRecord
self.table_name = "marketplace_cart_products"

belongs_to :cart, inverse_of: :cart_products

belongs_to :product, inverse_of: :cart_products
delegate :name, :description, :price, :price_cents, to: :product

validates_uniqueness_of :product, scope: :cart_id
validate :editable_cart

attribute :quantity, :integer, default: 0

def self.model_name
@_model_name ||= ActiveModel::Name.new(self, ::Marketplace)
end

private

def editable_cart
return unless cart&.checked_out?

errors.add(:base, "Can't edit a checked-out cart!")
end
end
end
4 changes: 2 additions & 2 deletions app/furniture/marketplace/carts/_footer.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%
<%
marketplace = cart.marketplace
room = marketplace.room
space = room.space
Expand All @@ -7,7 +7,7 @@
<tfoot id="cart-footer-<%= cart.id%>" class="bg-gray-50">
<tr>
<td></td>
<th> <%= link_to("checkout", new_space_room_marketplace_checkout_path(space, room, marketplace), html_options = {}) %> </th>
<th> <%= button_to("checkout", [space, room, marketplace, :checkouts], data: { turbo: false }) %> </th>
<th scope="row" class="text-right px-1 py-3.5">Total: </th>
<td class="text-left px-3 py-3.5 font-bold">
<%= render "marketplace/carts/total", cart: cart %>
Expand Down
43 changes: 43 additions & 0 deletions app/furniture/marketplace/checkout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,54 @@ class Checkout < ApplicationRecord
self.location_parent = :marketplace

belongs_to :cart, inverse_of: :checkout

has_many :ordered_products, through: :cart, source: :cart_products, class_name: "Marketplace::OrderedProduct"
delegate :marketplace, to: :cart
belongs_to :shopper, inverse_of: :checkouts


# It would be nice to validate instead the presence of :ordered_products, but my attempts at this raise:
# ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection:
# Cannot modify association 'Marketplace::Checkout#ordered_products' because the source reflection class 'CartProduct' is associated to 'Cart' via :has_many.
validates :stripe_line_items, presence: true

enum status: {
pre_checkout: "pre_checkout",
paid: "paid",
}

def self.model_name
@_model_name ||= ActiveModel::Name.new(self, ::Marketplace)
end


def create_stripe_session(success_url: , cancel_url: )
Stripe::Checkout::Session.create({
line_items: stripe_line_items,
mode: "payment",
success_url: success_url,
cancel_url: cancel_url
}, {
api_key: marketplace.stripe_api_key
})
end

private

def stripe_line_items
return [] unless cart.present?

cart.cart_products.map do |cart_product|
{
price_data: {
currency: "USD",
unit_amount: cart_product.product.price_cents,
product_data: {name: cart_product.product.name}
},
quantity: cart_product.quantity,
adjustable_quantity: { enabled: true }
}
end
end
end
end
4 changes: 4 additions & 0 deletions app/furniture/marketplace/checkout_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ def create?

checkout.shopper.person == current_person
end

def show?
create?
end
end
end
5 changes: 3 additions & 2 deletions app/furniture/marketplace/checkouts/_checkout.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<h1>Ordered on <%= checkout.created_at.to_fs(:long_ordinal) %>
<div class="-mx-4 mt-8 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:-mx-6 md:mx-0 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300 table-fixed">
<thead class="bg-gray-50">
Expand All @@ -15,14 +16,14 @@
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<%= render checkout.cart.cart_products %>
<%= render checkout.ordered_products %>
</tbody>
<tfoot id="checkout-footer-<%= checkout.id%>" class="bg-gray-50">
<tr>
<td></td>
<th scope="row" class="text-right px-1 py-3.5">Total: </th>
<td class="text-left px-3 py-3.5 font-bold">
<%= render "marketplace/carts/total" %>
<%= render "marketplace/carts/total" %>
</td>
</tr>
</tfoot>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
<%- breadcrumb :marketplace_checkout, checkout%>
<%= render checkout %>
<%= render checkout %>
36 changes: 32 additions & 4 deletions app/furniture/marketplace/checkouts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,51 @@
class Marketplace
class CheckoutsController < FurnitureController
def new

def show
authorize(checkout)
if params[:stripe_session_id].present?
checkout.update!(status: :paid, stripe_session_id: params[:stripe_session_id])
checkout.cart.update!(status: :checked_out)
flash[:notice] = t('.success')
end
end

def create
authorize(checkout)

if checkout.save
stripe_session = checkout.create_stripe_session(
success_url: "#{polymorphic_url(checkout.location)}?stripe_session_id={CHECKOUT_SESSION_ID}",
cancel_url: polymorphic_url(marketplace.location)
)
redirect_to stripe_session.url, status: :see_other, allow_other_host: true
else
redirect_to(
[marketplace.room.space, marketplace.room],
# TODO: make this a nicer, I18ed message
alert: flash[:alert] = checkout.errors.full_messages.join(" ")
)
end
end

helper_method def checkout
@checkout ||= cart.build_checkout(shopper: shopper)
@checkout ||= if params[:id]
Checkout.find(params[:id])
else
cart.checkout || cart.build_checkout(shopper: shopper)
end
end

helper_method def shopper
@shopper ||= if current_person.is_a?(Guest)
Shopper.find_or_create_by(id: session[:current_cart] ||= SecureRandom.uuid)
Shopper.find_or_create_by(id: session[:guest_shopper_id] ||= SecureRandom.uuid)
else
Shopper.find_or_create_by(person: current_person)
end
end

helper_method def cart
@cart ||= marketplace.carts.find_or_create_by(shopper: shopper)
@cart ||= marketplace.carts.find_or_create_by(shopper: shopper, status: :pre_checkout)
end

helper_method def marketplace
Expand Down
3 changes: 3 additions & 0 deletions app/furniture/marketplace/locales/en.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@

en:
marketplace:
checkouts:
show:
success: "You successfully checked out!"
marketplace:
edit: "Configure Marketplace"
marketplaces:
Expand Down
1 change: 1 addition & 0 deletions app/furniture/marketplace/marketplace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Marketplace < FurniturePlacement
has_many :products, inverse_of: :marketplace, dependent: :destroy
has_many :carts, inverse_of: :marketplace, dependent: :destroy

# The Secret Stripe API key belonging to the owner of the Marketplace
def stripe_api_key=(key)
settings["stripe_api_key"] = key
end
Expand Down
4 changes: 2 additions & 2 deletions app/furniture/marketplace/marketplaces/_marketplace.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<%- shopper = if current_person.is_a?(Guest)
Marketplace::Shopper.find_or_create_by(id: session[:current_cart] ||= SecureRandom.uuid)
Marketplace::Shopper.find_or_create_by(id: session[:guest_shopper_id] ||= SecureRandom.uuid)
else
Marketplace::Shopper.find_or_create_by(person: current_person)
end %>
<%- cart = marketplace.carts.find_or_create_by(shopper: shopper) %>
<%- cart = marketplace.carts.find_or_create_by(shopper: shopper, status: :pre_checkout) %>
<%= render cart %>

Expand Down
8 changes: 8 additions & 0 deletions app/furniture/marketplace/ordered_product.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Marketplace
class OrderedProduct < CartProduct
include WithinLocation
self.location_parent = :checkout

has_one :checkout, through: :cart
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<tr id="<%=dom_id(ordered_product)%>">
<td class="w-full max-w-0 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:w-auto sm:max-w-none sm:pl-6">
<%= ordered_product.name %>
<dl class="font-normal lg:hidden">
<dt class="sr-only"><%= ordered_product.class.human_attribute_name(:description) %></dt>
<dd class="mt-1 truncate text-gray-700"><%= ordered_product.description %></dd>
</dl>
</td>
<td class="hidden px-3 py-4 text-sm text-gray-500 lg:table-cell">
<%= ordered_product.description %>
</td>
<td class="hidden px-3 py-4 text-sm text-gray-500 sm:table-cell">
<%= humanized_money_with_symbol(ordered_product.price) %>
</td>
</tr>
6 changes: 6 additions & 0 deletions db/migrate/20230112022651_add_checkout_metadata.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddCheckoutMetadata < ActiveRecord::Migration[7.0]
def change
add_column :marketplace_checkouts, :status, :string, default: "pre_checkout", null: false
add_column :marketplace_checkouts, :stripe_session_id, :string
end
end
5 changes: 5 additions & 0 deletions db/migrate/20230112024425_add_status_to_cart.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddStatusToCart < ActiveRecord::Migration[7.0]
def change
add_column :marketplace_carts, :status, :string, default: "pre_checkout", null: false
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2022_12_27_185339) do
ActiveRecord::Schema[7.0].define(version: 2023_01_12_024425) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
Expand Down Expand Up @@ -139,6 +139,7 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "shopper_id"
t.string "status", default: "pre_checkout", null: false
t.index ["marketplace_id"], name: "index_marketplace_carts_on_marketplace_id"
t.index ["shopper_id"], name: "index_marketplace_carts_on_shopper_id"
end
Expand All @@ -148,6 +149,8 @@
t.uuid "shopper_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "status", default: "pre_checkout", null: false
t.string "stripe_session_id"
t.index ["cart_id"], name: "index_marketplace_checkouts_on_cart_id"
t.index ["shopper_id"], name: "index_marketplace_checkouts_on_shopper_id"
end
Expand Down
Binary file modified docs/erd.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion spec/factories/furniture/marketplace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
factory :marketplace_cart, class: "Marketplace::Cart" do
marketplace
association(:shopper, factory: :marketplace_shopper)

trait :with_products do
after(:build) do |cart, _evaluator|
build(:marketplace_cart_product, cart: cart)
end
end
end

factory :marketplace_shopper, class: "Marketplace::Shopper" do
end

end
10 changes: 10 additions & 0 deletions spec/furniture/marketplace/cart_product_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@

it { is_expected.to belong_to(:product).inverse_of(:cart_products) }
it { is_expected.to validate_uniqueness_of(:product).scoped_to(:cart_id) }

describe "#quantity" do
let(:cart) { create(:marketplace_cart, :with_products) }

it "can't be edited for a checked_out cart" do
cart.cart_products.first.update!(quantity: 42)
cart.checked_out!
expect { cart.cart_products.first.update!(quantity: 17) }.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
10 changes: 10 additions & 0 deletions spec/furniture/marketplace/checkout_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@
it { is_expected.to permit(guest, guest_checkout) }
it { is_expected.not_to permit(member, guest_checkout) }
end

permissions :show? do
it { is_expected.to permit(member, member_checkout) }
it { is_expected.not_to permit(non_member, member_checkout) }
it { is_expected.not_to permit(guest, member_checkout) }
it { is_expected.to permit(non_member, non_member_checkout) }
it { is_expected.not_to permit(member, non_member_checkout) }
it { is_expected.to permit(guest, guest_checkout) }
it { is_expected.not_to permit(member, guest_checkout) }
end
end
1 change: 1 addition & 0 deletions spec/furniture/marketplace/checkout_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
RSpec.describe Marketplace::Checkout, type: :model do
it { is_expected.to belong_to(:cart).inverse_of(:checkout) }
it { is_expected.to belong_to(:shopper).inverse_of(:checkouts) }
it { is_expected.to have_many(:ordered_products).through(:cart).source(:cart_products).class_name("Marketplace::OrderedProduct") }
end
Loading

0 comments on commit e6bf54c

Please sign in to comment.