How do I allow a user to make multiple payments on one booking using Stripe?
I'm building an events app using Rails and Stripe to handle payments. I've used javascript for my booking page in order to allow a user to book and pay for multiple spaces rather than just one at a time. However, when I do a test Stripe payment it's only processing the cost for one space. So if an event costs £10 and I want to book 4 spaces, my booking page allows me to indicate this and shows a cost of £40 but the payment on my Stripe dashboard only shows £10.
How do I rectify this?
I have a bookings controller for my Stripe processing code -
def create
# actually process the booking
@event = Event.find(params[:event_id])
@booking = @event.bookings.new(booking_params)
@booking.user = current_user
if @booking.save
# CHARGE THE USER WHO'S BOOKED
Stripe::Charge.create(amount: @event.price_pennies, currency: "gbp",
card: @booking.stripe_token, description: "Booking number #{@booking.id}", items: [{quantity: @booking.quantity}])
flash[:success] = "Your place on our event has been booked"
redirect_to event_path(@event)
else
flash[:error] = "Payment unsuccessful"
render "new"
end
if @event.is_free?
@booking.save!
flash[:success] = "Your place on our event has been booked"
redirect_to event_path(@event)
end
end
I'm sure this is all basic MVC stuff but I cannot find the solution. I think it has to do with the amount: @event.price_pennies and/or how to handle the items: quantity array but I'm not sure. I don't think its anything to do with my views code so haven't put this up on here.
This is my booking params code with quantity included. I don't have a column in my bookings schema table for total_amount - should I add this and change @event.price_pennies to @booking.total_amount ?
private
def booking_params
params.require(:booking).permit(:stripe_token, :quantity)
end
Hey Mike,
So your code here creates a charge for the event price, but you need to actually multiply that by the number of seats in the booking. You've also got some potential issues here in that the booking might save but the payment fails letting the user get away with a free event.
It's probably good to add the total_amount
so you can save that to the model in case the price changes later down the line for some reason.
So what I would do is refactor your code here into a method on the model as a start. This will simplify your controller and clean things up in a way that's a lot more manageable.
I would do something like this:
class Booking < ActiveRecord::Base
def reserve
# Don't process this booking if it isn't valid
return unless valid?
# Free events don't need to do anything special
if event.is_free?
save
# Paid events should charge the customer's card
else
begin
charge = Stripe::Charge.create(amount: @event.price_pennies, currency: "gbp", card: @booking.stripe_token, description: "Booking number #{@booking.id}", items: [{quantity: @booking.quantity}])
self.stripe_charge_id = charge.id
save
rescue Stripe::CardError => e
errors.add(:base, e.message)
false
end
end
end
def create
# actually process the booking
@event = Event.find(params[:event_id])
@booking = @event.bookings.new(booking_params)
@booking.user = current_user
if @booking.reserve
flash[:success] = "Your place on our event has been booked"
redirect_to event_path(@event)
else
flash[:error] = "Booking unsuccessful"
render "new"
end
end
Now this is mostly just psuedo code, nothing really tested by the gist is this:
- If you take all the logic and condense it into a function inside the booking, you can have it handle the free and paid events. You can have it decide what it needs to do to reserve
- If it's free, you simply just save the model and return the true or false
- If it's paid, you try to charge the card, if that fails, you return the error and false
- If the payment is successful you save a reference to the stripe charge and then save the record and return the result
You can still refactor this even further to separate out the free and paid reservations to simplify those a bit as well, but I'll leave that up to you. The main point is that by putting this into the model, you can create a new method called "reserve" that handles the logic for doing a reservation AND potentially payment all in one operation rather than trying to do them separately in your controller where things would get really messy.
This is just a start, and as things get more complicated you'll want to refactor this, probably into a service object of some kind, and retain the logic there, but it's should showcase the way you can begin to organize and group the work you need to do a bit better. Hope this helps!
Hi Chris, I really appreciate such a detailed response. So, what your saying is I need to strip out a lot of the logic I'm using in my controller and placing this in my model? This is something which I've felt I needed to do but wasn't quite sure how to go about it.
If I add total_amount to my bookings schema, should I add a method in my model like -
def total_amount
@booking.quantity * @event.price
end
What I mean is, would this help resolve the specific issue surrounding one user booking more than one space on a specific event or does the logic you've outlined above clear that up?
Yep! That will also give you the benefit of making things more more cohesive from the controller perpsective because it only has to know if it worked or not, rather than knowing how to reserve different types of bookings.
So I actually forgot to include the total_amount bit. I would actually add a column in your database for it and set it at reservation.
class Booking < ActiveRecord::Base
def reserve
# Don't process this booking if it isn't valid
return unless valid?
# We can always set this, even for free events because their price will be 0.
self.total_amount = quantity * event.price_pennies
# Free events don't need to do anything special
if event.is_free?
save
# Paid events should charge the customer's card
else
begin
charge = Stripe::Charge.create(amount: total_amount, currency: "gbp", card: @booking.stripe_token, description: "Booking number #{@booking.id}", items: [{quantity: @booking.quantity}])
self.stripe_charge_id = charge.id
save
rescue Stripe::CardError => e
errors.add(:base, e.message)
false
end
end
end
So you can set it up to always save the total_amount to store for later (like a receipt would do). Free events it should just be quantity * 0. Make sure that the price for free events returns 0 by default if it doesn't already, and then you can use that total amount to charge with Stripe. Save it in pennies so that you can make sure you don't have rounding errors or anything and just store as integers because they're safer. You'll have to do formatting on the UI to print out in decimal format, but that's totally fine.
So, when I add total_amount as a column in my bookings table I add the datatype as 'integer'? I have price and price_pennies as integers also but I wasn't sure whether this had to be as a float or decimal as there will be prices like £8.99 or £4.50 for example but you say I can change this in the view?
Pretty much! Stripe will require you to send over the price in cents as an integer, so it's usually good to keep that the same in your app. So for your examples, they'd be stored as 899
and 450
in the database as integers. Then you divide by 100.0 to get £8.99.
This way you don't have to go back and forth converting things and to and from cents and you would only do that for display purposes. Feels a little weird at first but dramatically reduces your risk of mistakes with money which is good.
I must be already doing this as they show as that now, although I've been building this thing for so long I've forgotten doing it haha!!
Okay, I've implemented the above but now I get this error -
NoMethodError in BookingsController#create
undefined method `*' for nil:NilClass
In relation to the total_amount method in my Booking model -
self.total_amount = quantity * event.price_pennies
Should it be booking.quantity ?
Yeah, so you'll probably want to make sure that the quantity on a booking is always 1 or more and your event's price_pennies
should return 0 if the event is free, and greater than 0 if it is paid. That way you always have these numbers and you don't need to add any more if statements to these other calculations.
I assumed I'd done this but obviously not. Where do I set the defaults for these - in the controller or model?
I would probably add a validation for the quantity on the Booking, and then you may want to add a before_save callback for Event to set to 0 if it is free.
before_save :set_zero_price_pennies, if: :is_free?
def set_zero_price_pennies
self.price_pennies = 0
end
And you can also validate that Event always has a value for price_pennies
of greater than or equal to 0 so that that column is also never nil.
I've just noticed that price_pennies is set at this in my schema -
t.integer "price_pennies", default: 0, null: false
Does this change anything regarding the above discussion? I think the issue may surround quantity as there's no default value set for that.
Yeah exactly, this would take care of it on a database level which would mean that your could should always have a value. And then not having a default on the booking is probably the thing that triggered the nil error. It doesn't super matter if you have a default value there, but it probably does make sense to add a validation requiring you to always have 1+ quantity on the booking so that value is never nil.
I'm still not solving this. I seem to be bouncing from one issue to another. Can't believe how difficult it is to create a process whereby a user can make multiple bookings in one payment.
Do I need to put something in my seeds file to handle the default value for quantity? I've tried the following -
Booking.find_or_create_by_name('my_booking', quantity:1)
But I get a 'No method error : undefined method error' on my command line when I do rake db:seed. As I said I'm bouncing all over the place with this so apologies if I keep coming back looking for answers but I'm really stabbing in the dark as I've never done this before and I'm struggling to find solutions on my own.
Gonna have to see your stacktrace regarding that error. I think maybe you're just having trouble wrapping your head around everything. I had trouble with it for a while the first time I was trying to register and purchase at the same time.
As far as making sure your quantity is required:
validates :quantity, presence: true, numericality: { greater_than: 0 }
For the free events (as previously discussed above) should I do something like this -
before_save : set_price_to_zero_if_free
def set_price_to_zero_if_free
self.event.price >= 1 ( or 0?) unless self.event.is_free
end
Well, so you don't need that anymore because you said you've already got your database defaulting the column to 0
. 👍 Free ones would be defaulted to 0, and your paid ones will always have a value because it'll be required in the form.
Ah okay. I appear to be making progress - there's no error messages now but I'm getting "Booking unsuccessful" everytime I complete a test payment.
Inspect all the steps in the reserve
method and print out the calculations and such. There's probably a piece in there that's not correct and needs a little fixing. You can use byebug
to play with those calculations interactively which I find to be super helpful for debugging.
Rather than @booking.stripe_token (etc) should it just be self.stripe_token or just stripe_token as its in the model rather than the controller?
I'll check out byebug.