Add paypal payments

This commit is contained in:
Deon George 2020-07-27 14:49:59 +10:00
parent 9887996da8
commit 1242dffa20
No known key found for this signature in database
GPG Key ID: 7670E8DC27415254
18 changed files with 1483 additions and 433 deletions

View File

@ -39,3 +39,9 @@ PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
PAYPAL_MODE=sandbox
PAYPAL_SANDBOX_CLIENT_ID=
PAYPAL_SANDBOX_SECRET=
PAYPAL_LIVE_CLIENT_ID=
PAYPAL_LIVE_SECRET=

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\Request;
use App\Models\Checkout;
class CheckoutController extends Controller
{
public function cart_invoice(Request $request,Invoice $o=NULL)
{
if ($o) {
$request->session()->put('invoice.cart.'.$o->id,$o->id);
}
if (! $request->session()->get('invoice.cart'))
return redirect()->to('u/home');
return View('u.invoice.cart')
->with('invoices',Invoice::find(array_values($request->session()->get('invoice.cart'))));
}
public function fee(Request $request,Checkout $o): float
{
return $o->fee($request->post('total',0));
}
public function pay(Request $request,Checkout $o)
{
return redirect('pay/paypal/authorise');
}
}

View File

@ -0,0 +1,251 @@
<?php
namespace App\Http\Controllers;
use App\Models\PaymentItem;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use PayPalCheckoutSdk\Core\PayPalHttpClient;
use PayPalCheckoutSdk\Core\ProductionEnvironment;
use PayPalCheckoutSdk\Core\SandboxEnvironment;
use PayPalCheckoutSdk\Orders\OrdersCreateRequest;
use PayPalCheckoutSdk\Orders\OrdersCaptureRequest;
use PayPalHttp\HttpException;
use App\Models\Checkout;
use App\Models\Invoice;
use App\Models\Payment;
class PaypalController extends Controller
{
private $client;
private $o = NULL;
// Create a new instance with our paypal credentials
public function __construct()
{
if (config('paypal.settings.mode') == 'sandbox')
$environment = new SandboxEnvironment(config('paypal.sandbox_client_id'),config('paypal.sandbox_secret'));
else
$environment = new ProductionEnvironment(config('paypal.live_client_id'),config('paypal.live_secret'));
$this->client = new PayPalHttpClient($environment);
$this->o = Checkout::where('name','paypal')->firstOrFail();
}
public function cancel(Request $request)
{
return redirect()->to('u/invoice/cart');
}
/**
* Authorize a paypal payment, and redirect the user to pay.
*
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function authorise(Request $request)
{
$currency = 'AUD'; // @todo TO determine from DB.;
$cart = $request->session()->get('invoice.cart');
if (! $cart)
return redirect()->to('u/home');
$invoices = Invoice::find($cart);
$paypal = new OrdersCreateRequest();
$paypal->prefer('return=minimal');
// Paypal Purchase Units
$items = collect();
foreach ($invoices as $io) {
$fee = $this->o->fee($io->due,count($cart));
$total = round($io->due+$fee,2);
$items->push([
'reference_id'=>$io->id,
'invoice_id'=>$io->id,
'description'=>'Invoice Payment',
'custom_id'=>sprintf('%s:%s',$io->account_id,$fee*100),
'amount'=>[
'value'=>$total,
'currency_code'=>$currency,
'breakdown'=>[
'item_total'=>[
'value'=>$total,
'currency_code'=>$currency,
]
],
],
'items'=>[
[
'name'=>'Invoice: '.$io->id,
'unit_amount'=>[
'value'=>$total,
'currency_code'=>$currency,
],
'quantity'=>1,
'description'=>'Invoice Payment',
'category'=>'DIGITAL_GOODS',
],
]
]);
}
$data = collect();
$data->put('intent','CAPTURE');
$data->put('purchase_units',$items->toArray());
$data->put('application_context',[
'return_url' => url('pay/paypal/capture'),
'cancel_url' => url('u/invoice/cart'),
]);
$paypal->body = $data->toArray();
try {
$response = $this->client->execute($paypal);
} catch (HttpException $e) {
Log::error('Paypal Exception',['request'=>$paypal,'response'=>$e->getMessage()]);
return redirect()->to('u/invoice/cart')->withErrors('Paypal Exception: '.$e->getCode());
} catch (\HttpException $e) {
Log::error('HTTP Exception',['request'=>$request,'response'=>$e->getMessage()]);
return redirect()->to('u/invoice/cart')->withErrors('HTTP Exception: '.$e->getCode());
}
// Get the approval link
$redirect_url = '';
foreach ($response->result->links as $link) {
if ($link->rel == 'approve') {
$redirect_url = $link->href;
break;
}
}
if ($redirect_url) {
return redirect()->away($redirect_url);
}
return redirect()->to('u/invoice/cart')->withErrors('An error occurred with Paypal?');
}
/**
* Capture a paypal payment
*
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function capture(Request $request)
{
$paypal = new OrdersCaptureRequest($request->query('token'));
$paypal->prefer('return=representation');
$redirect_url = '';
try {
$response = $this->client->execute($paypal);
} catch (HttpException $e) {
$result = json_decode($e->getMessage());
Log::error(sprintf('Paypal Declined: Code: %s, DebugID: %s, Name: %s,Message: %s',$e->getCode(),$result->debug_id,$result->name,$result->message));
switch ($result->name) {
case 'UNPROCESSABLE_ENTITY':
foreach ($result->details as $detail)
Log::error(sprintf('Paypal Declined: Issue: %s Message: %s',$detail->issue,$detail->description));
// If we got a redirect link, lets redirect
foreach ($result->links as $link) {
if ($link->rel == 'redirect') {
$redirect_url = $link->href;
break;
}
}
break;
default:
Log::error(sprintf('Paypal Unhandled: %s',$result));
}
// If we got a redirect.
if ($redirect_url) {
Log::error('Paypal Capture: Redirect back to Paypal.');
return redirect()->away($redirect_url);
}
return redirect()->to('u/invoice/cart')->withErrors('An error occurred with Paypal?');
} catch (\HttpException $e) {
Log::error('HTTP Exception',['request'=>$paypal,'response'=>$e->getMessage()]);
return redirect()->to('u/invoice/cart')->withErrors('HTTP Exception: '.$e->getCode());
}
if (! $response OR ! $response->result->purchase_units) {
Log::error('Paypal Capture: No Purchase Units?');
return redirect()->to('u/invoice/cart')->withErrors('Paypal Exception: NPU');
}
// If we got here, we got a payment
foreach ($response->result->purchase_units as $pu) {
foreach ($pu->payments->captures as $cap) {
$po = new Payment;
switch ($cap->status) {
case 'PENDING':
$po->pending_status = TRUE;
$po->pending = $cap->status_details->reason;
break;
case 'FAILED':
Log::error(sprintf('Paypal Payment Failed: Invoice: %s (%s).',$pu->invoice_id,$cap->error->details[0]->description));
continue 2;
default:
$po->pending_status = TRUE;
$po->pending = $cap->status_details->reason ?? 'Unknown Status';
break;
}
$po->date_payment = Carbon::parse($cap->create_time);
$po->checkout_id = $this->o->id;
$po->checkout_data = $cap->id;
list($account_id,$fee) = explode(':',$cap->custom_id);
$po->fees_amt = $fee/100;
$po->total_amt = $cap->amount->value-$po->fees_amt;
$po->account_id = $account_id;
$pio = new PaymentItem;
$pio->site_id = 1; // @todo To implement
$pio->invoice_id = $cap->invoice_id;
$pio->alloc_amt = $cap->amount->value-$po->fees_amt;
$po->items->push($pio);
// @todo To store payment fees on invoice
try {
$po->pushNew();
} catch (\Exception $e) {
Log::error('Error recording payment',['po'=>$po,'e'=>$e->getMessage(),'token'=>$request->query('token')]);
}
}
}
$request->session()->forget('invoice.cart');
Log::info('Paypal Payment Recorded',['po'=>$po->id]);
return redirect()->to('u/home')->with('success','Payment recorded thank you.');
}
}

View File

@ -38,6 +38,6 @@ class AccountController extends Controller
$io->items->push($o);
}
return View('u.invoice',['o'=>$io]);
return View('u.invoice.home',['o'=>$io]);
}
}

View File

@ -55,7 +55,7 @@ class UserHomeController extends Controller
*/
public function invoice(Invoice $o): View
{
return View('u.invoice',['o'=>$o]);
return View('u.invoice.home',['o'=>$o]);
}
/**
@ -66,7 +66,7 @@ class UserHomeController extends Controller
*/
public function invoice_pdf(Invoice $o)
{
return PDF::loadView('u.invoice', ['o'=>$o])->stream(sprintf('%s.pdf',$o->invoice_account_id));
return PDF::loadView('u.invoice.home',['o'=>$o])->stream(sprintf('%s.pdf',$o->invoice_account_id));
}
/**

View File

@ -13,4 +13,31 @@ class Checkout extends Model
{
return $this->hasMany(Payment::class);
}
/** SCOPES **/
/**
* Search for a record
*
* @param $query
* @param string $term
* @return
*/
public function scopeActive($query)
{
return $query->where('active',TRUE);
}
/** FUNCTIONS **/
public function fee(float $amt,int $items=1): float
{
if (! $this->fee_passon OR ! $items)
return 0;
$fee = $amt+$this->fee_fixed/$items;
$fee /= (1-$this->fee_variable);
return round($fee-$amt,2);
}
}

View File

@ -94,7 +94,18 @@ class Invoice extends Model
public function getPaidAttribute()
{
return $this->currency()->round($this->paymentitems->sum('alloc_amt'));
return $this->currency()->round(
$this->paymentitems
->filter(function($item) { return ! $item->payment->pending_status; })
->sum('alloc_amt'));
}
public function getPendingPaidAttribute()
{
return $this->currency()->round(
$this->paymentitems
->filter(function($item) { return $item->payment->pending_status; })
->sum('alloc_amt'));
}
public function getSubTotalAttribute()

View File

@ -4,11 +4,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\PushNew;
use App\Traits\NextKey;
class Payment extends Model
{
use NextKey;
use NextKey,PushNew;
const RECORD_ID = 'payment';
public $incrementing = FALSE;
@ -20,6 +21,9 @@ class Payment extends Model
protected $dateFormat = 'U';
protected $with = ['account.country.currency','items'];
// Array of items that can be updated with PushNew
protected $pushable = ['items'];
public function account()
{
return $this->belongsTo(Account::class);

View File

@ -4,7 +4,21 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\NextKey;
use App\Traits\PushNew;
class PaymentItem extends Model
{
use NextKey,PushNew;
const RECORD_ID = 'payment_item';
public $incrementing = FALSE;
const CREATED_AT = 'date_orig';
const UPDATED_AT = 'date_last';
protected $table = 'ab_payment_item';
public function payment() {
return $this->belongsTo(Payment::class);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App;
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

View File

@ -21,6 +21,7 @@
"laravel/passport": "^8.2",
"laravel/socialite": "^4.2",
"leenooks/laravel": "^6.0",
"paypal/paypal-checkout-sdk": "^1.0",
"spatie/laravel-demo-mode": "^2.5",
"spinen/laravel-quickbooks-client": "^3.1"
},

1334
composer.lock generated

File diff suppressed because it is too large Load Diff

45
config/paypal.php Normal file
View File

@ -0,0 +1,45 @@
<?php
return [
/**
* Set our Sandbox and Live credentials
*/
'sandbox_client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''),
'sandbox_secret' => env('PAYPAL_SANDBOX_SECRET', ''),
'live_client_id' => env('PAYPAL_LIVE_CLIENT_ID', ''),
'live_secret' => env('PAYPAL_LIVE_SECRET', ''),
/**
* SDK configuration settings
*/
'settings' => [
/**
* Payment Mode
*
* Available options are 'sandbox' or 'live'
*/
'mode' => env('PAYPAL_MODE', 'sandbox'),
// Specify the max connection attempt (3000 = 3 seconds)
'http.ConnectionTimeOut' => 3000,
// Specify whether or not we want to store logs
'log.LogEnabled' => true,
// Specigy the location for our paypal logs
'log.FileName' => storage_path() . '/logs/paypal.log',
/**
* Log Level
*
* Available options: 'DEBUG', 'INFO', 'WARN' or 'ERROR'
*
* Logging is most verbose in the DEBUG level and decreases
* as you proceed towards ERROR. WARN or ERROR would be a
* recommended option for live environments.
*
*/
'log.LogLevel' => 'DEBUG'
],
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddpendingToPayment extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('ab_payment', function (Blueprint $table) {
$table->string('pending')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('ab_payment', function (Blueprint $table) {
$table->dropColumn('pending');
});
}
}

View File

@ -0,0 +1,111 @@
@extends('adminlte::layouts.app')
@section('htmlheader_title')
Payment Cart
@endsection
@section('contentheader_title')
Payment Cart
@endsection
@section('contentheader_description')
@endsection
@section('main-content')
<div class="row">
<div class="col-4">
<div class="card">
<div class="card-header">
<span class="card-title">Invoices to Pay</span>
</div>
<div class="card-body">
<form method="POST" action="{{ url('u/checkout/pay') }}">
{{ csrf_field() }}
<input type="hidden" name="type" value="invoice">
<div class="input-group flex-nowrap mb-5">
<div class="input-group-prepend">
<span class="input-group-text">Payment Method</span>
</div>
<select class="form-control" id="paymethod" name="checkout_id[]" required>
<option></option>
@foreach (\App\Models\Checkout::active()->orderBy('name')->get() as $oo)
<option value="{{ $oo->id }}">{{ $oo->name }}</option>
@endforeach
</select>
</div>
<table id="invoices" class="table table-sm w-100">
<tr>
<th>Invoice</th>
<th class="text-right">Balance Due</th>
</tr>
@foreach ($invoices as $io)
<input type="hidden" name="invoice_id[]" value="{{ $io->id }}">
<tr>
<td>{{ $io->id }}</td>
<td class="text-right">{{ $io->due }}</td>
</tr>
@endforeach
<tfoot>
<tr>
<th class="text-right">Sub Total</th>
<td class="text-right">{{ number_format($invoices->sum('due'),2) }}</td>
</tr>
<tr>
<th class="text-right">Payment Fees</th>
<td class="text-right"><span id="payfee">TBA</span></td>
</tr>
<tr>
<th class="text-right">Payment Total</th>
<th class="text-right"><span id="paytotal">TBA</span></th>
</tr>
<tr>
<th colspan="2">
<input type="submit" class="btn btn-dark mt-2" name="pay" value="Cancel">
<input type="submit" class="btn btn-success mt-2 float-right" name="pay" value="Submit" disabled>
</th>
</tr>
</tfoot>
</table>
</form>
</div>
</div>
</div>
</div>
@endsection
@section('page-scripts')
@css('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.12/css/select2.min.css','select-css')
@js('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.12/js/select2.min.js','select-js')
<script>
$(document).ready(function() {
$('#paymethod').select2({
placeholder: 'Payment method...',
}).on('change',function(item) {
$.ajax({
type: "POST",
data: {total: {{ $invoices->sum('due') }},count: {{ $invoices->count() }} },
dataType: "json",
cache: true,
url: '{{ url('api/u/checkout/fee') }}'+'/'+$(this).val(),
timeout: 25000,
error: function(x) {
alert("Failed to submit, please try again...");
},
success: function(data) {
$("span[id=payfee]").html(data.toFixed(2));
$("span[id=paytotal]").html(({{ $invoices->sum('due') }}+data).toFixed(2));
$("input[type=submit]").prop('disabled',false);
}
});
});
});
</script>
@append

View File

@ -155,6 +155,11 @@
<td class="text-right">${{ number_format($o->total,$o->currency()->rounding) }}</td>
</tr>
@if($o->id)
<tr>
<th>&nbsp;</th>
<th>Payments To Clear:</th>
<td class="text-right">${{ number_format($o->pending_paid,$o->currency()->rounding) }}</td>
</tr>
<tr>
<th>&nbsp;</th>
<th>Payments:</th>
@ -182,8 +187,10 @@
<div class="col-12">
<a href="javascript:window.print();" class="btn btn-default"><i class="fa fa-print"></i> Print</a>
@if($o->id)
<button type="button" class="btn btn-success float-right"><i class="fa fa-credit-card"></i> Submit Payment</button>
<a href="{{ url(sprintf('u/invoice/%s/pdf',$o->id)) }}" class="btn btn-primary float-right" style="margin-right: 5px;">
<a href="{{ url('u/invoice/cart',$o->id) }}" class="btn btn-success float-right">
<i class="fa fa-credit-card"></i> Pay
</a>
<a href="{{ url(sprintf('u/invoice/%s/pdf',$o->id)) }}" class="btn btn-primary float-right mr-2">
<i class="fa fa-download"></i> Download PDF
</a>
@endif

View File

@ -29,5 +29,7 @@ Route::group(['middleware'=>['auth:api','role:reseller']], function() {
Route::group(['middleware'=>'auth:api'], function() {
Route::get('/u/services/{o}','UserServicesController@services')
->where('o','[0-9]+')
->middleware('can:view,o');;
->middleware('can:view,o');
Route::post('/u/checkout/fee/{o}','CheckoutController@fee')
->where('o','[0-9]+');
});

View File

@ -57,9 +57,14 @@ Route::group(['middleware'=>['theme:adminlte-be','auth'],'prefix'=>'u'],function
Route::get('account/{o}/invoice','User\AccountController@view_invoice_next')
->where('o','[0-9]+')
->middleware('can:view,o');
Route::post('checkout/pay','CheckoutController@pay');
Route::get('invoice/{o}','UserHomeController@invoice')
->where('o','[0-9]+')
->middleware('can:view,o');
Route::get('invoice/cart','CheckoutController@cart_invoice');
Route::get('invoice/cart/{o}','CheckoutController@cart_invoice')
->where('o','[0-9]+')
->middleware('can:view,o');
Route::get('invoice/{o}/pdf','UserHomeController@invoice_pdf')
->where('o','[0-9]+')
->middleware('can:view,o');
@ -90,9 +95,13 @@ Route::group(['middleware'=>['theme:metronic-fe']],function() {
Route::get('product_order/{o}','OrderController@product_order');
Route::get('product_info/{o}','OrderController@product_info');
Route::redirect('/home','/u/home');
Route::demoAccess('/uc-access');
Route::redirect('/under-construction','http://www.graytech.net.au');
Route::get('/u/{type}/{action}/{id}','UserHomeController@oldsite');
Route::redirect('home','u/home');
Route::demoAccess('uc-access');
Route::redirect('under-construction','http://www.graytech.net.au');
Route::get('u/{type}/{action}/{id}','UserHomeController@oldsite');
Route::get('/search','SearchController@search');
Route::get('search','SearchController@search');
Route::get('pay/paypal/authorise','PaypalController@authorise');
Route::get('pay/paypal/cancel','PaypalController@cancel');
Route::get('pay/paypal/capture','PaypalController@capture');