Duplicate records are created in case of concurrent requests - php

I have two tables orders and driver_orders.The sample code is :
DB::beginTransaction();
try{
//check if the order status is pending.If the status is other than pending then one of the driver has accepted the order.
$fetch_order = $this->order->where([
['id',$id],
['status','pending']
])->lockForUpdate()->first();
if(!fetch_order){
throw new Exception('Order is already assigned to another driver.');
}
$driver_id = Auth::id();
//checks if logged in driver has pending order to be delivered.
$check_user_pending_order = $this->driver_order->where([
['driver_id', $driver_id ],
['status','!=','delivered']
])->lockForUpdate()->first();
if($check_user_pending_order){
throw new Exception('You have pending order to be delivered.');
}
$data['order_id'] = $fetch_order->id;
$data['driver_id'] = $driver_id;
$data['amount'] = $fetch_order->amount;
$data['status'] = 'assigned';
//create record in driver_order table
$this->driver_order->create($array);
//update the status in order table
$fetch_order->update([
'status' => 'assigned'
]);
DB::commit();
return response()->json([
'status' => '200',
'message' => 'Order successfully assigned. Get ready to deliver the order.'
]);
}
catch(\Exception $ex){
return response()->json([
'status' => '403',
'message' => $ex->getMessage()
],403);
}
The issue is I am getting concurrent request which tends to create duplicate records for the same order in driver_orders table. I used the locking but it does not solve the problem as well. Please suggest me the way to tackle the above mention issue.

Related

How to make sure I don't oversell tickets with Laravel and Stripe?

I have a Laravel 9 app, where admins can create events and a limited number of tickets, with different conditions (this means an event can have multiple types of tickets, with different conditions and different prices), then, users can buy those tickets.
I want to make sure each user can buy only one ticket for each event, and assure that if payment is made, this user will receive their ticket.
My concerns are that I need to make sure that each ticket is reserved to one user and one user only at a time. After that, I want to make sure every time user has completed payment, that his reserved ticket is marked as sold, no matter what.
I know I should be using some sort of webhook as a callback, but I couldn't figure out what events should i listen to, or how to set up properly those webhooks in my Laravel App.
If anyone knows any way to do it better, I am completely open to a redesign.
I currently have this setup, where each type of ticket belongs to one event. Then, when admin publishes that ticket for n people, n unique sellable tickets (usts) are created (this is each and every one of the individual tickets people can buy, with a unique identifier).
When a user wants to buy one ticket, I have a controller that first reserves that ticket for that user.
Check out if is there any unique sellable ticket (ust) that is currently not sold or reserved to anyone.
If there is any avaiable ust, create Stripe Checkout Session and store Stripe Session Id to that ust object in database.
After payment is completed, the return url calls another method that does the next:
3.1. Find any unique sellable tickets reserved to user, but not yet marked as sold.
3.2. Foreach ticket that logged in user has reserved but not sold, check out in Stripe api if payment has been completed.
3.3 If payment has been completed, check sold as true.
Notes: I have set up stripe Checkout to expire in 30 minutes, so thats why i select any usthat is marked as not sold and reserved more than 30 minutes ago as available.
These are my reserving ticket and Stripe Callback functions so far:
public function ReserveEventTicket($event, $ticket){
try {
$event = Event::where('slug', $event)->firstOrFail();
} catch (\Throwable $th) {
return back()->withErrors(['Evento no encontrado']);
}
if(Carbon::createFromFormat('Y-m-d H:i:s', $event->end_datetime)->isPast()){
return back()->withErrors(['Este evento ya ha terminado.']);
}
try {
$ticket = Ticket::where('slug', $ticket)->where('event_id', $event->id)->firstOrFail();
} catch (\Throwable $th) {
return back()->withErrors(['Entrada no encontrada']);
}
//El usuario no ha comprado la entrada.
foreach ($event->tickets as $us_ticket) {
$AlreadyBoughtFound = $us_ticket->usts->where('receiver_id', Auth::user()->id);
if($AlreadyBoughtFound->count() != 0){
return back()->withErrors(['¡No tan rápido! No puedes comprar otra entrada para este evento.']);
}
}
//Reservar ticket
try {
$UST = Ust::where('ticket_id', $ticket->id)
->where(function($q){
$q->where('reserved_datetime', '<', Carbon::parse()->subMinutes(32));
$q->orWhere('reserved_datetime', null);
})
->where('sold', false)->lockForUpdate()->firstOrFail();
} catch (\Throwable $th) {
return back()->withErrors(['No quedan entradas disponibles.']);
}
try {
DB::transaction(function () use($UST) {
$reserveUST = $UST->update([
'reserved' => true,
'reserved_to' => Auth::user()->id,
'reserved_datetime' => Carbon::now()->format('Y-m-d H:i:s')
]);
if(\Session::has('referral')){
$pr = PrVenue::where('code', \Session::get('referral'))->get()->first();
if($pr){
if($pr->venue_id == $UST->ticket->event->venue->id){
$addReferralSold = ReferralTransaction::create([
'ticket_id' => $UST->id,
'user_id' => $pr->user_id,
'distributor' => $pr->distributor,
]);
}
}
}
});
} catch (\Throwable $th) {
return back()->withErrors(['Error reservando la entrada'.$th]);
}
try {
if($ticket->pay_at_door){
if(config('pricing.payments.pay_at_door.style') == "variable"){
$stripe = new \Stripe\StripeClient(config('services.STRIPE_SECRET_KEY'));
$price = $stripe->prices->retrieve($ticket->stripe_price_id,[]);
$application_fee = round(config('pricing.pamyments.pay_at_door.amount')*$price->unit_amount*100, 0);
}else if(config('pricing.payments.pay_at_door.style') == "fixed"){
$application_fee = config('pricing.pamyments.pay_at_door.amount');
}
}else{
$application_fee = 140;
}
\Stripe\Stripe::setApiKey(config('services.STRIPE_API_KEY'));
$session = \Stripe\Checkout\Session::create([
'line_items' => [[
'price' => $ticket->stripe_price_id,
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => "https://phplaravel-843314-2906175.cloudwaysapps.com/events/SucessCallback?session_id={CHECKOUT_SESSION_ID}",
'cancel_url' => url('events/SucessCallback?session_id={CHECKOUT_SESSION_ID}'),
'client_reference_id' => $UST->id,
'expires_at' => time() + (60 * 30),
'payment_intent_data' => [
'application_fee_amount' => $application_fee,
'transfer_data' => [
'destination' => $event->organization->stripe_user_id
],
],
]);
DB::transaction(function () use($session, $UST) {
$associateUST = $UST->update([
'stripe_session_id' => $session->id
]);
});
} catch (\Throwable $th) {
return back()->withErrors(['Error en la pasarela de pago. Volver a intentar.'.$th]);
}
return redirect($session->url);
}
public function SuccessfullPaymentCallback(Request $req){
$stripe = new \Stripe\StripeClient(config('services.STRIPE_API_KEY'));
$session = $stripe->checkout->sessions->retrieve(
$req->session_id,[]);
//Check if session has been completed and if user matches.
if($session->payment_status == 'paid'){
try {
DB::transaction(function () use($session) {
$UST = Ust::where('reserved', true)->where('stripe_session_id', $session->id)->where('id', $session->client_reference_id)->firstOrFail();
$update = $UST->update([
'receiver_id' => Auth::user()->id,
'sold' => true
]);
$NewUST = $UST->refresh();
$updateReferralSold = ReferralTransaction::where('ticket_id', $UST->id)->update([
'sold' => true,
]);
});
$ust = User::find(Auth::user()->id)->usts->where('stripe_session_id', $session->id)->first();
$tick = array();
$tick['code'] = $ust->code;
$tick['event_name'] = $ust->ticket->event->name;
$tick['event_link'] = url('events/'.$ust->ticket->event->slug);
$tick['event_image'] = ImageController::ShowImage($ust->ticket->event->poster);
$tick['venue_name'] = $ust->ticket->event->venue->name;
$tick['zone'] = $ust->ticket->zone->name ?? '-';
$tick['ticket'] = $ust->ticket->name;
$tick['limit_time'] = ($ust->ticket->time_limit) ? $ust->ticket->max_datetime : '-';
//Revisar esto bien
Storage::put('public/ust_'.$tick['code'].'.png', \QrCode::format('png')->merge('/public/images/icons/icon-128x128bg.png')->size(400)->generate($tick['code']));
Mail::send('mails.TicketEmail', ["tick"=>$tick], function($message)use($tick) {
$message->to(Auth::user()->email, Auth::user()->email)
->subject('Entrada para '.$tick['event_name']);
});
//Mail::to([])->send(new TicketEmail($ust->ticket->id, $ust->code, $ust->stripe_session_id));
} catch(\Throwable $th) {
Storage::put('file.txt', $th);
Storage::put('file.png', \QrCode::format('png')->merge('/public/images/icons/icon-128x128bg.png')->size(400)->generate('dsalfjadshñfjah'));
return back()->withErrors(['No se ha completado el proceso de adquisición de tu entrada. Vuelve a iniciar sesión para arreglar el problema.']);
}
}else{
try {
$UST = Ust::where('reserved', true)->where('stripe_session_id', $session->id)->where('id', $session->client_reference_id)->update([
'reserved' => false,
'stripe_session_id' => null,
'reserved_to' => null,
'reserved_datetime' => null,
'receiver_id' => null
]);
} catch(\Throwable $th) {
return back()->withErrors(['El pago no se ha completado y no se ha adquirido tu entrada.']);
}
}
return redirect(url('me/wallet'))->with('success', 'Compra completada correctamente.');
}
After payment is completed, the return url calls another method that does the next: 3.1. Find any unique sellable tickets reserved to user, but not yet marked as sold. 3.2. Foreach ticket that logged in user has reserved but not sold, check out in Stripe api if payment has been completed. 3.3 If payment has been completed, check sold as true.
If you implement webhook events, you won't have to loop over each ticket in your system. Instead, you'll automatically get a checkout.session.completed webhook event from Stripe that will inform you of which ticket has been purchased. So in your webhook handler code, you can mark the ticket as "sold".
I'm not a Laravel expert but here are a few resources on how you can handle webhooks [0] [1]. The concept is basically that Stripe sends you a POST request with an Event object in the request body. You set up an endpoint where Stripe sends that request and you return an HTTP response to it.
[0] https://github.com/spatie/laravel-stripe-webhooks
[1] https://laracasts.com/discuss/channels/general-discussion/stripe-webhooks-with-laravel

Laravel transaction not rolling back

I'm trying to use Laravel transactions for an operation that requires a change in customer's balance.
In this specific situation, when an already approved (status = 1) process is edited, the update operation must subtract the current process amount and add the new process amount to the customer's wallet.
This is the current code:
$process = Process::findOrFail($id);
// CHECKS THE CURRENT PROCESS SUBSIDY
$old_subsidy = $process->subsidy;
try {
DB::beginTransaction();
$process->update($request->all());
// SUMS NEW PROCESS SUBSIDY ONLY WHEN PROCESS IS ALREADY APPROVED
if($process->status == 1) {
$customer = Customer::findOrFail($process->customer_id);
$customer->balance = $request->subsidy - $old_subsidy;
$customer->save();
}
DB::commit();
$notification = array(
'title' => __('Success'),
'message' => __('Process updated with success'),
'class' => 'bg-success'
);
return redirect()->route('processes.show',$process->id)->with($notification);
} catch (\Exception $e) {
DB::rollback();
$notification = array(
'title' => __('Erro'),
'message' => __('Ocorreu um erro'),
'class' => 'bg-danger'
);
return redirect()->back()->with($notification);
}
To test, i'm forcing an error, on this line, requesting a customer id that doesn't exist, like: $customer = Customer::findOrFail(99999). The error message is shown but the changes in the process are still being saved instead of roled back.
Am i missing something here?
Thanks in advance!

Laravel - Prevent multiple requests at the same time creating duplicate records

I have a cancel order method that refunds the user.
However, using the API, if a user calls the endpoint twice (in a loop) for the same record, it refunds the user twice. If I try 3x Api calls at once, first 2 requests gets the refund, 3rd request doesn't.
public function cancelOrder($orderId) {
// First I tried to solve with cache,
// but it is not fast enough to catch the loop
if (Cache::has("api-canceling-$orderId")) {
return response()->json(['message' => "Already cancelling"], 403);
}
Cache::put("api-voiding-$labelId", true, 60);
// Here I get the transaction, and check if 'transaction->is_cancelled'.
// I thought cache will be faster but apparently not enough.
$transaction = Transaction::where('order_id', $orderId)
->where('user_id', auth()->user()->id)
->where('type', "Order Charge")
->firstOrFail();
if ($transaction->is_cancelled) {
return response()->json(['message' => "Order already cancelled"], 403);
}
// Here I do the api call to 3rd party service and wait for response
try {
$result = (new OrderCanceller)->cancel($orderId);
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
$transaction->is_cancelled = true;
$transaction->save();
// This is the operation getting called twice.
Transaction::createCancelRefund($transaction);
return response()->json(['message' => 'Refund is made to your account']);
}
The createCancelRefund() method looks like this:
public static function createCancelRefund($transaction) {
Transaction::create([
'user_id' => $transaction->user_id,
'credit_movement' => $transaction->credit_movement * -1,
'type' => "Order Refund"
]);
}
Things I have tried:
Wrapping everything inside cancelOrder() method into DB::transaction({}, 5) closure. (Also tried DB::beginTransaction() approach)
Using ->lockForUpdate() on $transaction = Transaction::where('order_id', $orderId)... query.
Wrapping createCancelRefund() content inside DB::transaction({}, 5), but I think as it's create() it didn't help.
Tried using cache but it was not as fast.
Looked up to throttling but doesn't seem like it can prevent this (if I say 2 request/min, duplicate creation still occurs)
What is the proper way of preventing duplicate refund creations inside createCancelRefund()?
Atomic Lock solved my problem.
Atomic locks allow for the manipulation of distributed locks without worrying about race conditions. For example, Laravel Forge uses atomic locks to ensure that only one remote task is being executed on a server at a time
public function cancelOrder($orderId) {
return Cache::lock("api-canceling-{$orderId}")->get(function () use ($orderId) {
$transaction = Transaction::where('order_id', $orderId)
->where('user_id', auth()->user()->id)
->where('type', "Order Charge")
->firstOrFail();
if ($transaction->is_cancelled) {
return response()->json(['message' => "Order already cancelled"], 403);
}
try {
$result = (new OrderCanceller)->cancel($orderId);
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
$transaction->is_cancelled = true;
$transaction->save();
// This is the operation getting called twice.
Transaction::createCancelRefund($transaction);
return response()->json(['message' => 'Refund is made to your account']);
});
return response()->json(['message' => "Already cancelling"], 403);
}

How to add and update in one api in laravel

I am trying to add and update using the same API, currently, I can add but I am not clear about how to update using the same API.
I am adding folders against id and my body response looks like this:
{
"id": "2",
"folder_detail": [1,3,4]
}
I can add folders with id 1,3 and 4 against id 2 but next time when I hit the same API with folder[1,3,5] it should update folders details not should add again, I can do that by making separate API but I want to do that in one API.
My Controller code:
try {
$folder = Doctor::where('id', $request->get('id'))->first();
$folder->doctor()->attach($request->get('folder_detail', []));
DB::commit();
return response([
'status' => true,
'message' => 'Folder detail added',
], 200);
} catch (\Exception $ex) {
DB::rollback();
return response([
'status' => false,
'message' => __('messages.validation_errors'),
'errors' => $ex->getMessage(),
], 500);
}
}
public function doctor()
{
return $this->belongsToMany('App\Folder', 'folder_details');
}
Your help will be highly appreciated?
Okay so after our back and forth in the comments I think you are looking for something like this:
$folders = $request->get('folder_detail', []);
foreach($folders as $folder) {
$record = Folder::firstOrNew(['id', $folder]);
$record->doctor_id = $request->id;
// You can add your own records here, or use the 'fill' function
}
So, this way, you loop through all your folders. Check if the folder with the specific ID already exists, if it does not, it creates a new one. The doctor is linked through the doctor_id on your record.
Find record if exist then update otherwise create
$post = $request->all();
$doctor = Doctor::find($post['id']);
if($doctor){
$doctor->update([
//your data for update doctor model
]);
//remove old folders which is related with this doctor
Folder::where('doctor_id', $doctor->id)->delete();
//add current data of folder
if(!empty($post['folder_detail'])){
foreach($post['folder_detail'] as $folder){
Folder::create([
'doctor_id' => $doctor->id,
'folder_detail' => $folder
]);
}
}
//return your response here
} else {
$doctor = Doctor::create([
//data for create doctore
]);
//add current data of folder
if(!empty($post['folder_detail'])){
foreach($post['folder_detail'] as $folder){
Folder::create([
'doctor_id' => $doctor->id,
'folder_detail' => $folder
]);
}
}
//return your response here
}

updating value in laravel using api call

I have been trying to update the handicap score using a post request. But I seem to get an error saying : creating default object from empty value.
Code :
public function handicap(Request $request)
{
$user = Auth::user();
$rules = array(
'handicap' => 'required'
);
$validator = Validator::make(Input::all(), $rules);
// process the login
if ($validator->fails())
{
return response()->json(['msg' => 'Failed to update Handicap score!'], 200);
}
else {
if(Handicap::where('user_id', '=', $user->id)->exists())
{
$handicap = Handicap::find($user->id);
$handicap->user_id = $user->id;
$handicap->handicap = $request->input('handicap');
$handicap->save();
return response()->json(['msg' => 'You have successfully updated your handicap score!'], 200);
}
else
{
$handicap = new Handicap;
$handicap->user_id = $user->id;
$handicap->handicap = $request->input('handicap');
$handicap->save();
return response()->json(['msg' => 'You have added your handicap score successfully!'], 200);
}
}
}
If user does not exist in Handicap table then the else block code runs and creates a handicap score for the user else the if block needs to execute and update the score. I tried many alternatives but dont seem to get it working. Dont know what am I doing wrong.
I checked the $user, $handicap variables using return. those variables have the info that I need to add to the table. Its just that Its not updating.
Your problem probably comes from the line you have Handicap::find($user->id). Obviously it's null, because such model was not found, even though your if statement returns true.
In your if statement you have where('user_id' , '=', $user->id), but you are using Handicap::find($user->id) which is basically Handicap::where('id', '=', $user->id)->first().
Try changing it to:
$handicap = Handicap::where('users_id', '=', $user->id)->first();
You may give this a try:
public function handicap(Request $request)
{
$validator = Validator::make(Input::all(), [
'handicap' => 'required'
]);
// process the login
if ($validator->fails()) {
return response()->json(['msg' => 'Failed to update Handicap score!'], 200);
}
$handicap = Handicap::firstOrNew([
'user_id' => $request->user()->id;
]);
$handicap->user_id = $request->user()->id;
$handicap->handicap = $request->handicap;
$handicap->save();
return response()->json(['msg' => 'You have successfully updated your handicap score!'], 200);
}

Categories