Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feature/coupons #148

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
16 changes: 6 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,19 @@ Or, if you've replaced your `application.css` with an `application.scss` (like I
```

## Using Coupons

While more robust coupon support is expected in the future, the simple way to use a coupon is to first create it:
Coupons mirror the functionality of Stripe coupons https://stripe.com/docs/api#coupons, creating a coupon subsequently adds that coupon to your Stripe account.

```ruby
coupon = Coupon.create(code: '30-days-free', free_trial_length: 30)
coupon = Coupon.create(code: '10percentoff', duration: 'repeating', percent_off: 10)
```

Then assign it to a _new_ subscription before saving:

And can then be assigned to a new subscription by setting a session variable.
```ruby
subscription = Subscription.new(...)
subscription.coupon = coupon
subscription.save
session[:koudoku_coupon_code] = '10percentoff'
```
*Note:*
Destroying a coupon locally will delete that coupon from Stripe and Stripe doesn't support updating coupons after they've been created.

It should be noted that these coupons are different from the coupons provided natively by Stripe.

## Implementing Logging, Notifications, etc.

The included module defines the following empty "template methods" which you're able to provide an implementation for in `Subscription`:
Expand Down
63 changes: 63 additions & 0 deletions app/concerns/koudoku/coupon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
module Koudoku::Coupon
extend ActiveSupport::Concern

included do
VALID_DURATIONS = %['repeating', 'once', 'forever']

# Callbacks
after_create :create_stripe_coupon
after_destroy :delete_from_stripe

# Validations
validates_presence_of :duration, :code
validates_uniqueness_of :code
validate :exclusivity_of_percentage_and_amount_off
validate :presence_of_atleast_percentage_or_amount_off
validate :duration_in_months_is_relative_to_duration
validate :stripe_valid_duration

## Stripe implementation

def create_stripe_coupon
coupon_hash = {
id: code,
duration: duration,
duration_in_months: duration_in_months,
max_redemptions: max_redemptions,
percent_off: percentage_off,
amount_off: amount_off,
}
coupon_hash[:redeem_by] = redeem_by.strftime('%s') if redeem_by.present?

Stripe::Coupon.create(coupon_hash)
end

def delete_from_stripe
Stripe::Coupon.retrieve(code).delete
end

private

def duration_in_months_is_relative_to_duration
if duration != 'repeating' && duration_in_months.present?
errors.add(:duration_in_months, 'can only be set when duration is :repeating')
end
end

def exclusivity_of_percentage_and_amount_off
if percentage_off.present? && amount_off.present?
errors.add(:percentage_off, 'cannot set both amount_off and percentage_off')
end
end

def presence_of_atleast_percentage_or_amount_off
if percentage_off.blank? && amount_off.blank?
errors.add(:percentage_off, 'need to set atleast one off attribute')
end
end

def stripe_valid_duration
errors.add(:duration, "is not valid. Valid durations include #{VALID_DURATIONS.join(',')}") unless VALID_DURATIONS.include? duration
end
end
end
8 changes: 3 additions & 5 deletions app/concerns/koudoku/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,10 @@ def processing!

# If the class we're being included in supports coupons ..
if respond_to? :coupon
if coupon.present? and coupon.free_trial?
customer_attributes[:trial_end] = coupon.free_trial_ends.to_i
if coupon.present?
customer_attributes[:coupon] = coupon.code
end
end

customer_attributes[:coupon] = @coupon_code if @coupon_code

# create a customer at that package level.
customer = Stripe::Customer.create(customer_attributes)
Expand Down Expand Up @@ -159,7 +157,7 @@ def describe_difference(plan_to_describe)
# Set a Stripe coupon code that will be used when a new Stripe customer (a.k.a. Koudoku subscription)
# is created
def coupon_code=(new_code)
@coupon_code = new_code
coupon = Coupon.find_by_code(new_code)
end

# Pretty sure this wouldn't conflict with anything someone would put in their model
Expand Down
28 changes: 16 additions & 12 deletions app/controllers/koudoku/subscriptions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ def load_plans
@plans = ::Plan.order(:price)
end

def load_coupon
@coupon = ::Coupon.find_by_code(session[:koudoku_coupon_code]) if session[:koudoku_coupon_code]
end

def unauthorized
render status: 401, template: "koudoku/subscriptions/unauthorized"
false
Expand All @@ -17,21 +21,21 @@ def unauthorized
def load_owner
unless params[:owner_id].nil?
if current_owner.present?

# we need to try and look this owner up via the find method so that we're
# taking advantage of any override of the find method that would be provided
# by older versions of friendly_id. (support for newer versions default behavior
# below.)
searched_owner = current_owner.class.find(params[:owner_id]) rescue nil

# if we couldn't find them that way, check whether there is a new version of
# friendly_id in place that we can use to look them up by their slug.
# in christoph's words, "why?!" in my words, "warum?!!!"
# (we debugged this together on skype.)
if searched_owner.nil? && current_owner.class.respond_to?(:friendly)
searched_owner = current_owner.class.friendly.find(params[:owner_id]) rescue nil
end

if current_owner.try(:id) == searched_owner.try(:id)
@owner = current_owner
else
Expand Down Expand Up @@ -62,7 +66,7 @@ def current_owner

def redirect_to_sign_up
# this is a Devise default variable and thus should not change its name
# when we change subscription owners from :user to :company
# when we change subscription owners from :user to :company
session["user_return_to"] = new_subscription_path(plan: params[:plan])
redirect_to new_registration_path(Koudoku.subscriptions_owned_by.to_s)
end
Expand All @@ -76,7 +80,7 @@ def index

# Load all plans.
@plans = ::Plan.order(:display_order).all

# Don't prep a subscription unless a user is authenticated.
unless no_owner?
# we should also set the owner of the subscription here.
Expand All @@ -97,7 +101,7 @@ def new
else
redirect_to_sign_up
end

else
raise I18n.t('koudoku.failure.feature_depends_on_devise')
end
Expand All @@ -118,10 +122,10 @@ def create
@subscription = ::Subscription.new(subscription_params)
@subscription.subscription_owner = @owner
@subscription.coupon_code = session[:koudoku_coupon_code]

if @subscription.save
flash[:notice] = after_new_subscription_message
redirect_to after_new_subscription_path
redirect_to after_new_subscription_path
else
flash[:error] = I18n.t('koudoku.failure.problem_processing_transaction')
render :new
Expand Down Expand Up @@ -153,7 +157,7 @@ def update

private
def subscription_params

# If strong_parameters is around, use that.
if defined?(ActionController::StrongParameters)
params.require(:subscription).permit(:plan_id, :stripe_id, :current_price, :credit_card_token, :card_type, :last_four)
Expand All @@ -163,15 +167,15 @@ def subscription_params
end

end

def after_new_subscription_path
return super(@owner, @subscription) if defined?(super)
owner_subscription_path(@owner, @subscription)
end

def after_new_subscription_message
controller = ::ApplicationController.new
controller.respond_to?(:new_subscription_notice_message) ?
controller.respond_to?(:new_subscription_notice_message) ?
controller.try(:new_subscription_notice_message) :
I18n.t('koudoku.confirmations.subscription_upgraded')
end
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/koudoku/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def install
template "app/models/plan.rb"

# Add coupons.
generate("model coupon code:string free_trial_length:string")
generate("model coupon code:string interval:integer amount_off:float percent_off:integer redeem_by:datetime max_redemptions:integer duration:string duration_in_months:integer")
template "app/models/coupon.rb"

# Update the owner relationship.
Expand Down
3 changes: 2 additions & 1 deletion lib/generators/koudoku/templates/app/models/coupon.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Coupon < ActiveRecord::Base

include Koudoku::Coupon

has_many :subscriptions

end
50 changes: 50 additions & 0 deletions spec/concerns/koudoku/coupon_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require 'spec_helper'

describe Koudoku::Coupon do
describe "validations" do
before :each do
@coupon = Coupon.new(duration: 'once', code: 'code', percentage_off: 10)
end

it "invalid if amount_off and percentage_off set at the sametime" do
@coupon.amount_off = 1.99
@coupon.percentage_off = 50

expect(@coupon).to_not be_valid
end

it "valid if only percentage_off is set" do
@coupon.amount_off = nil
@coupon.percentage_off = 50

expect(@coupon).to be_valid
end

it "valid if only amount_off is set" do
@coupon.amount_off = 1.99
@coupon.percentage_off = nil

expect(@coupon).to be_valid
end

it "invalid if neither amount_off or percentage_off is set" do
@coupon.amount_off = nil
@coupon.percentage_off = nil

expect(@coupon).to_not be_valid
end

it "valid if duration_in_months is set when duration is repeating " do
@coupon.duration = 'repeating'
@coupon.duration_in_months = 2
expect(@coupon).to be_valid
end

it "invalid if duration_in_months is set when duration not repeating" do
@coupon.duration = 'once'
@coupon.duration_in_months = 2

expect(@coupon).to_not be_valid
end
end
end
5 changes: 4 additions & 1 deletion spec/dummy/app/models/coupon.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
class Coupon < ActiveRecord::Base

include Koudoku::Coupon

has_many :subscriptions

end
1 change: 1 addition & 0 deletions spec/dummy/app/models/subscription.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Subscription < ActiveRecord::Base
include Koudoku::Subscription


belongs_to :customer
belongs_to :coupon

Expand Down
4 changes: 3 additions & 1 deletion spec/dummy/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Rails.application.routes.draw do
mount Koudoku::Engine, at: "koudoku"

# Added by Koudoku.
mount Koudoku::Engine, at: 'koudoku'
scope module: 'koudoku' do
get 'pricing' => 'subscriptions#index', as: 'pricing'
end
Expand Down
7 changes: 7 additions & 0 deletions spec/dummy/db/migrate/20130318204502_create_coupons.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ def change
create_table :coupons do |t|
t.string :code
t.string :free_trial_length
t.integer :coupons, :interval
t.float :coupons, :amount_off
t.integer :coupons, :percentage_off
t.datetime :coupons, :redeem_by
t.integer :coupons, :max_redemptions
t.string :coupons, :duration
t.integer :coupons, :duration_in_months

t.timestamps
end
Expand Down
36 changes: 22 additions & 14 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,53 @@
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended to check this file into your version control system.
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(:version => 20130520163946) do
ActiveRecord::Schema.define(version: 20130520163946) do

create_table "coupons", :force => true do |t|
create_table "coupons", force: true do |t|
t.string "code"
t.string "free_trial_length"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.integer "coupons"
t.integer "interval"
t.float "amount_off"
t.integer "percentage_off"
t.datetime "redeem_by"
t.integer "max_redemptions"
t.string "duration"
t.integer "duration_in_months"
t.datetime "created_at"
t.datetime "updated_at"
end

create_table "customers", :force => true do |t|
create_table "customers", force: true do |t|
t.string "email"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.datetime "created_at"
t.datetime "updated_at"
end

create_table "plans", :force => true do |t|
create_table "plans", force: true do |t|
t.string "name"
t.string "stripe_id"
t.float "price"
t.text "features"
t.boolean "highlight"
t.integer "display_order"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.datetime "created_at"
t.datetime "updated_at"
t.string "interval"
end

create_table "subscriptions", :force => true do |t|
create_table "subscriptions", force: true do |t|
t.string "stripe_id"
t.integer "plan_id"
t.string "last_four"
t.integer "coupon_id"
t.string "card_type"
t.float "current_price"
t.integer "customer_id"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.datetime "created_at"
t.datetime "updated_at"
end

end