Changeset View
Changeset View
Standalone View
Standalone View
src/app/Http/Controllers/API/V4/PaymentsController.php
<?php | <?php | ||||
namespace App\Http\Controllers\API\V4; | namespace App\Http\Controllers\API\V4; | ||||
use App\Payment; | |||||
use App\Wallet; | |||||
use App\Http\Controllers\Controller; | use App\Http\Controllers\Controller; | ||||
use App\Providers\PaymentProvider; | |||||
use App\Wallet; | |||||
use Illuminate\Http\Request; | use Illuminate\Http\Request; | ||||
use Illuminate\Support\Facades\Auth; | use Illuminate\Support\Facades\Auth; | ||||
use Illuminate\Support\Facades\Validator; | use Illuminate\Support\Facades\Validator; | ||||
class PaymentsController extends Controller | class PaymentsController extends Controller | ||||
{ | { | ||||
/** | /** | ||||
* Create a new payment. | * Get the auto-payment mandate info. | ||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function mandate() | |||||
{ | |||||
$user = Auth::guard()->user(); | |||||
// TODO: Wallet selection | |||||
$wallet = $user->wallets->first(); | |||||
$mandate = self::walletMandate($wallet); | |||||
return response()->json($mandate); | |||||
} | |||||
/** | |||||
* Create a new auto-payment mandate. | |||||
* | * | ||||
* @param \Illuminate\Http\Request $request The API request. | * @param \Illuminate\Http\Request $request The API request. | ||||
* | * | ||||
* @return \Illuminate\Http\JsonResponse The response | * @return \Illuminate\Http\JsonResponse The response | ||||
*/ | */ | ||||
public function store(Request $request) | public function mandateCreate(Request $request) | ||||
{ | { | ||||
$current_user = Auth::guard()->user(); | $current_user = Auth::guard()->user(); | ||||
// TODO: Wallet selection | // TODO: Wallet selection | ||||
$wallet = $current_user->wallets()->first(); | $wallet = $current_user->wallets->first(); | ||||
$rules = [ | |||||
'amount' => 'required|numeric', | |||||
'balance' => 'required|numeric|min:0', | |||||
]; | |||||
// Check required fields | // Check required fields | ||||
$v = Validator::make( | $v = Validator::make($request->all(), $rules); | ||||
$request->all(), | |||||
[ | // TODO: allow comma as a decimal point? | ||||
'amount' => 'required|int|min:1', | |||||
] | |||||
); | |||||
if ($v->fails()) { | if ($v->fails()) { | ||||
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | ||||
} | } | ||||
// Register the user in Mollie, if not yet done | $amount = (int) ($request->amount * 100); | ||||
// FIXME: Maybe Mollie ID should be bound to a wallet, but then | |||||
// The same customer could technicly have multiple | |||||
// Mollie IDs, then we'd need to use some "virtual" email | |||||
// address (e.g. <wallet-id>@<user-domain>) instead of the user email address | |||||
$customer_id = $current_user->getSetting('mollie_id'); | |||||
$seq_type = 'oneoff'; | |||||
if (empty($customer_id)) { | |||||
$customer = mollie()->customers()->create([ | |||||
'name' => $current_user->name(), | |||||
'email' => $current_user->email, | |||||
]); | |||||
$seq_type = 'first'; | // Validate the minimum value | ||||
$customer_id = $customer->id; | if ($amount < PaymentProvider::MIN_AMOUNT) { | ||||
$current_user->setSetting('mollie_id', $customer_id); | $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; | ||||
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | } | ||||
$payment_request = [ | $wallet->setSetting('mandate_amount', $request->amount); | ||||
'amount' => [ | $wallet->setSetting('mandate_balance', $request->balance); | ||||
$request = [ | |||||
'currency' => 'CHF', | 'currency' => 'CHF', | ||||
// a number with two decimals is required | 'amount' => $amount, | ||||
'value' => sprintf('%.2f', $request->amount / 100), | 'description' => \config('app.name') . ' Auto-Payment Setup', | ||||
], | |||||
'customerId' => $customer_id, | |||||
'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring' | |||||
'description' => 'Kolab Now Payment', // required | |||||
'redirectUrl' => \url('/wallet'), // required for non-recurring payments | |||||
'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), | |||||
'locale' => 'en_US', | |||||
]; | ]; | ||||
// Create the payment in Mollie | $provider = PaymentProvider::factory($wallet); | ||||
$payment = mollie()->payments()->create($payment_request); | |||||
$result = $provider->createMandate($wallet, $request); | |||||
$result['status'] = 'success'; | |||||
return response()->json($result); | |||||
} | |||||
/** | |||||
* Revoke the auto-payment mandate. | |||||
* | |||||
* @return \Illuminate\Http\JsonResponse The response | |||||
*/ | |||||
public function mandateDelete() | |||||
{ | |||||
$user = Auth::guard()->user(); | |||||
// TODO: Wallet selection | |||||
$wallet = $user->wallets->first(); | |||||
$provider = PaymentProvider::factory($wallet); | |||||
// Store the payment reference in database | $provider->deleteMandate($wallet); | ||||
self::storePayment($payment, $wallet->id, $request->amount); | |||||
return response()->json([ | return response()->json([ | ||||
'status' => 'success', | 'status' => 'success', | ||||
'redirectUrl' => $payment->getCheckoutUrl(), | 'message' => \trans('app.mandate-delete-success'), | ||||
]); | ]); | ||||
} | } | ||||
/** | /** | ||||
* Update payment status (and balance). | * Update a new auto-payment mandate. | ||||
* | * | ||||
* @param \Illuminate\Http\Request $request The API request. | * @param \Illuminate\Http\Request $request The API request. | ||||
* | * | ||||
* @return \Illuminate\Http\Response The response | * @return \Illuminate\Http\JsonResponse The response | ||||
*/ | */ | ||||
public function webhook(Request $request) | public function mandateUpdate(Request $request) | ||||
{ | { | ||||
$db_payment = Payment::find($request->id); | $current_user = Auth::guard()->user(); | ||||
// Mollie recommends to return "200 OK" even if the payment does not exist | // TODO: Wallet selection | ||||
if (empty($db_payment)) { | $wallet = $current_user->wallets->first(); | ||||
return response('Success', 200); | |||||
} | |||||
// Get the payment details from Mollie | $rules = [ | ||||
$payment = mollie()->payments()->get($request->id); | 'amount' => 'required|numeric', | ||||
'balance' => 'required|numeric|min:0', | |||||
]; | |||||
if (empty($payment)) { | // Check required fields | ||||
return response('Success', 200); | $v = Validator::make($request->all(), $rules); | ||||
} | |||||
if ($payment->isPaid()) { | // TODO: allow comma as a decimal point? | ||||
if (!$payment->hasRefunds() && !$payment->hasChargebacks()) { | |||||
// The payment is paid and isn't refunded or charged back. | if ($v->fails()) { | ||||
// Update the balance, if it wasn't already | return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | ||||
if ($db_payment->status != 'paid') { | |||||
$db_payment->wallet->credit($db_payment->amount); | |||||
} | |||||
} elseif ($payment->hasRefunds()) { | |||||
// The payment has been (partially) refunded. | |||||
// The status of the payment is still "paid" | |||||
// TODO: Update balance | |||||
} elseif ($payment->hasChargebacks()) { | |||||
// The payment has been (partially) charged back. | |||||
// The status of the payment is still "paid" | |||||
// TODO: Update balance | |||||
} | |||||
} | } | ||||
// This is a sanity check, just in case the payment provider api | $amount = (int) ($request->amount * 100); | ||||
// sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. | |||||
if ($db_payment->status != 'paid') { | // Validate the minimum value | ||||
$db_payment->status = $payment->status; | if ($amount < PaymentProvider::MIN_AMOUNT) { | ||||
$db_payment->save(); | $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; | ||||
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | } | ||||
return response('Success', 200); | $wallet->setSetting('mandate_amount', $request->amount); | ||||
$wallet->setSetting('mandate_balance', $request->balance); | |||||
return response()->json([ | |||||
'status' => 'success', | |||||
'message' => \trans('app.mandate-update-success'), | |||||
]); | |||||
} | } | ||||
/** | /** | ||||
* Charge a wallet with a "recurring" payment. | * Create a new payment. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet to charge | * @param \Illuminate\Http\Request $request The API request. | ||||
* @param int $amount The amount of money in cents | |||||
* | * | ||||
* @return bool | * @return \Illuminate\Http\JsonResponse The response | ||||
*/ | */ | ||||
public static function directCharge(Wallet $wallet, $amount): bool | public function store(Request $request) | ||||
{ | { | ||||
$customer_id = $wallet->owner->getSetting('mollie_id'); | $current_user = Auth::guard()->user(); | ||||
if (empty($customer_id)) { | // TODO: Wallet selection | ||||
return false; | $wallet = $current_user->wallets->first(); | ||||
$rules = [ | |||||
'amount' => 'required|numeric', | |||||
]; | |||||
// Check required fields | |||||
$v = Validator::make($request->all(), $rules); | |||||
// TODO: allow comma as a decimal point? | |||||
if ($v->fails()) { | |||||
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); | |||||
} | } | ||||
// Check if there's at least one valid mandate | $amount = (int) ($request->amount * 100); | ||||
$mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) { | |||||
return $mandate->isValid(); | |||||
}); | |||||
if (empty($mandates)) { | // Validate the minimum value | ||||
return false; | if ($amount < PaymentProvider::MIN_AMOUNT) { | ||||
$min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; | |||||
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; | |||||
return response()->json(['status' => 'error', 'errors' => $errors], 422); | |||||
} | } | ||||
$payment_request = [ | $request = [ | ||||
'amount' => [ | 'type' => PaymentProvider::TYPE_ONEOFF, | ||||
'currency' => 'CHF', | 'currency' => 'CHF', | ||||
// a number with two decimals is required | 'amount' => $amount, | ||||
'value' => sprintf('%.2f', $amount / 100), | 'description' => \config('app.name') . ' Payment', | ||||
], | |||||
'customerId' => $customer_id, | |||||
'sequenceType' => 'recurring', | |||||
'description' => 'Kolab Now Recurring Payment', | |||||
'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), | |||||
]; | ]; | ||||
// Create the payment in Mollie | $provider = PaymentProvider::factory($wallet); | ||||
$payment = mollie()->payments()->create($payment_request); | |||||
// Store the payment reference in database | $result = $provider->payment($wallet, $request); | ||||
self::storePayment($payment, $wallet->id, $amount); | |||||
return true; | $result['status'] = 'success'; | ||||
return response()->json($result); | |||||
} | } | ||||
/** | /** | ||||
* Create self URL | * Update payment status (and balance). | ||||
* | * | ||||
* @param string $route Route/Path | * @param string $provider Provider name | ||||
* | * | ||||
* @return string Full URL | * @return \Illuminate\Http\Response The response | ||||
*/ | */ | ||||
protected static function serviceUrl(string $route): string | public function webhook($provider) | ||||
{ | { | ||||
$url = \url($route); | $code = 200; | ||||
$app_url = trim(\config('app.url'), '/'); | if ($provider = PaymentProvider::factory($provider)) { | ||||
$pub_url = trim(\config('app.public_url'), '/'); | $code = $provider->webhook(); | ||||
} | |||||
if ($pub_url != $app_url) { | return response($code < 400 ? 'Success' : 'Server error', $code); | ||||
$url = str_replace($app_url, $pub_url, $url); | |||||
} | } | ||||
return $url; | /** | ||||
* Charge a wallet with a "recurring" payment. | |||||
* | |||||
* @param \App\Wallet $wallet The wallet to charge | |||||
* @param int $amount The amount of money in cents | |||||
* | |||||
* @return bool | |||||
*/ | |||||
public static function directCharge(Wallet $wallet, $amount): bool | |||||
{ | |||||
$request = [ | |||||
'type' => PaymentProvider::TYPE_RECURRING, | |||||
'currency' => 'CHF', | |||||
'amount' => $amount, | |||||
'description' => \config('app.name') . ' Recurring Payment', | |||||
]; | |||||
$provider = PaymentProvider::factory($wallet); | |||||
if ($result = $provider->payment($wallet, $request)) { | |||||
return true; | |||||
} | |||||
return false; | |||||
} | } | ||||
/** | /** | ||||
* Create a payment record in DB | * Returns auto-payment mandate info for the specified wallet | ||||
* | |||||
* @param \App\Wallet $wallet A wallet object | |||||
* | * | ||||
* @param object $payment Mollie payment | * @return array A mandate metadata | ||||
* @param string $wallet_id Wallet ID | |||||
* @param int $amount Amount of money in cents | |||||
*/ | */ | ||||
protected static function storePayment($payment, $wallet_id, $amount): void | public static function walletMandate(Wallet $wallet): array | ||||
{ | { | ||||
$db_payment = new Payment(); | $provider = PaymentProvider::factory($wallet); | ||||
$db_payment->id = $payment->id; | |||||
$db_payment->description = $payment->description; | // Get the Mandate info | ||||
$db_payment->status = $payment->status; | $mandate = (array) $provider->getMandate($wallet); | ||||
$db_payment->amount = $amount; | |||||
$db_payment->wallet_id = $wallet_id; | $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); | ||||
$db_payment->save(); | $mandate['balance'] = 0; | ||||
foreach (['amount', 'balance'] as $key) { | |||||
if (($value = $wallet->getSetting("mandate_{$key}")) !== null) { | |||||
$mandate[$key] = $value; | |||||
} | |||||
} | |||||
return $mandate; | |||||
} | } | ||||
} | } |