├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── dev.secret.exs ├── prod.exs ├── prod.secret.exs ├── test.exs └── test.secret.exs ├── lib ├── accounting │ ├── invoice.ex │ ├── order_fulfillment.ex │ ├── sales_order.ex │ └── transaction.ex ├── api.ex ├── api │ ├── cart.ex │ ├── catalog.ex │ └── checkout.ex ├── db │ └── postgres.ex ├── fulfillment.ex ├── fulfillment │ ├── delivery.ex │ └── fulfillment_order.ex ├── mailer.ex ├── mailer │ ├── dummy_mail_server.ex │ └── template.ex ├── mix │ └── tasks │ │ └── start.ex ├── sales.ex ├── sales │ ├── cart_item.ex │ ├── catalog.ex │ └── shopping.ex ├── store.ex └── util.ex ├── mix.exs ├── mix.lock └── test ├── catalog_test.exs ├── fulfillment_test.exs ├── sales_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | cover 3 | deps 4 | erl_crash.dump 5 | *.ez 6 | node_modules 7 | .DS_Store 8 | Mnesia.nonode@nohost 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | New BSD License 2 | http://www.opensource.org/licenses/bsd-license.php 3 | Copyright (c) 2016, Big Machine, Inc 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | 8 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 9 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | Neither the name of the Big Machine, Inc, Rob Conery, nor the names of this project's contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 12 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 13 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 14 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 15 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 16 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## An eCommerce Experiment with Elixir and OTP 2 | 3 | Peach is an API-only eCommerce storefront. For now there is no templating, no HTML, etc - it's just an API. I started out with Phoenix and decided that it didn't quite fit what I wanted so I switch over to [Maru](https://github.com/falood/maru) - a small, Grape-inspired API front end. It fits perfectly. 4 | 5 | I also was thinking about [Trot](https://github.com/hexedpackets/trot) but decided that I wanted to leave the CMS/HTML stuff to a framework or application that is very good at it; Peach can be just one part of the puzzle. 6 | 7 | I've been [writing a blog series](http://rob.conery.io/category/redfour) about creating an eCommerce store with Elixir and I plan to continue on, as this is an application that I'll need very soon. You're welcome to help :). 8 | 9 | 10 | ## Installing 11 | 12 | You'll need a database (Postgres) called `redfour`: 13 | 14 | ```sh 15 | createdb redfour 16 | ``` 17 | 18 | Update the config files in the web app to use your connection. Next you'll need a Stripe key for `stripity-stripe`; put that in `/config/dev.secret.exs` so you can make charges. 19 | 20 | You can start everything up with: 21 | 22 | ``` 23 | mix peach.start 24 | ``` 25 | 26 | ## Tests 27 | 28 | I have the core catalog and cart stuff started out, but have changed course enough times that I had to rip the bulk of the tests out as I mused on architecture. I need more tests, obviously, especially for the API which (according to the docs) is pretty simple to do. 29 | 30 | You can run the tests (once you have the database created) using `mix test --trace`. 31 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :shopping, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:shopping, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :moebius, connection: [ 4 | database: "redfour" 5 | ] 6 | 7 | config :maru, Peach.API, 8 | http: [port: 8080], 9 | versioning: [using: :path] 10 | 11 | import_config "dev.secret.exs" 12 | -------------------------------------------------------------------------------- /config/dev.secret.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :stripity_stripe, secret_key: "z5AkFOxXhZ1N7VBHHQnBpnPwS7WftE5e" 4 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :maru, Peach.API, http: [port: 443] #make sure this is 443 4 | config :moebius, connection: [ 5 | database: "redfour" 6 | ] 7 | import_config "prod.secret.exs" 8 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # In this file, we keep production configuration that 4 | # you likely want to automate and keep it away from 5 | # your version control system. 6 | config :web, Bigmachine.Web.Endpoint, 7 | secret_key_base: "0SPGllQlen9DmRADKPN+EMLKfev2bkNHLVbcd4+zD9UZ9Ibk4eXj4Kb01TViThRu" 8 | 9 | config :moebius, connection: [database: "redfour"] 10 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | config :maru, Peach.API, http: [port: 8880] 3 | config :moebius, connection: [database: "redfour"] 4 | import_config "test.secret.exs" 5 | -------------------------------------------------------------------------------- /config/test.secret.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :stripity_stripe, secret_key: "z5AkFOxXhZ1N7VBHHQnBpnPwS7WftE5e" 4 | -------------------------------------------------------------------------------- /lib/accounting/invoice.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Accounting.Invoice do 2 | import Peach.Util 3 | 4 | defstruct [ 5 | id: nil, 6 | key: UUID.uuid4(), 7 | order_key: nil, 8 | bill_to: nil, 9 | email: nil, 10 | ship_to: nil, 11 | billing_address: nil, 12 | shipping_address: nil, 13 | items: [], 14 | discounts: [], 15 | status: "open", 16 | amount_due: 0.00, 17 | amount_paid: 0.00, 18 | paid_at: nil, 19 | payment: nil, 20 | ip: nil 21 | ] 22 | 23 | def create_from_stripe_response(order: order, payment: payment, response: response) do 24 | %Peach.Accounting.Invoice{ 25 | bill_to: payment["token"]["card"]["name"], 26 | email: order.customer_email, 27 | ship_to: order.customer_name, 28 | billing_address: %{ 29 | street: payment["token"]["card"]["address_line1"], 30 | city: payment["token"]["card"]["address_city"], 31 | state: payment["token"]["card"]["address_state"], 32 | zip: payment["token"]["card"]["address_zip"], 33 | country: payment["token"]["card"]["country"], 34 | }, 35 | shipping_address: order.address, 36 | items: order.items, 37 | discounts: order.discounts, 38 | status: "paid", 39 | amount_due: order.summary.subtotal, 40 | amount_paid: response.amount, 41 | order_key: order.key, 42 | ip: payment["client_ip"], 43 | paid_at: now_iso, 44 | payment: payment 45 | } 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /lib/accounting/order_fulfillment.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Accounting.OrderFulfillment do 2 | defstruct [ 3 | id: nil, 4 | key: nil, 5 | order_key: nil, 6 | message: nil, 7 | shipping_address: nil, 8 | order_items: [], 9 | customer_id: nil, 10 | customer_email: nil, 11 | customer_name: nil, 12 | status: "pending", 13 | logs: [], 14 | deliverables: [], 15 | created_at: nil 16 | ] 17 | end 18 | -------------------------------------------------------------------------------- /lib/accounting/sales_order.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Accounting.SalesOrder do 2 | use GenServer 3 | import Peach.Util 4 | alias Peach.Sales.CartItem 5 | alias Peach.Db.Postgres, as: Db 6 | alias __MODULE__ 7 | import Plug.Conn 8 | 9 | defstruct [ 10 | store_id: nil, 11 | customer_id: nil, 12 | status: "open", 13 | customer_name: "Rorb Conery", 14 | customer_email: "rob@conery.io", 15 | address: %{street: "PO Box 803", street2: nil, city: "Hanalei", state: "HI", zip: "96714", country: "USA"}, 16 | id: nil, 17 | key: nil, 18 | landing: "/", 19 | message: nil, 20 | ip: "127.0.0.1", 21 | items: [], 22 | history: [], 23 | transactions: [], 24 | invoice: nil, 25 | payment: nil, 26 | summary: %{item_count: 0, total: 0.00, subtotal: 0.0}, 27 | logs: [%{entry: "order Created", date: now_iso}], 28 | discounts: [], 29 | deliverables: [] 30 | ] 31 | 32 | def summarize(order) do 33 | Enum.reduce order.items, %{item_count: 0, subtotal: 0, total: 0}, fn(item,acc) -> 34 | quantity = item.quantity + acc.item_count 35 | subtotal = (item.price * item.quantity) + acc.subtotal 36 | total = ((item.price-item.discount) * item.quantity) + acc.total 37 | %{item_count: quantity, subtotal: subtotal, total: total} 38 | end 39 | end 40 | 41 | def add_log_entry(order, entry) do 42 | %{order | message: entry, logs: List.insert_at(order.logs, -1, %{entry: entry, date: now_iso})} 43 | end 44 | 45 | 46 | def remove_item({:new, %{order: order, new_item: %{sku: sku}}}), do: handle_error(order, "SKU '#{sku}' not found in the cart") 47 | def remove_item({:found, %{order: order, existing: item}}) do 48 | order = %{order | items: List.delete(order.items, item)} 49 | {:ok, order: order, log: "#{item.sku} removed from cart"} 50 | end 51 | 52 | def change_quantity({:new, %{order: order, new_item: %{sku: sku}}}, _), do: handle_error(order, "SKU '#{sku}' not found in the cart") 53 | def change_quantity({:found, %{order: order, existing: item} = located}, quantity: new_quantity) do 54 | items = update_items(located, %{quantity: new_quantity}) 55 | order = %{order | items: items} 56 | {:ok, order: order, log: "#{item.sku} updated to #{new_quantity}"} 57 | end 58 | 59 | def add_item({:new, %{order: order, new_item: item}}) do 60 | order = %{order | items: List.insert_at(order.items, -1, item)} 61 | {:ok, order: order, log: "#{item.sku} added to cart"} 62 | end 63 | 64 | def add_item({:found, %{order: order, existing: existing, new_item: item} = located}) do 65 | new_quantity = existing.quantity + item.quantity 66 | order = %{order | items: update_items(located, %{quantity: new_quantity})} 67 | {:ok, order: order, log: "#{item.sku} updated to #{new_quantity}"} 68 | end 69 | 70 | def update_items(%{order: order, idx: idx, existing: existing}, %{quantity: val}) do 71 | new_item = %{existing | quantity: val} 72 | List.replace_at(order.items, idx, new_item) 73 | end 74 | 75 | def locate_item(order, %{sku: sku} = item) do 76 | case Enum.find_index(order.items, &(&1.sku == sku)) do 77 | nil -> {:new, %{order: order, new_item: item}} 78 | idx -> {:found, %{order: order, idx: idx, existing: Enum.at(order.items, idx), new_item: item}} 79 | end 80 | end 81 | 82 | defp handle_error(order, mssg) do 83 | {:error, order: order, message: mssg} 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/accounting/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Accounting.Transaction do 2 | alias __MODULE__ 3 | 4 | defstruct [ 5 | key: UUID.uuid4(), 6 | order_key: nil, 7 | processor: nil, 8 | payment: nil, 9 | processor_response: nil, 10 | amount_due: 0.00, 11 | amount_paid: 0.00 12 | ] 13 | 14 | def create_from_stripe_response(order: order, payment: payment, response: response) do 15 | %Transaction{ 16 | order_key: order.key, 17 | processor: "stripe", 18 | payment: payment, 19 | processor_response: response, 20 | amount_due: order.summary.subtotal, 21 | amount_paid: response.amount 22 | } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.API do 2 | use Maru.Router 3 | plug Plug.Logger 4 | plug Peach.Store 5 | 6 | mount Peach.Router.Catalog 7 | mount Peach.Router.Cart 8 | 9 | 10 | #TODO: Put a manifest here? Would make good sense and feel a bit 11 | #HATEOS YO 12 | get do 13 | manifest = %{ 14 | all_products: "/v1/catalog", 15 | product_by_sku: "/v1/catalog/:sku", 16 | products_by_collection: "/v1/catalog/collections/:collection", 17 | collections: "/v1/catalog/collections/" 18 | } 19 | conn |> json(manifest) 20 | end 21 | 22 | rescue_from Unauthorized do 23 | IO.inspect "401: Unauthorized" 24 | conn 25 | |> put_status(401) 26 | |> text("Unauthorized") 27 | end 28 | 29 | rescue_from Maru.Exceptions.NotFound, as: e do 30 | IO.inspect "404: URL Not Found at path /#{e.path_info}" 31 | conn |> 32 | put_status(404) |> 33 | text("This URL is invalid") 34 | end 35 | 36 | rescue_from Maru.Exceptions.MethodNotAllow do 37 | IO.inspect "405: Method Not allowed" 38 | conn 39 | |> put_status(405) 40 | |> text("Method Not Allowed") 41 | end 42 | 43 | rescue_from [MatchError, UndefinedFunctionError], as: e do 44 | e |> IO.inspect 45 | 46 | conn 47 | |> put_status(500) 48 | |> text "Run time error" 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/api/cart.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Router.Cart do 2 | use Maru.Router 3 | plug Plug.Logger 4 | 5 | alias Peach.Sales 6 | 7 | version "v1" 8 | 9 | namespace :cart do 10 | get do: conn |> json conn.assigns.order 11 | 12 | desc "Adding an item to the cart" 13 | params do 14 | group :product do 15 | requires :sku, type: String 16 | optional :quantity, type: Integer, default: 1 17 | end 18 | end 19 | 20 | #create 21 | post do 22 | conn 23 | |> Sales.select_item(sku: params[:sku], quantity: params[:quantity]) 24 | |> json conn.assigns.order 25 | end 26 | 27 | #hopefully the quantity will come through the query string OK 28 | put ":sku" do 29 | conn 30 | |> Sales.change_item(sku: params[:sku], quantity: String.to_integer(params[:quantity])) 31 | |> json conn.assigns.order 32 | end 33 | 34 | delete ":sku" do 35 | conn 36 | |> Sales.remove_item(sku: params[:sku]) 37 | |> json conn.assigns.order 38 | end 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/api/catalog.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Router.Catalog do 2 | use Maru.Router 3 | plug Plug.Logger 4 | 5 | alias Peach.Sales.Catalog 6 | 7 | version "v1" 8 | 9 | namespace :catalog do 10 | 11 | get do: conn |> json Catalog.products 12 | 13 | namespace :collections do 14 | 15 | get do: conn |> json Catalog.collections 16 | get ":id", do: conn |> json Catalog.products(collection: params[:id]) 17 | 18 | end 19 | 20 | get ":slug", do: conn |> json Catalog.product(sku: params[:slug]) 21 | 22 | end 23 | 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/api/checkout.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Router.Checkout do 2 | use Maru.Router 3 | plug Plug.Logger 4 | 5 | alias Peach.Sales 6 | 7 | version "v1" 8 | 9 | namespace :checkout do 10 | 11 | params do 12 | requires :token, type: String 13 | requires :customer_email, type: String 14 | optional :customer_name, type: String 15 | end 16 | 17 | post do 18 | case charge_customer(conn, params) do 19 | %{assigns: %{order: %{status: "closed"}}} = conn -> json(conn, %{success: true, message: "Charge Successful"}) 20 | %{assigns: %{message: err}} = conn -> json(conn, %{success: false, message: err}) 21 | end 22 | end 23 | end 24 | 25 | 26 | defp charge_customer(conn, params) do 27 | payment = params["token"] 28 | order = conn.assigns.order 29 | 30 | #create the charge bits 31 | args = [ 32 | source: payment["id"], 33 | capture: true, 34 | receipt_email: params["customer_email"] 35 | ] 36 | 37 | #talk to Stripe 38 | charge = Stripe.Charges.create(trunc(order.summary.total), args) 39 | 40 | #eval the response 41 | case charge do 42 | {:ok, response} -> conn |> Sales.record_sale(payment: params, processor: "stripe", response: response) |> Sales.close 43 | {:error, err} -> conn |> assign(:order, %{order | message: err}) 44 | end 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /lib/db/postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Db.Postgres do 2 | use Moebius.Database 3 | import Moebius.DocumentQuery 4 | alias Peach.Accounting.SalesOrder 5 | alias Peach.Db.Postgres, as: Db 6 | 7 | #order stuff 8 | def find_or_create_order(%{key: key} = args) do 9 | res = case db(:orders) |> contains(key: key) |> Db.first do 10 | nil -> db(:orders) |> Db.save(struct(%SalesOrder{}, args)) 11 | found -> %{found | items: reset_to_cart_items(found)} 12 | end 13 | end 14 | 15 | def save_transaction_details(order, invoice: invoice, transaction: trans) do 16 | transaction fn(tx) -> 17 | db(:transactions) |> Db.save(trans, tx) 18 | db(:invoices) |> Db.save(invoice, tx) 19 | db(:orders) |> Db.save(order, tx) 20 | end 21 | end 22 | def save_order(order) do 23 | db(:orders) |> Db.save(order) 24 | end 25 | 26 | def remove_order(order) do 27 | db(:orders) |> delete(order.id) |> Db.first 28 | end 29 | 30 | def get_mailer(key: key) do 31 | mailer = db(:mailers) |> contains(key: key) |> Db.first 32 | struct %Peach.Mailer{}, mailer 33 | end 34 | 35 | #catalog 36 | def products() do 37 | db(:products) |> contains(status: "published") |> Db.run 38 | end 39 | 40 | def collections() do 41 | db(:collections) |> Db.run 42 | end 43 | 44 | defp reset_to_cart_items(order) do 45 | for item <- order.items, do: struct(%Peach.Sales.CartItem{}, item) 46 | end 47 | 48 | #sales 49 | def record_transaction(tx) do 50 | db(:transactions) |> Db.save(tx) 51 | end 52 | 53 | def get_transaction(key: key) do 54 | db(:transactions) |> contains(key: key) |> Db.first 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /lib/fulfillment.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Fulfillment do 2 | alias Peach.Db.Postgres, as: Db 3 | import Plug.Conn 4 | import Peach.Util 5 | 6 | 7 | #this should be executed as a task 8 | def execute(%Peach.Accounting.SalesOrder{status: "payment-recieved"} = order) do 9 | order 10 | |> prepare_delivery 11 | |> provision 12 | |> send_order_confirmation_email 13 | |> send_order_fulfilled_email 14 | end 15 | 16 | def prepare_delivery(order) do 17 | deliverables = for item <- order.items, do: Peach.Fulfillment.Delivery.prepare item 18 | %{order | deliverables: deliverables} 19 | end 20 | 21 | defp fail(order, reason) do 22 | #log it, and set to "failed?" 23 | end 24 | 25 | defp provision(order) do 26 | order 27 | end 28 | 29 | defp prepare_mailers do 30 | #get the mailer bits from the DB 31 | order 32 | end 33 | 34 | defp send_order_confirmation_email(order) do 35 | order 36 | end 37 | 38 | defp send_order_fulfilled_email(order) do 39 | order 40 | end 41 | 42 | defp save_order({:error, order: order, message: mssg}), do: %{order | message: mssg} 43 | defp save_order({:ok, order: order, log: log}) do 44 | %{order | message: log, logs: List.insert_at(order.logs, -1, %{entry: log, date: now_iso})} 45 | |> Db.save_order 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/fulfillment/delivery.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Fulfillment.Delivery do 2 | 3 | def prepare(%{sku: sku} = item) do 4 | #get the product - the Catalog is in memory so this shouldn't be N+1 5 | product = Peach.Sales.Catalog.product sku: item.sku 6 | end 7 | 8 | #A pattern-match for vimeo deliveries 9 | def set_delivery(%{delivery: %{type: :download, provider: "vimeo"}} = delivery) do 10 | delivery 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /lib/fulfillment/fulfillment_order.ex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robconery/peach/c934b2d15b22c841b241db054879ba8a28740e9c/lib/fulfillment/fulfillment_order.ex -------------------------------------------------------------------------------- /lib/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Mailer do 2 | import Moebius.DocumentQuery 3 | alias Peach.Fulfillment.Db 4 | 5 | defstruct [ 6 | key: nil, 7 | subject: nil, 8 | body: nil, 9 | to: nil, 10 | sent_at: nil, 11 | logs: [] 12 | ] 13 | 14 | def prepare(key: key, order_key: order_key, to: email, name: name, site_name: site_name, info: info) do 15 | mailer = db(:mailers) |> contains(key: key) |> Db.first 16 | body = Earmark.to_html(mailer.template) 17 | body 18 | |> String.replace("{{name}}", name) 19 | |> String.replace("{{site_name}}", site_name) 20 | |> String.replace("{{info}}", info) 21 | |> String.replace("{{key}}", order_key) 22 | 23 | subject = String.replace(mailer.subject, "{{key}}", order_key) 24 | 25 | %Peach.Mailer{ 26 | key: key, 27 | subject: subject, 28 | to: email, 29 | logs: [%{date: Db.now_iso, entry: "Mailer prepared"}] 30 | } 31 | end 32 | 33 | def find(key: key), do: Db.get_mailer(key: key) 34 | 35 | def send(%Peach.Mailer{to: to, subject: subject} = mailer) when not to == nil and not subject == nil do 36 | service = Application.get_env(:fulfillment, :mailer) 37 | %{mailer | logs: List.insert_at(mailer.logs, -1, "Preparing to send"), sent_at: Db.now_iso} 38 | |> service.send 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/mailer/dummy_mail_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Fulfillment.DummyMailServer do 2 | 3 | def send(%Peach.Mailer{} = mailer) do 4 | mailer = %{mailer | sent_at: Peach.Fulfillment.Db.now_iso} 5 | {:ok, mailer} 6 | end 7 | 8 | 9 | end 10 | -------------------------------------------------------------------------------- /lib/mailer/template.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Mailer.Template do 2 | defstruct [ 3 | site_name: nil, 4 | key: nil, 5 | info: nil, 6 | from_name: nil, 7 | site_email: nil 8 | ] 9 | 10 | def create_from_order(%Peach.Accounting.SalesOrder{key: key, items: items} = order) do 11 | %Peach.Mailer.Template{ 12 | site_name: "Red:4 Store", 13 | key: order.key, 14 | info: Order.items_to_html_list(order), 15 | from_name: "Rob Conery", 16 | site_email: "store@redfour.io" 17 | } 18 | end 19 | 20 | def apply_to_mailer(%Peach.Mailer{} = mailer, args) when is_list(args) do 21 | EEx.eval_string mailer.template, args 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mix/tasks/start.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Peach.Start do 2 | use Mix.Task 3 | 4 | def run(args) do 5 | settings = Application.get_env(:maru, :http) 6 | IO.puts "Starting Peach" 7 | Mix.Task.run "run", run_args() ++ args 8 | IO.puts "Listening on port 8080" 9 | end 10 | 11 | defp run_args do 12 | if iex_running?, do: [], else: ["--no-halt"] 13 | end 14 | 15 | defp iex_running? do 16 | Code.ensure_loaded?(IEx) && IEx.started? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/sales.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Sales do 2 | 3 | import Peach.Util 4 | alias Peach.Sales.CartItem 5 | alias Peach.Db.Postgres, as: Db 6 | alias Peach.Accounting.SalesOrder 7 | import Plug.Conn 8 | 9 | def start_link(%{key: key}) when is_atom(key), do: start_link(%{key: Atom.to_string(key)}) 10 | def start_link(%{key: key} = args) when is_binary(key) do 11 | order = Db.find_or_create_order(args) 12 | Agent.start_link fn -> order end, name: get_name(key) 13 | end 14 | 15 | def current(%Plug.Conn{cookies: %{"order_key" => key}} = conn) do 16 | unless exists?(key), do: Peach.Store.start_order key: key 17 | order = Agent.get(get_name(key), &(&1)) 18 | assign conn, :order, order 19 | end 20 | 21 | def select_item(_pid, sku: sku) when (sku == nil), 22 | do: {:error, "A sku is required"} 23 | 24 | def select_item(_pid, sku: _sku, quantity: quantity) when quantity <=0, 25 | do: {:error, "Adding 0 (or fewer) items to the cart is not supported. Please use remove_item or change_item"} 26 | 27 | def select_item(%Plug.Conn{cookies: %{"order_key" => key}} = conn, sku: sku, quantity: quantity) 28 | when (sku != nil) and quantity > 0 do 29 | product = Peach.Sales.Catalog.product sku: sku 30 | order = conn.assigns.order 31 | 32 | order = case CartItem.create_from_product product, quantity do 33 | nil -> %{order | message: "That item doesn't exist in our catalog"} 34 | cart_item -> order |> SalesOrder.locate_item(cart_item) |> SalesOrder.add_item |> save_change 35 | end 36 | assign conn, :order, order 37 | end 38 | 39 | def change_item(%Plug.Conn{cookies: %{"order_key" => key}} = conn, sku: sku, quantity: quantity) when quantity > 0 do 40 | order = conn.assigns.order 41 | |> SalesOrder.locate_item(%{sku: sku}) 42 | |> SalesOrder.change_quantity(quantity: quantity) 43 | |> save_change 44 | 45 | assign conn, :order, order 46 | end 47 | 48 | def change_item(%Plug.Conn{} = conn, sku: sku, quantity: quantity) when quantity <= 0, 49 | do: remove_item(conn, sku: sku) 50 | 51 | def change_item(_pid, sku: sku) when sku ==nil, do: {:error, "A sku is required"} 52 | 53 | def remove_item(%Plug.Conn{cookies: %{"order_key" => key}} = conn, sku: sku) when sku !=nil do 54 | order = conn.assigns.order 55 | |> SalesOrder.locate_item(%{sku: sku}) 56 | |> SalesOrder.remove_item 57 | |> save_change 58 | assign conn, :order, order 59 | end 60 | 61 | def remove_item(_pid, sku: sku) when sku ==nil, 62 | do: {:error, "A sku is required"} 63 | 64 | 65 | def empty_items(%Plug.Conn{cookies: %{"order_key" => key}} = conn) do 66 | order = {:ok, order: %{conn.assigns.order | items: []}, log: "Cart emptied"} |> save_change 67 | assign conn, :order, order 68 | end 69 | 70 | 71 | def record_sale(%Plug.Conn{cookies: %{"order_key" => key}} = conn, payment: payment, processor: "stripe", response: response) do 72 | 73 | #TODO: This is losing it's structiness... why? 74 | order = conn.assigns.order 75 | 76 | invoice = Peach.Accounting.Invoice.create_from_stripe_response(order: order, payment: payment, response: response) 77 | payment_details = %{ 78 | processor: "stripe", 79 | payment: payment, 80 | response: response 81 | } 82 | 83 | order = {:ok, order: %{order | invoice: invoice, payment: payment_details, status: "payment-received"}, log: "Recording sale"} 84 | |> save_change 85 | 86 | assign conn, :order, order 87 | end 88 | 89 | def close(%Plug.Conn{cookies: %{"order_key" => key}, assigns: %{order: %{status: "payment-received"}}} = conn) do 90 | 91 | #reset the cookie 92 | conn = put_resp_cookie(conn, "order_key", UUID.uuid4()) 93 | 94 | #set the status to closed 95 | final = {:ok, order: %{conn.assigns.order | status: "closed"}, log: "Closing order"} |> save_change 96 | 97 | #stop the Agent 98 | Agent.stop(get_name(key), :normal) 99 | 100 | #empty the order 101 | assign conn, :order, final 102 | end 103 | 104 | 105 | ############################ Privvies 106 | defp get_name(key) when is_binary(key), do: {:global, {:order, key}} 107 | defp get_name(%Plug.Conn{cookies: %{"order_key" => key}}), do: get_name(key) 108 | 109 | defp exists?(key) when is_binary(key) do 110 | :global.whereis_name({:order_key, key}) != :undefined 111 | end 112 | 113 | defp save_change({:error, order: order, message: mssg}), do: %{order | message: mssg} 114 | defp save_change({:ok, order: order, log: log}) do 115 | #pull the agent and reset state, saving in the DB as well 116 | Agent.get_and_update get_name(order.key), fn _order -> 117 | saved = %{order | summary: SalesOrder.summarize(order)} 118 | |> SalesOrder.add_log_entry(log) 119 | |> Db.save_order 120 | {saved, saved} 121 | end 122 | end 123 | 124 | end 125 | -------------------------------------------------------------------------------- /lib/sales/cart_item.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Sales.CartItem do 2 | import Peach.Util 3 | alias __MODULE__ 4 | 5 | defstruct [ 6 | store_id: nil, 7 | options: nil, 8 | sku: nil, 9 | quantity: 1, 10 | price: 0.00, 11 | name: nil, 12 | description: nil, 13 | image: nil, 14 | discount: 0.00, 15 | created_at: now_iso, 16 | vendor: nil, 17 | requires_shipping: false, 18 | downloadable: false 19 | ] 20 | def create_from_product(nil, _quantity), do: nil 21 | def create_from_product(%{sku: sku} = product, quantity) when not is_nil(sku) do 22 | cart_item = struct %CartItem{}, product 23 | %{cart_item | quantity: quantity} 24 | end 25 | 26 | end 27 | 28 | # def handle_call({:select_item, item, _quantity}, _sender, order) when item == nil do 29 | # order = %{order | message: "That item doesn't exist in our catalog"} 30 | # {:reply, order, order} 31 | # end 32 | # 33 | # def handle_call({:select_item, item, quantity}, _sender, order) when item != nil do 34 | # cart_item = struct %CartItem{}, item 35 | # cart_item = %{cart_item | quantity: quantity} 36 | # 37 | # order = order 38 | # |> SalesOrder.locate_item(cart_item) 39 | # |> SalesOrder.add_item 40 | # |> save_change 41 | # 42 | # {:reply, order, order} 43 | # 44 | # end 45 | -------------------------------------------------------------------------------- /lib/sales/catalog.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Sales.Catalog do 2 | use GenServer 3 | import Moebius.DocumentQuery 4 | alias Peach.Db.Postgres, as: Db 5 | 6 | @name __MODULE__ 7 | 8 | #standard entry point 9 | def start_link do 10 | GenServer.start_link(__MODULE__,[], name: @name) 11 | end 12 | 13 | def init([]) do 14 | #load the products into the session 15 | products = Db.products() 16 | collections = Db.collections() 17 | {:ok, %{products: products, collections: collections}} 18 | end 19 | 20 | #public api 21 | def products() do 22 | GenServer.call(@name, {:products}) 23 | end 24 | 25 | def collection(slug: slug) do 26 | GenServer.call(@name, {:products_by_collection, slug: slug}) 27 | end 28 | 29 | def collections do 30 | GenServer.call(@name, {:collections}) 31 | end 32 | 33 | def product(sku: sku) do 34 | GenServer.call(@name, {:sku, sku}) 35 | end 36 | 37 | def products(collection: slug) do 38 | GenServer.call(@name, {:products_by_collection, slug: slug}) 39 | end 40 | 41 | #GenServer hooks 42 | 43 | def handle_call({:products}, _sender, %{products: products} = state) do 44 | {:reply, products, state} 45 | end 46 | 47 | def handle_call({:collections}, _sender, %{collections: collections} = state) do 48 | {:reply, collections, state} 49 | end 50 | 51 | def handle_call({:products_by_collection, slug: slug}, _sender, %{products: products} = state) do 52 | result = for p <- products, Enum.any?(p.collections, &(&1 == slug)), do: p 53 | {:reply, result, state} 54 | end 55 | 56 | def handle_call({:sku, sku}, _sender, %{products: products} = state) do 57 | result = Enum.find products, nil, &(&1.sku == sku) 58 | {:reply, result, state} 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/sales/shopping.ex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robconery/peach/c934b2d15b22c841b241db054879ba8a28740e9c/lib/sales/shopping.ex -------------------------------------------------------------------------------- /lib/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Store do 2 | use Application 3 | import Plug.Conn 4 | 5 | import Supervisor.Spec, warn: false 6 | 7 | def init([]) do 8 | 9 | end 10 | 11 | def call(conn, _opts \\ []) do 12 | conn = conn |> fetch_cookies 13 | key = conn.cookies["order_key"] || UUID.uuid4() 14 | conn 15 | |> put_resp_cookie("order_key", key, http_only: true, max_age: 60 * 60 * 24 * 14) 16 | |> Peach.Sales.current 17 | end 18 | 19 | #app stuff 20 | def start(_type, _args) do 21 | start_moebius 22 | start_catalog 23 | start_order_supervisor 24 | start_order_processor 25 | end 26 | 27 | def start_moebius do 28 | #TODO: Pull this from App env 29 | db_worker = worker(Peach.Db.Postgres, [Moebius.get_connection]) 30 | Supervisor.start_link [db_worker], strategy: :one_for_one 31 | 32 | end 33 | 34 | def start_order_supervisor do 35 | #spec the session supervisor 36 | worker = worker(Peach.Sales, []) 37 | Supervisor.start_link([worker], strategy: :simple_one_for_one, name: Peach.SalesSupervisor) 38 | end 39 | 40 | def start_order_processor do 41 | worker = worker(Peach.Fulfillment, []) 42 | Supervisor.start_link([worker], strategy: :simple_one_for_one, name: Peach.FulfillmentSupervisor) 43 | end 44 | 45 | def start_catalog do 46 | #start the supervised Catalog - one per domain 47 | catalog_worker = worker(Peach.Sales.Catalog, []) 48 | Supervisor.start_link [catalog_worker], strategy: :one_for_one 49 | end 50 | 51 | def start_order(key: key) do 52 | Supervisor.start_child(Peach.SalesSupervisor, [%{key: key}]) 53 | end 54 | 55 | def fulfill_order(%Peach.Accounting.SalesOrder{status: "payment-received"} = order) do 56 | Supervisor.start_child(Peach.FulfillmentSupervisor, [order]) 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /lib/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Peach.Util do 2 | 3 | def now_iso do 4 | {:ok, date} = Timex.Date.now |> Timex.DateFormat.format("%Y-%m-%d %H:%M:%S%z", :strftime) 5 | date 6 | end 7 | 8 | end 9 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Peach.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :peach, 6 | version: "0.0.1", 7 | elixir: "~> 1.2", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type "mix help compile.app" for more information 16 | def application do 17 | [ 18 | applications: [:logger, :tzdata, :moebius, :maru], 19 | mod: {Peach.Store, []} 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:moebius, github: "robconery/moebius", branch: "2.0"}, 26 | {:uuid, "~> 1.1" }, 27 | {:plug, "~> 1.1.1"}, 28 | {:poison, "~> 2.0.1", optional: true}, 29 | {:maru, "~> 0.8"}, 30 | {:stripity_stripe, "~> 1.4.0"} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"certifi": {:hex, :certifi, "0.3.0"}, 2 | "combine": {:hex, :combine, "0.7.0"}, 3 | "connection": {:hex, :connection, "1.0.2"}, 4 | "cowboy": {:hex, :cowboy, "1.0.4"}, 5 | "cowlib": {:hex, :cowlib, "1.0.2"}, 6 | "db_connection": {:hex, :db_connection, "0.2.4"}, 7 | "decimal": {:hex, :decimal, "1.1.1"}, 8 | "exfswatch": {:hex, :exfswatch, "0.1.1"}, 9 | "exsync": {:hex, :exsync, "0.1.2"}, 10 | "fs": {:hex, :fs, "0.9.2"}, 11 | "hackney": {:hex, :hackney, "1.4.10"}, 12 | "httpoison": {:hex, :httpoison, "0.8.1"}, 13 | "idna": {:hex, :idna, "1.1.0"}, 14 | "inflex": {:hex, :inflex, "1.5.0"}, 15 | "maru": {:hex, :maru, "0.9.5"}, 16 | "mimerl": {:hex, :mimerl, "1.0.2"}, 17 | "moebius": {:git, "https://github.com/robconery/moebius.git", "e3648d4d7fe4bfc1760ff903c0aaf9225c50b695", [branch: "2.0"]}, 18 | "plug": {:hex, :plug, "1.1.2"}, 19 | "poison": {:hex, :poison, "2.0.1"}, 20 | "poolboy": {:hex, :poolboy, "1.5.1"}, 21 | "postgrex": {:hex, :postgrex, "0.11.1"}, 22 | "ranch": {:hex, :ranch, "1.2.1"}, 23 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, 24 | "stripity_stripe": {:hex, :stripity_stripe, "1.4.0"}, 25 | "timex": {:hex, :timex, "1.0.2"}, 26 | "tzdata": {:hex, :tzdata, "0.5.6"}, 27 | "uuid": {:hex, :uuid, "1.1.3"}} 28 | -------------------------------------------------------------------------------- /test/catalog_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shoe do 2 | use Moebius.Database 3 | end 4 | defmodule Peach.CatalogTest do 5 | use ExUnit.Case 6 | alias Peach.Sales.Catalog 7 | 8 | test "the catalog returns all products" do 9 | products = Catalog.products 10 | assert length(products) == 10 11 | end 12 | 13 | test "catalog returns product by collection" do 14 | products = Catalog.collection slug: "gift-ideas" 15 | assert length(products) == 4 16 | end 17 | 18 | test "catalog returns product by sku" do 19 | product = Catalog.product sku: "johnny-liftoff" 20 | assert product.sku == "johnny-liftoff" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/fulfillment_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Peach.FulfillmentTest do 2 | use ExUnit.Case 3 | alias Peach.Fulfillment 4 | alias Peach.Sales 5 | import Plug.Conn 6 | 7 | @order_args key: "test" 8 | @item sku: "honeymoon-mars", quantity: 1 9 | 10 | @payment %{card: %{address_city: "Boca Raton", 11 | address_country: "United States", address_line1: "93293 Thi", 12 | address_line1_check: "pass", address_line2: "", address_state: "FL", 13 | address_zip: "33433", address_zip_check: "pass", brand: "Visa", 14 | country: "US", cvc_check: "pass", dynamic_last4: "", exp_month: "12", 15 | exp_year: "2019", funding: "unknown", id: "card_7yAj4icmWrQYyQ", 16 | last4: "1111", name: "Heavy Larry", object: "card", 17 | tokenization_method: ""}, client_ip: "73.140.245.24", 18 | created: "1456365469", email: "rob@conery.io", id: "tok_7yAjmK8BlCoPxh", 19 | livemode: "false", object: "token", type: "card", used: "false"} 20 | 21 | @response %{amount: 92900, amount_refunded: 0, application_fee: nil, 22 | balance_transaction: "txn_7yAjbHNrzJnR3l", captured: true, 23 | created: 1456365472, currency: "usd", customer: nil, description: nil, 24 | destination: nil, dispute: nil, failure_code: nil, failure_message: nil, 25 | fraud_details: %{}, id: "ch_7yAjebowHZ0qZn", invoice: nil, livemode: false, 26 | metadata: %{}, object: "charge", order: nil, paid: true, 27 | receipt_email: "rob@conery.io", receipt_number: nil, refunded: false, 28 | refunds: %{data: [], has_more: false, object: "list", total_count: 0, 29 | url: "/v1/charges/ch_7yAjebowHZ0qZn/refunds"}, shipping: nil, 30 | source: %{address_city: "Boca Raton", address_country: "United States", 31 | address_line1: "93293 Thi", address_line1_check: "pass", 32 | address_line2: nil, address_state: "FL", address_zip: "33433"}, 33 | statement_descriptor: nil, status: "succeeded"} 34 | 35 | setup do 36 | 37 | conn = %Plug.Conn{} 38 | |> fetch_cookies 39 | |> Peach.Store.call 40 | |> Sales.empty_items 41 | |> Sales.select_item(sku: "honeymoon-mars", quantity: 1) 42 | |> Sales.record_sale(payment: @payment, processor: "stripe", response: @response) 43 | 44 | #Peach.Store.fulfill_order conn.assigns.order 45 | 46 | {:ok, order: conn.assigns.order} 47 | end 48 | 49 | test "deliverables are set", %{order: order} do 50 | order = order |> Fulfillment.prepare_delivery 51 | assert length(order.deliverables) == length(order.items) 52 | end 53 | 54 | # test "an invoice is created and nothing is nil", %{order: order} do 55 | # invoice = Processor.create_invoice "fulfillment_test" 56 | # nils_present = Enum.any? Map.values(invoice), &(&1 == nil) 57 | # refute nils_present 58 | # end 59 | # 60 | # test "a customer email is sent" do 61 | # 62 | # end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /test/sales_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Peach.OrderTest do 2 | use ExUnit.Case 3 | import Plug.Conn 4 | alias Peach.Sales 5 | alias Peach.Db.Postgres, as: Db 6 | 7 | @order_args key: "test" 8 | @item sku: "honeymoon-mars", quantity: 1 9 | 10 | setup do 11 | "delete from orders" |> Db.run 12 | 13 | conn = %Plug.Conn{} 14 | |> fetch_cookies 15 | |> Peach.Store.call 16 | |> Sales.empty_items 17 | 18 | {:ok, conn: conn} 19 | end 20 | 21 | test "a order is created using conn", %{conn: conn} do 22 | conn = conn |> Sales.current 23 | assert conn.assigns.order.id 24 | end 25 | 26 | test "It adds an item", %{conn: conn} do 27 | conn = Sales.select_item(conn, sku: "illudium-q36", quantity: 1) 28 | assert length(conn.assigns.order.items) == 1 29 | end 30 | 31 | test "Adding multiple items", %{conn: conn} do 32 | conn = Sales.select_item(conn, sku: "illudium-q36", quantity: 1) 33 | conn = Sales.select_item(conn, sku: "johnny-liftoff", quantity: 1) 34 | 35 | assert length(conn.assigns.order.items) == 2 36 | end 37 | 38 | test "Adding multiple items and changing second to qty 5", %{conn: conn} do 39 | conn = conn 40 | |> Sales.select_item(sku: "illudium-q36", quantity: 1) 41 | |> Sales.select_item(sku: "johnny-liftoff", quantity: 1) 42 | |> Sales.change_item(sku: "johnny-liftoff", quantity: 5) 43 | 44 | assert length(conn.assigns.order.items) == 2 45 | end 46 | 47 | test "It won't add an item that doesn't exist", %{conn: conn} do 48 | conn = Sales.select_item conn, sku: "poop", quantity: 1 49 | assert length(conn.assigns.order.items) == 0 50 | assert conn.assigns.order.message == "That item doesn't exist in our catalog" 51 | end 52 | 53 | test "quantity, name, price and sku are set", %{conn: conn} do 54 | conn = Sales.select_item(conn, @item) 55 | assert length(conn.assigns.order.items) == 1 56 | 57 | conn = Sales.select_item conn, @item 58 | assert length(conn.assigns.order.items) == 1 59 | first = List.first(conn.assigns.order.items) 60 | assert first.quantity == 2 61 | assert first.name == "Honeymoon on Mars" 62 | assert first.price == 1233200 63 | end 64 | 65 | test "It increments quantity when item exists", %{conn: conn} do 66 | conn = Sales.select_item(conn, @item) 67 | assert length(conn.assigns.order.items) == 1 68 | 69 | conn = Sales.select_item conn, @item 70 | assert length(conn.assigns.order.items) == 1 71 | first = List.first(conn.assigns.order.items) 72 | assert first.quantity == 2 73 | end 74 | 75 | test "it removes an item", %{conn: conn} do 76 | conn = Sales.select_item conn, @item 77 | assert length(conn.assigns.order.items) == 1 78 | conn = Sales.remove_item conn, sku: "honeymoon-mars" 79 | assert length(conn.assigns.order.items) == 0 80 | end 81 | 82 | test "it removes an item with conn", %{conn: conn} do 83 | conn = Sales.select_item conn, @item 84 | assert length(conn.assigns.order.items) == 1 85 | conn = Sales.remove_item conn, sku: "honeymoon-mars" 86 | assert length(conn.assigns.order.items) == 0 87 | end 88 | 89 | test "a summary is returned", %{conn: conn} do 90 | conn = conn 91 | |> Sales.select_item(@item) 92 | |> Sales.select_item(@item) 93 | 94 | order = conn.assigns.order 95 | 96 | assert order.summary.item_count == 2 97 | assert order.summary.subtotal == 2466400 98 | assert order.summary.total == 2466400 99 | end 100 | 101 | 102 | test "changing quantities", %{conn: conn} do 103 | conn = conn 104 | |> Sales.select_item(@item) 105 | |> Sales.change_item(sku: "honeymoon-mars", quantity: 12) 106 | first = List.first(conn.assigns.order.items) 107 | assert first.quantity == 12 108 | end 109 | 110 | test "removing an item not in the cart results in an error", %{conn: conn} do 111 | conn = Sales.remove_item conn, sku: "pop" 112 | assert conn.assigns.order.message == "SKU 'pop' not found in the cart" 113 | end 114 | 115 | test "changing quantity of a sku not there results in error", %{conn: conn} do 116 | conn = Sales.change_item conn, sku: "asdasd", quantity: 12 117 | assert conn.assigns.order.message == "SKU 'asdasd' not found in the cart" 118 | end 119 | 120 | test "using a nil sku for add returns error", %{conn: conn} do 121 | case Sales.select_item conn, sku: nil do 122 | {:error, mssg} -> assert mssg 123 | _ -> flunk "Should have received an error" 124 | end 125 | end 126 | 127 | test "sending 0 to add returns an error", %{conn: conn} do 128 | case Sales.select_item conn, sku: "honeymoon-mars", quantity: 0 do 129 | {:error, mssg} -> assert mssg 130 | _ -> flunk "Should have received an error" 131 | end 132 | end 133 | 134 | test "sending negative number to add returns an error", %{conn: conn} do 135 | case Sales.select_item conn, sku: "honeymoon-mars", quantity: -3 do 136 | {:error, mssg} -> assert mssg 137 | _ -> flunk "Should have received an error" 138 | end 139 | end 140 | 141 | test "using a nil sku for conn.assigns returns error", %{conn: conn} do 142 | case Sales.change_item conn, sku: nil do 143 | {:error, mssg} -> assert mssg 144 | _ -> flunk "Should have received an error" 145 | end 146 | end 147 | 148 | test "using a nil sku for remove returns error", %{conn: conn} do 149 | case Sales.remove_item conn, sku: nil do 150 | {:error, mssg} -> assert mssg 151 | _ -> flunk "Should have received an error" 152 | end 153 | end 154 | 155 | test "it records a sale", %{conn: conn} do 156 | payment = %{card: %{address_city: "Boca Raton", 157 | address_country: "United States", address_line1: "93293 Thi", 158 | address_line1_check: "pass", address_line2: "", address_state: "FL", 159 | address_zip: "33433", address_zip_check: "pass", brand: "Visa", 160 | country: "US", cvc_check: "pass", dynamic_last4: "", exp_month: "12", 161 | exp_year: "2019", funding: "unknown", id: "card_7yAj4icmWrQYyQ", 162 | last4: "1111", name: "Heavy Larry", object: "card", 163 | tokenization_method: ""}, client_ip: "73.140.245.24", 164 | created: "1456365469", email: "rob@conery.io", id: "tok_7yAjmK8BlCoPxh", 165 | livemode: "false", object: "token", type: "card", used: "false"} 166 | 167 | response = %{amount: 92900, amount_refunded: 0, application_fee: nil, 168 | balance_transaction: "txn_7yAjbHNrzJnR3l", captured: true, 169 | created: 1456365472, currency: "usd", customer: nil, description: nil, 170 | destination: nil, dispute: nil, failure_code: nil, failure_message: nil, 171 | fraud_details: %{}, id: "ch_7yAjebowHZ0qZn", invoice: nil, livemode: false, 172 | metadata: %{}, object: "charge", order: nil, paid: true, 173 | receipt_email: "rob@conery.io", receipt_number: nil, refunded: false, 174 | refunds: %{data: [], has_more: false, object: "list", total_count: 0, 175 | url: "/v1/charges/ch_7yAjebowHZ0qZn/refunds"}, shipping: nil, 176 | source: %{address_city: "Boca Raton", address_country: "United States", 177 | address_line1: "93293 Thi", address_line1_check: "pass", 178 | address_line2: nil, address_state: "FL", address_zip: "33433"}, 179 | statement_descriptor: nil, status: "succeeded"} 180 | 181 | conn = conn 182 | |> Sales.record_sale(payment: payment, processor: "stripe", response: response) 183 | order = conn.assigns.order 184 | 185 | assert order.invoice 186 | assert order.payment 187 | assert order.payment.processor == "stripe" 188 | assert order.payment.response 189 | 190 | end 191 | 192 | test "it stops the order on close", %{conn: conn} do 193 | payment = %{card: %{address_city: "Boca Raton", 194 | address_country: "United States", address_line1: "93293 Thi", 195 | address_line1_check: "pass", address_line2: "", address_state: "FL", 196 | address_zip: "33433", address_zip_check: "pass", brand: "Visa", 197 | country: "US", cvc_check: "pass", dynamic_last4: "", exp_month: "12", 198 | exp_year: "2019", funding: "unknown", id: "card_7yAj4icmWrQYyQ", 199 | last4: "1111", name: "Heavy Larry", object: "card", 200 | tokenization_method: ""}, client_ip: "73.140.245.24", 201 | created: "1456365469", email: "rob@conery.io", id: "tok_7yAjmK8BlCoPxh", 202 | livemode: "false", object: "token", type: "card", used: "false"} 203 | 204 | response = %{amount: 92900, amount_refunded: 0, application_fee: nil, 205 | balance_transaction: "txn_7yAjbHNrzJnR3l", captured: true, 206 | created: 1456365472, currency: "usd", customer: nil, description: nil, 207 | destination: nil, dispute: nil, failure_code: nil, failure_message: nil, 208 | fraud_details: %{}, id: "ch_7yAjebowHZ0qZn", invoice: nil, livemode: false, 209 | metadata: %{}, object: "charge", order: nil, paid: true, 210 | receipt_email: "rob@conery.io", receipt_number: nil, refunded: false, 211 | refunds: %{data: [], has_more: false, object: "list", total_count: 0, 212 | url: "/v1/charges/ch_7yAjebowHZ0qZn/refunds"}, shipping: nil, 213 | source: %{address_city: "Boca Raton", address_country: "United States", 214 | address_line1: "93293 Thi", address_line1_check: "pass", 215 | address_line2: nil, address_state: "FL", address_zip: "33433"}, 216 | statement_descriptor: nil, status: "succeeded"} 217 | 218 | conn = conn 219 | |> Sales.record_sale(payment: payment, processor: "stripe", response: response) 220 | |> Sales.close 221 | 222 | 223 | assert :global.whereis_name({:order_key, conn.assigns.order.key}) == :undefined 224 | end 225 | 226 | end 227 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | import Moebius.DocumentQuery 4 | alias Peach.Db.Postgres, as: Db 5 | 6 | "drop table if exists products" |> Db.run 7 | "drop table if exists vendors" |> Db.run 8 | "drop table if exists collections" |> Db.run 9 | "drop table if exists sessions" |> Db.run 10 | 11 | 12 | products = [%{collections: ["equipment", "featured"], cost: 2320, 13 | description: "At last, after two thousand years of research, the illudium Q-36 explosive space modulator is ready! Simply line up the trajectory, wait for planetary synchronization, make sure no rabbits are around - and kaboom! You *should* hear an earth-shattering kaboom.", 14 | domain: "localhost", image: "illudium-q36.jpg", inventory: 99, 15 | name: "Illudium Q-36 Explosive Space Modulator", price: 499500, 16 | published_at: "2016-02-12T01:21:29.147Z", sku: "illudium-q36", 17 | status: "published", 18 | requires_shipping: true, 19 | downloadable: false, 20 | delivery: %{type: :post, methods: [:ground, :air, :cargo]}, 21 | summary: "Need to destroy a planet? The new and improved Illudium Q36 will leave no stone un-vaporized!", 22 | vendor: %{name: "Martian Armaments, Ltd", slug: "martian-armaments"}}, 23 | %{collections: ["vacations", "gift-ideas"], cost: 3345, 24 | description: "The most important part of any wedding is where it took place - because the pictures you take show just how amazing you and your partner are. So give your friends something to be jealous about with a wedding on Mars!", 25 | domain: "localhost", image: "honeymoon-mars.jpg", inventory: 22, 26 | name: "Honeymoon on Mars", price: 1233200, 27 | published_at: "2016-02-12T01:21:29.147Z", sku: "honeymoon-mars", 28 | status: "published", 29 | requires_shipping: false, 30 | downloadable: true, 31 | delivery: %{type: :download, provider: "vimeo", key: "XYZ", size: "10Mb", bucket: "BUKKIT", file_name: "file.mp4"}, 32 | summary: "Tired of boring wedding pictures? Wow your friends with your marriage on Mars!", 33 | vendor: %{name: "Red Planet Love Machine", slug: "red-planet"}}, 34 | %{collections: ["equipment"], cost: 67743, 35 | description: "Why walk **when you can fly**! Weak Martian gravity means you too can fly wherever you want, whenever you want with some rockets on your back. Light, portable and really loud - you'll be the talk of the Martian skies! ", 36 | domain: "localhost", image: "johnny-liftoff.jpg", inventory: 43, 37 | name: "Johnny Liftoff Rocket Suit", price: 8933300, 38 | published_at: "2016-02-12T01:21:29.147Z", sku: "johnny-liftoff", 39 | status: "published", 40 | requires_shipping: true, 41 | downloadable: false, 42 | delivery: %{type: :post, methods: [:ground, :air, :cargo]}, 43 | summary: "Keep your feet off the ground with our space-age rocket suit", 44 | vendor: %{name: "Martian Armaments, Ltd", slug: "martian-armaments"}}, 45 | %{collections: ["equipment"], cost: 53212, 46 | description: "Not quite flying, *not quite driving*: you'll love our new Sky-Hook Technology which powers the new, unique Mars Mobile. Point to fun things from our big windows and **see Mars in style**.", 47 | domain: "localhost", image: "mars-mobile.jpg", inventory: 64, 48 | name: "The Mars Mobile", price: 6532100, 49 | published_at: "2016-02-12T01:21:29.147Z", sku: "mars-mobile", 50 | status: "published", 51 | requires_shipping: true, 52 | downloadable: false, 53 | delivery: %{type: :post, methods: [:ground, :air, :cargo]}, 54 | summary: "Wheels? Who needs them! Use our space-age sky hooks to get around the surface of Mars!", 55 | vendor: %{name: "Martian Armaments, Ltd", slug: "martian-armaments"}}, 56 | %{collections: ["vacations", "gift-ideas"], cost: 2320, 57 | description: "Driving is for **Earth-bound sissies**, and flying can be darn expensive. Strap your space boots on and come with us on a five-day trek across the surface of Mars. We'll try not to die of thirst by melting buried ice reserves and, if we're lucky, we'll find a Martian or two to eat.", 58 | domain: "localhost", image: "mars-trek.jpg", inventory: 99, 59 | name: "A Five-day Trek on Mars", price: 6532100, 60 | published_at: "2016-02-12T01:21:29.147Z", sku: "mars-trek", 61 | status: "published", 62 | requires_shipping: true, 63 | downloadable: false, 64 | delivery: %{type: :post, methods: [:ground, :air, :cargo]}, 65 | summary: "Hop, skip, bounce your way along the major valleys and craters of Mars!", 66 | vendor: %{name: "Marinaris Outfitters", slug: "marinaris"}}, 67 | %{collections: ["vacations", "gift-ideas", "featured"], cost: 887, 68 | description: "A perfect first date or anniversay gift! The sunsets on Mars remind you just how warm Earth was, and how tired you are of the color red!", 69 | domain: "localhost", image: "martian-sunset-cruise.jpg", inventory: 1, 70 | name: "A Lovely Martian Sunset Cruise For Two", price: 92900, 71 | published_at: "2016-02-12T01:21:29.147Z", sku: "martian-sunset-cruise", 72 | status: "published", 73 | requires_shipping: false, 74 | downloadable: true, 75 | delivery: %{type: :download, provider: "vimeo", key: "XYZ", size: "10Mb", bucket: "BUKKIT", file_name: "file.mp4"}, 76 | summary: "What better way to say 'I love you' then watching a cold Martian sunset?", 77 | vendor: %{name: "Marinaris Outfitters", slug: "marinaris"}}, 78 | %{collections: ["vacations"], cost: 4432, 79 | description: "Spend the day in the Buck Rogers way - racing from the surface of Mars against a talented field of pilots. Be the first to touch down on Phobos, grab the next clue, and then head over to Deimos for the final clue. The winner will be the first space racer to return to the hidden finish line on Mars!", 80 | domain: "localhost", image: "moon-races.jpg", inventory: 99, 81 | name: "The Amazing Mars Moon Race", price: 6400000, 82 | published_at: "2016-02-12T01:21:29.147Z", sku: "moon-races", 83 | status: "published", 84 | requires_shipping: false, 85 | downloadable: true, 86 | delivery: %{type: :download, provider: "vimeo", key: "XYZ", size: "10Mb", bucket: "BUKKIT", file_name: "file.mp4"}, 87 | summary: "To the moons and back! Race in space with this fun adventure.", 88 | vendor: %{name: "Marinaris Outfitters", slug: "marinaris"}}, 89 | %{collections: ["vacations", "gift-ideas", "featured"], cost: 65332, 90 | description: "Why wait in long lines for a rocket to take you slowly back to Earth when you can use our brand new Mars Ejection Technology? We'll launch you at the Earth at 10 times the speed of our passenger rockets - all you need to do is slow down enough so you don't burn up. Cut your return time (and possibly yourself) in half!", 91 | domain: "localhost", image: "one-way-reentry.jpg", inventory: 99, 92 | name: "One Time Re-entry to Earth", price: 8326200, 93 | published_at: "2016-02-12T01:21:29.147Z", sku: "one-way-reentry", 94 | status: "published", 95 | requires_shipping: false, 96 | downloadable: true, 97 | delivery: %{type: :download, provider: "vimeo", key: "XYZ", size: "10Mb", bucket: "BUKKIT", file_name: "file.mp4"}, 98 | summary: "Sometimes you need to get back in a hurry, our One-way Re-entry might get you there.", 99 | vendor: %{name: "Martian Armaments, Ltd", slug: "martian-armaments"}}, 100 | %{collections: ["vacations"], cost: 5467, 101 | description: "Get yourself in deep with our Weekend Trip to Valles Marinaris. We'll trek along the terrifying edges of the huge, deep crack in the surface of Mars. You'll forget your fears as we walk deeper and deeper into the interior of the Red Planet.", 102 | domain: "localhost", image: "valles-marineris-weekend.jpg", 103 | inventory: 33, name: "A Weekend in Valles Marinaris", price: 3533300, 104 | published_at: "2016-02-12T01:21:29.147Z", sku: "valles-marineris-weekend", 105 | status: "published", 106 | requires_shipping: false, 107 | downloadable: true, 108 | delivery: %{type: :download, provider: "vimeo", key: "XYZ", size: "10Mb", bucket: "BUKKIT", file_name: "file.mp4"}, 109 | summary: "Come get lost in one of the biggest trenches in the solar system", 110 | vendor: %{name: "Red Planet Love Machine", slug: "red-planet"}}, 111 | %{collections: ["equipment", "vacations"], cost: 45532, 112 | description: "The Martian surface is scarred by huge numbers of craters - but we like to think of these gigantic holes as 'scars of love' - so say I love you by buying your very own! Whether it's a personal vanity treat or for that very special someone, a Martian crater shows just how *deep* you really are.", 113 | domain: "localhost",image: "your-own-crater.jpg", inventory: 0, 114 | name: "Your Very Own Crater", price: 9999900, 115 | published_at: "2016-02-12T01:21:29.147Z", sku: "your-own-crater", 116 | status: "published", 117 | requires_shipping: true, 118 | downloadable: false, 119 | delivery: %{type: :post, methods: [:ground, :air, :cargo]}, 120 | summary: "Buy one for yourself or the one you love! Nothing says 'you're truly special' like a huge hole in the ground", 121 | vendor: %{name: "Red Planet Love Machine", slug: "red-planet"}}] 122 | 123 | for p <- products do 124 | db(:products) |> searchable([:name, :description, :summary]) |> Db.save(p) 125 | end 126 | 127 | 128 | collections = [%{description: "The good stuff", domain: "localhost", slug: "featured"}, 129 | %{description: "Fun places to go", domain: "localhost", 130 | slug: "vacations"}, 131 | %{description: "Mars can be unforgiving...", domain: "localhost", 132 | slug: "equipment"}, 133 | %{description: "Something for your favorite people", domain: "localhost", 134 | slug: "gift-ideas"}] 135 | 136 | for c <- collections, do: db(:collections) |> Db.save(c) 137 | 138 | vendors = [%{domain: "localhost", image: "martian-armaments.jpg", 139 | name: "Martian Armaments, Ltd", slug: "martian-armaments"}, 140 | %{domain: "localhost", image: "red-planet.jpg", 141 | name: "Red Planet Love Machine", slug: "red-planet"}, 142 | %{domain: "localhost", image: "marinaris.jpg", 143 | name: "Marinaris Outfitters", slug: "marinaris"}] 144 | 145 | for v <- vendors, do: db(:vendors) |> Db.save(v) 146 | 147 | Db.create_document_table :transactions 148 | Db.create_document_table :invoices 149 | --------------------------------------------------------------------------------