diff --git a/Gemfile b/Gemfile index 9584dc5e..7b0a08b0 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,8 @@ gem 'authority' # Billing gem 'stripe' gem 'stripe_event' +gem 'paypal_client' +gem 'paypal-checkout-sdk' # Design gem 'material_icons' diff --git a/Gemfile.lock b/Gemfile.lock index d2306eab..7b2fafb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1105,6 +1105,10 @@ GEM factory_bot_rails (5.1.1) factory_bot (~> 5.1.0) railties (>= 4.2.0) + faraday (0.17.3) + multipart-post (>= 1.2, < 3) + faraday_middleware (0.13.1) + faraday (>= 0.7.4, < 1.0) faye-websocket (0.10.9) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) @@ -1233,6 +1237,7 @@ GEM mixpanel-ruby (2.2.2) multi_json (1.14.1) multi_test (0.1.2) + multipart-post (2.1.1) mustache (1.1.1) nenv (0.3.0) nested_form (0.3.2) @@ -1263,6 +1268,13 @@ GEM activerecord (>= 4.0, < 6.1) parser (2.6.5.0) ast (~> 2.4.0) + paypal-checkout-sdk (1.0.3) + paypalhttp (~> 1.0.0) + paypal_client (0.3.1) + activesupport (> 4.2.8) + faraday (~> 0.15) + faraday_middleware (~> 0.12) + paypalhttp (1.0.0) pg (1.2.1) pry (0.12.2) coderay (~> 1.1.0) @@ -1523,6 +1535,8 @@ DEPENDENCIES newrelic_rpm paperclip paranoia + paypal-checkout-sdk + paypal_client pg (~> 1.1) pry puma (~> 4.3) diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 747fb94a..150982fa 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,7 +1,7 @@ class SubscriptionsController < ApplicationController protect_from_forgery except: :stripe_webhook - before_action :authenticate_user! + before_action :authenticate_user!, except: [:redeem] before_action :set_navbar_actions before_action :set_sidenav_expansion @@ -32,6 +32,58 @@ class SubscriptionsController < ApplicationController def show end + def prepay + @invoices = current_user.paypal_invoices + .where.not(status: 'CREATED') + .includes(:page_unlock_promo_code) + .order('id desc') + + promo_code_ids = @invoices.map(&:page_unlock_promo_code_id).flatten + @promo_codes = PageUnlockPromoCode.where(id: promo_code_ids) + end + + def redeem + @code = PageUnlockPromoCode.find_by(code: params[:code]) + end + + def prepay_redirect_to_paypal + months = params[:months].to_i + + # Create an invoice on Paypal to be paid + ppi = PaypalService.create_prepay_invoice(months) + + # Create a mirrored invoice of our own to mark paid later + invoice = PaypalInvoice.create!( + user: current_user, + paypal_id: ppi.id, + status: ppi.status, + months: months, + amount_cents: 100 * PaypalService.months_price(months), + approval_url: ppi.links.detect { |l| l.rel == "approve" }.href + ) + + # Send the user off to pay! + # redirect_to PaypalService.checkout_url(invoice, prepay_path) + redirect_to invoice.approval_url + end + + def capture_paypal_prepay + # request = PayPalCheckoutSdk::Orders::OrdersCaptureRequest::new("APPROVED-ORDER-ID") + + # begin + # # Call API with your client and get a response for your call + # response = client.execute(request) + + # # If call returns body in response, you can get the deserialized version from the result attribute of the response + # order = response.result + # puts order + # rescue PayPalHttp::HttpError => ioe + # # Something went wrong server-side + # puts ioe.status_code + # puts ioe.headers["debug_id"] + # end + end + def change new_plan_id = params[:stripe_plan_id] possible_plan_ids = SubscriptionService.available_plans.pluck(:stripe_plan_id) diff --git a/app/jobs/paypal_acceptance_wait_job.rb b/app/jobs/paypal_acceptance_wait_job.rb new file mode 100644 index 00000000..78207429 --- /dev/null +++ b/app/jobs/paypal_acceptance_wait_job.rb @@ -0,0 +1,43 @@ +class PaypalAcceptanceWaitJob < ApplicationJob + queue_as :paypal + + def perform(*args) + invoice_id = args.shift + invoice = PaypalInvoice.find_by(paypal_id: invoice_id) + + info = PaypalService.order_info(invoice_id) + if info[:status] == 'CREATED' + # If we're still in a CREATED state, keep requeuing for up to 24 hours + if DateTime.current <= invoice.created_at + 24.hours + PaypalAcceptanceWaitJob + .set(wait: 30.seconds) + .perform_later(invoice.paypal_id) + end + + elsif info[:status] == 'APPROVED' + # Once a user has approved a payment, we need to capture that payment + unless invoice.status == 'APPROVED' + invoice.update(status: 'APPROVED') + invoice.capture_funds! + end + + PaypalAcceptanceWaitJob + .set(wait: 30.seconds) + .perform_later(invoice.paypal_id) + + elsif info[:status] == 'COMPLETED' + # Once a payment has been captured, generate the code for use! + unless invoice.status == 'COMPLETED' + invoice.update(status: 'COMPLETED') + invoice.generate_promo_code! + end + + else + # Something unexpected happened! Wow! + invoice.update(status: info[:status]) + raise info.inspect + + end + + end +end diff --git a/app/models/paypal_invoice.rb b/app/models/paypal_invoice.rb new file mode 100644 index 00000000..5698f54a --- /dev/null +++ b/app/models/paypal_invoice.rb @@ -0,0 +1,37 @@ +class PaypalInvoice < ApplicationRecord + belongs_to :user + belongs_to :page_unlock_promo_code, optional: true + + after_create :watch_for_approval + + def next_step_url + # switch to receipt after approving + self.approval_url + end + + def watch_for_approval + PaypalAcceptanceWaitJob.perform_later(self.paypal_id) + end + + def capture_funds! + PaypalService.capture_invoice_funds(self.paypal_id) + end + + def generate_promo_code! + self.page_unlock_promo_code = PageUnlockPromoCode.create( + code: 'PP' + (0...12).map { (65 + rand(26)).chr }.join, + page_types: Rails.application.config.content_types[:premium].map(&:name), + uses_remaining: 1, + days_active: 30 * self.months.to_i, + internal_description: "Prepaid with PayPal", + description: "Prepaid Premium subscription" + ) + self.save! + end + + def activateable? + self.status == 'COMPLETED' && + page_unlock_promo_code.present? && + page_unlock_promo_code.uses_remaining > 0 + end +end diff --git a/app/models/users/user.rb b/app/models/users/user.rb index f905feb2..43af45b1 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -30,6 +30,7 @@ class User < ApplicationRecord BillingPlan::PREMIUM_IDS.include?(self.selected_billing_plan_id) || active_promo_codes.any? end has_many :promotions, dependent: :destroy + has_many :paypal_invoices has_many :image_uploads, dependent: :destroy diff --git a/app/services/paypal_service.rb b/app/services/paypal_service.rb new file mode 100644 index 00000000..f9526714 --- /dev/null +++ b/app/services/paypal_service.rb @@ -0,0 +1,138 @@ +class PaypalService < Service + include PayPalCheckoutSdk::Orders + + def self.create_prepay_invoice(n_months) + # Create a Paypal invoice to redirect to for payment + request = PayPalCheckoutSdk::Orders::OrdersCreateRequest::new + request.request_body({ + intent: "CAPTURE", + purchase_units: [{ + amount: { + currency_code: "USD", + value: PaypalService.months_price(n_months), + description: "Notebook.ai Premium (#{n_months} month#{'s' unless n_months == 1})", + payment_instruction: "some text for paymen instruction", + soft_descriptor: "soft descriptor" + } + }] + }) + + begin + response = PaypalService.client.execute(request) + order = response.result + + order + + rescue PayPalHttp::HttpError => ioe + # Something went wrong server-side + # puts ioe.status_code + # puts ioe.headers["debug_id"] + raise ioe.inspect + end + end + + def self.capture_invoice_funds(invoice_id) + request = PayPalCheckoutSdk::Orders::OrdersCaptureRequest::new(invoice_id) + + begin + # Call API with your client and get a response for your call + response = client.execute(request) + + # If call returns body in response, you can get the deserialized version from the result attribute of the response + order = response.result + puts order + + rescue PayPalHttp::HttpError => ioe + # Something went wrong server-side + puts ioe.status_code + puts ioe.headers["debug_id"] + end + end + + def self.checkout_url(invoice, return_path) + app_host = Rails.env.production? ? 'https://www.notebook.ai' : 'http://localhost:3000' + paypal_host = Rails.env.production? ? 'https://www.paypal.com' : 'https://www.sandbox.paypal.com' + + values = { + business: 'sb-43r7at861878@business.example.com', + cmd: "_xclick", + # image_url: 150x50 url + + upload: 1, + + invoice: invoice.paypal_id, + item_name: "Notebook.ai Premium", + item_number: "#{invoice.months} month#{'s' unless invoice.months == 1} of Premium", + quantity: '1', + amount: months_price(invoice.months), + + no_note: 1, + no_shipping: 1, + + return: "#{app_host}#{return_path}", + cancel_return: "#{app_host}/my/billing/prepay", + } + + "#{paypal_host}/cgi-bin/webscr?" + values.to_query + end + + def self.capture_payment(paypal_invoice_id) + resp = Faraday.post("https://api.sandbox.paypal.com/v2/checkout/orders/#{paypal_invoice_id}/capture") do |req| + # req.params['limit'] = 100 + req.headers['Content-Type'] = 'application/json' + req.headers['Authorization'] = 'Basic ' + # req.body = {query: 'salmon'}.to_json + end + end + + def self.order_info(order_id) + request = OrdersGetRequest::new(order_id) + response = client::execute(request) + + puts "Status Code: " + response.status_code.to_s + puts "Status: " + response.result.status + puts "Order ID: " + response.result.id + puts "Intent: " + response.result.intent + puts "Links:" + for link in response.result.links + puts "\t#{link["rel"]}: #{link["href"]}\tCall Type: #{link["method"]}" + end + puts "Gross Amount: " + response.result.purchase_units[0].amount.currency_code + response.result.purchase_units[0].amount.value + + { + order_id: response.result.id, + status: response.result.status + } + end + + def self.months_price(n_months) + case n_months + when 1 + 9.00 + when 3 + 24.00 + when 6 + 48.00 + when 12 + 84.00 + else + raise "Invalid month prepay: #{n_months}" + end + end + + def self.client + @paypal_client ||= begin + client_id = Rails.application.config.paypal[:client_id] + client_secret = Rails.application.config.paypal[:client_secret] + + environment = if Rails.env.production? + PayPal::PayPalEnvironment.new(client_id, client_secret) + else + PayPal::SandboxEnvironment.new(client_id, client_secret) + end + + PayPal::PayPalHttpClient.new(environment) + end + end + +end \ No newline at end of file diff --git a/app/views/subscriptions/new.html.erb b/app/views/subscriptions/new.html.erb index c680a91d..f18835dd 100644 --- a/app/views/subscriptions/new.html.erb +++ b/app/views/subscriptions/new.html.erb @@ -227,9 +227,6 @@ - - -
diff --git a/app/views/subscriptions/prepay.html.erb b/app/views/subscriptions/prepay.html.erb new file mode 100644 index 00000000..b072b08a --- /dev/null +++ b/app/views/subscriptions/prepay.html.erb @@ -0,0 +1,209 @@ +
+
+
+
+
You can now purchase sharable Premium codes for yourself or others
+

+ Making a purchase below will generate a single-use code that can be redeemed at any time for a Premium subscription on Notebook.ai. + You can purchase these codes for yourself if you'd like to prepay for a certain amount of months without a recurring monthly subscription, + or for others if you'd like to gift a Notebook.ai Premium membership to someone else! You can purchase as many codes as you'd like + and redeem them whenever you'd like. +

+
+
+
+
+ +
Choose a Premium code to purchase
+
+
+ <%= link_to prepay_paypal_gateway_path(months: 1), class: 'black-text' do %> +
+
+
+ star + 1 month of Premium +
+

+ All Premium features + for $9.00 +

+
+
+ <% end %> +
+
+ <%= link_to prepay_paypal_gateway_path(months: 3), class: 'black-text' do %> +
+
+
+ star + 3 months of Premium +
+

+ All Premium features + for $24.00
+ (save $3.00 compared to monthly) +

+
+
+ <% end %> +
+
+ <%= link_to prepay_paypal_gateway_path(months: 6), class: 'black-text' do %> +
+
+
+ star + 6 months of Premium +
+

+ All Premium features + for $48.00
+ (save $6.00 compared to monthly) +

+
+
+ <% end %> +
+
+ <%= link_to prepay_paypal_gateway_path(months: 12), class: 'black-text' do %> +
+
+
+ star + 12 months of Premium +
+

+ All Premium features + for $84.00
+ (save $24.00 compared to monthly) +

+
+
+ <% end %> +
+
+ +
+
+ Selecting a package will take you to Paypal to complete your purchase, where you can pay from a Paypal balance or with a credit/debit card. + Afterwards, you will be redirected here where you will find the code you've purchased listed below. +
+
+ +
+
+
Your purchased Premium codes
+

+ Please note that codes may take up to 5 minutes to appear here after purchase. +

+ + <% if current_user.on_premium_plan? && @invoices.any? %> +
+ Since you already have a Premium subscription active, you won't be able to activate any codes below. + Activation links will appear again whenever you don't have Premium active. +
+ <% end %> +
+ <% @invoices.each do |invoice| %> +
+
+
+
+ <% if invoice.page_unlock_promo_code&.uses_remaining&.zero? %> + used + <% end %> + <%= pluralize invoice.months, 'month' %> of Premium +
+

+ <% if invoice.page_unlock_promo_code.present? %> + <% if invoice.activateable? %> + Promo code: <%= invoice.page_unlock_promo_code.code %> + <% end %> + <% else %> + <% if invoice.status == 'APPROVED' %> + Processing payment... + <% else %> + <%= link_to 'Awaiting payment', invoice.next_step_url %> + <% end %> + <% end %> +

+

+ <% unless invoice.page_unlock_promo_code&.uses_remaining&.zero? %> + <% if invoice.status == 'CREATED' %> + Ready for payment + <% else %> + Purchased + <% end %> + <%= time_ago_in_words invoice.created_at %> ago + <% end %> +

+ <% if invoice.page_unlock_promo_code %> +
+

+

+ Unlocks these <%= pluralize invoice.page_unlock_promo_code.page_types.count, 'page type' %> + for <%= invoice.months * 30 %> days: +
+ <% invoice.page_unlock_promo_code.page_types.each do |page_type| %> + <% page_type_class = page_type.constantize %> + <%= link_to polymorphic_path(page_type_class) do %> + + <%= page_type_class.icon %> + + <% end %> + <% end %> +

+ <% end %> +
+ <% if invoice.activateable? %> +
+ <%= link_to '#', class: 'btn hoverable black-text white right activator', onclick: 'return false' do %> + share + Share + <% end %> + <% unless current_user.on_premium_plan? %> + <%= form_for :promotional_code, url: redeem_path do |form| %> + <%= form.hidden_field :promo_code, value: invoice.page_unlock_promo_code.code %> + <%= form.submit 'Activate', class: 'hoverable btn blue white-text', data: { confirm: "Are you sure you wish to activate this promo code? It can only be used once." } %> + <% end %> + <% end %> +
+ <% else %> +
+ <% if invoice.page_unlock_promo_code && invoice.page_unlock_promo_code.uses_remaining.zero? %> + Activated <%= time_ago_in_words invoice.page_unlock_promo_code.promotions.last.created_at %> ago + <% else %> + <% if invoice.status == 'CREATED' %> + Ready for payment + <% else %> + Purchased + <% end %> + <%= time_ago_in_words invoice.created_at %> ago + <% end %> +
+ <% end %> + <% if invoice.page_unlock_promo_code.present? %> +
+ + Share this code + close + +

+ Anyone you send this code to can redeem it for their own account, activating the + <%= pluralize invoice.months, 'month' %> of Premium immediately. To share it, send + the following link with them: +

+

+ +

+

+ If they don't already have a Notebook.ai account, you'll also be credited with their referral. +

+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/subscriptions/redeem.html.erb b/app/views/subscriptions/redeem.html.erb new file mode 100644 index 00000000..a8f5d63d --- /dev/null +++ b/app/views/subscriptions/redeem.html.erb @@ -0,0 +1,89 @@ +<% if @code.nil? %> +
+ This code is no longer valid. +
+ +<% elsif @code.uses_remaining.zero?%> +
+ This code has been activated! +
+
+ <%= link_to 'Click here to customize your notebook pages', customization_content_types_path, class: 'btn btn-large blue white-text' %> +
+ +<% else %> +

+ Someone has gifted you
+ <%= pluralize(@code.days_active / 30, 'month') %>
+ of Notebook.ai Premium! +

+ +
+
+
+
+
Activate your free Premium benefits
+

+ You can activate your Premium benefits by clicking "Activate" below. + Your account will be immediately upgraded for <%= pluralize @code.days_active, 'day' %> + and will automatically downgrade back to the free tier afterwards instead of renewing monthly. + This code can only be used <%= pluralize @code.uses_remaining, 'time' %>, so thank your friend! +

+
+
+
+ + <% if user_signed_in? %> + <% if current_user.on_premium_plan? %> +
+
+

+ Since you already have a Premium subscription active, you can't activate this code yet. + An activation link will appear again whenever you don't have Premium active. +

+

+ Don't worry—codes don't expire! +

+
+
+ <% else %> + <%= form_for :promotional_code, url: redeem_path do |form| %> + <%= form.hidden_field :promo_code, value: @code.code %> + <%= form.submit 'Activate', class: 'hoverable btn btn-large blue white-text col s12', data: { confirm: "Are you sure you wish to activate this promo code? It can only be used once." } %> + <% end %> + <% end %> + <% else %> +
+ Before activating your Premium, please either create a Notebook.ai account or log in to an existing one.
+
+
+ <%= link_to 'Create an account', new_user_registration_path, class: 'hoverable btn-large btn blue white-text', style: 'width: 100%' %> +
+
+ <%= link_to 'Log in', new_user_session_path, class: 'hoverable btn-large btn blue white-text', style: 'width: 100%' %> +
+ <% end %> + <% unless user_signed_in? %> +
+ When you're logged in, return to this link so you can redeem this code! +
+ <% end %> + +
+ +
Notebook pages available with Premium
+

+ Create any of these pages — and as many as you need — to start putting ideas to paper and flesh your world out one piece at a time. + Each page comes with a fully-customizable template that will ask you questions to get you started, but stay out of your way when you've got your + momentum. +

+
+ <% (Rails.application.config.content_types[:all]).each do |content_type| %> +
+ <%= render partial: 'cards/intros/content_type_intro', locals: { content_type: content_type } %> +
+ <% end %> +
+ + +<% end %> \ No newline at end of file diff --git a/config/initializers/mixpanel.rb b/config/initializers/mixpanel.rb index 5a169195..a0bd17ee 100644 --- a/config/initializers/mixpanel.rb +++ b/config/initializers/mixpanel.rb @@ -1 +1 @@ -Rails.application.config.mixpanel_token = ENV.fetch('MIXPANEL_TOKEN', 'test') \ No newline at end of file +Rails.application.config.mixpanel_token = ENV.fetch('MIXPANEL_TOKEN', 'test') diff --git a/config/initializers/paypal.rb b/config/initializers/paypal.rb new file mode 100644 index 00000000..e99b530b --- /dev/null +++ b/config/initializers/paypal.rb @@ -0,0 +1,4 @@ +Rails.application.config.paypal = { + client_id: ENV.fetch('PAYPAL_CLIENT_ID', ''), + client_secret: ENV.fetch('PAYPAL_SECRET', '') +} diff --git a/config/routes.rb b/config/routes.rb index 19cbb6a2..4a572765 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,6 +49,9 @@ Rails.application.routes.draw do get '/subscription', to: 'subscriptions#new', as: :subscription get '/history', to: 'subscriptions#history', as: :billing_history get '/referrals', to: 'subscriptions#referrals', as: :referrals + get '/prepay', to: 'subscriptions#prepay', as: :prepay + get '/prepay_redirect_to_paypal', to: 'subscriptions#prepay_redirect_to_paypal', as: :prepay_paypal_gateway + get '/gift/:code', to: 'subscriptions#redeem', as: :gift_code get '/to/:stripe_plan_id', to: 'subscriptions#change', as: :change_subscription diff --git a/db/migrate/20200110050855_create_paypal_invoices.rb b/db/migrate/20200110050855_create_paypal_invoices.rb new file mode 100644 index 00000000..1daf105c --- /dev/null +++ b/db/migrate/20200110050855_create_paypal_invoices.rb @@ -0,0 +1,13 @@ +class CreatePaypalInvoices < ActiveRecord::Migration[6.0] + def change + create_table :paypal_invoices do |t| + t.string :paypal_id + t.string :status + t.references :user, null: false, foreign_key: true + t.integer :months + t.integer :amount_cents + + t.timestamps + end + end +end diff --git a/db/migrate/20200116062906_add_promo_code_link_to_payo_paypal_invoice.rb b/db/migrate/20200116062906_add_promo_code_link_to_payo_paypal_invoice.rb new file mode 100644 index 00000000..bc108ef5 --- /dev/null +++ b/db/migrate/20200116062906_add_promo_code_link_to_payo_paypal_invoice.rb @@ -0,0 +1,5 @@ +class AddPromoCodeLinkToPayoPaypalInvoice < ActiveRecord::Migration[6.0] + def change + add_reference :paypal_invoices, :page_unlock_promo_code, foreign_key: true + end +end diff --git a/db/migrate/20200116072334_add_approval_link_to_paypal_invoices.rb b/db/migrate/20200116072334_add_approval_link_to_paypal_invoices.rb new file mode 100644 index 00000000..9951ce35 --- /dev/null +++ b/db/migrate/20200116072334_add_approval_link_to_paypal_invoices.rb @@ -0,0 +1,5 @@ +class AddApprovalLinkToPaypalInvoices < ActiveRecord::Migration[6.0] + def change + add_column :paypal_invoices, :approval_url, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index dae5ee24..710d0582 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_01_03_190122) do +ActiveRecord::Schema.define(version: 2020_01_16_072334) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false @@ -1664,6 +1664,20 @@ ActiveRecord::Schema.define(version: 2020_01_03_190122) do t.integer "past_owner_id" end + create_table "paypal_invoices", force: :cascade do |t| + t.string "paypal_id" + t.string "status" + t.integer "user_id", null: false + t.integer "months" + t.integer "amount_cents" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "page_unlock_promo_code_id" + t.string "approval_url" + t.index ["page_unlock_promo_code_id"], name: "index_paypal_invoices_on_page_unlock_promo_code_id" + t.index ["user_id"], name: "index_paypal_invoices_on_user_id" + end + create_table "planet_countries", force: :cascade do |t| t.integer "user_id" t.integer "planet_id" @@ -2858,6 +2872,8 @@ ActiveRecord::Schema.define(version: 2020_01_03_190122) do add_foreign_key "location_notable_towns", "users" add_foreign_key "notice_dismissals", "users" add_foreign_key "page_tags", "users" + add_foreign_key "paypal_invoices", "page_unlock_promo_codes" + add_foreign_key "paypal_invoices", "users" add_foreign_key "planet_countries", "countries" add_foreign_key "planet_countries", "planets" add_foreign_key "planet_countries", "users" diff --git a/test/fixtures/paypal_invoices.yml b/test/fixtures/paypal_invoices.yml new file mode 100644 index 00000000..829411f0 --- /dev/null +++ b/test/fixtures/paypal_invoices.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + paypal_id: MyString + status: MyString + user: one + months: 1 + amount_cents: 1 + +two: + paypal_id: MyString + status: MyString + user: two + months: 1 + amount_cents: 1 diff --git a/test/models/paypal_invoice_test.rb b/test/models/paypal_invoice_test.rb new file mode 100644 index 00000000..11d75838 --- /dev/null +++ b/test/models/paypal_invoice_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class PaypalInvoiceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end